Review fix pass 1 on the task 7 work; four findings, all closed. 1. The Qt demo's region sat across the middle of the window, which is exactly where its own scripted input check pans: after the check's box zoom the press opened DragMode::MoveRegion, the x range never moved, and the check printed 'input check pan FAIL' -- under an exit 0, because the verdict was printed and not returned. The region now rides the left sixth (as the GLFW demo's does), both checks pass, and the verdict is wired to the exit status. The same audit found two more printed-only verdicts -- the GTK demo's scripted-resize layout check and its screenshot write (and Qt's) -- now wired too. What stays printed-only is the screenshot line of the GLFW host and the ImGui widget, where the dump happens inside the adapter's frame loop. 2. A follow-ing link group was invisible to the input layer: a member's own axis must be XFollow::Off (link_x() refuses a Live member), so Controller's x_zoom_anchor() extrapolated a coordinate from the pointer instead of using the live edge and a wheel over a row's axis strip walked the window back by 19% of its width (behind 0.000000 -> 0.285809 s). The group now publishes its own edge to every member (Axis::live_owner + Plot::set_x_live_owner, written by Figure::push_link, cleared when the group stops following or the row unlinks), and x_zoom_anchor() reads it. Public-header addition, called out. Measured after: behind 0.012992 s and the window's right edge bit-identical to the edge the group published at the gesture. 3. The multiplot summary printed x_link_count() -- a number of groups -- under 'rows in it'. It now reports the members and the group count separately, and every row line carries its own group id. 4. Host::controller_at() promised to clamp a pixel in a row gap to the nearest row and only clamped below the last one, so a gap pixel went to row 0. It now selects by distance, and tests/test_glfw_host.cpp pins it with a 40 px gap -- the first coverage of the host's figure routing, which needs no window. New cases: tests/test_controller.cpp x2 (the anchor and the hint's lifecycle), tests/test_glfw_host.cpp x3 (routing, the gap, the single-plot fallback): 229 cases, 0 failed, ASan clean. All five demos exit 0, the three self-checking modes exit 0, and the amended assertions were confirmed failing against the pre-fix behaviour.
454 lines
17 KiB
C++
454 lines
17 KiB
C++
#include <glfw/glfw_host.hpp>
|
|
|
|
// Before GLFW's header, so it does not include <GL/gl.h>: that header's macros
|
|
// (GL_COLOR_BUFFER_BIT and friends) would rewrite `plot::gl`'s enumerators of the
|
|
// same name. The adapter needs GLFW for the window, the context and the events
|
|
// only -- every GL entry point comes from plot::gl::load().
|
|
#define GLFW_INCLUDE_NONE
|
|
#include <GLFW/glfw3.h>
|
|
|
|
#include <plot/gl/loader.hpp>
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <limits>
|
|
|
|
namespace plot::glfw {
|
|
namespace {
|
|
|
|
/// GLFW_MOD_* and `plot::input::Mod` are the same bits, by design: the
|
|
/// controller documents `Mod` as the host-translatable mask. A key event's mask
|
|
/// therefore passes through, and only a pointer or wheel event, whose callbacks
|
|
/// carry no mask, has to ask the window for the current key state.
|
|
static_assert(static_cast<u32>(GLFW_MOD_SHIFT) == static_cast<u32>(input::Mod::Shift));
|
|
static_assert(static_cast<u32>(GLFW_MOD_CONTROL) == static_cast<u32>(input::Mod::Ctrl));
|
|
static_assert(static_cast<u32>(GLFW_MOD_ALT) == static_cast<u32>(input::Mod::Alt));
|
|
static_assert(static_cast<u32>(GLFW_MOD_SUPER) == static_cast<u32>(input::Mod::Super));
|
|
|
|
u32 mods_now(GLFWwindow* w) noexcept {
|
|
u32 m = 0;
|
|
if (glfwGetKey(w, GLFW_KEY_LEFT_SHIFT) == GLFW_PRESS ||
|
|
glfwGetKey(w, GLFW_KEY_RIGHT_SHIFT) == GLFW_PRESS) {
|
|
m |= GLFW_MOD_SHIFT;
|
|
}
|
|
if (glfwGetKey(w, GLFW_KEY_LEFT_CONTROL) == GLFW_PRESS ||
|
|
glfwGetKey(w, GLFW_KEY_RIGHT_CONTROL) == GLFW_PRESS) {
|
|
m |= GLFW_MOD_CONTROL;
|
|
}
|
|
if (glfwGetKey(w, GLFW_KEY_LEFT_ALT) == GLFW_PRESS ||
|
|
glfwGetKey(w, GLFW_KEY_RIGHT_ALT) == GLFW_PRESS) {
|
|
m |= GLFW_MOD_ALT;
|
|
}
|
|
if (glfwGetKey(w, GLFW_KEY_LEFT_SUPER) == GLFW_PRESS ||
|
|
glfwGetKey(w, GLFW_KEY_RIGHT_SUPER) == GLFW_PRESS) {
|
|
m |= GLFW_MOD_SUPER;
|
|
}
|
|
return m;
|
|
}
|
|
|
|
bool any_button_down(GLFWwindow* w) noexcept {
|
|
return glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_LEFT) == GLFW_PRESS ||
|
|
glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_RIGHT) == GLFW_PRESS ||
|
|
glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_MIDDLE) == GLFW_PRESS;
|
|
}
|
|
|
|
/// `glfwGetProcAddress` returns GLFW's `void (*)(void)`, `plot::gl::ProcLoader`
|
|
/// returns `void*`: a function pointer respelled as an object pointer. POSIX
|
|
/// guarantees the two have the same representation, and the bit copy states that
|
|
/// where a `reinterpret_cast` would be merely conditionally supported.
|
|
void* resolve_gl(const char* name) noexcept {
|
|
GLFWglproc p = glfwGetProcAddress(name);
|
|
void* out = nullptr;
|
|
static_assert(sizeof(out) == sizeof(p), "function and object pointers must match");
|
|
std::memcpy(&out, &p, sizeof(out));
|
|
return out;
|
|
}
|
|
|
|
bool fail(Status* st, ErrorCode code, const char* message) noexcept {
|
|
if (st != nullptr) {
|
|
st->code = code;
|
|
st->message = message;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// `PLOT_DEMO_EXIT_AFTER_MS` as seconds: 0.0 when unset, unparsable or not
|
|
/// positive, which is "run until the window is closed".
|
|
f64 exit_after_seconds() noexcept {
|
|
const char* v = std::getenv("PLOT_DEMO_EXIT_AFTER_MS");
|
|
if (v == nullptr || v[0] == '\0') return 0.0;
|
|
char* end = nullptr;
|
|
const f64 ms = std::strtod(v, &end);
|
|
if (end == v || !(ms > 0.0)) return 0.0;
|
|
return ms * 1.0e-3;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
Host::Host() noexcept : controller_(plot_) {}
|
|
|
|
Host::~Host() { close(); }
|
|
|
|
void Host::close() noexcept {
|
|
// The renderer's RAII handles call glDelete* from their destructors, so the
|
|
// context has to be current -- and alive -- when they run.
|
|
if (renderer_) {
|
|
if (window_ != nullptr) glfwMakeContextCurrent(window_);
|
|
renderer_.reset();
|
|
}
|
|
if (window_ != nullptr) {
|
|
glfwDestroyWindow(window_);
|
|
window_ = nullptr;
|
|
}
|
|
glfwMakeContextCurrent(nullptr);
|
|
glfwTerminate();
|
|
// The figure and its row controllers are deliberately *not* dropped here: they are the
|
|
// caller's state, created by set_figure(), and a second run() reuses them (the run loop
|
|
// hands each row its rect every frame). Dropping them would silently stop routing
|
|
// gestures to the rows on the second run.
|
|
have_pointer_ = false;
|
|
// 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;
|
|
fb_height_ = 0;
|
|
fb_scale_x_ = 1.0f;
|
|
fb_scale_y_ = 1.0f;
|
|
}
|
|
|
|
bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
|
|
if (st != nullptr) *st = Status{};
|
|
close(); // a second run() starts from a clean window
|
|
|
|
if (glfwInit() != GLFW_TRUE) {
|
|
return fail(st, ErrorCode::Unsupported, "glfwInit() failed");
|
|
}
|
|
|
|
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
|
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
|
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
|
// Required on macOS 10.7+, ignored elsewhere.
|
|
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
|
|
// A run with a deadline is the automated path, so it stays off-screen: a
|
|
// bounded CI run must never flash a window on a real display.
|
|
const f64 exit_after_s = exit_after_seconds();
|
|
if (exit_after_s > 0.0) glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
|
|
|
|
GLFWwindow* w = glfwCreateWindow(cfg.width > 0 ? cfg.width : 1,
|
|
cfg.height > 0 ? cfg.height : 1,
|
|
cfg.title != nullptr ? cfg.title : "plot", nullptr, nullptr);
|
|
if (w == nullptr) {
|
|
const char* desc = nullptr;
|
|
(void)glfwGetError(&desc);
|
|
std::fprintf(stderr, "plot::glfw: glfwCreateWindow() failed: %s\n",
|
|
desc != nullptr ? desc : "no error description");
|
|
close();
|
|
return fail(st, ErrorCode::Unsupported,
|
|
"glfwCreateWindow() failed: no OpenGL 3.3 core context (see stderr)");
|
|
}
|
|
|
|
window_ = w;
|
|
glfwSetWindowUserPointer(w, this);
|
|
glfwMakeContextCurrent(w);
|
|
glfwSwapInterval(cfg.vsync ? 1 : 0);
|
|
glfwSetCursorPosCallback(w, &Host::cursor_pos_cb);
|
|
glfwSetMouseButtonCallback(w, &Host::mouse_button_cb);
|
|
glfwSetScrollCallback(w, &Host::scroll_cb);
|
|
glfwSetKeyCallback(w, &Host::key_cb);
|
|
glfwSetFramebufferSizeCallback(w, &Host::framebuffer_size_cb);
|
|
refresh_sizes();
|
|
|
|
if (!plot::gl::load(&resolve_gl)) {
|
|
close();
|
|
return fail(st, ErrorCode::Unsupported,
|
|
"plot::gl::load() failed: the context is missing a required OpenGL 3.3 entry point");
|
|
}
|
|
|
|
renderer_.emplace();
|
|
if (!renderer_->init(st)) {
|
|
close();
|
|
if (st != nullptr && st->code == ErrorCode::Ok) {
|
|
*st = Status{ErrorCode::GLFailure, "Renderer::init() failed"};
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const char* const screenshot = std::getenv("PLOT_DEMO_SCREENSHOT");
|
|
const f64 start = glfwGetTime();
|
|
f64 previous = start;
|
|
|
|
while (glfwWindowShouldClose(window_) == GLFW_FALSE) {
|
|
glfwPollEvents();
|
|
refresh_sizes();
|
|
|
|
if (fb_width_ == 0 || fb_height_ == 0) {
|
|
// Minimised, or a size the compositor reports as zero: nothing to draw
|
|
// into, and nothing to hand `begin_frame` -- a stale larger size would be
|
|
// a lie about the framebuffer. Idle on events rather than spin, and still
|
|
// honour the exit deadline here: a bounded run that never gets a drawable
|
|
// must end, not idle past the deadline it was given. There is no frame to
|
|
// dump in this state, so the screenshot is skipped.
|
|
if (exit_after_s > 0.0 && glfwGetTime() - start >= exit_after_s) {
|
|
std::fprintf(stderr,
|
|
"[glfw] exit deadline reached with no drawable (framebuffer 0x0);"
|
|
" nothing to dump\n");
|
|
break;
|
|
}
|
|
glfwWaitEventsTimeout(0.05);
|
|
continue;
|
|
}
|
|
|
|
// The plot's region is the whole window here, which is what the controller
|
|
// and `begin_frame` must agree on: one rect, in framebuffer pixels, set
|
|
// before the frame callback runs so it sees the layout it draws into.
|
|
const Rect viewport{0.0f, 0.0f, static_cast<f32>(fb_width_), static_cast<f32>(fb_height_)};
|
|
if (figure_ != nullptr) {
|
|
// Figure mode: the rows tile the whole framebuffer, and it is `Figure::layout()`
|
|
// 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);
|
|
figure_->layout();
|
|
// Every row's controller gets that row's rect, so a gesture -- routed by
|
|
// controller_at() 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.
|
|
for (u32 i = 0; i < row_controllers_.size(); ++i) {
|
|
row_controllers_[i]->set_viewport(figure_->layout().rows[i]);
|
|
}
|
|
} else {
|
|
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
|
|
}
|
|
|
|
const f64 now = glfwGetTime();
|
|
const f64 dt = now - previous;
|
|
previous = now;
|
|
// A figure's rows take the pointer, but the frame callback still has to be given a
|
|
// `Plot&`: row 0 is the one it gets, so the callback argument and `figure()->plot(0)`
|
|
// are the same plot.
|
|
if (frame) frame(figure_ != nullptr ? figure_->plot(0u) : plot_, dt);
|
|
|
|
const bool exiting = exit_after_s > 0.0 && (now - start) >= exit_after_s;
|
|
|
|
renderer_->begin_frame(fb_width_, fb_height_, viewport);
|
|
if (figure_ != nullptr) {
|
|
renderer_->draw(*figure_);
|
|
} else {
|
|
renderer_->draw(plot_);
|
|
}
|
|
if (exiting && screenshot != nullptr && screenshot[0] != '\0') {
|
|
Status shot;
|
|
const bool dumped = renderer_->dump_frame_ppm(screenshot, &shot);
|
|
std::printf("[glfw] screenshot %s: %s\n", screenshot,
|
|
dumped ? "written" : shot.message);
|
|
std::fflush(stdout);
|
|
}
|
|
renderer_->end_frame();
|
|
glfwSwapBuffers(window_);
|
|
|
|
if (exiting) break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void Host::set_title(const char* title) noexcept {
|
|
if (window_ != nullptr) glfwSetWindowTitle(window_, title != nullptr ? title : "");
|
|
}
|
|
|
|
void Host::refresh_sizes() noexcept {
|
|
if (window_ == nullptr) return;
|
|
int fw = 0;
|
|
int fh = 0;
|
|
int ww = 0;
|
|
int wh = 0;
|
|
glfwGetFramebufferSize(window_, &fw, &fh);
|
|
glfwGetWindowSize(window_, &ww, &wh);
|
|
float sx = 1.0f;
|
|
float sy = 1.0f;
|
|
glfwGetWindowContentScale(window_, &sx, &sy);
|
|
content_scale_x_ = sx;
|
|
content_scale_y_ = sy;
|
|
// Assigned unconditionally, zero included: an iconified window really does
|
|
// report a 0x0 framebuffer (Windows, and several Linux window managers), and
|
|
// the loop's no-drawable guard reads exactly this. Only ever writing a
|
|
// positive size would leave the guard unreachable and keep handing
|
|
// `begin_frame` a stale size for a framebuffer that is no longer that big.
|
|
fb_width_ = fw > 0 ? static_cast<u32>(fw) : 0u;
|
|
fb_height_ = fh > 0 ? static_cast<u32>(fh) : 0u;
|
|
if (ww > 0 && fw > 0) fb_scale_x_ = static_cast<f32>(fw) / static_cast<f32>(ww);
|
|
if (wh > 0 && fh > 0) fb_scale_y_ = static_cast<f32>(fh) / static_cast<f32>(wh);
|
|
// The renderer draws its logical style metrics at this same ratio, so the chrome
|
|
// is the same physical size at any scale: one value states the display, and a
|
|
// 12 px label is 24 device px on the 2x panel both these sizes describe. Read here
|
|
// rather than in the frame loop because this is the one place the two sizes are,
|
|
// and re-read whenever the window moves to a display with another scale.
|
|
if (renderer_) renderer_->style().ui_scale = fb_scale_x_;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ events
|
|
|
|
Host* Host::from(GLFWwindow* w) noexcept {
|
|
return w != nullptr ? static_cast<Host*>(glfwGetWindowUserPointer(w)) : nullptr;
|
|
}
|
|
|
|
void Host::cursor_pos_cb(GLFWwindow* w, double x, double y) {
|
|
if (Host* h = from(w)) h->on_cursor_pos(x, y);
|
|
}
|
|
|
|
void Host::mouse_button_cb(GLFWwindow* w, int button, int action, int mods) {
|
|
if (Host* h = from(w)) h->on_mouse_button(button, action, mods);
|
|
}
|
|
|
|
void Host::scroll_cb(GLFWwindow* w, double dx, double dy) {
|
|
if (Host* h = from(w)) h->on_scroll(dx, dy);
|
|
}
|
|
|
|
void Host::key_cb(GLFWwindow* w, int key, int scancode, int action, int mods) {
|
|
(void)scancode;
|
|
if (Host* h = from(w)) h->on_key(key, action, mods);
|
|
}
|
|
|
|
void Host::framebuffer_size_cb(GLFWwindow* w, int width, int height) {
|
|
(void)width;
|
|
(void)height;
|
|
if (Host* h = from(w)) h->refresh_sizes();
|
|
}
|
|
|
|
void Host::on_cursor_pos(f64 wx, f64 wy) noexcept {
|
|
// Window coordinates -> framebuffer pixels: the same translation the viewport
|
|
// and every other Rect here use, so a gesture lands where it was aimed.
|
|
const f32 x = static_cast<f32>(wx) * fb_scale_x_;
|
|
const f32 y = static_cast<f32>(wy) * fb_scale_y_;
|
|
input::PointerEvent e;
|
|
e.x = x;
|
|
e.y = y;
|
|
if (have_pointer_) {
|
|
e.dx = x - last_pointer_x_;
|
|
e.dy = y - last_pointer_y_;
|
|
}
|
|
last_pointer_x_ = x;
|
|
last_pointer_y_ = y;
|
|
have_pointer_ = true;
|
|
e.mods = mods_now(window_);
|
|
e.down = any_button_down(window_);
|
|
controller_at(e.x, e.y).on_pointer(e);
|
|
}
|
|
|
|
void Host::on_mouse_button(int button, int action, int mods) noexcept {
|
|
input::Button b;
|
|
switch (button) {
|
|
case GLFW_MOUSE_BUTTON_LEFT: b = input::Button::Left; break;
|
|
case GLFW_MOUSE_BUTTON_MIDDLE: b = input::Button::Middle; break;
|
|
case GLFW_MOUSE_BUTTON_RIGHT: b = input::Button::Right; break;
|
|
default: return; // the controller has no gesture for the auxiliary buttons
|
|
}
|
|
double wx = 0.0;
|
|
double wy = 0.0;
|
|
glfwGetCursorPos(window_, &wx, &wy);
|
|
input::PointerEvent e;
|
|
e.x = static_cast<f32>(wx) * fb_scale_x_;
|
|
e.y = static_cast<f32>(wy) * fb_scale_y_;
|
|
// The press/release is also a pointer sample: the next move's delta starts
|
|
// here, or a drag's first motion would include the jump to the click.
|
|
last_pointer_x_ = e.x;
|
|
last_pointer_y_ = e.y;
|
|
have_pointer_ = true;
|
|
e.button = b;
|
|
e.mods = static_cast<u32>(mods);
|
|
e.pressed = action == GLFW_PRESS;
|
|
e.released = action == GLFW_RELEASE;
|
|
e.down = any_button_down(window_);
|
|
controller_at(e.x, e.y).on_pointer(e);
|
|
}
|
|
|
|
void Host::on_scroll(f64 dx, f64 dy) noexcept {
|
|
double wx = 0.0;
|
|
double wy = 0.0;
|
|
glfwGetCursorPos(window_, &wx, &wy);
|
|
input::WheelEvent e;
|
|
e.x = static_cast<f32>(wx) * fb_scale_x_;
|
|
e.y = static_cast<f32>(wy) * fb_scale_y_;
|
|
e.dx = static_cast<f32>(dx);
|
|
e.dy = static_cast<f32>(dy); // GLFW's positive y (scroll up) is the zoom-in sign
|
|
e.mods = mods_now(window_);
|
|
controller_at(e.x, e.y).on_wheel(e);
|
|
}
|
|
|
|
void Host::on_key(int key, int action, int mods) noexcept {
|
|
int mapped = 0;
|
|
switch (key) {
|
|
// The controller zooms on '+', which on most layouts is shift+'='; the '='
|
|
// key is mapped to it as well, so both spellings zoom in.
|
|
case GLFW_KEY_EQUAL:
|
|
case GLFW_KEY_KP_ADD: mapped = input::kKeyZoomIn; break;
|
|
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;
|
|
default: return;
|
|
}
|
|
input::KeyEvent e;
|
|
e.key = mapped;
|
|
e.mods = static_cast<u32>(mods);
|
|
// GLFW reports auto-repeat as its own action; a held key keeps acting, exactly
|
|
// as holding the key on a keyboard does.
|
|
e.down = action != GLFW_RELEASE;
|
|
// A key has no position of its own: it goes to the row the pointer was last over,
|
|
// which is the row the user is looking at.
|
|
input::Controller& controller =
|
|
(figure_ == nullptr || last_row_ >= row_controllers_.size()) ? controller_
|
|
: *row_controllers_[last_row_];
|
|
controller.on_key(e);
|
|
}
|
|
|
|
void Host::set_figure(Figure* figure) noexcept {
|
|
figure_ = figure;
|
|
row_controllers_.clear();
|
|
last_row_ = 0u;
|
|
if (figure_ == nullptr) 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)));
|
|
}
|
|
}
|
|
|
|
input::Controller* Host::row_controller(u32 row) noexcept {
|
|
return row < row_controllers_.size() ? row_controllers_[row].get() : nullptr;
|
|
}
|
|
|
|
input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
|
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
|
const FigureLayout& layout = figure_->layout();
|
|
const std::vector<Rect>& rows = layout.rows;
|
|
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
|
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
|
|
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the
|
|
// row whose rect is nearest. Selection by distance rather than by "the first that
|
|
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows
|
|
// tile the figure at the default gap of 0, so this is unreachable until a host asks
|
|
// for one, and a gesture in a visible gap must not be routed to row 0 by default.
|
|
u32 row = 0u;
|
|
f32 best = std::numeric_limits<f32>::infinity();
|
|
for (u32 i = 0; i < rows.size(); ++i) {
|
|
const f32 top = rows[i].y;
|
|
const f32 bottom = rows[i].y + rows[i].h;
|
|
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
|
|
if (distance < best) {
|
|
best = distance;
|
|
row = i;
|
|
if (distance == 0.0f) break; // inside this row: nothing can be nearer
|
|
}
|
|
}
|
|
(void)x; // the rows tile the figure vertically, so y alone picks one
|
|
last_row_ = row;
|
|
input::Controller& controller = *row_controllers_[row];
|
|
// The row's own viewport, so the geometry this controller hit-tests is the geometry
|
|
// the last frame drew: the same rect `Figure::layout()` gave the row's plot.
|
|
controller.set_viewport(rows[row]);
|
|
return controller;
|
|
}
|
|
|
|
} // namespace plot::glfw
|