epezent / epezent/implot

[Feature] Input ownership: let an in-plot tool claim the mouse drag instead of pan/box-select

Open
#716 3 comments 0 reactions 0 assignees View on GitHub
prio:medium status:idea type:feat
Dominant language
C++
Stars
6.2k
Forks
693
PR merge metrics
No merged PRs in 30d

Description

**Summary:** while a drawing tool is armed, we need a left drag inside one plot to mean "draw" rather than pan or box-select, for that frame only, with crosshair and wheel zoom still live. The only available mechanism is mutating the global `ImPlot::GetInputMap()` around the plot. It works, but it rests on four undocumented invariants and forces us to pin ImPlot to an exact commit. Dear ImGui already models mouse buttons as owned keys (`SetKeyOwner(ImGuiKey_MouseLeft, id)`, plus owner-aware `IsMouseDown` and `IsMouseClicked`), and ImPlot already claims the wheel that way, but it never tests ownership before acting on the mouse buttons: those reads go straight to the raw `IO.Mouse*` arrays, so an overlay cannot participate. Detail below, including a silent failure we shipped for a week without noticing.

Filed at @ocornut 's suggestion in ocornut/imgui#9484. He asked for an over-descriptive writeup, so this is longer than a normal issue on purpose. cc @epezent

### What we are building

An orderflow trading terminal in C++20/WebAssembly (Dear ImGui docking + ImPlot, SDL3 + WebGL2 via Emscripten). It has a TradingView-style drawing layer: about 20 tools (trendlines, rays, rectangles, channels, fib, long/short position boxes, freehand brush, text) drawn directly inside the candlestick plot.
Source is AGPL-3.0: https://github.com/edgedepthhq/edgedepth-terminal

There is a clip of it running, including the ImPlot candle chart and the in-plot drawing layer, in the gallery post linked above.

### The requirement

While a drawing tool is armed, a left drag inside the plot must mean "draw", not pan, and must not start a box selection. Double click must not auto-fit, and right click must not open the plot context menu. Everything else stays alive: crosshair, hover, wheel zoom, axis drags. It is also scoped: the indicator subplots rendered below the price plot keep stock controls. So what we want is a per-frame, per-plot transfer of ownership of one or two inputs, not a modal disabling of the plot.

### What we do today

Two mechanisms, and the contrast between them is the whole point of this issue.

