// 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 #include #include #include #include #include #include #include #include #include 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(1)); for (int i = 0; i < 4096; ++i) series.push(static_cast(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(ctx.width), static_cast(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 rgba(static_cast(kFb) * kFb * 4u, 0u); plot::gl::glPixelStorei(plot::gl::GL_PACK_ALIGNMENT, 4); plot::gl::glReadPixels(0, 0, static_cast(ctx.width), static_cast(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(kFb - 1u - y) * kFb * 4u; for (u32 x = 0; x < kFb; ++x) { if (is_host(row + static_cast(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::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(f.scroll_y), static_cast(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. f.frame(ImVec2(200.0f, 200.0f), false); 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(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(f.child_origin.x); const u32 y0 = static_cast(f.child_origin.y); const u32 x1 = x0 + static_cast(f.child_w); const u32 y1 = y0 + static_cast(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(f.child_origin.x); const u32 y0 = static_cast(f.child_origin.y); const u32 x1 = x0 + static_cast(f.child_w); // the *requested* rectangle const u32 y1 = y0 + static_cast(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); }