Files
plotter/adapters/imgui/imgui_plot.hpp
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

167 lines
8.7 KiB
C++

#pragma once
#include <optional>
#include <plot/core/rect.hpp>
#include <plot/input/controller.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
namespace plot::imgui {
/// Embeds a `Plot` in a Dear ImGui window as a child region, and drives that plot
/// from ImGui's own input state.
///
/// `draw()` opens a child window of the requested size, translates its screen
/// rectangle into framebuffer pixels, hands that rectangle to
/// `Renderer::begin_frame()` and draws the plot into it: a scissor- and
/// viewport-bounded sub-rectangle of the very framebuffer ImGui is about to draw
/// its chrome into, not an offscreen texture. Nothing is rendered through a
/// texture or an FBO, and ImGui's frame is left undisturbed -- the renderer
/// clears only its own rect and restores the GL state it changed, and ImGui's
/// backend re-establishes its viewport and blend state before its first draw.
///
/// Two pixel spaces meet here, exactly as they do in the GLFW adapter. ImGui
/// lays out and reports in logical *points* -- `io.DisplaySize`, the cursor's
/// screen position, a child's content region -- while this library speaks
/// *framebuffer pixels*. The bridge is `io.DisplayFramebufferScale`, which the
/// platform backend fills in from the window and framebuffer sizes; on an
/// unscaled display it is (1, 1) and nothing changes.
///
/// ## The coordinate assumption
///
/// Points -> pixels through `io.DisplayFramebufferScale` is *the whole
/// framebuffer's* mapping only when both of these hold:
///
/// * the GL viewport the host is drawing its frame with is the whole
/// framebuffer -- origin (0, 0), size `DisplaySize * DisplayFramebufferScale`
/// -- and
/// * the bound draw target is the default framebuffer, not an offscreen one.
///
/// Under that assumption -- the one every ImGui platform backend documents, and
/// what a window that spans its framebuffer gives -- the child's point rectangle
/// is the pixel rectangle the plot is laid out and scissored into, and a gesture
/// lands on the pixel it was aimed at. A host that renders into an FBO, or with
/// a viewport that is not the whole framebuffer (a tiled compositor, a scaled
/// surface, a pass into a texture), breaks the assumption. This widget cannot
/// detect it: it has no view of the host's viewport and no way to ask. Such a
/// host must place the plot itself -- `Renderer::begin_frame()` takes the
/// framebuffer size and the plot's rect directly, so it needs no widget -- or
/// apply its own points -> pixels mapping on top of `plot.set_viewport()`.
///
/// ## What the widget draws over
///
/// The widget renders *immediately*, from inside the child, before ImGui renders
/// its draw data (see the frame order in the demo). It therefore writes the
/// child's pixels before every ImGui command of that frame, and ImGui has no way
/// to be told the child is opaque. The host must not paint over the child's
/// rectangle:
///
/// * the hosting window must not fill its background across the child -- use
/// `ImGui::SetNextWindowBgAlpha(0.0f)` or `ImGuiWindowFlags_NoBackground`
/// (`ImGuiCol_WindowBg`'s default alpha would otherwise dim the plot), and
/// * no other window may overlap the rectangle with opaque content.
///
/// 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
/// delivered to the plot's `Controller` -- the one the widget owns, reachable
/// through `controller()`. ImGui reports levels (`io.MouseDown`) where the
/// 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 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
/// invisible item makes ImGui withhold the wheel from the hosting window. The
/// claim takes effect from the frame after it is made, because ImGui decides
/// window scrolling in its own `NewFrame()` before any widget runs -- so a
/// hosting window with a scroll range can still scroll on the very first wheel of
/// a hover that has only just started. (The child's
/// `ImGuiWindowFlags_NoScrollWithMouse` would not achieve this on its own: ImGui
/// deliberately bubbles the wheel up to the parent when a child refuses it.)
/// ImGui consumes `io.MouseWheel` itself at the end of the frame, so a widget
/// cannot consume it any later than this.
///
/// Not thread-safe: like everything else in the render thread's frame.
class PlotWidget {
public:
PlotWidget() noexcept = default;
PlotWidget(const PlotWidget&) = delete;
PlotWidget& operator=(const PlotWidget&) = delete;
/// Allocates a child region of `size` (ImGui points) identified by `id`, and
/// renders `plot` into the framebuffer pixels it covers with `renderer`, which
/// must already be initialised on the current context.
///
/// Must be called between `ImGui::NewFrame()` and `ImGui::Render()`, from a
/// 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 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 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. 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;
}
private:
/// Synthesises this frame's `plot::input` events from ImGui's levels.
void forward_input(bool hovered);
/// 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;
std::optional<input::Controller> controller_;
/// The previous frame's button levels, in ImGui's order (left, right, middle).
bool prev_down_[3] = {false, false, false};
f32 prev_x_ = 0.0f; ///< previous pointer position, in framebuffer pixels
f32 prev_y_ = 0.0f;
bool have_prev_pos_ = false;
/// True while a button pressed over the child is still down: keeps the release
/// flowing even after the pointer has left the region.
bool capturing_ = false;
};
} // namespace plot::imgui