// 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; // 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); 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. const std::vector& rows = figure_->layout().rows; // the one layout of this frame 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); } 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_); 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(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_); 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(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; 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(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. spread_active_ = false; spread_x_ = 0.0; 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(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_; // The figure's one layout for this call: both readers below take the row rects it produced, so an // event lays the figure out once and not once per reader -- this is the hottest path a multi-row // figure has, and a layout now rebuilds every row's ticks, grid and crosshair geometry. const std::vector& rows = figure_->layout().rows; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; (void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() const u32 row = row_in(rows, 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; } u32 Host::row_in(const std::vector& rows, f32 y) noexcept { if (rows.empty()) return 0u; // 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::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 } } 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_) { hovered = row_in(rows, last_pointer_y_); } else { for (u32 i = 0; i < row_count; ++i) { const Crosshair& candidate = figure_->plot(i).crosshair(); if (candidate.active != spread_active_ || (candidate.active && candidate.x != spread_x_)) { 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) { figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true); spread_x_ = row.crosshair().x; spread_active_ = true; } else { figure_->clear_crosshair(); spread_active_ = false; } } } // namespace plot::glfw