fix(imgui): keep the plot inside the child's clip rect and reject unusable input

The widget drew a region derived from the child's content rect alone, so a child
the hosting window cut off cleared and painted over the host's pixels; it also
scaled ImGui's (-FLT_MAX, -FLT_MAX) no-pointer sentinel and differenced it against
the last real position, turning a drag out of the window into a ~1e35 pan, and it
forgot the host's button levels across a re-bind, handing the next plot a press
that never happened.

The region is now the content rect intersected with the child's clip rect (a
no-op when nothing clips it), an invalid position is no position update, and the
button levels survive a re-bind. The controller sanitises non-finite coordinates
and deltas for any host, and the widget's input translation now has a committed
test on the headless EGL context.
This commit is contained in:
Martino Ferrari committed 2026-09-28 00:54:25 +02:00
1 parent 15931a2405
commit b5b9b42be1
8 files changed
+697 -58

No files matched your search

+440
View File
@@ -0,0 +1,440 @@
// 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());
// And a real position after the sentinel is not differenced against the sentinel.
f.frame(ImVec2(300.0f, 300.0f), false);
PLOT_CHECK_NEAR(f.x_range().min, pressed.min, 1e-9);
PLOT_CHECK_NEAR(f.x_range().max, pressed.max, 1e-9);
}
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));
}