// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include // Before GLFW's header, so it does not include : 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 #include #include #include #include #include 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(GLFW_MOD_SHIFT) == static_cast(input::Mod::Shift)); static_assert(static_cast(GLFW_MOD_CONTROL) == static_cast(input::Mod::Ctrl)); static_assert(static_cast(GLFW_MOD_ALT) == static_cast(input::Mod::Alt)); static_assert(static_cast(GLFW_MOD_SUPER) == static_cast(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; // 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; 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(fb_width_), static_cast(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); // 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& rows = row_rects_; for (u32 i = 0; i < row_controllers_.size(); ++i) { if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]); } // One hover, every row (spec §15): the crosshair the row controller under the pointer // placed is applied to the whole figure here, after the pointers of this frame have been // handled and before anything lays the rows out -- so every row's readout lists its own // signals at the one x the pointer is over. In the run loop rather than in a callback, so a // scripted run that injects its events straight into a row controller is synced too. sync_figure_crosshair(rows); // Only the row the pointer's events go to ever republishes its own hot handles, so a row // the pointer left keeps the `HotHandles` it last published and goes on drawing a hovered // marker or region bound as if the pointer were still on it -- and its `hovered_region()`/ // `grabbed_cursor()` keep answering for it. Clear every *other* row here, in the run loop, // where that row is known (the captured row while a drag owns the button, so an in-flight // gesture keeps its highlight). if (have_pointer_ && !rows.empty()) { const u32 active = pointer_row(rows); for (u32 i = 0; i < rows.size() && i < row_controllers_.size(); ++i) { if (i != active) figure_->plot(i).set_hot_handles(HotHandles{}); } } } 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(fw) : 0u; fb_height_ = fh > 0 ? static_cast(fh) : 0u; if (ww > 0 && fw > 0) fb_scale_x_ = static_cast(fw) / static_cast(ww); if (wh > 0 && fh > 0) fb_scale_y_ = static_cast(fh) / static_cast(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(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(wx) * fb_scale_x_; const f32 y = static_cast(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_); e.time_s = glfwGetTime(); // GLFW's monotonic clock; only differences are read feed_pointer_event(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(wx) * fb_scale_x_; e.y = static_cast(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(mods); 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); } 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(wx) * fb_scale_x_; e.y = static_cast(wy) * fb_scale_y_; e.dx = static_cast(dx); e.dy = static_cast(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; // 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; e.key = mapped; e.mods = static_cast(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; // A new figure holds no crosshair this host has spread: the comparison in // `sync_figure_crosshair()` starts from "nothing spread", which is the state its rows are in. // Nor does it hold a captured drag row: the controllers are rebuilt below, so the old // capture names a controller that no longer exists. spread_active_ = false; spread_x_ = 0.0; spread_y_ = 0.0; captured_ = false; captured_row_ = 0u; captured_button_ = input::Button::Left; if (figure_ == nullptr) { row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_` return; } // One controller per **plot** (a row of several cells has one for each), indexed by the // flattened plot index the figure uses. const u32 plots = figure_->plot_count(); row_controllers_.reserve(plots); for (u32 i = 0; i < plots; ++i) { row_controllers_.push_back(std::make_unique(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). The rects // are the figure's **cells** (one per plot, flattened), which for a figure with one // cell per row are exactly its row rects. row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector{}; } 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_; // 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& rows = row_rects_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; const u32 row = row_in(rows, x, y); 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; } void Host::feed_pointer_event(const input::PointerEvent& e) noexcept { if (figure_ == nullptr) { controller_.on_pointer(e); return; } pointer_target(e.x, e.y, e.button, e.pressed, e.released).on_pointer(e); } input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed, bool released) noexcept { if (figure_ == nullptr || row_controllers_.empty()) return controller_; const std::vector& rows = row_rects_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; u32 row = 0u; if (captured_) { // A button-down gesture belongs to the row it began in for its whole life: a drag // that crosses into a neighbouring row must not hand its motion -- or its 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_; // 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, x, 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 // mid-drag, and the guard keeps a stale index from reaching past the controller list. if (row >= row_controllers_.size()) row = 0u; last_row_ = row; input::Controller& controller = *row_controllers_[row]; controller.set_viewport(rows[row]); return controller; } u32 Host::pointer_row(const std::vector& rows) noexcept { if (captured_ && captured_row_ < rows.size()) return captured_row_; return row_in(rows, last_pointer_x_, last_pointer_y_); } u32 Host::row_in(const std::vector& rows, f32 x, f32 y) noexcept { if (rows.empty()) return 0u; // The cell the pixel is in, or -- when it is in none of them, which is the row gap or // column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the // figure's own margins -- the cell whose rect is nearest (squared distance from the // pixel to the rect). Selection by distance rather than by "the first that matches" is // what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a // visible gap must not be routed to plot 0 by default. For a figure with one cell per // row every cell spans the figure's width, so this reduces to the nearest row by y. u32 row = 0u; double best = std::numeric_limits::infinity(); for (u32 i = 0; i < rows.size(); ++i) { const Rect& r = rows[i]; // In double: in f32 the dy^2 term can be swallowed by a large dx^2 (pointer far outside). const double dx = x < r.x ? double(r.x) - x : (x >= r.right() ? double(x) - r.right() : 0.0); const double dy = y < r.y ? double(r.y) - y : (y >= r.bottom() ? double(y) - r.bottom() : 0.0); const double distance = dx * dx + dy * dy; if (distance < best) { best = distance; row = i; if (distance == 0.0) break; // inside this cell: nothing can be nearer } } return row; } void Host::sync_figure_crosshair(const std::vector& rows) noexcept { if (rows.empty() || row_controllers_.empty()) return; const u32 row_count = static_cast(rows.size()); // Which row's crosshair is the hover. With a pointer of the host's own it is the row that pointer // is over -- the same rule events are routed by. A scripted run injects its events straight into // a row controller and the host never sees a pointer, so there the hover is the row whose // crosshair is *not* the one this host spread last: the figure writes one value to every row, so // any row holding something else was changed from outside, and that is the one the run touched. // Nothing differing means every row still holds the last spread -- no new hover -- and the figure // keeps it. u32 hovered = row_count; if (have_pointer_) { // The row the pointer's events currently go to -- the captured row while a drag owns // the button, the hovered row otherwise -- so the hover follows the gesture it began. hovered = pointer_row(rows); } else { for (u32 i = 0; i < row_count; ++i) { const Crosshair& candidate = figure_->plot(i).crosshair(); // A row differs when its active state, its x, or the y of *its own* horizontal line // differs from what was spread: a pointer that moves to another row at the same x is // a new hover and must move the one horizontal line, not leave the old row's behind. const bool y_differs = candidate.horizontal && candidate.y != spread_y_; if (candidate.active != spread_active_ || (candidate.active && (candidate.x != spread_x_ || y_differs))) { hovered = i; break; } } if (hovered >= row_count) return; } // Applied to the whole figure: one x, every row; and the pointer row's **own** y, only to that // row -- so the row under the pointer gets the one horizontal line, and no row ever shows // another row's y (spec §17). The y is the row's own data coordinate, placed by its controller // from the pointer's pixel row, and it is never spread to a sibling. A row whose controller has // *no* crosshair (the pointer left its axes, which is the only thing that clears one) clears the // figure's, so the rows cannot be left showing a hover the pointer has abandoned. const Plot& row = figure_->plot(hovered); if (row.crosshair().active) { if (row.crosshair().horizontal) { // The row carries its own y (its controller placed both coordinates), so the figure's // four-argument overload gives the pointer row the one horizontal line. figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true); } else { // The row's crosshair is vertical-only -- a caller placed it through the // single-coordinate `Figure::set_crosshair()`, which is documented vertical-only. The // four-argument overload would turn the horizontal line on at the stored (meaningless) // y == 0, clamped to the axes edge and made permanent. Promote only the vertical line. figure_->set_crosshair(row.crosshair().x, true); } spread_x_ = row.crosshair().x; spread_y_ = row.crosshair().y; spread_active_ = true; } else { figure_->clear_crosshair(); spread_active_ = false; } } } // namespace plot::glfw