Add CurveTrack, a tone curve widget - #394
Merged
Merged
Conversation
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
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
|
This was referenced Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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 twoCurveEditoroverloads,CurveData,CurveFieldandBezierEditor. They are real, tested and demoed — and they edit animation keyframes. Measured againstCurveData.Sampleon the published 3.32.2 package, they cannot carry a curve applied to pixels:CurveShape.FreehandSamplereturns 0 everywhere, whatever the pointsNone 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
CurveTrackdoes not own the interpolation. The caller passes aFunc<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 aHistogramdrawn 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 degenerateHistogramcall 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 betweenPushIDandPopID), and it cannot populate an empty curve at all, because that gesture is the only way to make a point.CurveTrackStateholds the rules with no ImGui dependency and is tested without a context, asHandleTrackStateis. 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
pinnedEndscount was ambiguous — two at each end, or two total? It is abool pinEndsinstead, which is what was actually meant. The ends are pinned in x and free in y, since the ends are where clipping is expressed.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 PRThe full suite reports 45 failures across
ImGuiAppDemo.UITests(26),ImGuiWidgetsDemo.UITests(12) andImGuiMarkdownDemo.UITests(7). They are identical on cleanmainwith 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
ImageDecoderis handed the textversion https://git-lfs.github.com/spec/v1and correctly refuses it:76 65 72 73isvers. Flagging it because it makes a localdotnet testlook broken to anyone withoutgit lfsinstalled, which is worth knowing before someone goes hunting.🤖 Generated with Claude Code
https://claude.ai/code/session_015qxqZVzN8CJcDxTb5gtWua
Generated by Claude Code