Files
plotter/adapters/imgui/imgui_plot.cpp
T
Martino Ferrari 38ef459897
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
feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
2026-09-30 17:32:06 +02:00

294 lines
13 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <imgui/imgui_plot.hpp>
#include <imgui.h>
namespace plot::imgui {
namespace {
/// ImGui's button indices are left, right, middle; the controller's enum is the
/// same three, spelled out.
input::Button button_from_index(int index) noexcept {
switch (index) {
case 1:
return input::Button::Right;
case 2:
return input::Button::Middle;
default:
return input::Button::Left;
}
}
/// ImGui's modifier booleans as the controller's translatable mask. The bit
/// values are `input::Mod`'s by definition: it is documented as the mask a host
/// translates into, and the GLFW adapter's static_asserts pin the same layout.
u32 mods_from(const ImGuiIO& io) noexcept {
u32 mods = 0;
if (io.KeyShift) mods |= static_cast<u32>(input::Mod::Shift);
if (io.KeyCtrl) mods |= static_cast<u32>(input::Mod::Ctrl);
if (io.KeyAlt) mods |= static_cast<u32>(input::Mod::Alt);
if (io.KeySuper) mods |= static_cast<u32>(input::Mod::Super);
return mods;
}
} // namespace
void PlotWidget::draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer) {
ImGuiIO& io = ImGui::GetIO();
if (!controller_ || plot_ != &plot) {
// The controller holds a Plot& (and the drag state that goes with it), so a
// different plot is a different controller; re-binding drops any drag rather
// than replaying its edges against the new plot.
controller_.emplace(plot);
plot_ = &plot;
reset_pointer();
}
// The whole translation below rests on the backend having filled in a sane
// scale; a zero one would collapse the region and silently draw nothing.
IM_ASSERT(io.DisplayFramebufferScale.x > 0.0f && io.DisplayFramebufferScale.y > 0.0f);
// The child is a plot surface, not a scrollable panel: no scrollbar, whatever
// the item inside it measures. The wheel belongs to the plot while it is
// hovered, and is taken away from window scrolling by the key owner below.
const ImGuiWindowFlags child_flags = ImGuiWindowFlags_NoScrollbar;
// The child is the plot's surface: it must paint nothing of its own over it.
// ChildBg is transparent in every stock theme, but a host may have pushed a
// panel colour; forcing it here means the widget can never cover its own plot.
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f));
const bool shown =
ImGui::BeginChild(id, ImVec2(size.x, size.y), ImGuiChildFlags_None, child_flags);
if (!shown) {
// Clipped away entirely (or collapsed): there is no region to draw into, and
// no rect to aim a gesture at. EndChild is still owed.
ImGui::EndChild();
ImGui::PopStyleColor();
return;
}
// A borderless child has no padding and no border, so the content region is the
// child's rectangle itself: the pixels the plot draws are exactly the ones the
// host laid out for it -- if the host leaves them visible. ImGui hands a child
// the clip rect of an ancestor that cuts it off (and the child's own rect when
// nothing does, which makes the intersection below a no-op); the widget's GL
// draws do not go through ImGui's clipping, so a child the hosting window cuts
// off would otherwise clear and paint over whatever the host drew outside it.
const ImVec2 origin = ImGui::GetCursorScreenPos();
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 clip_min = ImGui::GetWindowDrawList()->GetClipRectMin();
const ImVec2 clip_max = ImGui::GetWindowDrawList()->GetClipRectMax();
const f32 x0 = origin.x > clip_min.x ? origin.x : clip_min.x;
const f32 y0 = origin.y > clip_min.y ? origin.y : clip_min.y;
const f32 x1 = origin.x + avail.x < clip_max.x ? origin.x + avail.x : clip_max.x;
const f32 y1 = origin.y + avail.y < clip_max.y ? origin.y + avail.y : clip_max.y;
if (x1 <= x0 || y1 <= y0) {
// Nothing of the child is visible: no region to draw into, no rect to aim a
// gesture at, and InvisibleButton wants a non-zero size.
ImGui::EndChild();
ImGui::PopStyleColor();
return;
}
const ImVec2 visible{x1 - x0, y1 - y0};
// An invisible item covering the *visible* region is what makes it *the* hovered
// thing: `IsItemHovered()` is false while a drag elsewhere in the host is active,
// so the plot does not pan when the user drags a slider next to it, and
// `SetItemKeyOwner()` -- ImGui's documented way for a widget to take the wheel
// away from window scrolling -- needs an item to own it. Both buttons are
// enabled: the controller's left and right drags are the plot's own, and ImGui
// only needs to know that the pointer is interacting with this region.
ImGui::SetCursorScreenPos(ImVec2(x0, y0));
ImGui::InvisibleButton("surface", visible,
ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight |
ImGuiButtonFlags_MouseButtonMiddle);
const bool hovered = ImGui::IsItemHovered();
ImGui::SetItemKeyOwner(ImGuiKey_MouseWheelY);
const ImVec2 scale = io.DisplayFramebufferScale;
const f32 fb_w_f = io.DisplaySize.x * scale.x;
const f32 fb_h_f = io.DisplaySize.y * scale.y;
// Points -> framebuffer pixels: the assumption documented on the class. State
// it in the arithmetic so a reader sees where the mapping enters.
const Rect region{x0 * scale.x, y0 * scale.y, visible.x * scale.x, visible.y * scale.y};
if (fb_w_f >= 1.0f && fb_h_f >= 1.0f) {
// One viewport for the gesture geometry and the frame: the controller
// forwards it to the plot (and re-lays it out), and `begin_frame` gets the
// same rect and the whole framebuffer it is a sub-rectangle of.
controller_->set_viewport(region);
forward_input(hovered);
// The backend's framebuffer scale is the display's scale, so the renderer draws
// its logical style metrics at it: the same assertion above pins it positive,
// and the chrome is then the same physical size whatever surface the host
// renders into.
renderer.style().ui_scale = scale.x;
const u32 fb_w = static_cast<u32>(fb_w_f + 0.5f);
const u32 fb_h = static_cast<u32>(fb_h_f + 0.5f);
renderer.begin_frame(fb_w, fb_h, region);
renderer.draw(plot);
renderer.end_frame();
// `end_frame` leaves the GL state neutral and the viewport at the plot's
// rect; ImGui's backend sets its own viewport, blend function and so on
// before its first draw, so the chrome renders correctly after this.
}
ImGui::EndChild();
ImGui::PopStyleColor();
}
void PlotWidget::forward_input(bool hovered) {
ImGuiIO& io = ImGui::GetIO();
const ImVec2 scale = io.DisplayFramebufferScale;
// ImGui reports "the pointer is not on this window" as a sentinel position:
// imgui_impl_glfw's cursor-enter callback leaves (-FLT_MAX, -FLT_MAX) when the
// pointer leaves, and ClearInputMouse() does the same on a lost focus. Scaling
// that and differencing it against the last real position would hand the
// controller a ~1e35 "movement" for that frame -- and with a press captured over
// the plot, that movement is a pan, so the range would leave the representable
// world (and only `f` would bring it back, since a gesture that sets a range also
// clears auto scale). An invalid position is therefore no position *update*: the
// last real one stands, the movement is zero, and the button levels below still
// flow, so the release that ends a drag is never lost.
const bool pos_valid = ImGui::IsMousePosValid(&io.MousePos);
const f32 px = pos_valid ? io.MousePos.x * scale.x : prev_x_;
const f32 py = pos_valid ? io.MousePos.y * scale.y : prev_y_;
const bool down[3] = {io.MouseDown[0], io.MouseDown[1], io.MouseDown[2]};
const bool any_down = down[0] || down[1] || down[2];
const u32 mods = mods_from(io);
if ((!pos_valid && !have_prev_pos_) || (!hovered && !capturing_)) {
// Either no position has ever been reported (nothing to aim at, no movement
// to forward), or the pointer is nowhere near the plot. Remember the levels
// either way, so re-entering the region does not synthesise an edge out of the
// state it left with -- and so a re-bound plot, which keeps its state too,
// cannot fabricate one either.
prev_down_[0] = down[0];
prev_down_[1] = down[1];
prev_down_[2] = down[2];
if (pos_valid) {
prev_x_ = px;
prev_y_ = py;
have_prev_pos_ = true;
}
return;
}
// The movement since the previous frame is what a pan consumes; it rides the
// first event of this frame and nothing else, so one frame's motion can never
// be counted twice. An invalid position contributes none of it.
const f32 dx = (pos_valid && have_prev_pos_) ? px - prev_x_ : 0.0f;
const f32 dy = (pos_valid && have_prev_pos_) ? py - prev_y_ : 0.0f;
bool delta_pending = true;
const auto deliver = [&](input::Button button, bool pressed, bool released) {
input::PointerEvent e;
e.x = px;
e.y = py;
if (delta_pending) {
e.dx = dx;
e.dy = dy;
delta_pending = false;
}
e.button = button;
e.mods = mods;
e.down = any_down;
e.pressed = pressed;
e.released = released;
// ImGui's own clock: monotonic seconds since the context was created. The
// double-click detector differences two presses' stamps, so only the unit
// matters -- and ImGui's backend-driven time keeps running while the pointer
// is off-window, which is exactly what a wall clock should do.
e.time_s = ImGui::GetTime();
controller_->on_pointer(e);
};
// ImGui reports levels; the controller wants edges. One event per button whose
// level changed, which is the press or the release the host's callback would
// have carried.
for (int i = 0; i < 3; ++i) {
if (down[i] == prev_down_[i]) continue;
deliver(button_from_index(i), down[i], !down[i]);
}
if (delta_pending) {
// No transition this frame: one plain move sample, so a pan in progress
// advances by exactly the movement since the last one and
// Controller::hovered_cursor() sees where the pointer is.
deliver(input::Button::Left, false, false);
}
if (hovered && io.MouseWheel != 0.0f) {
input::WheelEvent e;
e.x = px;
e.y = py;
e.dx = io.MouseWheelH;
e.dy = io.MouseWheel; // ImGui's positive y (scroll up) is the zoom-in sign
e.mods = mods;
controller_->on_wheel(e);
}
// Keyboard gestures, on the same hover gate as the pointer: a key pressed
// while the plot is under the pointer is aimed at the plot. `+` on most
// layouts is shift+`=`, and either spelling zooms in, as in the GLFW adapter.
//
// Every mapped key is asked with repeat = true. This is the adapter's whole key
// -repeat policy, and the odd one out among the hosts: GLFW, Qt and GTK each
// deliver the OS's auto-repeat as extra press events, while ImGui edge-detects
// presses once and only re-fires `IsKeyPressed` while the key is *held* if it is
// asked to. Holding `+` or an arrow on a plot is a zoom/pan the user expects to
// keep going (docs/embedding.md's shared policy: a held key repeats in every
// adapter), so the repeat is requested here. Escape repeats harmlessly --
// cancelling an already-cancelled drag is a no-op.
if (hovered && !io.WantTextInput) {
const auto key = [&](int code) {
input::KeyEvent e;
e.key = code;
e.mods = mods;
e.down = true; // ImGui already edge-detected the press
controller_->on_key(e);
};
const auto pressed = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, true); };
if (pressed(ImGuiKey_Equal) || pressed(ImGuiKey_KeypadAdd)) key(input::kKeyZoomIn);
if (pressed(ImGuiKey_Minus) || pressed(ImGuiKey_KeypadSubtract)) key(input::kKeyZoomOut);
if (pressed(ImGuiKey_F)) key(input::kKeyFit);
if (pressed(ImGuiKey_LeftArrow)) key(input::kKeyLeft);
if (pressed(ImGuiKey_RightArrow)) key(input::kKeyRight);
if (pressed(ImGuiKey_UpArrow)) key(input::kKeyUp);
if (pressed(ImGuiKey_DownArrow)) key(input::kKeyDown);
if (pressed(ImGuiKey_Escape)) key(input::kKeyEscape);
}
prev_down_[0] = down[0];
prev_down_[1] = down[1];
prev_down_[2] = down[2];
if (pos_valid) {
prev_x_ = px;
prev_y_ = py;
have_prev_pos_ = true;
}
// Capture: a press delivered while the pointer was over the child keeps the
// release flowing even once the pointer has left it.
capturing_ = any_down && (capturing_ || hovered);
}
void PlotWidget::reset_pointer() noexcept {
// The remembered button levels deliberately survive a re-bind, because they are
// the host's input state and not the plot's: clearing them would make the next
// frame synthesise a press out of a button that is already down, and a host
// drawing two plots through one widget re-binds twice a frame. Only the
// position memory and the capture are dropped, so the new plot's first event
// carries no movement carried over from the old one.
prev_x_ = 0.0f;
prev_y_ = 0.0f;
have_prev_pos_ = false;
capturing_ = false;
}
} // namespace plot::imgui