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

+84
View File
@@ -5,6 +5,7 @@
#include <plot/data/array_series.hpp>
#include <cmath>
#include <limits>
#include <utility>
#include <vector>
@@ -248,6 +249,89 @@ PLOT_TEST(key_minus_zooms_out) {
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
}
/// A host can hand the controller a coordinate that is not a number: ImGui reports
/// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out
/// of a degenerate mapping of its own. The pan arithmetic runs through the axis
/// transforms, so such a value would leave a range that is not a range -- and
/// because a gesture that sets a range also clears auto scale, panning and zooming
/// could not bring it back.
PLOT_TEST(non_finite_pointer_does_not_move_a_range) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const plot::Range xr = p.x_range(ax);
const plot::Range yr = p.y_range(ay);
c.on_pointer({100, 100, 0, 0, plot::input::Button::Left, 0, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
const float inf = std::numeric_limits<float>::infinity();
const float nan = std::numeric_limits<float>::quiet_NaN();
const float sentinel = -std::numeric_limits<float>::max(); // ImGui's "no pointer"
// The pointer-left-the-window sentinel arrives at the unrepresentable end of the
// axis, so scaling and differencing it is a 1e35 pan. It must read as no
// movement at all.
c.on_pointer({sentinel, sentinel, inf, -inf, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK(std::isfinite(p.x_range(ax).min) && std::isfinite(p.x_range(ax).max));
PLOT_CHECK(std::isfinite(p.y_range(ay).min) && std::isfinite(p.y_range(ay).max));
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-9);
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan); // still the same drag
// A usable position with an unusable delta is no movement either, and a later
// usable position is not differenced against the sentinel.
c.on_pointer({180, 130, inf, nan, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-9);
c.on_pointer({220, 160, 40, 30, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK(std::isfinite(p.x_range(ax).min)); // a real move still pans
PLOT_CHECK(p.x_range(ax).min < xr.min);
// The buttons in a sanitised event still act: the release ends the drag.
c.on_pointer({220, 160, 0, 0, plot::input::Button::Left, 0, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
}
PLOT_TEST(non_finite_wheel_does_not_break_a_range) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const float inf = std::numeric_limits<float>::infinity();
const float nan = std::numeric_limits<float>::quiet_NaN();
// A zoom is anchored at the data coordinate under the pointer, so a position that
// is not a number must not become the anchor: the axis would be re-centred on
// NaN, which no gesture could undo. The fallback is a real coordinate and the
// zoom still happens. (A *finite* sentinel -- ImGui's `-FLT_MAX` for "no pointer"
// -- is not the controller's to interpret: finite coordinates are legal input.
// The adapter translates that one; see tests/test_imgui_widget.cpp.)
const double span_before = p.x_range(ax).span();
c.on_wheel({nan, nan, 0.0f, 1.0f, 0});
PLOT_CHECK(std::isfinite(p.x_range(ax).min) && std::isfinite(p.x_range(ax).max));
PLOT_CHECK(std::isfinite(p.y_range(ay).min) && std::isfinite(p.y_range(ay).max));
PLOT_CHECK(std::fabs(p.x_range(ax).min) < 1e6 && std::fabs(p.x_range(ax).max) < 1e6);
PLOT_CHECK(p.x_range(ax).span() < span_before);
// A non-finite amount is no zoom at all.
const plot::Range xr = p.x_range(ax);
const plot::Range yr = p.y_range(ay);
c.on_wheel({400.0f, 200.0f, 0.0f, nan, 0});
c.on_wheel({400.0f, 200.0f, 0.0f, inf, 0});
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-12);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-12);
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-12);
}
PLOT_TEST(key_f_auto_fits_traces) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});