**1. Dragging an existing drawing: claim the ActiveID.** The DragPoint pattern,
via imgui_internal.h ([source](https://github.com/edgedepthhq/edgedepth-terminal/blob/c12ebae/src/ui/drawing/drawing_layer.cpp#L911)):

```cpp
// on press over a handle:
ImGui::SetActiveID(drag_gid, ImGui::GetCurrentWindow());
// every frame while held:
ImGui::KeepAliveID(drag_gid);
// on release, or on Esc:
ImGui::ClearActiveID();
```

ImPlot's own `ButtonBehavior` yields, pan never engages, and nothing global is touched. This path composes perfectly and we have no complaints about it.

**2. Armed tool, before any press: mutate `ImPlot::GetInputMap()`.** There is no item to be active yet, so ownership cannot be claimed the same way. The map is public API, but it is documented for permanent modifications and we need a scoped one, so we save, override, and restore around the single plot
([source](https://github.com/edgedepthhq/edgedepth-terminal/blob/c12ebae/src/ui/drawing/drawing_layer.cpp#L643-L678)):

```cpp
constexpr int kImpossibleMod =
ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiMod_Alt | ImGuiMod_Super;

ImPlotInputMap& map = ImPlot::GetInputMap();
saved_map_ = map;
map.Fit = ImGuiMouseButton_Middle;
map.PanMod = kImpossibleMod; // Pan/Select unreachable, buttons stay valid
map.SelectMod = kImpossibleMod;
map.Menu = ImGuiMouseButton_Middle;
// ... after EndPlot, before the indicator subplots:
ImPlot::GetInputMap() = saved_map_;
```

### What we learned while writing this issue

We originally applied that override from inside the BeginPlot/EndPlot scope, believing the map was read at EndPlot time. Preparing this writeup, we read implot.cpp at our pinned commit and found the map is consumed in `UpdateInput()`, called from `SetupFinish()`, which is the first setup-locking call after BeginPlot (in practice the first plot item). Our override was therefore landing after the frame's read, and our restore was landing before the next frame's read. It never took effect at all, and we had not noticed, because mechanism 1 covers drags of existing drawings and two-click placement rarely produces a sustained left drag.

It now runs immediately before BeginPlot and is verified working: an armed freehand drag no longer pans, placement still commits, and crosshair and wheel zoom stay live. But the episode is the point. The correctness of a scoped override depends entirely on an undocumented sample point, and getting it wrong fails silently rather than loudly.

The working version leans on four things, none of them stated in the API:

1. **When the map is sampled** is discoverable only by reading implot.cpp. If `UpdateInput` ever moves relative to BeginPlot, setup-lock, or EndPlot, a scoped override silently breaks by a frame, or entirely, as ours did. This is the main reason we pin ImPlot to an exact commit, with a note to re-verify by hand before moving the pin.
2. **There is no "none" binding.** Button fields index size-5 IO arraysdirectly, so an out-of-range sentinel is an out-of-bounds read rather than a disable.
3. Making Pan and Select unreachable while keeping every button field valid therefore requires an impossible modifier chord (all four mods at once). That is a trick rather than an expressed intent, and it stops working quietly if chord matching ever changes.
4. **The map is global**, so scoping it to one plot among several is a manual save/restore dance layered on top of point 1.

### A second case, same theme, this one pure ImGui: key ownership

Escape means "cancel the drawing in progress" to the drawing layer, and "stop the replay" to the terminal shell. They are independent subsystems that both poll `IsKeyPressed(ImGuiKey_Escape)` at different points in the frame. With no shared notion of a key having been consumed, we built a side channel: the layer stamps the current frame number, and the replay handler skips itself when the stamp matches
([source](https://github.com/edgedepthhq/edgedepth-terminal/blob/c12ebae/src/core/drawing_manager.h#L74-L75)):

```cpp
void mark_escape_consumed(int frame) { esc_frame_ = frame; }
bool escape_consumed(int frame) const { return esc_frame_ == frame; }
```

We have since grown a second identical side channel for another panel, so this is a pattern for us rather than a one-off. If there is already an intended idiom for "sibling subsystems, first consumer wins" on a key like Escape, we would happily adopt it. We did not find a documented one.

### ImPlot already uses this system, but only in one direction

Mouse buttons are already `ImGuiKey` values (`ImGuiKey_MouseLeft`), and imgui_internal.h exposes ownership over them along with owner-aware read wrappers:

```cpp
IMGUI_API void SetKeyOwner(ImGuiKey key, ImGuiID owner_id, ImGuiInputFlags flags = 0);
IMGUI_API bool TestKeyOwner(ImGuiKey key, ImGuiID owner_id);
IMGUI_API bool IsMouseDown(ImGuiMouseButton button, ImGuiID owner_id);
IMGUI_API bool IsMouseClicked(ImGuiMouseButton button, ImGuiInputFlags flags, ImGuiID owner_id = 0);
```

ImPlot already participates in this, outward, for the wheel. `UpdateInput()` claims it so an enclosing scrollable window backs off:

```cpp
ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, plot.ID); // implot.cpp 2046, 2063
```

What it never does is the reverse. Before acting on a mouse button it reads the raw IO arrays, with no ownership test anywhere (line numbers current on master as of today):

```cpp
IO.MouseClicked[gp.InputMap.Select] // 1897
IO.MouseDoubleClicked[gp.InputMap.Fit] // 1902
IO.MouseDown[gp.InputMap.Pan] // 1909
IO.MouseReleased[gp.InputMap.Menu] // 1971
IO.MouseDragMaxDistanceSqr[gp.InputMap.Pan] // 2015
```

There is a matching asymmetry a few lines above those. The hover and held decision runs through `ImGui::ButtonBehavior(plot.PlotRect, plot.ID, ...)`, which respects ActiveID, and that is exactly why mechanism 1 above works with no map mutation and no pinning. The button and modifier gating wrapped around it does not, so an overlay has no way to participate in it.

So the ask is small and symmetric with what you already do for the wheel: test ownership on those button reads, with the plot's ID as the owner. An overlay wanting the next drag would call `SetKeyOwner(ImGuiKey_MouseLeft, my_id)` before BeginPlot, and pan and box-select would decline on their own. That gives no global mutation, no impossible modifier chord, correct per-plot scoping for free, and nothing for integrators to pin. It would also let the Escape case above fall out of the same system instead of needing a side channel.

### What would help

Not proposing a specific API, just the shape of the need:

- A supported way for code running just before or inside BeginPlot/EndPlot to claim specific inputs for that frame, scoped to that plot, ideally composing with the existing ImGui ownership described above rather than forming a parallel system.
- Failing that, even documenting when the global map is sampled, and guaranteeing it, would let us unpin ImPlot.

### Versions

- ImPlot `d65a2bef53d32502407de3a4be80f191e2f412d7`
- Dear ImGui docking branch `dee5bf3ec`
- Emscripten to WebAssembly, SDL3 + WebGL2 backend

Happy to test any prototype against a real drawing layer with about 20 tools, which is probably a harsher input case than most. I can also cut this down to a minimal standalone repro if that would be more useful than the shipped code.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by reading implot.cpp's UpdateInput() and the button reads around lines 1897-2015, then review the linked drawing_layer.cpp repro at lines 643-678 and 911. Verify how input ownership and the global input map interact across BeginPlot, setup locking, and EndPlot. Done means a supported, per-plot input-ownership path exists, or the map sampling point is documented and stable.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.