feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
This commit is contained in:
1 parent
f132a5ec92
commit
38ef459897
77 files changed
+5905
-688
No files matched your search
+53
-19
@@ -111,6 +111,10 @@ void Host::close() noexcept {
|
||||
// hands each row its rect every frame). Dropping them would silently stop routing
|
||||
// gestures to the rows on the second run.
|
||||
have_pointer_ = false;
|
||||
// The cached rows described a figure's geometry; the next run() re-reads them from
|
||||
// scratch, and until then `controller_at()` answers the single-plot controller
|
||||
// rather than rects from a window that no longer exists.
|
||||
row_rects_.clear();
|
||||
// A closed host reports no size: a second run() must not see the previous
|
||||
// window's framebuffer size before its first refresh_sizes().
|
||||
fb_width_ = 0;
|
||||
@@ -210,13 +214,12 @@ 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);
|
||||
// Every row's controller gets that row's rect, so a gesture -- routed by
|
||||
// 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. 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;
|
||||
// The frame's one layout of the figure, into the cache every event handler
|
||||
// between now and the next frame reads (R7): the rows below, and the routing of
|
||||
// whatever the next `glfwPollEvents()` batch delivers, all take *these* rects
|
||||
// rather than each paying for another layout.
|
||||
refresh_row_rects();
|
||||
const std::vector<Rect>& rows = row_rects_;
|
||||
for (u32 i = 0; i < row_controllers_.size(); ++i) {
|
||||
if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]);
|
||||
}
|
||||
@@ -353,6 +356,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_);
|
||||
e.time_s = glfwGetTime(); // GLFW's monotonic clock; only differences are read
|
||||
feed_pointer_event(e);
|
||||
}
|
||||
|
||||
@@ -380,6 +384,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_);
|
||||
e.time_s = glfwGetTime(); // the double-click detector reads differences of these
|
||||
feed_pointer_event(e);
|
||||
}
|
||||
|
||||
@@ -406,9 +411,16 @@ void Host::on_key(int key, int action, int mods) noexcept {
|
||||
case GLFW_KEY_MINUS:
|
||||
case GLFW_KEY_KP_SUBTRACT: mapped = input::kKeyZoomOut; break;
|
||||
case GLFW_KEY_F: mapped = input::kKeyFit; break;
|
||||
case GLFW_KEY_ESCAPE:
|
||||
if (action == GLFW_PRESS) glfwSetWindowShouldClose(window_, GLFW_TRUE);
|
||||
return;
|
||||
// Arrows pan (10% of the span per press, repeats included) and Escape cancels
|
||||
// the active drag: translated, exactly like the ASCII set above, and the
|
||||
// controller decides what each key does. Escape no longer closes the window --
|
||||
// the window manager's close button and Alt+F4 are the way to quit, and a key
|
||||
// the controller has a gesture for must reach it.
|
||||
case GLFW_KEY_ESCAPE: mapped = input::kKeyEscape; break;
|
||||
case GLFW_KEY_LEFT: mapped = input::kKeyLeft; break;
|
||||
case GLFW_KEY_RIGHT: mapped = input::kKeyRight; break;
|
||||
case GLFW_KEY_UP: mapped = input::kKeyUp; break;
|
||||
case GLFW_KEY_DOWN: mapped = input::kKeyDown; break;
|
||||
default: return;
|
||||
}
|
||||
input::KeyEvent e;
|
||||
@@ -438,12 +450,27 @@ void Host::set_figure(Figure* figure) noexcept {
|
||||
spread_y_ = 0.0;
|
||||
captured_ = false;
|
||||
captured_row_ = 0u;
|
||||
if (figure_ == nullptr) return;
|
||||
captured_button_ = input::Button::Left;
|
||||
if (figure_ == nullptr) {
|
||||
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
|
||||
return;
|
||||
}
|
||||
const u32 rows = figure_->row_count();
|
||||
row_controllers_.reserve(rows);
|
||||
for (u32 i = 0; i < rows; ++i) {
|
||||
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
||||
}
|
||||
// The new figure's rects go into the cache now, not at the next frame: an event
|
||||
// arriving between set_figure() and the first frame -- a scripted run, the headless
|
||||
// tests -- must be routed against the figure it was just handed, and the next
|
||||
// `refresh_row_rects()` re-reads them once per frame as usual.
|
||||
refresh_row_rects();
|
||||
}
|
||||
|
||||
void Host::refresh_row_rects() noexcept {
|
||||
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
||||
// instead of paying this again, which is the whole point of the cache (R7).
|
||||
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
|
||||
}
|
||||
|
||||
input::Controller* Host::row_controller(u32 row) noexcept {
|
||||
@@ -452,10 +479,12 @@ input::Controller* Host::row_controller(u32 row) noexcept {
|
||||
|
||||
input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
|
||||
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||
// The figure's one layout for this call: both readers below take the row rects it produced, so an
|
||||
// event lays the figure out once and not once per reader -- this is the hottest path a multi-row
|
||||
// figure has, and a layout now rebuilds every row's ticks, grid and crosshair geometry.
|
||||
const std::vector<Rect>& rows = figure_->layout().rows;
|
||||
// The cached rects `refresh_row_rects()` filled -- the same ones the last frame
|
||||
// drew -- and not a fresh `Figure::layout()`: an event pays no layout, which is
|
||||
// what made a burst of pointer moves on a multi-row figure re-lay every row out
|
||||
// per move (R7). Between a resize and the loop's next refresh the cache is one
|
||||
// frame stale, which is the documented worst case.
|
||||
const std::vector<Rect>& rows = row_rects_;
|
||||
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
||||
const u32 row = row_in(rows, y);
|
||||
@@ -472,13 +501,14 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
|
||||
controller_.on_pointer(e);
|
||||
return;
|
||||
}
|
||||
pointer_target(e.x, e.y, e.pressed, e.released).on_pointer(e);
|
||||
pointer_target(e.x, e.y, e.button, e.pressed, e.released).on_pointer(e);
|
||||
}
|
||||
|
||||
input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept {
|
||||
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, 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;
|
||||
const std::vector<Rect>& rows = row_rects_;
|
||||
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||
|
||||
u32 row = 0u;
|
||||
@@ -488,12 +518,16 @@ input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool 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;
|
||||
// Only the owning button's release ends the capture (R5): a second button's
|
||||
// release -- pressed meanwhile as a chord -- must not strand the first button's
|
||||
// drag, and its press must not steal the capture to the row it landed in.
|
||||
if (released && button == captured_button_) captured_ = false;
|
||||
} else {
|
||||
row = row_in(rows, y);
|
||||
if (pressed) {
|
||||
captured_ = true;
|
||||
captured_row_ = row;
|
||||
captured_button_ = button;
|
||||
}
|
||||
}
|
||||
// The captured row is re-read against this frame's rects: the figure can be resized
|
||||
|
||||
+51
-12
@@ -128,9 +128,10 @@ class Host {
|
||||
input::Controller* row_controller(u32 row) noexcept;
|
||||
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
|
||||
/// the single-plot loop -- while no figure is set, and the controller of the figure
|
||||
/// row that pixel lands in otherwise. The row rects come from `Figure::layout()`, the
|
||||
/// same ones the renderer draws, so a gesture lands on the row it was aimed at; a
|
||||
/// pixel inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
|
||||
/// row that pixel lands in otherwise. The row rects come from the cache
|
||||
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
|
||||
/// lands on the row it was aimed at and no event lays the figure out; a pixel
|
||||
/// inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
|
||||
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
|
||||
/// that leaves the data area still ends where it should.
|
||||
///
|
||||
@@ -147,13 +148,38 @@ class Host {
|
||||
///
|
||||
/// 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
|
||||
/// 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.
|
||||
///
|
||||
/// Single-gesture policy: a figure routes one button-down gesture at a time. The
|
||||
/// first button to press claims the capture; a second button pressed meanwhile --
|
||||
/// a chord, or a trackpad that reports a second finger as a button -- neither
|
||||
/// steals the capture to another row nor ends it, and only the owning button's
|
||||
/// release ends it. Whatever the second button's events do to the controllers is
|
||||
/// the controllers' business; the host only guarantees they all arrive at the row
|
||||
/// the first press claimed.
|
||||
void feed_pointer_event(const input::PointerEvent& e) noexcept;
|
||||
|
||||
/// Re-reads the figure's row rects into the cache every event handler below
|
||||
/// reads -- the one `Figure::layout()` the routing costs between two frames. The
|
||||
/// run loop calls this once per frame, right after the figure's viewport is set,
|
||||
/// so the rects the handlers answer from are the rects that frame draws; the
|
||||
/// handlers themselves never lay the figure out.
|
||||
///
|
||||
/// A driver that injects events without running the loop -- a scripted run, the
|
||||
/// headless tests -- calls this after re-configuring the figure (a new viewport,
|
||||
/// a new row gap, `set_figure()`): it is the scripted equivalent of the frame the
|
||||
/// run loop would have staged. Until it runs, the handlers answer from the last
|
||||
/// refreshed rects, which is also what happens across a window resize: GLFW
|
||||
/// delivers the resize and any pointer events queued behind it in one batch, the
|
||||
/// events before the resize see the rows the previous frame drew, and the loop's
|
||||
/// next `refresh_row_rects()` brings the cache onto the new geometry -- rows are
|
||||
/// at most one frame stale, never wrong by a second layout.
|
||||
void refresh_row_rects() noexcept;
|
||||
|
||||
/// The gesture controller the host feeds. Valid for the host's whole life.
|
||||
input::Controller& controller() noexcept { return controller_; }
|
||||
|
||||
@@ -198,11 +224,12 @@ class Host {
|
||||
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;
|
||||
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
|
||||
/// every motion and the release until *that 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, input::Button button, 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
|
||||
@@ -254,11 +281,23 @@ class Host {
|
||||
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.
|
||||
/// The row a button-down drag has captured, and the button that owns the capture:
|
||||
/// the row its press landed in owns that button's motions and its release until
|
||||
/// the button comes up. `captured_` is false between gestures, and cleared when
|
||||
/// another figure is set. The button is recorded because only the owning button's
|
||||
/// release ends the capture (R5): a second button pressed while the drag runs is
|
||||
/// routed to the same row and must neither steal the capture nor end it, or its
|
||||
/// release would strand the first button's drag.
|
||||
bool captured_ = false;
|
||||
u32 captured_row_ = 0u;
|
||||
input::Button captured_button_ = input::Button::Left;
|
||||
|
||||
/// The figure's row rects as of the last `refresh_row_rects()` -- the cache the
|
||||
/// event routing reads instead of laying the figure out per event (R7). Filled by
|
||||
/// `set_figure()` and once per frame by the run loop; empty while no figure is
|
||||
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
|
||||
/// single-plot controller on.
|
||||
std::vector<Rect> row_rects_;
|
||||
|
||||
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
|
||||
u32 fb_height_ = 0;
|
||||
|
||||
@@ -229,4 +229,115 @@ inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept {
|
||||
return -dy;
|
||||
}
|
||||
|
||||
/// The keyvals this adapter maps, as GDK's own numbers, mirrored here so the
|
||||
/// mapping below -- and a test that drives it -- needs no GTK include directory.
|
||||
/// `gtk_plot_area.cpp` static-asserts each against GDK's own macro.
|
||||
///
|
||||
/// These are the *function* keyvals (the `0xFF00` page), fixed by X11's keysym
|
||||
/// convention GDK inherits; a keypad arrow (a keypad key with Num Lock off) is a
|
||||
/// different keyval and deliberately unmapped, exactly as the GLFW adapter maps
|
||||
/// the main-row arrows and not `GLFW_KEY_KP_4`.
|
||||
constexpr u32 kGdkKeyEscape = 0xFF1Bu;
|
||||
constexpr u32 kGdkKeyLeft = 0xFF51u;
|
||||
constexpr u32 kGdkKeyUp = 0xFF52u;
|
||||
constexpr u32 kGdkKeyRight = 0xFF53u;
|
||||
constexpr u32 kGdkKeyDown = 0xFF54u;
|
||||
|
||||
/// One key-press translation, by keyval first and Unicode fallback second.
|
||||
///
|
||||
/// GTK names a key by its keyval, which is layout- and level-dependent: `+` is
|
||||
/// usually shift+`=` and arrives as a different keyval, but both decode to the
|
||||
/// same Unicode code point, which is why the printable keys go through
|
||||
/// `gdk_keyval_to_unicode()`. The function keys decode to no code point at all,
|
||||
/// so they are matched on the keyval itself, before that fallback. `0` means "no
|
||||
/// plot key": the caller returns the event to GTK unhandled.
|
||||
///
|
||||
/// The keypad's plus and minus decode to the same code points as the main row's,
|
||||
/// so they come along for free -- the same set the GLFW adapter maps with
|
||||
/// `GLFW_KEY_KP_ADD`/`GLFW_KEY_KP_SUBTRACT`.
|
||||
inline int key_from(u32 keyval, u32 unicode) noexcept {
|
||||
switch (keyval) {
|
||||
case kGdkKeyEscape: return input::kKeyEscape;
|
||||
case kGdkKeyLeft: return input::kKeyLeft;
|
||||
case kGdkKeyRight: return input::kKeyRight;
|
||||
case kGdkKeyUp: return input::kKeyUp;
|
||||
case kGdkKeyDown: return input::kKeyDown;
|
||||
default: break;
|
||||
}
|
||||
switch (unicode) {
|
||||
case static_cast<u32>(input::kKeyZoomIn):
|
||||
case static_cast<u32>('='): return input::kKeyZoomIn;
|
||||
case static_cast<u32>(input::kKeyZoomOut):
|
||||
case static_cast<u32>('_'): return input::kKeyZoomOut;
|
||||
case static_cast<u32>(input::kKeyFit):
|
||||
case static_cast<u32>(input::kKeyFitUpper): return input::kKeyFit;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The state one `GtkGestureZoom` is folded into `input::GestureEvent`s from.
|
||||
///
|
||||
/// `GtkGestureZoom` reports a **cumulative** scale (`gtk_gesture_zoom_get_scale_delta()`
|
||||
/// and the `scale-changed` argument are the zoom since the gesture began, 1.0 at
|
||||
/// the start) and knows the focal point only as the bounding box of the pointer
|
||||
/// the gesture holds (`gtk_gesture_bounding_box_center()`), also cumulative in the
|
||||
/// sense that it is where the fingers are *now*. The controller wants the
|
||||
/// increments -- `GestureEvent::scale` is this frame's zoom factor about the focal
|
||||
/// point, 1.0 = no change, and `(dx, dy)` the focal point's movement since the
|
||||
/// previous gesture event -- so the area owns one of these and folds each
|
||||
/// `scale-changed` exactly like the Qt adapter folds its pinch (`PinchTracker`).
|
||||
///
|
||||
/// The focal point is cached in **logical** widget pixels, like `DragAnchor` above,
|
||||
/// for the same reason: the mapping's factor is re-derived on every `::resize`,
|
||||
/// and a cache in device pixels would compare two points taken at different
|
||||
/// factors. The difference of two logical points, scaled by the factor in force
|
||||
/// now, is the movement the fingers actually made.
|
||||
///
|
||||
/// A plain value with no toolkit dependency, like every other type here: the area
|
||||
/// owns one, and the tests drive it directly.
|
||||
struct ZoomAnchor {
|
||||
bool active = false; ///< true between the gesture's begin and its end
|
||||
f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded
|
||||
f32 x = 0.0f; ///< last focal point, logical widget pixels
|
||||
f32 y = 0.0f;
|
||||
|
||||
/// Starts a gesture at its first cumulative `scale` and logical focal point.
|
||||
/// Records only: a zoom that just began has zoomed and moved nothing yet.
|
||||
void begin(f32 scale, Vec2 logical_center) noexcept {
|
||||
active = true;
|
||||
last_scale = scale;
|
||||
x = logical_center.x;
|
||||
y = logical_center.y;
|
||||
}
|
||||
|
||||
/// Folds one `scale-changed` into an incremental `GestureEvent`, in device
|
||||
/// pixels at `mapping`: the zoom about the focal point, and the focal point's
|
||||
/// movement since the previous update. A no-op before `begin()`, so a
|
||||
/// `scale-changed` that arrives without a begin -- a host that missed the start
|
||||
/// -- zooms nothing rather than zooming from a made-up anchor.
|
||||
input::GestureEvent update(f32 scale, Vec2 logical_center,
|
||||
const DeviceMapping& mapping) noexcept {
|
||||
input::GestureEvent g;
|
||||
if (!active) return g;
|
||||
// This frame's zoom: the cumulative ratio over the one last folded. The first
|
||||
// update after begin() divides the scale by itself -- 1.0, no zoom, as it
|
||||
// should be when the fingers have not moved apart yet.
|
||||
g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f;
|
||||
const Vec2 device = mapping.to_device(logical_center.x, logical_center.y);
|
||||
g.x = device.x;
|
||||
g.y = device.y;
|
||||
const Vec2 moved{logical_center.x - x, logical_center.y - y};
|
||||
const Vec2 device_moved = mapping.to_device(moved.x, moved.y);
|
||||
g.dx = device_moved.x;
|
||||
g.dy = device_moved.y;
|
||||
last_scale = scale;
|
||||
x = logical_center.x;
|
||||
y = logical_center.y;
|
||||
return g;
|
||||
}
|
||||
|
||||
/// Ends the gesture: a straggler update after this zooms nothing.
|
||||
void end() noexcept { active = false; }
|
||||
};
|
||||
|
||||
} // namespace plot::gtk4
|
||||
@@ -31,6 +31,11 @@ static_assert((1u << 2) == GDK_CONTROL_MASK, "mods_from's control bit must match
|
||||
static_assert((1u << 3) == GDK_ALT_MASK, "mods_from's alt bit must match GDK's");
|
||||
static_assert((1u << 26) == GDK_SUPER_MASK, "mods_from's super bit must match GDK's");
|
||||
static_assert((1u << 28) == GDK_META_MASK, "mods_from's meta bit must match GDK's");
|
||||
static_assert(plot::gtk4::kGdkKeyEscape == GDK_KEY_Escape, "the mirrored Escape keyval must match GDK's");
|
||||
static_assert(plot::gtk4::kGdkKeyLeft == GDK_KEY_Left, "the mirrored Left keyval must match GDK's");
|
||||
static_assert(plot::gtk4::kGdkKeyUp == GDK_KEY_Up, "the mirrored Up keyval must match GDK's");
|
||||
static_assert(plot::gtk4::kGdkKeyRight == GDK_KEY_Right, "the mirrored Right keyval must match GDK's");
|
||||
static_assert(plot::gtk4::kGdkKeyDown == GDK_KEY_Down, "the mirrored Down keyval must match GDK's");
|
||||
|
||||
namespace plot::gtk4 {
|
||||
namespace {
|
||||
@@ -158,6 +163,18 @@ PlotArea* PlotArea::create(Plot& plot) noexcept {
|
||||
g_signal_connect(drag, "drag-end", G_CALLBACK(&PlotArea::on_drag_end), self);
|
||||
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(drag));
|
||||
|
||||
// The two-finger pinch: `scale-changed` carries the cumulative zoom since the
|
||||
// gesture began, and the gesture's bounding-box centre is the focal point -- the
|
||||
// pair `ZoomAnchor` folds into the incremental `GestureEvent` the controller
|
||||
// takes (zoom about the focal point, focal movement as the pan). No button:
|
||||
// unlike the click/drag pair this gesture is touch-only by nature, and a mouse
|
||||
// never produces one.
|
||||
GtkGesture* zoom = gtk_gesture_zoom_new();
|
||||
g_signal_connect(zoom, "begin", G_CALLBACK(&PlotArea::on_zoom_begin), self);
|
||||
g_signal_connect(zoom, "scale-changed", G_CALLBACK(&PlotArea::on_zoom_scale_changed), self);
|
||||
g_signal_connect(zoom, "end", G_CALLBACK(&PlotArea::on_zoom_end), self);
|
||||
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(zoom));
|
||||
|
||||
// Vertical only, as the plan asks: a purely horizontal scroll is not a zoom, and
|
||||
// because this controller never sees one it cannot be mistaken for one. GTK
|
||||
// reports the unit per event, so a discrete wheel arrives in notches and a
|
||||
@@ -279,6 +296,7 @@ void PlotArea::unrealize() noexcept {
|
||||
renderer_.reset();
|
||||
press_open_ = false;
|
||||
drag_.have = false;
|
||||
zoom_.end();
|
||||
}
|
||||
|
||||
void PlotArea::render() noexcept {
|
||||
@@ -364,7 +382,8 @@ void PlotArea::on_resize(GtkGLArea*, int width, int height, void* self) noexcept
|
||||
|
||||
// --- pointer input --------------------------------------------------------
|
||||
|
||||
void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y) noexcept {
|
||||
void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
|
||||
u32 time_ms) noexcept {
|
||||
if (press_open_) return; // one press per button-down sequence: see drag_begin
|
||||
press_open_ = true;
|
||||
press_button_ = button;
|
||||
@@ -383,11 +402,12 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y)
|
||||
e.mods = mods;
|
||||
e.down = true;
|
||||
e.pressed = true;
|
||||
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
|
||||
controller_.on_pointer(e);
|
||||
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
|
||||
}
|
||||
|
||||
void PlotArea::pointer_release(Vec2 position) noexcept {
|
||||
void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept {
|
||||
if (!press_open_) return;
|
||||
press_open_ = false;
|
||||
|
||||
@@ -400,6 +420,7 @@ void PlotArea::pointer_release(Vec2 position) noexcept {
|
||||
e.mods = press_mods_;
|
||||
e.down = false;
|
||||
e.released = true;
|
||||
e.time_s = pointer_time_s(time_ms);
|
||||
controller_.on_pointer(e);
|
||||
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
|
||||
}
|
||||
@@ -412,7 +433,8 @@ void PlotArea::on_click_pressed(GtkGestureClick* gesture, int, double x, double
|
||||
if (!button) return; // a button the controller has no gesture for
|
||||
// The modifiers come off the *event*, not the gesture: GTK reports the state as
|
||||
// it was before the press, which is what the controller's gesture table reads.
|
||||
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y);
|
||||
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y,
|
||||
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
||||
}
|
||||
|
||||
void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double y,
|
||||
@@ -421,11 +443,11 @@ void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double
|
||||
auto* area = static_cast<PlotArea*>(self);
|
||||
// The first release closes the gesture; a drag's own `drag-end` may already have
|
||||
// done it (GTK emits both, `drag-end` first), and this is then a no-op.
|
||||
area->pointer_release(area->to_device(x, y));
|
||||
area->pointer_release(area->to_device(x, y),
|
||||
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
||||
}
|
||||
|
||||
void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
|
||||
(void)gesture;
|
||||
auto* area = static_cast<PlotArea*>(self);
|
||||
// A sequence can be cancelled rather than released -- GTK stops the click
|
||||
// gesture as soon as the drag gesture claims the same sequence, which happens on
|
||||
@@ -434,7 +456,11 @@ void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
|
||||
// drag in progress (an exotic cancellation by another widget or a device) closes
|
||||
// the press, which it has to, or the controller would stay in whatever mode the
|
||||
// press began.
|
||||
if (area->press_open_ && !area->drag_.have) area->pointer_release(area->pointer_);
|
||||
if (area->press_open_ && !area->drag_.have) {
|
||||
area->pointer_release(
|
||||
area->pointer_,
|
||||
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
||||
}
|
||||
}
|
||||
|
||||
void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
|
||||
@@ -453,7 +479,8 @@ void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double sta
|
||||
const std::optional<input::Button> button =
|
||||
button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)));
|
||||
if (button) {
|
||||
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y);
|
||||
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y,
|
||||
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,13 +509,14 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o
|
||||
e.button = area->press_button_;
|
||||
e.mods = area->press_mods_;
|
||||
e.down = true;
|
||||
e.time_s = area->pointer_time_s(
|
||||
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
||||
area->controller_.on_pointer(e);
|
||||
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
||||
}
|
||||
|
||||
void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y,
|
||||
void* self) noexcept {
|
||||
(void)gesture;
|
||||
auto* area = static_cast<PlotArea*>(self);
|
||||
// The release carries the authoritative end of the gesture: the controller reads
|
||||
// its position to finish a box zoom's selection, so this is the press point plus
|
||||
@@ -496,7 +524,46 @@ void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offs
|
||||
// and not the last update's position.
|
||||
const Vec2 position = area->drag_.end(offset_x, offset_y, area->mapping_);
|
||||
area->pointer_ = position;
|
||||
area->pointer_release(position);
|
||||
area->pointer_release(
|
||||
position, gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
||||
}
|
||||
|
||||
// --- the pinch ------------------------------------------------------------
|
||||
|
||||
void PlotArea::on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence*, void* self) noexcept {
|
||||
auto* area = static_cast<PlotArea*>(self);
|
||||
// The focal point is the centre of the bounding box around the pointers the
|
||||
// gesture holds, in logical widget pixels -- the same space every other
|
||||
// gesture coordinate here arrives in, and what `to_device()` maps.
|
||||
double cx = 0.0;
|
||||
double cy = 0.0;
|
||||
gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy);
|
||||
area->zoom_.begin(static_cast<f32>(gtk_gesture_zoom_get_scale_delta(gesture)),
|
||||
Vec2{static_cast<f32>(cx), static_cast<f32>(cy)});
|
||||
}
|
||||
|
||||
void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
|
||||
void* self) noexcept {
|
||||
auto* area = static_cast<PlotArea*>(self);
|
||||
double cx = 0.0;
|
||||
double cy = 0.0;
|
||||
gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy);
|
||||
// The `scale` argument and `gtk_gesture_zoom_get_scale_delta()` are the same
|
||||
// cumulative number -- the zoom since `::begin` -- so the folded event is this
|
||||
// *frame's* zoom factor about the fingers' centre, plus the centre's movement,
|
||||
// which is the two-finger pan. An update without a begin folds to a no-op (see
|
||||
// `ZoomAnchor::update`), so a gesture whose start the host never saw zooms
|
||||
// nothing rather than zooming from a made-up anchor.
|
||||
const input::GestureEvent g = area->zoom_.update(
|
||||
static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_);
|
||||
area->controller_.on_gesture(g);
|
||||
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
||||
}
|
||||
|
||||
void PlotArea::on_zoom_end(GtkGestureZoom*, GdkEventSequence*, void* self) noexcept {
|
||||
// No trailing event: the last `scale-changed` carried the gesture's final zoom,
|
||||
// and `end` only closes the folding so a straggler update zooms nothing.
|
||||
static_cast<PlotArea*>(self)->zoom_.end();
|
||||
}
|
||||
|
||||
int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
|
||||
@@ -550,6 +617,8 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v
|
||||
e.dx = 0.0f;
|
||||
e.dy = 0.0f;
|
||||
e.down = false;
|
||||
e.time_s = PlotArea::pointer_time_s(
|
||||
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
|
||||
area->controller_.on_pointer(e);
|
||||
}
|
||||
|
||||
@@ -560,20 +629,14 @@ int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval,
|
||||
(void)keycode;
|
||||
(void)keys;
|
||||
auto* area = static_cast<PlotArea*>(self);
|
||||
// The keys are named by their *symbol*: GTK reports a layout's keyval, and the
|
||||
// keypad's plus and minus are the same symbol as the main row's. `'='` is mapped
|
||||
// with `'+'` so both spellings of "zoom in" work (on most layouts `+` *is*
|
||||
// shift+`=`), the pair the GLFW and Qt adapters map.
|
||||
int key = 0;
|
||||
switch (gdk_keyval_to_unicode(keyval)) {
|
||||
case static_cast<guint32>(input::kKeyZoomIn):
|
||||
case static_cast<guint32>('='): key = input::kKeyZoomIn; break;
|
||||
case static_cast<guint32>(input::kKeyZoomOut):
|
||||
case static_cast<guint32>('_'): key = input::kKeyZoomOut; break;
|
||||
case static_cast<guint32>(input::kKeyFit):
|
||||
case static_cast<guint32>(input::kKeyFitUpper): key = input::kKeyFit; break;
|
||||
default: return FALSE; // not a plot key: GTK and the window keep it
|
||||
}
|
||||
// The whole translation lives in gtk_input.hpp (see `key_from`): the function
|
||||
// keys -- arrows, Escape -- are matched on the *keyval*, which is layout- and
|
||||
// level-independent for them, and the printable ones on the Unicode they decode
|
||||
// to, which is what makes `+` and shift+`=` (usually the same physical key) both
|
||||
// zoom in, and the keypad's plus and minus come along for free. `0` is "no plot
|
||||
// key": GTK and the window keep it.
|
||||
const int key = key_from(static_cast<u32>(keyval), static_cast<u32>(gdk_keyval_to_unicode(keyval)));
|
||||
if (key == 0) return FALSE;
|
||||
|
||||
input::KeyEvent e;
|
||||
e.key = key;
|
||||
|
||||
@@ -32,10 +32,12 @@ typedef struct _GtkGLArea GtkGLArea;
|
||||
typedef struct _GtkGesture GtkGesture;
|
||||
typedef struct _GtkGestureClick GtkGestureClick;
|
||||
typedef struct _GtkGestureDrag GtkGestureDrag;
|
||||
typedef struct _GtkGestureZoom GtkGestureZoom;
|
||||
typedef struct _GtkEventControllerScroll GtkEventControllerScroll;
|
||||
typedef struct _GtkEventControllerKey GtkEventControllerKey;
|
||||
typedef struct _GtkEventControllerMotion GtkEventControllerMotion;
|
||||
typedef struct _GdkGLContext GdkGLContext;
|
||||
typedef struct _GdkEventSequence GdkEventSequence;
|
||||
|
||||
namespace plot::gtk4 {
|
||||
|
||||
@@ -134,6 +136,10 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
|
||||
/// release, with the button and the modifier state, exactly as the Qt
|
||||
/// widget's `mousePressEvent`/`mouseReleaseEvent` do. It is also what begins
|
||||
/// a cursor move: a ctrl+press on a cursor opens `DragMode::MoveCursor` there.
|
||||
/// * `GtkGestureZoom` supplies the two-finger pinch: each `scale-changed` is
|
||||
/// folded by `ZoomAnchor` (`gtk_input.hpp`) into one `GestureEvent` -- this
|
||||
/// frame's zoom about the fingers' centre, and the centre's movement, which is
|
||||
/// the two-finger pan a trackpad produces alongside it.
|
||||
/// * `GtkEventControllerMotion` supplies hover (the cursor hit test reads the
|
||||
/// last position even with no button down). Its positions are forwarded with
|
||||
/// `down == false` on purpose: a motion event that claimed a button was down
|
||||
@@ -271,9 +277,16 @@ class PlotArea {
|
||||
void refresh_viewport() noexcept;
|
||||
|
||||
/// One pointer event, from whichever gesture saw it first. Owns the
|
||||
/// press/release bookkeeping the two gestures share.
|
||||
void pointer_press(input::Button button, u32 mods, double x, double y) noexcept;
|
||||
void pointer_release(Vec2 position) noexcept;
|
||||
/// press/release bookkeeping the two gestures share. `time_ms` is the event's
|
||||
/// own GDK stamp, in milliseconds, forwarded as the `PointerEvent::time_s` the
|
||||
/// double-click detector differences (see `pointer_time_s()`).
|
||||
void pointer_press(input::Button button, u32 mods, double x, double y,
|
||||
u32 time_ms) noexcept;
|
||||
void pointer_release(Vec2 position, u32 time_ms) noexcept;
|
||||
/// The gesture event's stamp, in the seconds `PointerEvent::time_s` speaks: GDK
|
||||
/// counts milliseconds since an arbitrary epoch, and only differences are read,
|
||||
/// so the epoch does not matter -- the unit does.
|
||||
static f64 pointer_time_s(u32 time_ms) noexcept { return static_cast<f64>(time_ms) * 1.0e-3; }
|
||||
/// Translates the gesture-reported position/delta in *logical* widget pixels
|
||||
/// into device pixels. The one place that mapping exists.
|
||||
Vec2 to_device(double x, double y) const noexcept;
|
||||
@@ -289,6 +302,15 @@ class PlotArea {
|
||||
static void on_click_released(GtkGestureClick* gesture, int n_press, double x, double y,
|
||||
void* self) noexcept;
|
||||
static void on_click_stopped(GtkGestureClick* gesture, void* self) noexcept;
|
||||
/// The pinch: `begin` anchors the folding, `scale-changed` folds one cumulative
|
||||
/// scale into an incremental `GestureEvent`, and `end` closes it. `begin`/`end`
|
||||
/// carry the sequence, which a one-active-pinch adapter never needs.
|
||||
static void on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence* sequence,
|
||||
void* self) noexcept;
|
||||
static void on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
|
||||
void* self) noexcept;
|
||||
static void on_zoom_end(GtkGestureZoom* gesture, GdkEventSequence* sequence,
|
||||
void* self) noexcept;
|
||||
static void on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
|
||||
void* self) noexcept;
|
||||
static void on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y,
|
||||
@@ -324,6 +346,9 @@ class PlotArea {
|
||||
/// cached rather than re-read per event, so a `drag-update` reports the movement
|
||||
/// since the previous one, and `have` is whether a drag is recognised.
|
||||
DragAnchor drag_;
|
||||
/// The pinch gesture's folding state (see `ZoomAnchor`): one per area, alive
|
||||
/// between `::begin` and `::end` of the one zoom the gesture recognises.
|
||||
ZoomAnchor zoom_;
|
||||
/// The last pointer position seen, in device pixels: the wheel's anchor when a
|
||||
/// scroll event carries no position of its own.
|
||||
Vec2 pointer_{};
|
||||
|
||||
@@ -201,6 +201,11 @@ void PlotWidget::forward_input(bool hovered) {
|
||||
e.down = any_down;
|
||||
e.pressed = pressed;
|
||||
e.released = released;
|
||||
// ImGui's own clock: monotonic seconds since the context was created. The
|
||||
// double-click detector differences two presses' stamps, so only the unit
|
||||
// matters -- and ImGui's backend-driven time keeps running while the pointer
|
||||
// is off-window, which is exactly what a wall clock should do.
|
||||
e.time_s = ImGui::GetTime();
|
||||
controller_->on_pointer(e);
|
||||
};
|
||||
|
||||
@@ -231,6 +236,15 @@ void PlotWidget::forward_input(bool hovered) {
|
||||
// Keyboard gestures, on the same hover gate as the pointer: a key pressed
|
||||
// while the plot is under the pointer is aimed at the plot. `+` on most
|
||||
// layouts is shift+`=`, and either spelling zooms in, as in the GLFW adapter.
|
||||
//
|
||||
// Every mapped key is asked with repeat = true. This is the adapter's whole key
|
||||
// -repeat policy, and the odd one out among the hosts: GLFW, Qt and GTK each
|
||||
// deliver the OS's auto-repeat as extra press events, while ImGui edge-detects
|
||||
// presses once and only re-fires `IsKeyPressed` while the key is *held* if it is
|
||||
// asked to. Holding `+` or an arrow on a plot is a zoom/pan the user expects to
|
||||
// keep going (docs/embedding.md's shared policy: a held key repeats in every
|
||||
// adapter), so the repeat is requested here. Escape repeats harmlessly --
|
||||
// cancelling an already-cancelled drag is a no-op.
|
||||
if (hovered && !io.WantTextInput) {
|
||||
const auto key = [&](int code) {
|
||||
input::KeyEvent e;
|
||||
@@ -239,15 +253,15 @@ void PlotWidget::forward_input(bool hovered) {
|
||||
e.down = true; // ImGui already edge-detected the press
|
||||
controller_->on_key(e);
|
||||
};
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Equal, false) ||
|
||||
ImGui::IsKeyPressed(ImGuiKey_KeypadAdd, false)) {
|
||||
key(input::kKeyZoomIn);
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Minus, false) ||
|
||||
ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract, false)) {
|
||||
key(input::kKeyZoomOut);
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_F, false)) key(input::kKeyFit);
|
||||
const auto pressed = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, true); };
|
||||
if (pressed(ImGuiKey_Equal) || pressed(ImGuiKey_KeypadAdd)) key(input::kKeyZoomIn);
|
||||
if (pressed(ImGuiKey_Minus) || pressed(ImGuiKey_KeypadSubtract)) key(input::kKeyZoomOut);
|
||||
if (pressed(ImGuiKey_F)) key(input::kKeyFit);
|
||||
if (pressed(ImGuiKey_LeftArrow)) key(input::kKeyLeft);
|
||||
if (pressed(ImGuiKey_RightArrow)) key(input::kKeyRight);
|
||||
if (pressed(ImGuiKey_UpArrow)) key(input::kKeyUp);
|
||||
if (pressed(ImGuiKey_DownArrow)) key(input::kKeyDown);
|
||||
if (pressed(ImGuiKey_Escape)) key(input::kKeyEscape);
|
||||
}
|
||||
|
||||
prev_down_[0] = down[0];
|
||||
|
||||
+105
-11
@@ -11,6 +11,9 @@
|
||||
#include <QOpenGLContext>
|
||||
#include <QPointF>
|
||||
#include <QWheelEvent>
|
||||
#include <QEvent>
|
||||
#include <QGestureEvent>
|
||||
#include <QPinchGesture>
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
@@ -93,6 +96,10 @@ PlotWidget::PlotWidget(Plot& plot, QWidget* parent)
|
||||
// The controller's keys (+, - and f) are input to the plot, and a widget without
|
||||
// a focus policy never receives a key event.
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
// Two-finger zoom/pan arrives as one gesture, which no mouse-event handler sees:
|
||||
// grabbing it routes QEvent::Gesture here, and `event()` folds it into the
|
||||
// controller's `GestureEvent`s (zoom about the focal point, focal pan).
|
||||
grabGesture(Qt::PinchGesture);
|
||||
}
|
||||
|
||||
PlotWidget::~PlotWidget() {
|
||||
@@ -243,6 +250,10 @@ void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released
|
||||
e.down = any_plot_button(event->buttons());
|
||||
e.pressed = pressed;
|
||||
e.released = released;
|
||||
// Qt's input-event stamp is milliseconds since an arbitrary epoch (the system
|
||||
// monotonic clock, in practice); the controller only ever differences them, for
|
||||
// the double-click detector, so the epoch does not matter -- the unit does.
|
||||
e.time_s = static_cast<f64>(event->timestamp()) * 1.0e-3;
|
||||
controller_.on_pointer(e);
|
||||
event->accept();
|
||||
update();
|
||||
@@ -256,16 +267,26 @@ void PlotWidget::mouseReleaseEvent(QMouseEvent* event) { forward_pointer(event,
|
||||
void PlotWidget::mouseDoubleClickEvent(QMouseEvent* event) { forward_pointer(event, true, false); }
|
||||
|
||||
void PlotWidget::wheelEvent(QWheelEvent* event) {
|
||||
// Qt reports eighths of a degree, 120 to a notch; the controller takes notches,
|
||||
// positive = zoom in, which is Qt's positive angleDelta().y() as well (the wheel
|
||||
// pushed away from the user). Passing the raw delta would zoom 120x too far.
|
||||
const f32 notches = static_cast<f32>(event->angleDelta().y()) / 120.0f;
|
||||
if (notches == 0.0f) {
|
||||
// A horizontal-only wheel is not a zoom, and it must not reach the controller:
|
||||
// a zero-notch zoom changes no range but still runs the range setters, which
|
||||
// clear auto scale -- so a stray horizontal scroll would silently stop a live
|
||||
// axis from following its stream. Ignored, so an enclosing scroll area may
|
||||
// still act on it.
|
||||
// Two units arrive, one per axis: Qt's `angleDelta()` is in eighths of a degree,
|
||||
// 120 to a notch, while `pixelDelta()` is in (high-resolution) scroll pixels and
|
||||
// is the only non-zero delta a device that reports pixels produces. A pixel
|
||||
// delta has no notch size Qt defines, so the divisor is this adapter's
|
||||
// **calibration**, not a conversion: 40 scroll pixels are taken to be one notch,
|
||||
// the order of a deliberate one- or two-finger scroll gesture, so such a gesture
|
||||
// zooms about one detent instead of a fraction of one (the same reasoning, and
|
||||
// the same role, as the GTK adapter's kSurfacePixelsPerNotch; a value measured
|
||||
// on real hardware should replace it).
|
||||
const QPoint angle = event->angleDelta();
|
||||
const QPoint pixels = event->pixelDelta();
|
||||
const bool pixel_scrolled = angle == QPoint(0, 0) && !pixels.isNull();
|
||||
const f32 notches = pixel_scrolled ? static_cast<f32>(pixels.y()) / 40.0f
|
||||
: static_cast<f32>(angle.y()) / 120.0f;
|
||||
const f32 notches_x = pixel_scrolled ? static_cast<f32>(pixels.x()) / 40.0f
|
||||
: static_cast<f32>(angle.x()) / 120.0f;
|
||||
if (notches == 0.0f && notches_x == 0.0f) {
|
||||
// No zoom and no pan: the event moves nothing, so it must not reach the
|
||||
// controller -- a zero-delta event would still run the range setters and clear
|
||||
// auto scale. Ignored, so an enclosing scroll area may still act on it.
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
@@ -273,7 +294,10 @@ void PlotWidget::wheelEvent(QWheelEvent* event) {
|
||||
input::WheelEvent e;
|
||||
e.x = p.x;
|
||||
e.y = p.y;
|
||||
e.dx = static_cast<f32>(event->angleDelta().x()) / 120.0f;
|
||||
// The horizontal delta is forwarded: the controller pans x by it (5% of the span
|
||||
// a notch) instead of zooming, which is what a sideways scroll on a trackpad
|
||||
// means -- and what every other adapter's events already let it do.
|
||||
e.dx = notches_x;
|
||||
e.dy = notches;
|
||||
e.mods = mods_from(event->modifiers());
|
||||
controller_.on_wheel(e);
|
||||
@@ -292,6 +316,16 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
|
||||
case Qt::Key_Minus:
|
||||
case Qt::Key_Underscore: mapped = input::kKeyZoomOut; break;
|
||||
case Qt::Key_F: mapped = input::kKeyFit; break;
|
||||
// Arrows pan (10% of the span per press, repeats included) and Escape cancels
|
||||
// the active drag -- translated, like the ASCII set above, and the controller
|
||||
// decides what each does. Not offered to the parent widget when handled: an
|
||||
// Escape that reaches a dialog's reject button would cancel the *window*, not
|
||||
// the plot's drag, because the widget has focus.
|
||||
case Qt::Key_Escape: mapped = input::kKeyEscape; break;
|
||||
case Qt::Key_Left: mapped = input::kKeyLeft; break;
|
||||
case Qt::Key_Right: mapped = input::kKeyRight; break;
|
||||
case Qt::Key_Up: mapped = input::kKeyUp; break;
|
||||
case Qt::Key_Down: mapped = input::kKeyDown; break;
|
||||
default:
|
||||
QOpenGLWidget::keyPressEvent(event); // not a plot key: nobody else wants it
|
||||
return;
|
||||
@@ -307,4 +341,64 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
|
||||
update();
|
||||
}
|
||||
|
||||
bool PlotWidget::event(QEvent* event) {
|
||||
if (event->type() == QEvent::DevicePixelRatioChange) {
|
||||
// The window moved to a display with another scale factor (or the scale of the
|
||||
// screen it is on changed). Everything the ratio decides -- the device
|
||||
// viewport the controller hit-tests against, and the `ui_scale` the next
|
||||
// frame's paint re-reads -- is refreshed here, before any event queued behind
|
||||
// this one maps a gesture through the old geometry. `paintGL()` would catch up
|
||||
// one frame later; a drag mid-gesture would pan by the wrong distance in
|
||||
// exactly that frame.
|
||||
refresh_viewport();
|
||||
}
|
||||
if (event->type() == QEvent::Gesture) {
|
||||
auto* gesture_event = static_cast<QGestureEvent*>(event);
|
||||
// The widget grabbed Qt::PinchGesture, so its pinch events arrive here; a
|
||||
// gesture this widget does not know is left for whoever else may want it.
|
||||
if (QPinchGesture* pinch =
|
||||
static_cast<QPinchGesture*>(gesture_event->gesture(Qt::PinchGesture));
|
||||
pinch != nullptr) {
|
||||
forward_pinch(pinch, gesture_event);
|
||||
}
|
||||
}
|
||||
return QOpenGLWidget::event(event);
|
||||
}
|
||||
|
||||
void PlotWidget::forward_pinch(QPinchGesture* gesture, QGestureEvent* event) {
|
||||
// The gesture reports cumulative values; `PinchTracker` folds them into the
|
||||
// incremental zoom and focal movement the controller's `GestureEvent` wants.
|
||||
// Positions are logical widget pixels here, exactly like a mouse event's, and
|
||||
// go through the same `devicePixelRatioF()` mapping `to_device()` applies.
|
||||
const QPointF logical_center = gesture->centerPoint();
|
||||
const f64 dpr = static_cast<f64>(devicePixelRatioF());
|
||||
const Vec2 logical{static_cast<f32>(logical_center.x()), static_cast<f32>(logical_center.y())};
|
||||
|
||||
switch (gesture->state()) {
|
||||
case Qt::GestureStarted:
|
||||
// The begin records the anchor only: a pinch that just started has zoomed
|
||||
// and moved nothing yet.
|
||||
pinch_.begin(static_cast<f32>(gesture->totalScaleFactor()), logical);
|
||||
break;
|
||||
case Qt::GestureUpdated:
|
||||
case Qt::GestureFinished: {
|
||||
// The finished state's last update is the gesture's final zoom: folded like
|
||||
// an update, so nothing the fingers did is dropped for ending.
|
||||
const input::GestureEvent g =
|
||||
pinch_.update(static_cast<f32>(gesture->totalScaleFactor()), logical, dpr);
|
||||
controller_.on_gesture(g);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Canceled: the fingers left the surface mid-gesture. No event -- whatever
|
||||
// zoom the updates delivered stands, and the next pinch begins fresh.
|
||||
break;
|
||||
}
|
||||
if (gesture->state() == Qt::GestureFinished || gesture->state() == Qt::GestureCanceled) {
|
||||
pinch_.end();
|
||||
}
|
||||
event->accept(gesture); // consumed: the plot zoomed/panned, no parent gets it too
|
||||
update();
|
||||
}
|
||||
|
||||
} // namespace plot::qt
|
||||
@@ -34,6 +34,9 @@ class QKeyEvent;
|
||||
class QMouseEvent;
|
||||
class QPointF;
|
||||
class QWheelEvent;
|
||||
class QEvent;
|
||||
class QGestureEvent;
|
||||
class QPinchGesture;
|
||||
|
||||
namespace plot::qt {
|
||||
|
||||
@@ -106,10 +109,15 @@ namespace plot::qt {
|
||||
/// reinterpretation rather than the edge synthesis the ImGui adapter needs.
|
||||
/// Wheel input is the one unit conversion: Qt's `angleDelta()` is in eighths of a
|
||||
/// degree, 120 to a notch, while `Controller::on_wheel()` takes notches, positive
|
||||
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event with
|
||||
/// no vertical component is refused before it reaches the controller, because a
|
||||
/// zero-notch zoom changes no range but still runs the range setters, which clear
|
||||
/// auto scale: a horizontal scroll must not silently stop a live axis following.
|
||||
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event whose
|
||||
/// deltas both vanish -- no angle and no pixels -- is refused before it reaches the
|
||||
/// controller, because a zero-notch, zero-pan event changes nothing and must not
|
||||
/// clear auto scale; anything with a vertical *or* horizontal component is
|
||||
/// forwarded, and the controller decides what each does (zoom on `dy`, pan on
|
||||
/// `dx`). The widget is also a pinch source: `Qt::PinchGesture` is grabbed in the
|
||||
/// constructor and folded into `input::GestureEvent`s by `PinchTracker`
|
||||
/// (`qt_pointer.hpp`), so a two-finger zoom-about-focal-point and a two-finger pan
|
||||
/// reach the controller like the equivalent wheel + drag pair would.
|
||||
///
|
||||
/// ## Threading
|
||||
///
|
||||
@@ -170,6 +178,12 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
|
||||
void initializeGL() override;
|
||||
void resizeGL(int w, int h) override;
|
||||
void paintGL() override;
|
||||
/// The one virtual that sees what no dedicated handler does: the device pixel
|
||||
/// ratio change (`QEvent::DevicePixelRatioChange`) and the grabbed pinch
|
||||
/// (`QEvent::Gesture`). Both refresh or feed the controller before the next
|
||||
/// paint, so a gesture arriving between the change and the frame maps against
|
||||
/// the geometry the ratio now implies.
|
||||
bool event(QEvent* event) override;
|
||||
|
||||
void mousePressEvent(QMouseEvent* event) override;
|
||||
void mouseMoveEvent(QMouseEvent* event) override;
|
||||
@@ -196,6 +210,10 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
|
||||
/// Sets the plot's viewport to the current device rect (through the controller,
|
||||
/// which refreshes the layout with it).
|
||||
void refresh_viewport();
|
||||
/// Folds one pinch update into a `GestureEvent` through `PinchTracker` and hands
|
||||
/// it to the controller. `event` is accepted for the pinch gesture, so Qt does
|
||||
/// not offer it to parents too.
|
||||
void forward_pinch(QPinchGesture* gesture, QGestureEvent* event);
|
||||
|
||||
Plot* plot_ = nullptr; ///< declared first: the controller is built over it
|
||||
input::Controller controller_;
|
||||
@@ -207,6 +225,9 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
|
||||
/// reports no delta of its own, and the ratio can change between two events
|
||||
/// (see `PointerAnchor` for why the anchor must not be device pixels).
|
||||
PointerAnchor anchor_;
|
||||
/// The pinch gesture's folding state (see `PinchTracker`): one per widget, alive
|
||||
/// between `Qt::GestureStarted` and `Finished`/`Canceled`.
|
||||
PinchTracker pinch_;
|
||||
|
||||
u64 frames_ = 0;
|
||||
f32 last_frame_ms_ = 0.0f;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/input/events.hpp>
|
||||
#include <plot/math/vec2.hpp>
|
||||
|
||||
namespace plot::qt {
|
||||
@@ -49,4 +50,69 @@ struct PointerAnchor {
|
||||
}
|
||||
};
|
||||
|
||||
/// The state one pinch gesture is folded into `input::GestureEvent`s from.
|
||||
///
|
||||
/// Qt reports a pinch as **cumulative** values with no delta of its own:
|
||||
/// `QPinchGesture::totalScaleFactor()` is the zoom since the gesture began, and
|
||||
/// `centerPoint()` the focal point now, both absolute in the gesture's life. The
|
||||
/// controller wants the opposite of both -- `GestureEvent::scale` is *this frame's*
|
||||
/// zoom factor (1.0 = no change) and `(dx, dy)` the focal point's movement since the
|
||||
/// previous gesture event -- so the widget owns one of these and folds each update:
|
||||
/// the zoom arrives as the ratio of the two cumulative scales, the pan as the
|
||||
/// difference of two focal points.
|
||||
///
|
||||
/// The focal point is cached in **logical** widget pixels, like `PointerAnchor`
|
||||
/// above, for the same reason: `devicePixelRatioF()` can change mid-gesture (the
|
||||
/// window dragged onto another display), and a cache in device pixels would compare
|
||||
/// a point taken at the old ratio with one taken at the new one -- a phantom zoom
|
||||
/// anchor movement, delivered to the controller as a pan. The difference of two
|
||||
/// logical points, scaled by the ratio in force now, is the movement the fingers
|
||||
/// actually made.
|
||||
///
|
||||
/// A plain value with no toolkit dependency: the widget owns one, and this is the
|
||||
/// part of the adapter's pinch translation a test can drive directly (see
|
||||
/// tests/test_qt_pointer.cpp).
|
||||
struct PinchTracker {
|
||||
bool active = false; ///< true between the gesture's begin and its end/cancel
|
||||
f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded
|
||||
f32 x = 0.0f; ///< last focal point, logical widget pixels
|
||||
f32 y = 0.0f;
|
||||
|
||||
/// Starts a gesture at Qt's first cumulative `scale` and logical focal point.
|
||||
/// The first event must carry no zoom and no movement -- a pinch that begins has
|
||||
/// zoomed nothing yet -- so this only records where the folding starts from.
|
||||
void begin(f32 scale, Vec2 logical_center) noexcept {
|
||||
active = true;
|
||||
last_scale = scale;
|
||||
x = logical_center.x;
|
||||
y = logical_center.y;
|
||||
}
|
||||
|
||||
/// Folds one update into an incremental `GestureEvent`, in device pixels at
|
||||
/// `dpr`: the zoom about the focal point, and the focal point's movement since
|
||||
/// the previous update. Zero zoom and zero movement before `begin()`, so an
|
||||
/// update that arrives without a begin -- a host that missed the start -- is a
|
||||
/// no-op rather than a zoom from a made-up anchor.
|
||||
input::GestureEvent update(f32 scale, Vec2 logical_center, f64 dpr) noexcept {
|
||||
input::GestureEvent g;
|
||||
if (!active) return g;
|
||||
// The frame's zoom factor: Qt's cumulative ratio over the one this tracker
|
||||
// last folded. The first update after begin() divides the scale by itself,
|
||||
// which is 1.0 -- a pinch that has not moved has zoomed nothing.
|
||||
g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f;
|
||||
g.x = static_cast<f32>(static_cast<f64>(logical_center.x) * dpr);
|
||||
g.y = static_cast<f32>(static_cast<f64>(logical_center.y) * dpr);
|
||||
g.dx = static_cast<f32>((static_cast<f64>(logical_center.x) - static_cast<f64>(x)) * dpr);
|
||||
g.dy = static_cast<f32>((static_cast<f64>(logical_center.y) - static_cast<f64>(y)) * dpr);
|
||||
last_scale = scale;
|
||||
x = logical_center.x;
|
||||
y = logical_center.y;
|
||||
return g;
|
||||
}
|
||||
|
||||
/// Ends the gesture: an update after this (a straggler `GestureUpdated` Qt
|
||||
/// delivers out of order, a cancel that still reports) is again a no-op.
|
||||
void end() noexcept { active = false; }
|
||||
};
|
||||
|
||||
} // namespace plot::qt
|
||||
Reference in new issue
Block a user