Fixing minor issues in multifigure plots
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

This commit is contained in:
Martino Ferrari committed 2026-10-01 15:32:05 +02:00
1 parent 38ef459897
commit 98cfc5805b
4 files changed
+394 -14

No files matched your search

+136 -1
View File
@@ -4,14 +4,17 @@
#pragma once
#include <functional>
#include <memory>
#include <optional>
#include <utility>
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/input/controller.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
@@ -171,6 +174,42 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
/// visible without waiting for the host's next frame, and the frame rate stays the
/// host's: the demo pumps `g_timeout_add(16, gtk_gl_area_queue_render)`.
///
/// ## Figure mode
///
/// `set_figure()` makes the area draw a whole `Figure` -- several rows sharing one
/// x axis -- instead of its own `Plot`, exactly as `glfw::Host::set_figure()` does
/// for the reference host, and for the same reason: the multi-row shape is one
/// `Figure`, not N widgets, because the x link groups, the shared y gutter and the
/// one shared x axis strip all live inside it and cannot be reassembled from
/// separate areas. The GTK plumbing above is unchanged by it; what changes is where
/// an event goes and what the renderer is handed.
///
/// A `Controller` is bound to a plot at construction, so figure mode holds **one
/// controller per row**, created by `set_figure()`, and routes each event to the row
/// it lands in (`controller_at()`). The row rects come from a cache
/// (`refresh_row_rects()`) that the render callback fills once per frame, so a burst
/// of pointer motion costs no layouts; between a resize and the next frame the cache
/// is one frame stale, which is the documented worst case.
///
/// A gesture belongs to the row its **press** landed in for its whole life: the
/// press claims the row, and every `drag-update`, pinch, wheel and the release that
/// follow while that press is open go there, however far the pointer travels. A drag
/// that crosses into the row below is therefore still the drag it began as, instead
/// of being handed to a controller that never saw the press -- which would leave the
/// first row's drag open for ever and let a later gesture resume it from the stale
/// anchor. The single open press `press_open_` already enforces (see the input
/// section) is that capture; this adapter needs no separate button bookkeeping the
/// way the GLFW host does, because GTK's two gestures share one sequence.
///
/// The **crosshair** is one crosshair over the figure and not one per row: the row
/// under the pointer places it, and `sync_figure_crosshair()` reads it back and
/// applies it to every row, so each row's readout lists its own traces at the one x
/// the pointer is over. Reading it back rather than deriving a second coordinate
/// here is what keeps the row's hover and the figure's the same number.
///
/// A key event has no position, so it goes to the row the last event was routed to,
/// which is the row the user is looking at.
///
/// ## Headless use
///
/// A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a
@@ -202,10 +241,56 @@ class PlotArea {
/// The plot this area renders. Valid for the widget's whole life.
Plot& plot() noexcept { return *plot_; }
const Plot& plot() const noexcept { return *plot_; }
/// The controller this area feeds, bound to `plot()`.
/// The controller this area feeds, bound to `plot()`. **Not** the one a figure's
/// gestures reach: see `row_controller()`.
input::Controller& controller() noexcept { return controller_; }
const input::Controller& controller() const noexcept { return controller_; }
/// Draws `figure` instead of this area's own plot, with one `input::Controller` per
/// row and every event routed to the row it lands in (see "Figure mode" above).
/// The figure is borrowed and must outlive the widget.
///
/// The frame callback is still handed a `Plot&`, and in figure mode it is **row
/// 0**, so the callback's argument and `figure()->plot(0)` are the same plot --
/// matching `glfw::Host`. A host that needs the figure itself captures it.
///
/// Passing null restores the single-plot path; `plot()` is untouched throughout,
/// so the area goes back to drawing exactly what it drew before.
void set_figure(Figure* figure) noexcept;
Figure* figure() noexcept { return figure_; }
const Figure* figure() const noexcept { return figure_; }
/// One figure row's controller -- the one a gesture on that row reaches -- or null
/// while no figure is set or the row does not exist. A host reads a drag mode or a
/// hover out of it.
input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at device pixel `(x, y)` belongs to: `controller()`
/// while no figure is set, and otherwise the controller of the figure row that
/// pixel lands in -- a pixel inside no row (a `Figure::set_row_gap()` gap, a
/// margin) is the *nearest* row rather than dropped, so a drag 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 pure query: it reports what a gesture *would* land on and never claims the
/// press capture, so a host may call it freely.
input::Controller& controller_at(f32 x, f32 y) noexcept;
/// The figure row a point in the widget's **logical** coordinates lands in -- the
/// coordinates every GTK event controller reports, so a host's own drop target or
/// context menu can name the row a pointer is over without converting anything. The
/// same nearest-row rule as `controller_at()`, and 0 while no figure is set.
u32 row_at(double x, double y) const noexcept;
/// Re-reads the figure's row rects into the cache the event routing answers from --
/// the one `Figure::layout()` the routing costs between two frames. The render
/// callback runs it once per frame, right after the figure's viewport is set, so
/// the rects the handlers use are the rects that frame draws. A driver that injects
/// events without rendering (a headless probe) calls it after re-configuring the
/// figure; a no-op with no figure set.
void refresh_row_rects() noexcept;
/// The renderer, or null until a successful `::realize`. Valid from the first
/// render onwards, and after the widget's context exists.
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
@@ -291,6 +376,32 @@ class PlotArea {
/// into device pixels. The one place that mapping exists.
Vec2 to_device(double x, double y) const noexcept;
/// The controller one event of this area's goes to, given the event's device y.
/// `controller_` while no figure is set; otherwise the row the gesture *belongs*
/// to: the row the open press claimed while a press is open, and the row under `y`
/// between gestures. That is the capture `controller_at()` deliberately omits, and
/// it is why this -- not `controller_at()` -- is what every handler below calls.
///
/// Hands the chosen row its own viewport, so the geometry it hit-tests is the
/// geometry the last frame drew.
input::Controller& target_controller(f32 y) noexcept;
/// The row of `row_rects_` a device y belongs to: the row it is in, or the nearest
/// one when it is in none (a row gap, a margin outside the figure). Selection by
/// distance rather than "the first that matches" is what makes a pixel in a visible
/// gap belong to the row it is *near* instead of to row 0.
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
/// The row this area's events currently go to: the press's row while one is open,
/// so an in-flight gesture keeps its highlight, and the row under the last pointer
/// position otherwise. 0 with no rows.
u32 pointer_row() const noexcept;
/// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once
/// per frame, from the render callback, after this frame's row rects are cached.
void sync_figure_crosshair() noexcept;
/// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's
/// own rect to its controller. The figure-mode half of `refresh_viewport()` and of
/// `render()`, which must not drift apart.
void lay_figure_out(Rect px) noexcept;
static void on_realize(GtkGLArea* area, void* self) noexcept;
static void on_unrealize(GtkGLArea* area, void* self) noexcept;
/// `gboolean` is `int`; a GtkGLArea render handler ignores the context.
@@ -329,6 +440,26 @@ class PlotArea {
Plot* plot_ = nullptr; ///< declared first: the controller is built over it
input::Controller controller_;
/// The figure this area draws instead of `plot_`, and one controller per row for it
/// -- one per `Plot`, because a `Controller` is bound to a plot at construction and
/// this is what makes a gesture on any row reach that row. Both are empty until
/// `set_figure()` is called, and the single-plot path reads neither.
Figure* figure_ = nullptr;
std::vector<std::unique_ptr<input::Controller>> row_controllers_;
/// 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. Empty while no figure
/// is set, which is the state the routing falls back to `controller_` on.
std::vector<Rect> row_rects_;
/// The row the last event was routed to, which is where a key event goes: a key has
/// no position of its own.
u32 last_row_ = 0;
/// The crosshair last spread over the figure (see `sync_figure_crosshair()`): what a
/// row is compared against to find the one something outside changed, 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.
f64 spread_x_ = 0.0;
f64 spread_y_ = 0.0;
bool spread_active_ = false;
GtkWidget* widget_ = nullptr;
std::optional<Renderer> renderer_;
FrameFn frame_;
@@ -340,6 +471,10 @@ class PlotArea {
bool press_open_ = false;
input::Button press_button_ = input::Button::Left;
u32 press_mods_ = 0;
/// The figure row the open press landed in -- the gesture's capture (see
/// `target_controller()`). Meaningless while `press_open_` is false and while no
/// figure is set.
u32 press_row_ = 0;
/// The drag gesture's anchor: its press point and last offset, in *logical*
/// pixels. Logical on purpose: a factor change between two samples of a drag
/// cannot then turn into a phantom movement (see `DragAnchor`). The offset is