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
+697
-58
No files matched your search
@@ -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];
|
||||
prev_x_ = px;
|
||||
prev_y_ = py;
|
||||
have_prev_pos_ = true;
|
||||
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];
|
||||
prev_x_ = px;
|
||||
prev_y_ = py;
|
||||
have_prev_pos_ = true;
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user