Skip to content

Add CurveTrack, a tone curve widget - #394

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/happy-rubin-w67sx1
Sep 12, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/happy-rubin-w67sx1

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Step 1 of ImageGui's M2c (ktsu-dev/ImageGui#14 is the design). A tone curve control, its pure interaction state, tests, a demo section, and the docs.

Why this is not one of the existing curve editors

ImGui.Widgets/Editors/ already has two CurveEditor overloads, CurveData, CurveField and BezierEditor. They are real, tested and demoed — and they edit animation keyframes. Measured against CurveData.Sample on the published 3.32.2 package, they cannot carry a curve applied to pixels:

Property Measured
Identity worst |f(x) − x| = 0.003920, biased down (1/255 is 0.003922)
Resolution 255 distinct output values across 0..1 — a 256-entry cache read with a truncating index
Monotonicity a 4-point curve decreases by 0.00078 at x ≈ 0.51
CurveShape.Freehand Sample returns 0 everywhere, whatever the points

None of that matters on a keyframe track: overshoot is expressive, 8-bit sampling is invisible, and nothing inverts if the curve dips. All of it matters on an image, where a curve that decreases inverts it locally and a neutral curve that is off by a step darkens it.

So this sits beside them and nothing is retired or changed.

The decision it rests on

CurveTrack does not own the interpolation. The caller passes a Func<float, float> and the widget plots it.

A curve editor is the one control whose drawing is its specification. A histogram drawn slightly off its bins is cosmetic; a curve drawn differently from the one being applied is a lie about what the user is editing. Taking the function in makes the drawn and applied curves one function by construction rather than two that agree today.

Shape and gestures

It overlays a rectangle and restores the cursor, like HandleTrack, so it can sit on a Histogram drawn into the same rectangle — which is how a tone curve is normally edited and what the demo shows. It paints no background; a caller with nothing underneath reserves the box with the degenerate Histogram call that is already defined to draw exactly the empty frame.

Gestures deliberately avoid the vendor's: press near a point to drag it, press away from every point to add one and drag it straight away, right-click a point to remove it. Adding on a plain press rather than a double-click matters — the editor that uses double-click throws when the gesture lands past its last point (CurveField's own remarks record it as unrecoverable, since the throw escapes between PushID and PopID), and it cannot populate an empty curve at all, because that gesture is the only way to make a point.

CurveTrackState holds the rules with no ImGui dependency and is tested without a context, as HandleTrackState is. Ordering is the one constraint that never yields: two points at one x make the curve vertical there, which is not a function of x and which no interpolation through them is defined for.

Two things found while building it

  • A pinnedEnds count was ambiguous — two at each end, or two total? It is a bool pinEnds instead, which is what was actually meant. The ends are pinned in x and free in y, since the ends are where clipping is expressed.
  • The default grab radius was ~4px. The same radius draws the points and catches the press, so a point small enough to be hard to hit is small enough to be hard to see; it is now half a line, floored at 6px.

Testing

  • CurveTrackStateTests — 24 tests, no ImGui context: ordering, y carried with its point when sorted, bounds, the minimum gap, pinned ends in x but not y, grabbing by distance in both axes, a miss reported so the caller can add, a stationary drag reporting no change, add refusing where it would not fit, remove refusing a pinned end and refusing to go below two points.
  • CurveTrackTests — 13 isolation UI tests driving the widget alone, including the one the design turns on: changing the sampler changes the pixels, and the plot asks across the whole domain.

dotnet test -c Release: ImGui.Widgets.Tests 281 passed, ImGui.Widgets.UITests all passed.

A finding about main, not about this PR

The full suite reports 45 failures across ImGuiAppDemo.UITests (26), ImGuiWidgetsDemo.UITests (12) and ImGuiMarkdownDemo.UITests (7). They are identical on clean main with this branch stashed — same three suites, same counts — so none are this PR's.

The cause is environmental rather than a defect: the demos load PNGs that are Git LFS pointers in a checkout where LFS objects were never fetched, so ImageDecoder is handed the text version https://git-lfs.github.com/spec/v1 and correctly refuses it:

InvalidImageDataException: Unrecognised image format; the file starts with 76 65 72 73.

76 65 72 73 is vers. Flagging it because it makes a local dotnet test look broken to anyone without git lfs installed, which is worth knowing before someone goes hunting.

🤖 Generated with Claude Code

https://claude.ai/code/session_015qxqZVzN8CJcDxTb5gtWua


Generated by Claude Code

The first step of ImageGui's M2c. Its design records why this sits beside the
existing CurveEditor overloads rather than reusing them: those edit animation
keyframes, and measured against CurveData.Sample they cannot carry a curve
applied to pixels. 255 distinct output values across 0..1, up to 0.003920 off
the identity and biased downward, not monotone, and Freehand samples to zero.
None of that matters on a keyframe track. All of it matters on an image.

CurveTrack does not own the interpolation. The caller passes a sampler and the
widget plots it, because a curve editor is the one control whose drawing is its
specification — a curve drawn differently from the one being applied is a lie
about what the user is editing. Taking the function in makes the drawn and the
applied curve one function rather than two that agree today.

It overlays a rectangle and restores the cursor, like HandleTrack, so it can sit
on a Histogram drawn into the same rectangle — which is how a tone curve is
normally edited, and the arrangement the demo shows. It paints no background;
a caller with nothing underneath reserves the box with the degenerate Histogram
call that is defined to draw exactly the empty frame.

The gestures avoid the vendor's: press near a point to drag it, press away to add
one and drag it straight away, right-click to remove. Adding on a plain press
rather than a double-click is deliberate — the editor that does it that way
throws when the gesture lands past its last point, and cannot populate an empty
curve at all because that gesture is the only way to make a point.

CurveTrackState holds the rules with no ImGui dependency, tested without a
context, as HandleTrackState is: ordering is the one constraint that never
yields, since two points at one x make the curve vertical there and no
interpolation through them is defined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qxqZVzN8CJcDxTb5gtWua
Comment thread ImGui.Widgets/CurveTrack.cs Fixed
Comment thread ImGui.Widgets/CurveTrack.cs Fixed
Combine the nested if in Interact. Activate has the side effect of setting the
active point, and short-circuiting is what keeps it from running on a frame the
item was not activated on, so the combined form is exactly equivalent.

Guard Remap's division against float.Epsilon rather than against zero. The
comment says what that actually is — the smallest denormal, so "exactly zero"
plus the handful of values a subtraction can leave that divide into something
absurd — rather than calling it a tolerance, which it is not. It is strictly
more conservative than the previous check in the only direction that matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qxqZVzN8CJcDxTb5gtWua
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant