`controller_at()` routed every pointer event by the row the pixel was in, ignoring the row a drag started in, so a press on one figure row followed by a motion and release in another delivered the motion and release to a controller whose `mode_ == None`. The owning controller kept `mode_`, `drag_region_`, `drag_cursor_` and `drag_fixed_x_` set for ever: `publish_hot()` kept publishing the grabbed handle, `grabbed_region()` reported a drag that had ended, and a later gesture re-entering the row resumed the stale edit from the old anchor. `feed_pointer_event()` now owns the routing: a press captures the row it landed in and every motion and the release until the button comes up go to that row (`pointer_target()`), cleared on release and when another figure is set. The GLFW callbacks build the event and call it, so a windowless host or a test can drive the routing too. Two neighbours in the same seam: * `sync_figure_crosshair()` called the four-argument overload unconditionally, so a documented vertical-only crosshair (`horizontal == false`) was promoted to a full cross at the stored y == 0, clamped to the axes edge and made permanent; the change detection also keyed on x alone, missing a y-only move to another row at the same x (two rows showing one horizontal line, spec 17). It now consults `crosshair().horizontal` and compares the hovered row's y. * Only the row under the pointer republished its hot handles, so a row the pointer left kept drawing a hovered marker or bound as if it were still under the pointer, and answered `hovered_region()`/`grabbed_cursor()` for it. The run loop clears every other row's handles, keeping the captured row while a drag owns the button, and reads the frame's one layout once instead of recomputing every row five times. Test: a press on row A's region, a motion into row B, a release in row B asserts no controller keeps a drag mode, nothing reports a grabbed handle, and a later press-and-motion that re-enters row A does not resume the old edit.
280 lines
15 KiB
C++
280 lines
15 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <vector>
|
|
|
|
#include <plot/core/rect.hpp>
|
|
#include <plot/core/status.hpp>
|
|
#include <plot/core/types.hpp>
|
|
#include <plot/input/controller.hpp>
|
|
#include <plot/plot/figure.hpp>
|
|
#include <plot/plot/plot.hpp>
|
|
#include <plot/render/renderer.hpp>
|
|
|
|
/// GLFW's window type. Forward-declared on purpose: this header is
|
|
/// self-contained and needs no GLFW include directory, so a consumer of the
|
|
/// adapter does not inherit the toolkit's headers (and their `<GL/gl.h>`, whose
|
|
/// macros would rewrite this library's enumerators).
|
|
struct GLFWwindow;
|
|
|
|
namespace plot::glfw {
|
|
|
|
/// What `Host::run()` opens the window with.
|
|
struct RunConfig {
|
|
int width = 1280;
|
|
int height = 720;
|
|
const char* title = "plot";
|
|
bool vsync = true;
|
|
};
|
|
|
|
/// One frame of application code, called by `Host::run()` after the viewport is
|
|
/// set and before the frame is staged, so it is where data is refreshed.
|
|
/// `dt` is the wall time since the previous call, in seconds.
|
|
using FrameFn = std::function<void(plot::Plot&, f64 dt)>;
|
|
|
|
/// The reference host: owns the GLFW window and its OpenGL 3.3 core context, the
|
|
/// GL entry points, the `Renderer`, the `Plot`, the `input::Controller` and the
|
|
/// frame loop, so nothing above it names GLFW or GL.
|
|
///
|
|
/// Per-frame sequence, in this order:
|
|
///
|
|
/// poll events -> controller.set_viewport(framebuffer-pixel rect) ->
|
|
/// frame(plot, dt) -> begin_frame -> draw -> [screenshot] -> end_frame ->
|
|
/// swap buffers
|
|
///
|
|
/// Two pixel spaces meet here and must not be confused. GLFW reports the cursor
|
|
/// and the window in *window* coordinates, while the framebuffer -- what
|
|
/// `begin_frame` takes and what every `Rect` in this library speaks -- can be
|
|
/// larger under a fractional or HiDPI scale, so `glfwGetFramebufferSize()` and
|
|
/// `glfwGetWindowSize()` disagree. The host calls `begin_frame` with the
|
|
/// framebuffer size and scales every pointer coordinate by the ratio
|
|
/// `framebuffer / window`, so a gesture lands on the pixels the plot was laid out
|
|
/// in. The same ratio states the display to the renderer
|
|
/// (`Style::ui_scale`), which is what makes the chrome -- labels, strokes, paddings
|
|
/// -- one physical size at any scale. On an unscaled display the ratio is 1.0 and
|
|
/// nothing changes.
|
|
///
|
|
/// Headless smoke mode, read once per `run()`:
|
|
///
|
|
/// `PLOT_DEMO_EXIT_AFTER_MS=<n>` stop the loop after n ms and return
|
|
/// (default 0: run until the window is closed)
|
|
/// `PLOT_DEMO_SCREENSHOT=<path>` on that final frame, write it with
|
|
/// `Renderer::dump_frame_ppm()` before the swap
|
|
///
|
|
/// That is what lets a CI run a GUI library under `xvfb-run`: no display access
|
|
/// and no screen capture, just a PPM on disk.
|
|
///
|
|
/// A run with a deadline is that automated path, so its window is created hidden
|
|
/// (`GLFW_VISIBLE` false): an automated run must not flash a window on whatever
|
|
/// display it lands on. It also means the loop is not throttled by a display
|
|
/// refresh, so the frame times it reports are the CPU's, not a vblank's. A run
|
|
/// without a deadline shows its window, as an interactive application must.
|
|
///
|
|
/// Not thread-safe, and confined to the thread that runs `run()`.
|
|
class Host {
|
|
public:
|
|
Host() noexcept;
|
|
~Host();
|
|
Host(const Host&) = delete;
|
|
Host& operator=(const Host&) = delete;
|
|
|
|
/// Opens the window, makes a 3.3 core context current, resolves the GL entry
|
|
/// points and initialises the renderer, then runs the loop until the window is
|
|
/// closed or the exit deadline passes. Returns false with `st` filled in when
|
|
/// GLFW, the context or `Renderer::init()` fails; a failed run leaves nothing
|
|
/// open.
|
|
///
|
|
/// On success the window *stays open*, so a caller can stop and join whatever
|
|
/// feeds the plot before the GL resources go away: `close()` (or the
|
|
/// destructor) releases the renderer, the context and the window, in that
|
|
/// order, on this thread. Calling `run()` again reopens the window and keeps
|
|
/// the plot.
|
|
bool run(const RunConfig& cfg, const FrameFn& frame, Status* st = nullptr);
|
|
|
|
/// Releases the renderer and the window. Idempotent, and safe on a host that
|
|
/// never ran.
|
|
void close() noexcept;
|
|
|
|
/// The plot the frame callback populates. Valid for the host's whole life.
|
|
Plot& plot() noexcept { return plot_; }
|
|
/// Draw a whole `Figure` instead of the host's own plot: the multi-plot shape,
|
|
/// several rows sharing one x axis, which one `Plot` cannot express. While a
|
|
/// figure is set the run loop lays it out over the framebuffer, routes each
|
|
/// pointer, wheel and key event to the row the event lands in (one `Controller`
|
|
/// per row, created here, each bound to that row's own `Plot`), and draws it with
|
|
/// `Renderer::draw(Figure&)`. The frame callback is handed the figure's **row 0**
|
|
/// as its `Plot&`, so `plot()` and the frame argument agree.
|
|
///
|
|
/// The figure's **crosshair** follows the pointer as one crosshair and not one per row: the run
|
|
/// loop reads the hover out of the row under the pointer and applies it to the whole figure
|
|
/// (`sync_figure_crosshair()`), so every row's readout lists its own signals at the one x the
|
|
/// pointer is over. That is what "synced if multiple plots available" means, and the host is the
|
|
/// layer that can do it -- it is the one that knows which row the pointer is in.
|
|
///
|
|
/// The figure is borrowed and must outlive the run. Passing null restores the
|
|
/// single-plot loop, which is what every other caller uses; the host's own
|
|
/// `plot_` is then untouched by any of this.
|
|
void set_figure(Figure* figure) noexcept;
|
|
Figure* figure() 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 (`controller()` is the single-plot loop's, and is
|
|
/// not the one figure gestures go to).
|
|
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()`,
|
|
/// 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.
|
|
///
|
|
/// 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. It is the pure
|
|
/// query -- what a gesture *would* land on -- and it does not move the capture
|
|
/// below; the GLFW callbacks and `feed_pointer_event()` are the routing.
|
|
input::Controller& controller_at(f32 x, f32 y) noexcept;
|
|
|
|
/// Feeds one pointer event, stated in framebuffer pixels, through the host's own
|
|
/// routing and into the controller it belongs to. The GLFW callbacks fill the
|
|
/// position and the button transitions out of GLFW and call exactly this, so a host
|
|
/// or a test with no window can drive the routing directly.
|
|
///
|
|
/// The routing is the row the event lands in, with one exception that is the whole
|
|
/// point of it: a press **captures** the row it landed in, and every motion and the
|
|
/// release until that button comes up go to the captured row. A drag that crosses
|
|
/// into a neighbouring figure row is therefore still the drag it began as -- the
|
|
/// owning controller sees the motion and the release that end it -- rather than
|
|
/// being handed to a controller whose `mode_ == None`, which would leave the owner's
|
|
/// drag open for ever and let a later gesture resume it from the old anchor.
|
|
void feed_pointer_event(const input::PointerEvent& e) noexcept;
|
|
|
|
/// The gesture controller the host feeds. Valid for the host's whole life.
|
|
input::Controller& controller() noexcept { return controller_; }
|
|
|
|
/// The renderer, or null while no window is open. Valid from inside the frame
|
|
/// callback and after a successful `run()`; `close()` releases it.
|
|
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
|
|
|
|
/// Retitles the open window -- from the frame callback, for a live
|
|
/// samples/second and frame-time readout. A no-op while no window is open.
|
|
void set_title(const char* title) noexcept;
|
|
|
|
/// Framebuffer pixels per window pixel, one ratio per axis, as of the last
|
|
/// event or frame. 1.0 on an unscaled display.
|
|
f32 framebuffer_scale_x() const noexcept { return fb_scale_x_; }
|
|
f32 framebuffer_scale_y() const noexcept { return fb_scale_y_; }
|
|
/// The window's content scale, one per axis, as of the last frame.
|
|
f32 content_scale_x() const noexcept { return content_scale_x_; }
|
|
f32 content_scale_y() const noexcept { return content_scale_y_; }
|
|
|
|
private:
|
|
/// Reads the framebuffer and window sizes and the content scale, refreshing
|
|
/// the window -> framebuffer ratios the pointer translation uses.
|
|
void refresh_sizes() noexcept;
|
|
|
|
/// Callbacks find their host through GLFW's per-window user pointer.
|
|
static Host* from(GLFWwindow* w) noexcept;
|
|
static void cursor_pos_cb(GLFWwindow* w, double x, double y);
|
|
static void mouse_button_cb(GLFWwindow* w, int button, int action, int mods);
|
|
static void scroll_cb(GLFWwindow* w, double dx, double dy);
|
|
static void key_cb(GLFWwindow* w, int key, int scancode, int action, int mods);
|
|
static void framebuffer_size_cb(GLFWwindow* w, int width, int height);
|
|
|
|
void on_cursor_pos(f64 wx, f64 wy) noexcept;
|
|
void on_mouse_button(int button, int action, int mods) noexcept;
|
|
void on_scroll(f64 dx, f64 dy) noexcept;
|
|
void on_key(int key, int action, int mods) noexcept;
|
|
|
|
/// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest
|
|
/// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event
|
|
/// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller
|
|
/// that has just laid the figure out does not make it lay out again for this.
|
|
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
|
|
|
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
|
|
/// a press claims the row it landed in (`captured_row_`) and every motion and the
|
|
/// release until the button comes up are routed to it; `controller_at()` is the pure
|
|
/// query that never captures. Also hands the target its row's viewport, so the
|
|
/// hit-test geometry is the last frame's.
|
|
input::Controller& pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept;
|
|
|
|
/// The row the pointer's events currently go to: the captured row while a drag owns
|
|
/// the button, the row under the pointer otherwise. The run loop uses it to clear
|
|
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
|
|
/// hover or a grab it last published.
|
|
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
|
|
|
|
/// Spreads the hover over a whole `Figure` (spec §15).
|
|
///
|
|
/// A figure's rows share one x, so the crosshair one row's controller placed is read back here and
|
|
/// applied to every row: the controller turns a pointer position into a data coordinate -- which
|
|
/// is its job, and the same one it does for a single plot -- and the figure spreads that one
|
|
/// coordinate over its rows. Reading it back rather than deriving a second coordinate here is what
|
|
/// keeps the hover the row shows and the hover the figure shows the same number.
|
|
///
|
|
/// The row it reads is the one under the host's pointer, or -- with no pointer of the host's own,
|
|
/// which is how a scripted run injects its events -- the row whose crosshair is not the value this
|
|
/// host spread last (`spread_x_`/`spread_active_`), i.e. the row something outside changed. A row
|
|
/// whose controller has no crosshair clears the figure's.
|
|
///
|
|
/// Called once per frame from the run loop, so a scripted run that bypasses the GLFW callbacks
|
|
/// gets the same synced figure a real pointer does. `rows` is the figure's row rects, which the
|
|
/// run loop has just read -- the one layout per frame this shares with the row viewports.
|
|
void sync_figure_crosshair(const std::vector<Rect>& rows) noexcept;
|
|
|
|
Plot plot_;
|
|
input::Controller controller_; ///< forwards its viewport to `plot_`
|
|
std::optional<Renderer> renderer_;
|
|
GLFWwindow* window_ = nullptr;
|
|
/// The figure the host 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 run loop's single-plot path reads
|
|
/// neither.
|
|
Figure* figure_ = nullptr;
|
|
std::vector<std::unique_ptr<input::Controller>> row_controllers_;
|
|
/// The row the last event landed in, which is where a key event goes: a key has
|
|
/// no position of its own, and the row the pointer is over is the one the user is
|
|
/// looking at.
|
|
u32 last_row_ = 0u;
|
|
/// The crosshair this host last spread over the figure (see
|
|
/// `sync_figure_crosshair()`): what a row is compared against to find the one a
|
|
/// scripted run touched, since the figure writes one value to every row.
|
|
/// `spread_active_` false means "spread nothing" -- the state before the first
|
|
/// sync, and the one a clear leaves. `spread_y_` is the horizontal line's own
|
|
/// value, compared as well so a y-only change -- the same x in another row --
|
|
/// is not mistaken for "nothing changed".
|
|
f64 spread_x_ = 0.0;
|
|
f64 spread_y_ = 0.0;
|
|
bool spread_active_ = false;
|
|
|
|
/// The row a button-down drag has captured: the row its press landed in, which
|
|
/// owns its motions and its release until the button comes up. `captured_` is
|
|
/// false between gestures, and cleared when another figure is set.
|
|
bool captured_ = false;
|
|
u32 captured_row_ = 0u;
|
|
|
|
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
|
|
u32 fb_height_ = 0;
|
|
/// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports
|
|
/// rather than from the content scale, which under a fractional scale does not
|
|
/// round back to the framebuffer size.
|
|
f32 fb_scale_x_ = 1.0f;
|
|
f32 fb_scale_y_ = 1.0f;
|
|
f32 content_scale_x_ = 1.0f;
|
|
f32 content_scale_y_ = 1.0f;
|
|
/// Last pointer position in framebuffer pixels: the deltas the controller pans
|
|
/// with are the movements since the previous event, which GLFW does not report.
|
|
f32 last_pointer_x_ = 0.0f;
|
|
f32 last_pointer_y_ = 0.0f;
|
|
bool have_pointer_ = false;
|
|
};
|
|
|
|
} // namespace plot::glfw
|