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:
1 parent
15931a2405
commit
b5b9b42be1
8 files changed
+691
-52
No files matched your search
+18
-14
@@ -34,21 +34,13 @@ target_compile_features(plot_core PUBLIC cxx_std_20)
|
||||
target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS})
|
||||
plot_apply_warnings(plot_core)
|
||||
|
||||
if(PLOT_BUILD_TESTS)
|
||||
# Test-only: counts the pyramid's covering-decomposition entries and its bucket
|
||||
# merges so a test can pin the query's O(log n) cost and the append path's
|
||||
# amortised O(1) cost, neither of which any answer reveals. Off in every
|
||||
# non-test build (see include/plot/data/detail/{cover,merge}_count.hpp).
|
||||
target_compile_definitions(plot_core PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
||||
PLOT_PYRAMID_COUNT_MERGES)
|
||||
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
# Adapters own the surface and translate their host's events; examples link the
|
||||
# adapter they demo. Both are opt-in, so the core library and its tests still
|
||||
# build on a bare toolchain.
|
||||
# adapter they demo. Both are opt-in, so the core library and its tests still build
|
||||
# on a bare toolchain.
|
||||
#
|
||||
# They are added before the test suite on purpose: a test that drives an adapter
|
||||
# needs that adapter's targets to exist, and with both switches on the suite can
|
||||
# then exercise them (see tests/CMakeLists.txt).
|
||||
if(PLOT_BUILD_GLFW)
|
||||
add_subdirectory(adapters/glfw)
|
||||
endif()
|
||||
@@ -60,3 +52,15 @@ endif()
|
||||
if(PLOT_BUILD_EXAMPLES)
|
||||
add_subdirectory(examples)
|
||||
endif()
|
||||
|
||||
if(PLOT_BUILD_TESTS)
|
||||
# Test-only: counts the pyramid's covering-decomposition entries and its bucket
|
||||
# merges so a test can pin the query's O(log n) cost and the append path's
|
||||
# amortised O(1) cost, neither of which any answer reveals. Off in every
|
||||
# non-test build (see include/plot/data/detail/{cover,merge}_count.hpp).
|
||||
target_compile_definitions(plot_core PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
||||
PLOT_PYRAMID_COUNT_MERGES)
|
||||
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@@ -67,27 +67,39 @@ void PlotWidget::draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer)
|
||||
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.
|
||||
// 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();
|
||||
if (avail.x <= 0.0f || avail.y <= 0.0f) {
|
||||
// A child with no room (a degenerate layout): there is no region to draw
|
||||
// into and no rect to aim a gesture at, and InvisibleButton wants a size.
|
||||
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 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
|
||||
// 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::InvisibleButton("surface", ImVec2(avail.x, avail.y),
|
||||
ImGui::SetCursorScreenPos(ImVec2(x0, y0));
|
||||
ImGui::InvisibleButton("surface", visible,
|
||||
ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight |
|
||||
ImGuiButtonFlags_MouseButtonMiddle);
|
||||
const bool hovered = ImGui::IsItemHovered();
|
||||
@@ -98,9 +110,9 @@ void PlotWidget::draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer)
|
||||
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{origin.x * scale.x, origin.y * scale.y, avail.x * scale.x, avail.y * scale.y};
|
||||
const Rect region{x0 * scale.x, y0 * scale.y, visible.x * scale.x, visible.y * scale.y};
|
||||
|
||||
if (region.w > 0.0f && region.h > 0.0f && fb_w_f >= 1.0f && fb_h_f >= 1.0f) {
|
||||
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.
|
||||
@@ -125,29 +137,46 @@ void PlotWidget::forward_input(bool hovered) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
const ImVec2 scale = io.DisplayFramebufferScale;
|
||||
|
||||
const f32 px = io.MousePos.x * scale.x;
|
||||
const f32 py = io.MousePos.y * scale.y;
|
||||
// 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 (!hovered && !capturing_) {
|
||||
// Nowhere near the plot: remember the levels anyway, so re-entering the
|
||||
// region does not synthesise an edge out of the state it left with.
|
||||
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.
|
||||
const f32 dx = have_prev_pos_ ? px - prev_x_ : 0.0f;
|
||||
const f32 dy = have_prev_pos_ ? py - prev_y_ : 0.0f;
|
||||
// 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) {
|
||||
@@ -216,18 +245,23 @@ void PlotWidget::forward_input(bool hovered) {
|
||||
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 {
|
||||
prev_down_[0] = false;
|
||||
prev_down_[1] = false;
|
||||
prev_down_[2] = false;
|
||||
// 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;
|
||||
|
||||
@@ -66,6 +66,12 @@ namespace plot::imgui {
|
||||
/// The child's own background is forced transparent by the widget, whatever the
|
||||
/// host's `ImGuiCol_ChildBg`, so the widget itself never paints over the plot.
|
||||
///
|
||||
/// The converse also holds: what ImGui clips away, the widget does not paint. GL
|
||||
/// draws do not go through ImGui's clipping, so the region is intersected with the
|
||||
/// clip rect ImGui gives the child (its own rectangle when nothing cuts it off,
|
||||
/// the visible part of it when an ancestor does) -- a child the hosting window cuts
|
||||
/// off must not clear and paint over whatever the host drew beside it.
|
||||
///
|
||||
/// ## Input
|
||||
///
|
||||
/// Pointer, wheel and key state are translated into `plot::input` events and
|
||||
@@ -74,13 +80,21 @@ namespace plot::imgui {
|
||||
/// controller wants edges (`PointerEvent::pressed`/`released`), so the widget
|
||||
/// remembers the previous frame's buttons and synthesises the transitions.
|
||||
///
|
||||
/// Events are forwarded while an invisible item covering the child's region is
|
||||
/// hovered -- `IsItemHovered()` rather than `IsWindowHovered()`, because the
|
||||
/// item is the child's whole rectangle (the child has no padding) and its hover
|
||||
/// is false while the host is dragging some other widget, so dragging a slider
|
||||
/// next to the plot cannot pan it. A press that started over the child keeps
|
||||
/// being delivered until every button is up again, so a drag that leaves the
|
||||
/// region still ends instead of leaving the plot mid-pan.
|
||||
/// Events are forwarded while an invisible item covering the child's visible
|
||||
/// region is hovered -- `IsItemHovered()` rather than `IsWindowHovered()`, because
|
||||
/// the item is the child's whole rectangle (the child has no padding) and its hover
|
||||
/// is false while the host is dragging some other widget, so dragging a slider next
|
||||
/// to the plot cannot pan it. A press that started over the child keeps being
|
||||
/// delivered until every button is up again, so a drag that leaves the region still
|
||||
/// ends instead of leaving the plot mid-pan.
|
||||
///
|
||||
/// An *invalid* pointer position -- ImGui's `(-FLT_MAX, -FLT_MAX)` sentinel for "the
|
||||
/// pointer is not on this window", which the platform backends leave behind when the
|
||||
/// pointer leaves or the window loses focus -- is no position update: the last real
|
||||
/// position stands, the movement for that frame is zero (a sentinel scaled by the
|
||||
/// framebuffer scale and differenced against a real position would be a ~1e35 pan),
|
||||
/// and the button levels still flow, so a drag that leaves the window still ends on
|
||||
/// its release.
|
||||
///
|
||||
/// Wheel input goes to the plot while it is hovered, and the plot's zoom wins
|
||||
/// over ImGui's window scrolling: `SetItemKeyOwner(ImGuiKey_MouseWheelY)` on that
|
||||
@@ -109,15 +123,19 @@ class PlotWidget {
|
||||
/// window; the plot is drawn (GL calls issued) during the call, inside the
|
||||
/// child, so the host's later ImGui commands never have to restore anything.
|
||||
///
|
||||
/// The plot's viewport is the child's content rectangle, so it fills the child
|
||||
/// The plot's viewport is the child's visible rectangle, so it fills the child
|
||||
/// exactly; the widget sets it every frame through the controller, and the plot
|
||||
/// is re-laid out to match. A degenerate region (a collapsed or clipped child)
|
||||
/// draws nothing and forwards nothing.
|
||||
/// is re-laid out to match. A degenerate or entirely
|
||||
/// clipped region draws nothing and forwards nothing, and a partially clipped one
|
||||
/// draws and accepts gestures only where the host leaves the child visible.
|
||||
void draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer);
|
||||
|
||||
/// The controller the widget feeds, or null before the first `draw()`. It is
|
||||
/// bound to whichever plot the last `draw()` was given; a `draw()` for a
|
||||
/// different plot re-binds it and drops any drag in progress.
|
||||
/// different plot re-binds it and drops any drag in progress. The remembered
|
||||
/// button levels survive the re-bind -- they are the host's state, not the
|
||||
/// plot's -- so a host drawing two plots through one widget does not hand the
|
||||
/// second one a fabricated press.
|
||||
input::Controller* controller() noexcept { return controller_ ? &*controller_ : nullptr; }
|
||||
const input::Controller* controller() const noexcept {
|
||||
return controller_ ? &*controller_ : nullptr;
|
||||
@@ -127,8 +145,9 @@ class PlotWidget {
|
||||
/// Synthesises this frame's `plot::input` events from ImGui's levels.
|
||||
void forward_input(bool hovered);
|
||||
|
||||
/// Forgets the previous frame's pointer state, so a re-bound plot cannot see an
|
||||
/// edge that belongs to the old one.
|
||||
/// Drops the remembered pointer position and any capture, keeping the remembered
|
||||
/// button levels: they are the host's state, and synthesising an edge out of the
|
||||
/// levels a re-bind started with would fabricate a press.
|
||||
void reset_pointer() noexcept;
|
||||
|
||||
Plot* plot_ = nullptr;
|
||||
|
||||
@@ -58,6 +58,13 @@ class Controller {
|
||||
/// refreshes the layout, so gesture geometry and the host agree.
|
||||
void set_viewport(Rect px);
|
||||
|
||||
/// Events are sanitised before they are acted on: a non-finite coordinate or
|
||||
/// delta -- a host's "no pointer" sentinel (ImGui reports `-FLT_MAX`), a NaN
|
||||
/// from a degenerate mapping, an infinity from a lost device -- becomes the last
|
||||
/// known position and no movement. Garbage must not reach the ranges: a gesture
|
||||
/// that sets a range also clears auto scale, so a range driven to 1e35 is one the
|
||||
/// user can neither pan nor zoom back. The buttons in the same event are still
|
||||
/// acted on, so a drag whose release arrives with a bad position still ends.
|
||||
void on_pointer(const PointerEvent& e);
|
||||
void on_wheel(const WheelEvent& e);
|
||||
void on_key(const KeyEvent& e);
|
||||
|
||||
@@ -39,6 +39,44 @@ f64 from_space(f64 v, bool log) noexcept {
|
||||
/// key) zoom in, so the span shrinks by `exp(-notches * kZoomPerNotch)`.
|
||||
f64 zoom_factor(f64 notches) noexcept { return std::exp(-notches * kZoomPerNotch); }
|
||||
|
||||
/// True when a coordinate is one arithmetic can work with. Every event here comes
|
||||
/// from a host, and a host can hand over anything: a toolkit's "no pointer"
|
||||
/// sentinel, a NaN from a degenerate mapping of its own, an infinity from a lost
|
||||
/// device. A non-finite value rides the pan and zoom arithmetic into a range that
|
||||
/// is no longer a range, and since a gesture that sets a range also clears auto
|
||||
/// scale, `f` would be the only way back -- so it must not get in.
|
||||
bool finite(f32 v) noexcept { return std::isfinite(v); }
|
||||
|
||||
/// `e`, with an unusable position replaced by the last known one and an unusable
|
||||
/// delta by no movement. Sanitising rather than rejecting keeps the buttons in the
|
||||
/// same event alive: a drag whose release arrived with a garbage position must
|
||||
/// still end.
|
||||
PointerEvent sanitized(const PointerEvent& e, bool have_pointer, f32 last_x,
|
||||
f32 last_y) noexcept {
|
||||
PointerEvent in = e;
|
||||
if (!finite(in.x) || !finite(in.y)) {
|
||||
in.x = have_pointer ? last_x : 0.0f;
|
||||
in.y = have_pointer ? last_y : 0.0f;
|
||||
in.dx = 0.0f;
|
||||
in.dy = 0.0f;
|
||||
return in;
|
||||
}
|
||||
if (!finite(in.dx)) in.dx = 0.0f;
|
||||
if (!finite(in.dy)) in.dy = 0.0f;
|
||||
return in;
|
||||
}
|
||||
|
||||
WheelEvent sanitized(const WheelEvent& e, bool have_pointer, f32 last_x, f32 last_y) noexcept {
|
||||
WheelEvent in = e;
|
||||
if (!finite(in.x) || !finite(in.y)) {
|
||||
in.x = have_pointer ? last_x : 0.0f;
|
||||
in.y = have_pointer ? last_y : 0.0f;
|
||||
}
|
||||
if (!finite(in.dx)) in.dx = 0.0f;
|
||||
if (!finite(in.dy)) in.dy = 0.0f;
|
||||
return in;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Controller::set_viewport(Rect px) {
|
||||
@@ -49,7 +87,8 @@ void Controller::set_viewport(Rect px) {
|
||||
plot_->layout();
|
||||
}
|
||||
|
||||
void Controller::on_pointer(const PointerEvent& e) {
|
||||
void Controller::on_pointer(const PointerEvent& raw) {
|
||||
const PointerEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
|
||||
has_pointer_ = true;
|
||||
pointer_x_ = e.x;
|
||||
pointer_y_ = e.y;
|
||||
@@ -66,7 +105,8 @@ void Controller::on_pointer(const PointerEvent& e) {
|
||||
if (e.down && mode_ != DragMode::None) update_drag(e);
|
||||
}
|
||||
|
||||
void Controller::on_wheel(const WheelEvent& e) {
|
||||
void Controller::on_wheel(const WheelEvent& raw) {
|
||||
const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
|
||||
plot_->layout();
|
||||
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
|
||||
if (!(factor > 0.0)) return;
|
||||
|
||||
@@ -40,6 +40,17 @@ add_executable(plot_tests
|
||||
egl_headless.cpp
|
||||
)
|
||||
target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library} Threads::Threads)
|
||||
|
||||
# The Dear ImGui widget is the one adapter with a test here: its input translation
|
||||
# (edge synthesis, the hover gate, drag capture, wheel containment) is logic this
|
||||
# project owns, not a toolkit's. The suite is otherwise ImGui-free, so the source
|
||||
# and the link are added only when the adapter exists -- which the top-level
|
||||
# CMakeLists.txt guarantees by adding the adapters before this directory.
|
||||
if(TARGET plot::imgui)
|
||||
target_sources(plot_tests PRIVATE test_imgui_widget.cpp)
|
||||
target_link_libraries(plot_tests PRIVATE plot::imgui imgui)
|
||||
endif()
|
||||
|
||||
# Same switches the library is built with: the cost tests read the counters
|
||||
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
|
||||
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user