feat(imgui): embeddable plot widget and demo

This commit is contained in:
Martino Ferrari committed 2026-09-28 00:38:31 +02:00
1 parent fb2c2f5e4d
commit 15931a2405
9 files changed
+1072 -38

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
# The Dear ImGui adapter: a widget that draws a plot into a child region of the
# framebuffer ImGui is rendering its own chrome into, and feeds that plot
# ImGui's mouse and keyboard state.
#
# ImGui is not a packaging dependency here: it is fetched pinned to a release
# tag (never a branch, so a build is reproducible) and only the sources this
# adapter needs are compiled -- the core plus the GLFW and OpenGL 3 backends.
# The fetched sources are third-party code: they are not held to this project's
# warning set, and the adapter and its example are.
include(FetchContent)
FetchContent_Declare(imgui
GIT_REPOSITORY https://github.com/ocornut/imgui.git
GIT_TAG v1.91.9b
GIT_SHALLOW TRUE)
# imgui has no top-level CMakeLists.txt, so MakeAvailable populates it and stops;
# the sources are added to the target below by name.
FetchContent_MakeAvailable(imgui)
if(NOT imgui_SOURCE_DIR OR NOT EXISTS "${imgui_SOURCE_DIR}/imgui.cpp")
message(FATAL_ERROR
"plot: the Dear ImGui sources for tag v1.91.9b are missing from "
"'${imgui_SOURCE_DIR}'. The FetchContent clone failed, so PLOT_BUILD_IMGUI "
"cannot link anything; either restore network access or pre-populate the "
"source with -DFETCHCONTENT_SOURCE_DIR_IMGUI=<path>.")
endif()
add_library(imgui STATIC
"${imgui_SOURCE_DIR}/imgui.cpp"
"${imgui_SOURCE_DIR}/imgui_draw.cpp"
"${imgui_SOURCE_DIR}/imgui_tables.cpp"
"${imgui_SOURCE_DIR}/imgui_widgets.cpp"
"${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp"
"${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp")
# Both include roots are public: a consumer of the backends -- the adapter and
# the example -- reaches them as <imgui.h> and <backends/imgui_impl_glfw.h>.
target_include_directories(imgui PUBLIC
"${imgui_SOURCE_DIR}"
"${imgui_SOURCE_DIR}/backends")
# imgui_impl_glfw.cpp needs GLFW; a build that also enables the GLFW adapter
# gets the same target, never a second copy.
plot_find_glfw(plot_imgui_glfw_dep)
target_link_libraries(imgui PUBLIC ${plot_imgui_glfw_dep})
target_compile_features(imgui PUBLIC cxx_std_20)
add_library(plot_imgui STATIC imgui_plot.cpp)
add_library(plot::imgui ALIAS plot_imgui)
# The public include root is `adapters/`, so the header is reached as
# <imgui/imgui_plot.hpp> -- the toolkit's name first, like every other adapter's.
target_include_directories(plot_imgui PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..")
# ImGui stays PRIVATE: the adapter's header includes no ImGui header, so a
# consumer of plot::imgui does not inherit the toolkit's headers.
target_link_libraries(plot_imgui PUBLIC plot::core PRIVATE imgui ${plot_imgui_glfw_dep})
plot_apply_warnings(plot_imgui)
+237
View File
@@ -0,0 +1,237 @@
#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.
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.
ImGui::EndChild();
ImGui::PopStyleColor();
return;
}
// 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
// `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),
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{origin.x * scale.x, origin.y * scale.y, avail.x * scale.x, avail.y * scale.y};
if (region.w > 0.0f && region.h > 0.0f && 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;
const f32 px = io.MousePos.x * scale.x;
const f32 py = io.MousePos.y * scale.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.
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;
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;
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];
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;
prev_x_ = 0.0f;
prev_y_ = 0.0f;
have_prev_pos_ = false;
capturing_ = false;
}
} // namespace plot::imgui
+147
View File
@@ -0,0 +1,147 @@
#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.
///
/// ## 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 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.
///
/// 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 content 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.
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.
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);
/// Forgets the previous frame's pointer state, so a re-bound plot cannot see an
/// edge that belongs to the old one.
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