Files
plotter/tests/test_imgui_widget.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

613 lines
26 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The Dear ImGui adapter's own logic, pinned against real ImGui frames on the
// tests' surfaceless EGL context -- no window, no display server.
//
// What is tested here is what this project owns, not the toolkit's: ImGui reports
// input as *levels* where `plot::input` wants edges, so the widget synthesises
// them, gates delivery on hover, captures a drag across a region exit, and takes
// the wheel away from window scrolling; and because it draws through GL rather
// than ImGui's draw list, it has to keep its painting inside the child's *visible*
// rectangle itself. The suite is otherwise ImGui-free, so this file and its link
// are added only when the adapter is built (tests/CMakeLists.txt).
#include "harness.hpp"
#include "egl_headless.hpp"
#include <imgui/imgui_plot.hpp>
#include <imgui.h>
#include <plot/data/stream_series.hpp>
#include <plot/gl/api.hpp>
#include <plot/render/renderer.hpp>
#include <cmath>
#include <cstdint>
#include <limits>
#include <utility>
#include <vector>
namespace {
/// The off-screen target and the ImGui display are the same size: the widget's
/// region is a sub-rectangle of the framebuffer, which is the point of the test.
constexpr u32 kFb = 512;
constexpr float kDisplay = 512.0f;
constexpr float kChild = 400.0f;
/// A plot the widget drives, and the host frame around it.
struct Fixture {
plottest::HeadlessContext ctx;
plot::Renderer renderer;
plot::data::StreamSeries series{4096, 0.0, 0.01};
plot::Plot plot;
plot::imgui::PlotWidget widget;
u32 ax = 0;
u32 ay = 0;
/// A second plot, so a host that draws two through one widget can be tested.
plot::Plot other;
u32 ax_other = 0;
u32 ay_other = 0;
float win_w = kDisplay;
float win_h = kDisplay;
float child_w = kChild;
float child_h = kChild;
/// Content past the window, so the hosting window has a scroll range: the wheel
/// containment test needs one to be able to observe a scroll at all.
bool tall_parent = false;
bool prev_down = false;
ImVec2 child_origin{0.0f, 0.0f}; ///< the child's top-left, read in-frame (points)
float scroll_y = 0.0f; ///< the hosting window's scroll, read in-frame
/// The pixels the widget painted: the bounding box of everything that is not the
/// host's clear colour, in top-left framebuffer coordinates.
u32 paint_min_x = kFb;
u32 paint_min_y = kFb;
u32 paint_max_x = 0;
u32 paint_max_y = 0;
/// The host's own frame. Green on purpose: no colour the plot draws, nor any
/// blend of them, is (0, 192, 0), so "the host's pixel survived" is decidable.
plot::Color host_color = plot::Color::hex(0x00C000);
std::uint8_t host_rgb_[3] = {0, 0, 0};
Fixture() : ctx(kFb, kFb) {}
~Fixture() { ImGui::DestroyContext(); }
Fixture(const Fixture&) = delete;
Fixture& operator=(const Fixture&) = delete;
bool init() {
plot::Status st;
if (!ctx.create(&st)) {
std::printf("[FAIL] headless context: %s\n", st.message);
return false;
}
if (!renderer.init(&st)) {
std::printf("[FAIL] renderer init: %s\n", st.message);
return false;
}
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.IniFilename = nullptr;
io.DisplaySize = ImVec2(kDisplay, kDisplay);
io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f);
io.Fonts->AddFontDefault();
std::uint8_t* pixels = nullptr;
int tw = 0;
int th = 0;
// The widget needs a built atlas exactly as a platform backend would leave it;
// no renderer backend is involved, so the texture id is a placeholder.
io.Fonts->GetTexDataAsRGBA32(&pixels, &tw, &th);
io.Fonts->SetTexID(static_cast<ImTextureID>(1));
for (int i = 0; i < 4096; ++i) series.push(static_cast<f32>(std::sin(0.05 * i)));
series.acquire();
ax = plot.add_x_axis({"time", {0.0, 20.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"signal", {-1.2, 1.2}, false, plot::AxisPlace::Left, false});
plot.add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
plot.set_x_range(ax, {0.0, 20.0}); // explicit: clears auto scale, so a pan shows
plot.set_y_range(ay, {-1.2, 1.2});
other.set_viewport(plot.viewport());
ax_other = other.add_x_axis({"time", {0.0, 50.0}, false, plot::AxisPlace::Bottom, false});
ay_other = other.add_y_axis({"signal", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
other.add_trace({&series, plot::Color::hex(0xFFB020), 1.5f, ax_other, ay_other});
other.set_x_range(ax_other, {0.0, 50.0});
other.set_y_range(ay_other, {-1.0, 1.0});
// The host colour as the pipeline actually produced it: the pixel tests compare
// bytes, not a rounding of the clear value.
clear_host();
std::uint8_t probe[4] = {0, 0, 0, 0};
pixel(0, 0, probe);
host_rgb_[0] = probe[0];
host_rgb_[1] = probe[1];
host_rgb_[2] = probe[2];
return true;
}
void clear_host() {
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height));
plot::gl::glClearColor(host_color.r, host_color.g, host_color.b, host_color.a);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
}
/// One widget frame with the host's input queued exactly as a platform backend
/// would queue it. `pos` is in ImGui points; `-FLT_MAX` is ImGui's "the pointer is
/// not on this window" sentinel.
void frame(const ImVec2& pos, bool down, float wheel = 0.0f, plot::Plot* target = nullptr) {
plot::Plot& shown = target != nullptr ? *target : plot;
ImGuiIO& io = ImGui::GetIO();
io.AddMousePosEvent(pos.x, pos.y);
if (down != prev_down) {
io.AddMouseButtonEvent(0, down);
prev_down = down;
}
if (wheel != 0.0f) io.AddMouseWheelEvent(0.0f, wheel);
// A bare window: no background to paint over the plot, and no title bar or
// scrollbar to add pixels the scan below would mistake for the widget's.
const ImGuiWindowFlags host_flags = ImGuiWindowFlags_NoBackground |
ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar;
ImGui::NewFrame();
ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(win_w, win_h), ImGuiCond_Always);
ImGui::Begin("host", nullptr, host_flags);
child_origin = ImGui::GetCursorScreenPos(); // where the widget's child goes
widget.draw("plot", shown, plot::Vec2{child_w, child_h}, renderer);
if (tall_parent) ImGui::Dummy(ImVec2(10.0f, 900.0f)); // content past the window
scroll_y = ImGui::GetScrollY();
ImGui::End();
ImGui::Render();
}
/// The framebuffer pixel at a *top-left* coordinate. `read_pixel` speaks GL's
/// bottom-left convention, as the render smoke test notes.
void pixel(u32 x, u32 y, std::uint8_t out[4]) {
if (!ctx.read_pixel(x, ctx.height - 1u - y, out)) PLOT_CHECK(false);
}
bool is_host(const std::uint8_t px[4]) const {
return px[0] == host_rgb_[0] && px[1] == host_rgb_[1] && px[2] == host_rgb_[2];
}
/// Records the bounding box of the pixels that are not the host's clear colour.
/// Reads the whole framebuffer once, so a draw that escaped the widget's region
/// shows up even if it is nowhere near the pixels a point check samples.
void scan_painted() {
std::uint8_t probe[4] = {0, 0, 0, 0};
pixel(0, 0, probe); // read_pixel binds the target the frame was drawn into
std::vector<std::uint8_t> rgba(static_cast<plot::usize>(kFb) * kFb * 4u, 0u);
plot::gl::glPixelStorei(plot::gl::GL_PACK_ALIGNMENT, 4);
plot::gl::glReadPixels(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height), plot::gl::GL_RGBA,
plot::gl::GL_UNSIGNED_BYTE, rgba.data());
paint_min_x = kFb;
paint_min_y = kFb;
paint_max_x = 0;
paint_max_y = 0;
for (u32 y = 0; y < kFb; ++y) {
// glReadPixels hands rows back bottom-up; this scan speaks top-left, the
// convention every Rect here uses.
const std::uint8_t* row =
rgba.data() + static_cast<plot::usize>(kFb - 1u - y) * kFb * 4u;
for (u32 x = 0; x < kFb; ++x) {
if (is_host(row + static_cast<plot::usize>(x) * 4u)) continue;
if (x < paint_min_x) paint_min_x = x;
if (x > paint_max_x) paint_max_x = x;
if (y < paint_min_y) paint_min_y = y;
if (y > paint_max_y) paint_max_y = y;
}
}
}
plot::Range x_range() const { return plot.x_range(ax); }
bool panning() const {
const plot::input::Controller* c = widget.controller();
return c != nullptr && c->drag_mode() == plot::input::DragMode::Pan;
}
};
constexpr float kMaxFloat = std::numeric_limits<float>::max();
} // namespace
PLOT_TEST(imgui_widget_pans_only_while_hovered_and_dragging) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
// A hovered pointer with no button moves nothing: the pointer must be *over* the
// plot and a drag must be in progress.
f.frame(ImVec2(200.0f, 200.0f), false);
const plot::Range hovered = f.x_range();
f.frame(ImVec2(260.0f, 200.0f), false);
PLOT_CHECK_NEAR(f.x_range().min, hovered.min, 1e-9);
PLOT_CHECK_NEAR(f.x_range().max, hovered.max, 1e-9);
// A press begins a pan, but a press alone moves nothing either.
f.frame(ImVec2(200.0f, 200.0f), true);
PLOT_CHECK(f.panning());
PLOT_CHECK_NEAR(f.x_range().min, hovered.min, 1e-9);
// The drag pans (grab-and-drag: the data follows the cursor) and keeps the span.
const plot::Range pressed = f.x_range();
f.frame(ImVec2(260.0f, 200.0f), true);
PLOT_CHECK(f.x_range().min < pressed.min - 1e-6);
PLOT_CHECK_NEAR(f.x_range().span(), pressed.span(), 1e-9);
// The release ends it, and the next hovered move must not pan again.
f.frame(ImVec2(260.0f, 200.0f), false);
PLOT_CHECK(!f.panning());
const plot::Range released = f.x_range();
f.frame(ImVec2(300.0f, 200.0f), false);
PLOT_CHECK_NEAR(f.x_range().min, released.min, 1e-9);
PLOT_CHECK_NEAR(f.x_range().max, released.max, 1e-9);
// A press and drag *outside* the child -- the slider-in-the-same-window case --
// must not touch the plot at all.
f.frame(ImVec2(470.0f, 470.0f), false);
f.frame(ImVec2(470.0f, 470.0f), true);
f.frame(ImVec2(440.0f, 440.0f), true);
PLOT_CHECK_NEAR(f.x_range().min, released.min, 1e-9);
f.frame(ImVec2(440.0f, 440.0f), false);
}
PLOT_TEST(imgui_widget_drag_capture_survives_leaving_the_child) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true);
const plot::Range pressed = f.x_range();
// Leftwards inside the child: the range minimum rises.
f.frame(ImVec2(160.0f, 200.0f), true);
PLOT_CHECK(f.x_range().min > pressed.min + 1e-6);
const plot::Range inside = f.x_range();
// Rightwards, with the pointer now outside the child: a captured drag keeps
// panning, or the gesture would silently stop following the cursor.
f.frame(ImVec2(470.0f, 470.0f), true);
PLOT_CHECK(f.x_range().min < inside.min - 1e-6);
// ... and the release arriving out there still ends it.
const plot::Range outside = f.x_range();
f.frame(ImVec2(470.0f, 470.0f), false);
PLOT_CHECK(!f.panning());
f.frame(ImVec2(430.0f, 430.0f), false);
PLOT_CHECK_NEAR(f.x_range().min, outside.min, 1e-9);
}
PLOT_TEST(imgui_widget_wheel_zooms_when_hovered_and_does_not_scroll_the_host) {
Fixture f;
f.tall_parent = true; // the hosting window can scroll, so containment is testable
if (!f.init()) PLOT_CHECK(false);
// Control: the wheel scrolls the hosting window when it is over the window's own
// area. Downwards, because ImGui clamps a scroll at the top and would prove
// nothing. Two frames of quiet first: ImGui sizes a window's content (and so its
// scroll range) from the previous frame, and a wheel arriving before that range
// is known is not scrolled anywhere.
f.frame(ImVec2(470.0f, 470.0f), false);
f.frame(ImVec2(470.0f, 470.0f), false);
const float scroll_before = f.scroll_y;
f.frame(ImVec2(470.0f, 470.0f), false, -1.0f);
PLOT_CHECK(f.scroll_y > scroll_before + 1.0f);
// The same wheel over the parent must not zoom the plot: it is not hovered.
const plot::Range over_parent = f.x_range();
f.frame(ImVec2(470.0f, 470.0f), false, -1.0f);
PLOT_CHECK_NEAR(f.x_range().span(), over_parent.span(), 1e-9);
// Over the plot, the wheel zooms it (positive scrolls up, which zooms in) and
// the hosting window does not scroll.
f.frame(ImVec2(200.0f, 200.0f), false); // hover: claims the wheel for the plot
const float scroll_before_plot = f.scroll_y;
const double span_before = f.x_range().span();
f.frame(ImVec2(200.0f, 200.0f), false, -1.0f);
f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK(f.x_range().span() > span_before);
PLOT_CHECK_NEAR(static_cast<double>(f.scroll_y), static_cast<double>(scroll_before_plot), 1e-3);
const double span_before_in = f.x_range().span();
f.frame(ImVec2(200.0f, 200.0f), false, 1.0f);
PLOT_CHECK(f.x_range().span() < span_before_in);
}
PLOT_TEST(imgui_widget_invalid_position_does_not_move_the_range) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true); // press over the plot: capture
const plot::Range pressed = f.x_range();
// The platform backends leave (-FLT_MAX, -FLT_MAX) behind when the pointer leaves
// the window (imgui_impl_glfw's cursor-enter callback) or the window loses focus
// (ClearInputMouse). Scaled by the framebuffer scale and differenced against the
// last real position, that is a ~1e35 movement -- and with a press captured, a
// ~1e35 pan, which `set_x_range` would take (and, clearing auto scale, would not
// give back).
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), true);
PLOT_CHECK(std::isfinite(f.x_range().min) && std::isfinite(f.x_range().max));
PLOT_CHECK_NEAR(f.x_range().min, pressed.min, 1e-9);
PLOT_CHECK_NEAR(f.x_range().max, pressed.max, 1e-9);
PLOT_CHECK(f.panning()); // still captured: only the position was unusable
// The release still ends the drag even with no usable position in the event.
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), false);
PLOT_CHECK(!f.panning());
// A drag held across a sentinel, then a real position again. The movement must be
// measured from the last *real* position -- 100 px from (200, 200) to (300, 300) --
// and not from the sentinel. This is asserted with the drag alive on purpose:
// nothing can move a range once the button is up, so the same check after the
// release could not fail and would prove nothing.
//
// The quiet frame before the second press is aged past the double-click window on
// purpose: this press lands in the same spot within the window of the first one,
// and the widget stamps presses with ImGui's clock, so without the age the two
// presses *are* a double-click -- the controller re-fits and begins no drag, which
// is the gesture the next test pins, not the sentinel behaviour this one is here
// for.
ImGuiIO& io = ImGui::GetIO();
io.DeltaTime = 0.4f;
f.frame(ImVec2(200.0f, 200.0f), false);
io.DeltaTime = 1.0f / 60.0f;
f.frame(ImVec2(200.0f, 200.0f), true);
const plot::Range second_press = f.x_range();
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), true);
PLOT_CHECK(f.panning());
const double axes_w = static_cast<double>(std::as_const(f.plot).layout().axes.w);
const double expected_shift = 100.0 / axes_w * second_press.span();
f.frame(ImVec2(300.0f, 300.0f), true);
PLOT_CHECK(f.panning());
PLOT_CHECK(std::isfinite(f.x_range().min));
PLOT_CHECK_NEAR(f.x_range().min, second_press.min - expected_shift, 0.05 * expected_shift);
f.frame(ImVec2(300.0f, 300.0f), false);
PLOT_CHECK(!f.panning());
}
PLOT_TEST(imgui_widget_paints_the_whole_child_when_nothing_clips_it) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
f.clear_host();
f.frame(ImVec2(200.0f, 200.0f), false);
f.scan_painted();
// The requested child rectangle, in top-left coordinates.
const u32 x0 = static_cast<u32>(f.child_origin.x);
const u32 y0 = static_cast<u32>(f.child_origin.y);
const u32 x1 = x0 + static_cast<u32>(f.child_w);
const u32 y1 = y0 + static_cast<u32>(f.child_h);
// The clamp against the clip rect must be a no-op when nothing clips the child:
// the painted rectangle is exactly the child's, corner to corner.
PLOT_CHECK(f.paint_min_x <= x0 + 1u && f.paint_min_y <= y0 + 1u);
PLOT_CHECK(f.paint_max_x >= x1 - 2u && f.paint_max_y >= y1 - 2u);
// ... and nothing of it escapes: the pixels past the child's edge are still the
// host's.
std::uint8_t out[4] = {0, 0, 0, 0};
f.pixel(x1 + 6u, y1 + 6u, out);
PLOT_CHECK(f.is_host(out));
f.pixel(x1 + 6u, y0 + 6u, out);
PLOT_CHECK(f.is_host(out));
}
PLOT_TEST(imgui_widget_rebinding_a_plot_does_not_fabricate_a_press) {
Fixture f;
if (!f.init()) PLOT_CHECK(false);
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true); // press over the first plot
PLOT_CHECK(f.panning());
// The host switches to its second plot with the button still held. ImGui reports
// a *level*, so there is no press edge in this frame: synthesising one out of the
// clear state a re-bind starts from would begin a drag the user never made.
f.frame(ImVec2(210.0f, 200.0f), true, 0.0f, &f.other);
PLOT_CHECK(f.widget.controller() != nullptr);
PLOT_CHECK(!f.panning());
const plot::Range other_before = f.other.x_range(f.ax_other);
f.frame(ImVec2(260.0f, 200.0f), true, 0.0f, &f.other);
PLOT_CHECK(!f.panning());
PLOT_CHECK_NEAR(f.other.x_range(f.ax_other).min, other_before.min, 1e-9);
f.frame(ImVec2(260.0f, 200.0f), false, 0.0f, &f.other); // release
PLOT_CHECK(!f.panning());
// Back on the first plot, a press that *is* a press still starts a drag: the
// button state was kept across the re-bind, not frozen.
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true);
PLOT_CHECK(f.panning());
}
PLOT_TEST(imgui_widget_paints_only_the_visible_part_of_a_clipped_child) {
Fixture f;
f.win_w = 200.0f; // the child (400x400) cannot fit, so ImGui clips it
f.win_h = 200.0f;
if (!f.init()) PLOT_CHECK(false);
f.clear_host();
// The pointer is outside the window entirely: this is about painting, not input.
f.frame(ImVec2(300.0f, 300.0f), false);
f.scan_painted();
const u32 x0 = static_cast<u32>(f.child_origin.x);
const u32 y0 = static_cast<u32>(f.child_origin.y);
const u32 x1 = x0 + static_cast<u32>(f.child_w); // the *requested* rectangle
const u32 y1 = y0 + static_cast<u32>(f.child_h);
// GL draws do not go through ImGui's clipping, so a child the window cuts off
// would otherwise clear and paint over the host's pixels beside it. The painted
// rectangle must stop well short of the requested one ...
PLOT_CHECK(f.paint_max_x + 20u < x1);
PLOT_CHECK(f.paint_max_y + 20u < y1);
// ... while still covering the part that is visible: this is a clamp, not a
// refusal to draw.
PLOT_CHECK(f.paint_max_x - f.paint_min_x > 100u);
PLOT_CHECK(f.paint_max_y - f.paint_min_y > 100u);
// The pixel deep inside the requested rectangle but outside the visible part kept
// the host's colour, and the one near the visible corner was painted.
std::uint8_t out[4] = {0, 0, 0, 0};
f.pixel(x1 - 5u, y1 - 5u, out);
PLOT_CHECK(f.is_host(out));
f.pixel(f.paint_min_x + 40u, f.paint_min_y + 40u, out);
PLOT_CHECK(!f.is_host(out));
}
/// The widget states the display's scale to the renderer, which is what makes the
/// chrome one physical size on any DPI: `io.DisplayFramebufferScale` is the same
/// factor the widget already maps points to framebuffer pixels by, so a host on a 2x
/// surface gets a 2x *device* font and a 2x device strip -- the same logical layout
/// as on a 1x one.
PLOT_TEST(imgui_widget_states_the_display_scale_to_the_renderer) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f);
f.frame(ImVec2(10.0f, 10.0f), false);
PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 1.0f, 1e-6);
const plot::f32 cell_1x = f.renderer.axis_font_px();
const plot::f32 strip_1x = f.plot.layout().y_axis_width;
PLOT_CHECK(cell_1x > 1.0f);
PLOT_CHECK(strip_1x > 1.0f);
// The same widget and the same logical size, on a 2x surface.
io.DisplayFramebufferScale = ImVec2(2.0f, 2.0f);
f.frame(ImVec2(10.0f, 10.0f), false);
PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 2.0f, 1e-6);
// The renderer hands it to the plot before laying out, so the *device* chrome
// doubles while the logical figure (device / scale) is what the 1x run measured.
PLOT_CHECK_NEAR(f.plot.ui_scale(), 2.0f, 1e-6);
PLOT_CHECK_NEAR(f.renderer.axis_font_px(), 2.0f * cell_1x, 1e-3);
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, 2.0f * strip_1x, 0.05f);
// Back to 1x: the scale is re-read every frame rather than latched at the first
// one, so a window moved to another display follows.
io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f);
f.frame(ImVec2(10.0f, 10.0f), false);
PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 1.0f, 1e-6);
PLOT_CHECK_NEAR(f.renderer.axis_font_px(), cell_1x, 1e-3);
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, strip_1x, 1e-3);
}
// --- keys -------------------------------------------------------------------
// A held key repeats. This is the adapter's own policy decision, not ImGui's
// default: ImGui edge-detects a press once, and `IsKeyPressed` only re-fires
// while the key is held when it is asked with repeat = true -- which the widget
// now does for every key it maps, because GLFW/Qt/GTK each deliver the OS's
// auto-repeat as extra presses and a held `+` or arrow must behave the same here
// (docs/embedding.md's shared policy). Held with `repeat = false`, the zoom
// happens exactly once; with `repeat = true`, once per repeat ImGui's timer
// fires, so the span keeps growing for as long as the key is down.
PLOT_TEST(imgui_widget_repeats_a_held_key) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
// A repeat clock a test can afford: first repeat one frame after the press,
// then one per frame, each frame a twentieth of a second of ImGui time.
io.DeltaTime = 0.05f;
io.KeyRepeatDelay = 0.05f;
io.KeyRepeatRate = 0.05f;
// Hover the plot (the keys are gated on hover) and press `-` once.
f.frame(ImVec2(200.0f, 200.0f), false);
io.AddKeyEvent(ImGuiKey_Minus, true);
f.frame(ImVec2(200.0f, 200.0f), false);
const double span_after_press = f.x_range().span();
PLOT_CHECK(span_after_press > 20.0); // one zoom-out detent on the {0, 20} fixture
// Hold it for half a second of ImGui time. Each repeat zooms again, so the
// span ends up several detents past the single press -- a single-fire widget
// would leave it exactly where the press left it.
for (int i = 0; i < 10; ++i) f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK(f.x_range().span() > span_after_press * 1.5);
io.AddKeyEvent(ImGuiKey_Minus, false);
f.frame(ImVec2(200.0f, 200.0f), false);
}
// The arrows pan and Escape cancels a drag, mapped to the controller's
// host-translated constants (`kKeyLeft`, `kKeyEscape`): one press of Left moves
// every x axis 10% of its span, and Escape hands the axes back the ranges they
// had when the drag began.
PLOT_TEST(imgui_widget_maps_arrows_and_escape) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
f.frame(ImVec2(200.0f, 200.0f), false);
const double span = f.x_range().span();
const double min_before = f.x_range().min;
// One Left press: every x axis slides by 10% of its span, and the span itself
// is untouched (a pan, not a zoom).
io.AddKeyEvent(ImGuiKey_LeftArrow, true);
f.frame(ImVec2(200.0f, 200.0f), false);
io.AddKeyEvent(ImGuiKey_LeftArrow, false);
f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK_NEAR(f.x_range().span(), span, 1e-9);
PLOT_CHECK(std::abs(f.x_range().min - min_before) > 0.05 * span);
PLOT_CHECK(std::abs(f.x_range().min - min_before) < 0.15 * span);
// A drag that has panned, then Escape while the button is still down: the
// ranges go back to where they were when the drag began, and the drag is over.
const double before_drag = f.x_range().min;
f.frame(ImVec2(200.0f, 200.0f), true);
PLOT_CHECK(f.panning());
f.frame(ImVec2(260.0f, 200.0f), true);
PLOT_CHECK(std::abs(f.x_range().min - before_drag) > 1e-6); // the pan moved it
io.AddKeyEvent(ImGuiKey_Escape, true);
f.frame(ImVec2(260.0f, 200.0f), true);
io.AddKeyEvent(ImGuiKey_Escape, false);
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
f.frame(ImVec2(260.0f, 200.0f), false); // the release ends nothing: already cancelled
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
}
// The widget stamps every pointer event with ImGui's clock, which is what makes
// the double-click -- two presses of the same button within 0.35 s and 4 px --
// exist on this host: re-fitting every axis is the controller's gesture, and the
// widget is the only supplier of the times it differences. Two quick presses on
// the fixture (whose x range was pinned to {0, 20}) must hand the axes back to
// the data -- the fit's own bounds -- and the second press must not begin a drag.
PLOT_TEST(imgui_widget_stamps_presses_so_a_double_click_can_reset) {
Fixture f;
PLOT_CHECK(f.init());
// One quiet hovered frame first. The very first frame's events are not the
// widget's yet: ImGui has not decided the hover (and its input queue trickles),
// so a press queued on frame 1 is swallowed by the hover gate and the controller
// never sees it. Every press this test means to deliver needs the hover live,
// which is the same reason the other tests here warm up with a hovered frame.
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true); // press 1
f.frame(ImVec2(200.0f, 200.0f), false); // release
// Press 2 in the same spot one frame later: ~1/60 s apart with the default
// DeltaTime, well inside the 0.35 s and 4 px window.
f.frame(ImVec2(200.0f, 200.0f), true);
const double fitted = f.x_range().span();
const double fitted_min = f.x_range().min;
PLOT_CHECK(fitted > 40.0); // the data's own span, not the fixture's {0, 20}
f.frame(ImVec2(220.0f, 200.0f), true); // a move with the second press's button
// ... begins no drag: the fit's values stand exactly.
PLOT_CHECK_NEAR(f.x_range().span(), fitted, 1e-9);
PLOT_CHECK_NEAR(f.x_range().min, fitted_min, 1e-9);
f.frame(ImVec2(220.0f, 200.0f), false);
}