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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user