Files
plotter/adapters/imgui/imgui_plot.cpp
T
Martino Ferrari b5b9b42be1 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.
2026-09-28 00:54:25 +02:00

272 lines
11 KiB
C++

#include <imgui/imgui_plot.hpp>
#include <imgui.h>
namespace plot::imgui {
namespace {
/// ImGui's button indices are left, right, middle; the controller's enum is the
/// same three, spelled out.
input::Button button_from_index(int index) noexcept {
switch (index) {
case 1:
return input::Button::Right;
case 2:
return input::Button::Middle;
default:
return input::Button::Left;
}
}
/// ImGui's modifier booleans as the controller's translatable mask. The bit
/// values are `input::Mod`'s by definition: it is documented as the mask a host
/// translates into, and the GLFW adapter's static_asserts pin the same layout.
u32 mods_from(const ImGuiIO& io) noexcept {
u32 mods = 0;
if (io.KeyShift) mods |= static_cast<u32>(input::Mod::Shift);
if (io.KeyCtrl) mods |= static_cast<u32>(input::Mod::Ctrl);
if (io.KeyAlt) mods |= static_cast<u32>(input::Mod::Alt);
if (io.KeySuper) mods |= static_cast<u32>(input::Mod::Super);
return mods;
}
} // namespace
void PlotWidget::draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer) {
ImGuiIO& io = ImGui::GetIO();
if (!controller_ || plot_ != &plot) {
// The controller holds a Plot& (and the drag state that goes with it), so a
// different plot is a different controller; re-binding drops any drag rather
// than replaying its edges against the new plot.
controller_.emplace(plot);
plot_ = &plot;
reset_pointer();
}
// The whole translation below rests on the backend having filled in a sane
// scale; a zero one would collapse the region and silently draw nothing.
IM_ASSERT(io.DisplayFramebufferScale.x > 0.0f && io.DisplayFramebufferScale.y > 0.0f);
// The child is a plot surface, not a scrollable panel: no scrollbar, whatever
// the item inside it measures. The wheel belongs to the plot while it is
// hovered, and is taken away from window scrolling by the key owner below.
const ImGuiWindowFlags child_flags = ImGuiWindowFlags_NoScrollbar;
// The child is the plot's surface: it must paint nothing of its own over it.
// ChildBg is transparent in every stock theme, but a host may have pushed a
// panel colour; forcing it here means the widget can never cover its own plot.
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f));
const bool shown =
ImGui::BeginChild(id, ImVec2(size.x, size.y), ImGuiChildFlags_None, child_flags);
if (!shown) {
// Clipped away entirely (or collapsed): there is no region to draw into, and
// no rect to aim a gesture at. EndChild is still owed.
ImGui::EndChild();
ImGui::PopStyleColor();
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 -- 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();
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 *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::SetCursorScreenPos(ImVec2(x0, y0));
ImGui::InvisibleButton("surface", visible,
ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight |
ImGuiButtonFlags_MouseButtonMiddle);
const bool hovered = ImGui::IsItemHovered();
ImGui::SetItemKeyOwner(ImGuiKey_MouseWheelY);
const ImVec2 scale = io.DisplayFramebufferScale;
const f32 fb_w_f = io.DisplaySize.x * scale.x;
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{x0 * scale.x, y0 * scale.y, visible.x * scale.x, visible.y * scale.y};
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.
controller_->set_viewport(region);
forward_input(hovered);
const u32 fb_w = static_cast<u32>(fb_w_f + 0.5f);
const u32 fb_h = static_cast<u32>(fb_h_f + 0.5f);
renderer.begin_frame(fb_w, fb_h, region);
renderer.draw(plot);
renderer.end_frame();
// `end_frame` leaves the GL state neutral and the viewport at the plot's
// rect; ImGui's backend sets its own viewport, blend function and so on
// before its first draw, so the chrome renders correctly after this.
}
ImGui::EndChild();
ImGui::PopStyleColor();
}
void PlotWidget::forward_input(bool hovered) {
ImGuiIO& io = ImGui::GetIO();
const ImVec2 scale = io.DisplayFramebufferScale;
// 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 ((!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. 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) {
input::PointerEvent e;
e.x = px;
e.y = py;
if (delta_pending) {
e.dx = dx;
e.dy = dy;
delta_pending = false;
}
e.button = button;
e.mods = mods;
e.down = any_down;
e.pressed = pressed;
e.released = released;
controller_->on_pointer(e);
};
// ImGui reports levels; the controller wants edges. One event per button whose
// level changed, which is the press or the release the host's callback would
// have carried.
for (int i = 0; i < 3; ++i) {
if (down[i] == prev_down_[i]) continue;
deliver(button_from_index(i), down[i], !down[i]);
}
if (delta_pending) {
// No transition this frame: one plain move sample, so a pan in progress
// advances by exactly the movement since the last one and
// Controller::hovered_cursor() sees where the pointer is.
deliver(input::Button::Left, false, false);
}
if (hovered && io.MouseWheel != 0.0f) {
input::WheelEvent e;
e.x = px;
e.y = py;
e.dx = io.MouseWheelH;
e.dy = io.MouseWheel; // ImGui's positive y (scroll up) is the zoom-in sign
e.mods = mods;
controller_->on_wheel(e);
}
// Keyboard gestures, on the same hover gate as the pointer: a key pressed
// while the plot is under the pointer is aimed at the plot. `+` on most
// layouts is shift+`=`, and either spelling zooms in, as in the GLFW adapter.
if (hovered && !io.WantTextInput) {
const auto key = [&](int code) {
input::KeyEvent e;
e.key = code;
e.mods = mods;
e.down = true; // ImGui already edge-detected the press
controller_->on_key(e);
};
if (ImGui::IsKeyPressed(ImGuiKey_Equal, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadAdd, false)) {
key(input::kKeyZoomIn);
}
if (ImGui::IsKeyPressed(ImGuiKey_Minus, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract, false)) {
key(input::kKeyZoomOut);
}
if (ImGui::IsKeyPressed(ImGuiKey_F, false)) key(input::kKeyFit);
}
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 {
// 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;
capturing_ = false;
}
} // namespace plot::imgui