From ca4786490c0141e547d7a5f7f4f95227ae4efc70 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:17:30 +0000 Subject: [PATCH 1/2] [minor] Add lift/gamma/gain colour wheels ColorWheel is a colour-grading trackball: the angle of the handle is a hue, its distance from the centre a strength, with a master slider underneath for luminance. LiftGammaGain draws the usual three side by side. Drags are relative, Shift is fine adjustment, and a double-click resets the ball or the slider on its own. ColorWheelValue.ToRgbOffset turns a wheel into a per-channel offset; applying it is left to the caller. The drag rules live in ColorWheelState, which has no ImGui dependency and is unit-tested on its own. Closes #511 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HEAQ6ynUNXdf4LpFq8o8MX --- CLAUDE.md | 2 +- ImGui.Widgets/ColorWheel.cs | 306 ++++++++++++++ ImGui.Widgets/ColorWheelState.cs | 195 +++++++++ ImGui.Widgets/ColorWheelValue.cs | 93 +++++ ImGui.Widgets/README.md | 1 + README.md | 4 +- examples/ImGuiWidgetsDemo/ColorWheelDemo.cs | 110 +++++ examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs | 2 + .../ColorWheelStateTests.cs | 395 ++++++++++++++++++ .../ImGui.Widgets.UITests/ColorWheelTests.cs | 221 ++++++++++ .../WidgetsDemoUITests.cs | 26 +- 11 files changed, 1351 insertions(+), 4 deletions(-) create mode 100644 ImGui.Widgets/ColorWheel.cs create mode 100644 ImGui.Widgets/ColorWheelState.cs create mode 100644 ImGui.Widgets/ColorWheelValue.cs create mode 100644 examples/ImGuiWidgetsDemo/ColorWheelDemo.cs create mode 100644 tests/ImGui.Widgets.Tests/ColorWheelStateTests.cs create mode 100644 tests/ImGui.Widgets.UITests/ColorWheelTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 1d4671f2..ae12298b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De ### Libraries - **ImGui.App** (`ktsu.ImGui.App`) - Application foundation with windowing, rendering, font/texture management, PID frame limiting, DPI awareness. Image decoding is self-contained (`ImGui.App/Images/`), so the package carries no imaging dependency; see [Image decoding](#image-decoding) below. -- **ImGui.Widgets** (`ktsu.ImGui.Widgets`) - Custom UI components, grouped as the README's feature list groups them: input and controls (`Switch`, `SegmentedControl`, `Stepper`, `RangeSlider`, `XYPad`, `Knob`/`KnobWithDrag`, `Rating`, `Chip`/`ChipGroup`, `PinInput`, `SearchBox`/`SearchBoxRanked`, `Combo`); display and status (`Avatar`, `Badge`/`BadgeDot`, `ColorIndicator`, `Icon`, `Text`, `Image`, `PageIndicator`); progress and loading (`RadialProgressBar` with `RadialCountdown`/`RadialCountUp`, `SkeletonLine`/`SkeletonRect`/`SkeletonCircle`); data and signals (`Histogram`, `HandleTrack`, `CurveTrack`, `DbMeter`, `Scope`); layout and containers (`DividerContainer`/`DividerZone`, `Grid`, `TabPanel`, `Card`, `Tree`, `ImageCanvas`, `PropertyGrid`, `OverlayHost`/`OverlayLayer`, `ScopedDisable`, `ScopedId`); and motion and gestures (`Tween`, `Spring`, `Easing`, `InertialScroll`, `GestureDetector`/`GestureMachine`). Also thin adapters delegating to `Hexa.NET.ImGui.Widgets`: `Spinner`, `BufferingBar`, `HorizontalSplitter`/`VerticalSplitter`, `ToggleSwitch`/`ToggleButton`/`TransparentButton`/`InlineButton`, `IconTreeNode`, `EnumCombo`, `TextCenteredV`/`TextCenteredH`/`TextCenteredVH`, `ImageCenteredV`/`ImageCenteredH`/`ImageCenteredVH`/`ImageScaleTo`, `Tooltip`, `Breadcrumb`, `DatePicker`/`YearPicker`, `FlameGraph`, `FileTreeView`, `OpenFileDialog`/`SaveFileDialog`/`OpenFolderDialog`, `RenameDialog`, `DialogMessageBox`/`ShowMessageBox`, `DockedWindow`. Seven of these look like duplicates of an existing ktsu widget; most are not, and the two that are have a recommended survivor — see [Hexa-backed vs ktsu widgets](#hexa-backed-vs-ktsu-widgets) below for the pair-by-pair verdict. `DatePicker` and `FileTreeView` need a Material Icons font registered via `FontHelper.AddCustomFont(io, data, size, FontHelper.GetMaterialIconRanges(), mergeWithPrevious: true)` (not `ImGuiAppConfig.Fonts`, which applies the Nerd Font mapping); see `examples/ImGuiAppDemo`. `YearPicker` needs no icon font. `OpenFileDialog`, `SaveFileDialog` and `OpenFolderDialog` need the same Material Icons font, for their toolbar, breadcrumb and file-tree glyphs; `RenameDialog`, `DialogMessageBox` and `ShowMessageBox` need none. `DockedWindow` composes Hexa's `ImWindow` internally rather than inheriting it — subclass it, override `Title` and `DrawContent()`, then call `Show()`/`Close()`. All of the dialogs and `DockedWindow` require a per-frame deferred-drawing pump; see [Deferred Drawing](#deferred-drawing-dialogs-and-docked-windows) below. Also includes callback-driven editors: `Sequencer`, `SequenceSource`, `CurveEditor`, `CurveSource`, `CurveData`, `BezierEditor`. Unlike the dialogs above, none of these need a deferred-drawing pump; see [Callback-driven editors](#callback-driven-editors) below. +- **ImGui.Widgets** (`ktsu.ImGui.Widgets`) - Custom UI components, grouped as the README's feature list groups them: input and controls (`Switch`, `SegmentedControl`, `Stepper`, `RangeSlider`, `XYPad`, `ColorWheel`/`LiftGammaGain`, `Knob`/`KnobWithDrag`, `Rating`, `Chip`/`ChipGroup`, `PinInput`, `SearchBox`/`SearchBoxRanked`, `Combo`); display and status (`Avatar`, `Badge`/`BadgeDot`, `ColorIndicator`, `Icon`, `Text`, `Image`, `PageIndicator`); progress and loading (`RadialProgressBar` with `RadialCountdown`/`RadialCountUp`, `SkeletonLine`/`SkeletonRect`/`SkeletonCircle`); data and signals (`Histogram`, `HandleTrack`, `CurveTrack`, `DbMeter`, `Scope`); layout and containers (`DividerContainer`/`DividerZone`, `Grid`, `TabPanel`, `Card`, `Tree`, `ImageCanvas`, `PropertyGrid`, `OverlayHost`/`OverlayLayer`, `ScopedDisable`, `ScopedId`); and motion and gestures (`Tween`, `Spring`, `Easing`, `InertialScroll`, `GestureDetector`/`GestureMachine`). Also thin adapters delegating to `Hexa.NET.ImGui.Widgets`: `Spinner`, `BufferingBar`, `HorizontalSplitter`/`VerticalSplitter`, `ToggleSwitch`/`ToggleButton`/`TransparentButton`/`InlineButton`, `IconTreeNode`, `EnumCombo`, `TextCenteredV`/`TextCenteredH`/`TextCenteredVH`, `ImageCenteredV`/`ImageCenteredH`/`ImageCenteredVH`/`ImageScaleTo`, `Tooltip`, `Breadcrumb`, `DatePicker`/`YearPicker`, `FlameGraph`, `FileTreeView`, `OpenFileDialog`/`SaveFileDialog`/`OpenFolderDialog`, `RenameDialog`, `DialogMessageBox`/`ShowMessageBox`, `DockedWindow`. Seven of these look like duplicates of an existing ktsu widget; most are not, and the two that are have a recommended survivor — see [Hexa-backed vs ktsu widgets](#hexa-backed-vs-ktsu-widgets) below for the pair-by-pair verdict. `DatePicker` and `FileTreeView` need a Material Icons font registered via `FontHelper.AddCustomFont(io, data, size, FontHelper.GetMaterialIconRanges(), mergeWithPrevious: true)` (not `ImGuiAppConfig.Fonts`, which applies the Nerd Font mapping); see `examples/ImGuiAppDemo`. `YearPicker` needs no icon font. `OpenFileDialog`, `SaveFileDialog` and `OpenFolderDialog` need the same Material Icons font, for their toolbar, breadcrumb and file-tree glyphs; `RenameDialog`, `DialogMessageBox` and `ShowMessageBox` need none. `DockedWindow` composes Hexa's `ImWindow` internally rather than inheriting it — subclass it, override `Title` and `DrawContent()`, then call `Show()`/`Close()`. All of the dialogs and `DockedWindow` require a per-frame deferred-drawing pump; see [Deferred Drawing](#deferred-drawing-dialogs-and-docked-windows) below. Also includes callback-driven editors: `Sequencer`, `SequenceSource`, `CurveEditor`, `CurveSource`, `CurveData`, `BezierEditor`. Unlike the dialogs above, none of these need a deferred-drawing pump; see [Callback-driven editors](#callback-driven-editors) below. - **ImGui.Popups** (`ktsu.ImGui.Popups`) - Modal dialogs: MessageOK, Prompt, InputString/Int/Float, FilesystemBrowser, SearchableList - **ImGui.Color** (`ktsu.ImGui.Color`) - Bridge between `ktsu.Semantics.Color` and ImGui. Colors are held as the semantic `Color` (linear) and `Srgb` types and converted only at the ImGui seam: `ColorImGuiExtensions` (`ToImColor`/`FromImColor`, `ToImGuiVector4`, `ToImGuiU32`) and `SrgbImGuiExtensions` (`Srgb` → `ImColor`/`ImGuiVector4`/`ImU32`, packed directly with no linear round-trip). The `ImColor` and `Srgb` `ToImGuiU32` apply the global style alpha like `ImGui.GetColorU32`; the linear `Color.ToImGuiU32` is a pure pack matching `ColorConvertFloat4ToU32`. `ImColor` extension operations: adjustments (lighten/darken, saturate/desaturate, hue offset, grayscale, invert, alpha), analysis (relative luminance, contrast ratio, perceptual distance), and contrast heuristics (`MostReadableTextColor`, `AdjustForSufficientContrast`). All color math delegates to `ktsu.Semantics.Color`. (There is no `ImColor` factory class — construct via `Color`/`Srgb` and convert.) - **ImGui.Styler** (`ktsu.ImGui.Styler`) - Theming system with 50+ built-in themes, scoped styling, Button.Alignment, Text.Color semantic colors, Indent utilities, Alignment helpers, theme-aware color palette (`Palette`, e.g. `Palette.Basic.Red`, `Palette.Semantic.Error`), and interactive theme browser. Color construction and manipulation live in `ImGui.Color`. diff --git a/ImGui.Widgets/ColorWheel.cs b/ImGui.Widgets/ColorWheel.cs new file mode 100644 index 00000000..49de1ff2 --- /dev/null +++ b/ImGui.Widgets/ColorWheel.cs @@ -0,0 +1,306 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.Color; +using ktsu.ImGui.Probes; + +/// +/// Provides custom ImGui widgets. +/// +public static partial class ImGuiWidgets +{ + /// + /// Draws a colour-grading wheel: a trackball ringed by the hues, whose handle is pushed towards + /// the colour to add, over a master slider for luminance. + /// + /// A unique identifier for the wheel, used for the ImGui ID and the probe name. The master slider is probed as label/master. + /// The wheel's hue, strength and master, updated while dragging. + /// The wheel's diameter in pixels. Non-positive falls back to a default derived from the font. + /// if the value changed this frame; otherwise . + /// + /// + /// This is in polar form: the angle of the handle is the hue and its distance + /// from the centre is the strength. Unlike the pad, the drag is relative — pressing anywhere on + /// the wheel grabs the handle where it is, so a press never jumps the grade. Hold Shift for fine + /// adjustment, and double-click to return the ball to the centre. + /// + /// + /// The slider under the ball is the master: an equal offset to every channel, from -1 at + /// the left end to 1 at the right. It drags the same relative way, Shift is fine on it too, + /// and a double-click returns it to zero. The ball and the slider reset separately, since a grade + /// that wants its colour back rarely wants its brightness thrown away with it. + /// + /// + /// The hues are laid out where a vectorscope puts them, red up and to the left, so a push on the + /// wheel moves a scope's trace the same way. The wheel only edits the value; turn it into a + /// per-channel offset with and apply it however the + /// grade in hand calls for. See for the usual set of three. + /// + /// + public static bool ColorWheel(string label, ref ColorWheelValue value, float diameter = 0.0f) + { + Ensure.NotNull(label); + + float side = diameter > 0.0f ? diameter : ImGui.GetTextLineHeight() * 8.0f; + ColorWheelState state = ColorWheelImpl.StateFor(label); + + Vector2 min = ImGui.GetCursorScreenPos(); + ImGui.InvisibleButton(label, new Vector2(side, side)); + ImGuiProbes.MarkItem(label); + + float radius = side * 0.5f; + Vector2 center = min + new Vector2(radius, radius); + float ringWidth = MathF.Max(3.0f, radius * 0.08f); + float travel = MathF.Max(1.0f, radius - ringWidth); + + bool changed = ColorWheelImpl.InteractBall(state, ref value, center, travel); + ColorWheelImpl.DrawWheel(ImGui.GetWindowDrawList(), value, center, radius, ringWidth, travel); + + // The master slider is scoped under the wheel's own label, so it is probed as label/master + // and two wheels' sliders stay distinct. + using (new ScopedId(label)) + { + changed |= ColorWheelImpl.MasterSlider(state, ref value, side); + } + + return changed; + } + + /// + /// Draws the three primary grading wheels side by side — lift for the shadows, gamma for the + /// midtones, gain for the highlights — each captioned with its name and value. + /// + /// A unique identifier for the set. It scopes the three wheels, which are probed as label/Lift, label/Gamma and label/Gain; it is not drawn. + /// The shadows wheel. + /// The midtones wheel. + /// The highlights wheel. + /// Each wheel's diameter in pixels. Non-positive falls back to a default derived from the font. + /// if any of the three changed this frame; otherwise . + /// + /// Each wheel behaves exactly as does. The widget edits the three values + /// and nothing else: how each is applied to an image is left to the caller, as + /// describes. + /// + public static bool LiftGammaGain(string label, ref ColorWheelValue lift, ref ColorWheelValue gamma, ref ColorWheelValue gain, float diameter = 0.0f) + { + Ensure.NotNull(label); + + float side = diameter > 0.0f ? diameter : ImGui.GetTextLineHeight() * 7.0f; + bool changed = false; + + using (new ScopedId(label)) + { + changed |= ColorWheelImpl.Captioned("Lift", ref lift, side); + ImGui.SameLine(); + changed |= ColorWheelImpl.Captioned("Gamma", ref gamma, side); + ImGui.SameLine(); + changed |= ColorWheelImpl.Captioned("Gain", ref gain, side); + } + + return changed; + } + + internal static class ColorWheelImpl + { + private static readonly Dictionary States = []; + + /// How many segments the hue ring is drawn in. + /// Six degrees apiece, which is finer than the eye can pick out a step in hue at this size. + private const int RingSegments = 60; + + /// Converts a screen position to disk units: centre at the origin, rim at one, y up. + internal static Vector2 ToDiskUnits(Vector2 screen, Vector2 center, float travel) => + new((screen.X - center.X) / travel, (center.Y - screen.Y) / travel); + + /// Converts a position in disk units back to the screen. + internal static Vector2 ToScreen(Vector2 disk, Vector2 center, float travel) => + new(center.X + (disk.X * travel), center.Y - (disk.Y * travel)); + + internal static ColorWheelState StateFor(string label) + { + uint id = ImGui.GetID(label); + if (!States.TryGetValue(id, out ColorWheelState? state)) + { + state = new ColorWheelState(); + States[id] = state; + } + + return state; + } + + internal static bool InteractBall(ColorWheelState state, ref ColorWheelValue value, Vector2 center, float travel) + { + ImGuiIOPtr io = ImGui.GetIO(); + Vector2 pointer = ToDiskUnits(io.MousePos, center, travel); + bool fine = io.KeyShift; + bool changed = false; + + if (ImGui.IsItemHovered() && ImGui.IsMouseDoubleClicked(ImGuiMouseButton.Left)) + { + // Keep the hue: a reset wheel pushed again starts from the direction it had, which + // is the direction it is most likely to be pushed. The master slider is its own + // control, with its own double-click, so it is left alone. + ColorWheelValue reset = value with { Strength = 0.0f }; + changed = reset != value; + value = reset; + state.End(); + return changed; + } + + if (ImGui.IsItemActivated()) + { + state.Begin(value, pointer, fine); + } + + if (ImGui.IsItemActive()) + { + ColorWheelValue next = state.Drag(value, pointer, fine); + changed = next != value; + value = next; + } + else + { + state.End(); + } + + return changed; + } + + internal static void DrawWheel(ImDrawListPtr drawList, ColorWheelValue value, Vector2 center, float radius, float ringWidth, float travel) + { + Span colors = ImGui.GetStyle().Colors; + bool hot = ImGui.IsItemHovered() || ImGui.IsItemActive(); + + // The ring: each segment in the hue that points that way. Disk angles run counter-clockwise + // with y up and ImGui's run clockwise with y down, hence the negated angle. + float ringRadius = radius - (ringWidth * 0.5f); + float step = MathF.PI * 2.0f / RingSegments; + for (int i = 0; i < RingSegments; i++) + { + float diskAngle = (i + 0.5f) * step; + float hue = (diskAngle * (180.0f / MathF.PI)) - ColorWheelState.RedAngle; + uint color = ColorWheelValue.HueToSrgb(hue).ToImGuiU32(); + + // A little past each end so the joins do not show as hairline gaps. + drawList.PathArcTo(center, ringRadius, -(i * step) + 0.01f, -((i + 1) * step) - 0.01f, 4); + drawList.PathStroke(color, ImDrawFlags.None, ringWidth); + } + + drawList.AddCircleFilled(center, radius - ringWidth, ImGui.GetColorU32(colors[(int)ImGuiCol.FrameBg]), 48); + + // Crosshair through neutral, so "this wheel does nothing" reads at a glance. + uint guide = ImGui.GetColorU32(colors[(int)ImGuiCol.Border]); + float inner = radius - ringWidth; + drawList.AddLine(center - new Vector2(inner, 0.0f), center + new Vector2(inner, 0.0f), guide); + drawList.AddLine(center - new Vector2(0.0f, inner), center + new Vector2(0.0f, inner), guide); + + Vector2 handle = ToScreen(ColorWheelState.ToDisk(value), center, travel); + uint hueColor = ColorWheelValue.HueToSrgb(value.Hue).ToImGuiU32(); + bool pushed = value.Strength > 0.0f; + if (pushed) + { + drawList.AddLine(center, handle, hueColor, 2.0f); + } + + float handleRadius = MathF.Max(4.0f, radius * 0.07f); + uint fill = ImGui.GetColorU32(colors[(int)(hot ? ImGuiCol.SliderGrabActive : ImGuiCol.SliderGrab)]); + drawList.AddCircleFilled(handle, handleRadius, fill, 16); + drawList.AddCircle(handle, handleRadius, pushed ? hueColor : guide, 16, 2.0f); + } + + internal static bool MasterSlider(ColorWheelState state, ref ColorWheelValue value, float width) + { + float height = MathF.Max(6.0f, ImGui.GetFrameHeight() * 0.5f); + Vector2 min = ImGui.GetCursorScreenPos(); + ImGui.InvisibleButton("master", new Vector2(width, height)); + ImGuiProbes.MarkItem("master"); + + ImGuiIOPtr io = ImGui.GetIO(); + float half = MathF.Max(1.0f, width * 0.5f); + float centerX = min.X + half; + float pointer = (io.MousePos.X - centerX) / half; + bool fine = io.KeyShift; + bool changed = false; + + if (ImGui.IsItemHovered() && ImGui.IsMouseDoubleClicked(ImGuiMouseButton.Left)) + { + changed = value.Master != 0.0f; + value = value with { Master = 0.0f }; + state.EndMaster(); + } + else + { + if (ImGui.IsItemActivated()) + { + state.BeginMaster(value.Master, pointer, fine); + } + + if (ImGui.IsItemActive()) + { + float next = state.DragMaster(value.Master, pointer, fine); + changed = next != value.Master; + value = value with { Master = next }; + } + else + { + state.EndMaster(); + } + } + + // A bar filled from the centre towards the value, so zero reads as nothing drawn and the + // sign reads as a side. + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Span colors = ImGui.GetStyle().Colors; + bool hot = ImGui.IsItemHovered() || ImGui.IsItemActive(); + Vector2 max = min + new Vector2(width, height); + float rounding = height * 0.5f; + drawList.AddRectFilled(min, max, ImGui.GetColorU32(colors[(int)ImGuiCol.FrameBg]), rounding); + + float master = float.IsFinite(value.Master) ? Math.Clamp(value.Master, -1.0f, 1.0f) : 0.0f; + float valueX = centerX + (master * half); + if (master != 0.0f) + { + uint fill = ImGui.GetColorU32(colors[(int)(hot ? ImGuiCol.SliderGrabActive : ImGuiCol.SliderGrab)]); + drawList.AddRectFilled(new Vector2(MathF.Min(centerX, valueX), min.Y), new Vector2(MathF.Max(centerX, valueX), max.Y), fill); + } + + uint guide = ImGui.GetColorU32(colors[(int)ImGuiCol.Border]); + drawList.AddLine(new Vector2(centerX, min.Y), new Vector2(centerX, max.Y), guide); + drawList.AddRect(min, max, guide, rounding); + + return changed; + } + + internal static bool Captioned(string name, ref ColorWheelValue value, float side) + { + ImGui.BeginGroup(); + bool changed = ColorWheel(name, ref value, side); + + CenteredText(name, side); + CenteredText( + value.IsNeutral + ? "Neutral" + : string.Format(CultureInfo.InvariantCulture, "{0:0}\u00b0 {1:0.00} {2:+0.00;-0.00;0.00}", ColorWheelValue.WrapHue(value.Hue), value.Strength, value.Master), + side); + + ImGui.EndGroup(); + return changed; + } + + private static void CenteredText(string text, float width) + { + float textWidth = ImGui.CalcTextSize(text).X; + float start = ImGui.GetCursorPosX(); + ImGui.SetCursorPosX(start + MathF.Max(0.0f, (width - textWidth) * 0.5f)); + ImGui.TextUnformatted(text); + } + } +} diff --git a/ImGui.Widgets/ColorWheelState.cs b/ImGui.Widgets/ColorWheelState.cs new file mode 100644 index 00000000..c19c5420 --- /dev/null +++ b/ImGui.Widgets/ColorWheelState.cs @@ -0,0 +1,195 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets; + +using System; +using System.Numerics; + +/// +/// Provides custom ImGui widgets. +/// +public static partial class ImGuiWidgets +{ + /// + /// The interaction behind one colour-balance trackball: where a value sits on the disk, and + /// how a drag moves it. + /// + /// + /// + /// Deliberately free of ImGui. Positions are in disk units: the wheel's centre is the + /// origin, its rim is at length one, and y runs upward. The widget converts the mouse into + /// those units before calling in, which is what lets every rule here be tested without a + /// graphics context. + /// + /// + /// The drag is relative, as a trackball's is: pressing anywhere on the wheel grabs the handle + /// where it already is, and moving the pointer moves the handle by the same amount. An + /// absolute drag, as uses, would make every press a jump, and a grade is + /// something adjusted by a hair from where it is far more often than it is set from scratch. + /// + /// + internal sealed class ColorWheelState + { + /// The screen angle, in degrees counter-clockwise from the right, at which red is drawn. + /// + /// Where a vectorscope puts its red target. Laying the wheel out the same way means a push + /// on the wheel moves the trace on the scope in the direction of the push. + /// + public const float RedAngle = 103.0f; + + /// How much of the pointer's movement reaches the handle while fine adjustment is held. + public const float FineScale = 0.25f; + + private Vector2 anchorPosition; + private Vector2 anchorPointer; + private bool anchorFine; + + private float masterAnchorValue; + private float masterAnchorPointer; + private bool masterAnchorFine; + + /// Gets a value indicating whether a drag of the trackball is in progress. + public bool IsDragging { get; private set; } + + /// Gets a value indicating whether a drag of the master slider is in progress. + public bool IsDraggingMaster { get; private set; } + + /// Returns where on the disk a value is drawn, in disk units. + public static Vector2 ToDisk(ColorWheelValue value) + { + float strength = Math.Clamp(float.IsFinite(value.Strength) ? value.Strength : 0.0f, 0.0f, 1.0f); + float angle = (ColorWheelValue.WrapHue(value.Hue) + RedAngle) * (MathF.PI / 180.0f); + return new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * strength; + } + + /// Returns the hue and strength a position on the disk stands for, with a master of zero. + /// The position in disk units. Anything past the rim is read as the rim. + /// The hue to keep when is the centre, which has none. + public static ColorWheelValue FromDisk(Vector2 position, float previousHue) + { + float length = position.Length(); + + // The centre has no direction. Keeping the hue the wheel already had is what stops a + // handle dragged through the middle from reporting a sudden swing to red. + if (!float.IsFinite(length) || length <= 1e-6f) + { + return new ColorWheelValue(ColorWheelValue.WrapHue(previousHue), 0.0f); + } + + float angle = MathF.Atan2(position.Y, position.X) * (180.0f / MathF.PI); + return new ColorWheelValue(ColorWheelValue.WrapHue(angle - RedAngle), MathF.Min(length, 1.0f)); + } + + /// Starts a drag from where already sits. + /// The wheel's value when the press landed. + /// The pointer, in disk units. + /// Whether fine adjustment is held. + public void Begin(ColorWheelValue value, Vector2 pointer, bool fine) + { + Anchor(ToDisk(value), pointer, fine); + IsDragging = true; + } + + /// Moves the handle by how far the pointer has moved since the drag began. + /// The wheel's value now. + /// The pointer, in disk units. + /// Whether fine adjustment is held. + /// The new value, or unchanged when no drag is in progress. + public ColorWheelValue Drag(ColorWheelValue current, Vector2 pointer, bool fine) + { + if (!IsDragging) + { + return current; + } + + // Toggling fine adjustment mid-drag re-anchors where the handle is now. Scaling the whole + // distance travelled so far by the new factor would throw the handle a long way on the + // frame the modifier changed. + if (fine != anchorFine) + { + Anchor(ToDisk(current), pointer, fine); + } + + Vector2 position = anchorPosition + ((pointer - anchorPointer) * (fine ? FineScale : 1.0f)); + + // Past the rim, pin the handle to it and re-anchor there. Without that the overshoot is + // remembered, and a drag back inward does nothing until the pointer has retraced all of + // it — the rim would feel sticky. + if (position.LengthSquared() > 1.0f) + { + position = Vector2.Normalize(position); + Anchor(position, pointer, fine); + } + + // A pointer held still must hand back the value it was given, not the same position + // round-tripped through an angle: consumers turn changes into undo entries, and a + // last-bit difference in the hue would mint one every frame the button is held. + return Vector2.DistanceSquared(position, ToDisk(current)) <= 1e-10f + ? current + : FromDisk(position, current.Hue) with { Master = current.Master }; + } + + /// Ends the drag. + public void End() => IsDragging = false; + + /// Starts a drag of the master slider from where it already sits. + /// The master value when the press landed. + /// + /// The pointer in bar units: the slider's centre is zero and its ends are minus and + /// plus one, so sweeping the bar's whole width sweeps the whole range. + /// + /// Whether fine adjustment is held. + public void BeginMaster(float master, float pointer, bool fine) + { + AnchorMaster(ClampMaster(master), pointer, fine); + IsDraggingMaster = true; + } + + /// Moves the master slider by how far the pointer has moved since the drag began. + /// The master value now. + /// The pointer, in bar units. + /// Whether fine adjustment is held. + /// The new master value, or unchanged when no drag is in progress. + /// Relative, re-anchored when fine adjustment toggles, and re-anchored at either end, for the same reasons as . + public float DragMaster(float current, float pointer, bool fine) + { + if (!IsDraggingMaster) + { + return current; + } + + if (fine != masterAnchorFine) + { + AnchorMaster(ClampMaster(current), pointer, fine); + } + + float master = masterAnchorValue + ((pointer - masterAnchorPointer) * (fine ? FineScale : 1.0f)); + if (master is < -1.0f or > 1.0f) + { + master = Math.Clamp(master, -1.0f, 1.0f); + AnchorMaster(master, pointer, fine); + } + + return MathF.Abs(master - current) <= 1e-7f ? current : master; + } + + /// Ends the master slider's drag. + public void EndMaster() => IsDraggingMaster = false; + + private static float ClampMaster(float master) => float.IsFinite(master) ? Math.Clamp(master, -1.0f, 1.0f) : 0.0f; + + private void AnchorMaster(float value, float pointer, bool fine) + { + masterAnchorValue = value; + masterAnchorPointer = pointer; + masterAnchorFine = fine; + } + + private void Anchor(Vector2 position, Vector2 pointer, bool fine) + { + anchorPosition = position; + anchorPointer = pointer; + anchorFine = fine; + } + } +} diff --git a/ImGui.Widgets/ColorWheelValue.cs b/ImGui.Widgets/ColorWheelValue.cs new file mode 100644 index 00000000..8ebdc5c2 --- /dev/null +++ b/ImGui.Widgets/ColorWheelValue.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets; + +using System; +using System.Numerics; + +using ktsu.Semantics.Color; + +/// +/// The setting of one colour-grading wheel: which way its trackball is pushed, how far, and where +/// its master slider sits. +/// +/// The direction of the push as a hue in degrees, 0 red, 120 green, 240 blue. +/// How far the push goes, from 0 (neutral) to 1 (the rim). +/// The master slider, from -1 to 1: an equal offset to all three channels, which moves luminance without moving colour. +/// +/// A wheel at zero strength still carries a hue. That is deliberate: the hue of a neutral wheel +/// is the direction it was last pushed, so pulling the handle back through the centre and out +/// again does not snap it to red on the way. +/// +public readonly record struct ColorWheelValue(float Hue, float Strength, float Master = 0.0f) +{ + /// Gets a wheel that does nothing: zero strength and master, pointing at red. + public static ColorWheelValue Neutral { get; } = new(0.0f, 0.0f); + + /// Gets a value indicating whether the wheel has no effect. + public bool IsNeutral => Strength <= 0.0f && Master == 0.0f; + + /// + /// Returns the per-channel colour offset this wheel stands for: the colour of + /// with its mean removed and scaled by , plus on every + /// channel. + /// + /// A red, green and blue offset whose three channels sum to three times . + /// + /// + /// Removing the mean is what makes the trackball a balance rather than a tint: pushing towards + /// red raises red and lowers green and blue by the same total, so the ball moves colour without + /// moving the average of the three channels. At full strength the largest channel moves by two + /// thirds, whichever hue is chosen. Brightness is the master slider's job, and it moves all + /// three channels together. + /// + /// + /// How the offset is applied is the caller's business, since lift, gamma and gain are applied + /// differently and no two grading pipelines agree on the exact curves. A typical use adds + /// offset * (1 - x) for lift, raises to 1 / (1 + offset) for gamma, and multiplies by + /// 1 + offset for gain, where x is the input channel. + /// + /// + public Vector3 ToRgbOffset() + { + Vector3 rgb = HueToRgb(Hue); + float mean = (rgb.X + rgb.Y + rgb.Z) / 3.0f; + float strength = float.IsFinite(Strength) ? Math.Clamp(Strength, 0.0f, 1.0f) : 0.0f; + float master = float.IsFinite(Master) ? Math.Clamp(Master, -1.0f, 1.0f) : 0.0f; + return ((rgb - new Vector3(mean)) * strength) + new Vector3(master); + } + + /// Returns the fully saturated, full-value colour of a hue in degrees. + /// The hue in degrees. Any value is accepted and wrapped into [0, 360). + /// The colour in gamma-encoded sRGB. + public static Srgb HueToSrgb(float hue) => new Hsv(WrapHue(hue), 1.0, 1.0).ToSrgb(); + + /// Returns the fully saturated, full-value colour of a hue in degrees as a vector. + /// The hue in degrees. Any value is accepted and wrapped into [0, 360). + /// The colour as gamma-encoded red, green and blue in [0, 1]. + public static Vector3 HueToRgb(float hue) + { + Srgb srgb = HueToSrgb(hue); + return new Vector3((float)srgb.R, (float)srgb.G, (float)srgb.B); + } + + /// Wraps a hue in degrees into [0, 360). + /// The hue in degrees. + /// The same direction as a value in [0, 360), or 0 for a non-finite input. + public static float WrapHue(float hue) + { + if (!float.IsFinite(hue)) + { + return 0.0f; + } + + float wrapped = hue % 360.0f; + if (wrapped < 0.0f) + { + wrapped += 360.0f; + } + + // A tiny negative input wraps to 360 exactly in single precision, which is outside the range. + return wrapped >= 360.0f ? 0.0f : wrapped; + } +} diff --git a/ImGui.Widgets/README.md b/ImGui.Widgets/README.md index 2828c900..97ec511a 100644 --- a/ImGui.Widgets/README.md +++ b/ImGui.Widgets/README.md @@ -16,6 +16,7 @@ The widgets below are grouped by what they are for. Everything is a static metho - **`Stepper`**: A `[-] value [+]` integer stepper with hold-to-repeat after a short delay - **`RangeSlider`**: Dual-handle slider for a span within a range; the handles cannot cross and stay a minimum distance apart - **`XYPad`**: Edits two normalized parameters at once from one pad +- **`ColorWheel`** / **`LiftGammaGain`**: Colour-grading wheels, singly or as the lift, gamma and gain set a grade is made of. Each is a trackball editing a hue and strength over a master slider for luminance, held in a `ColorWheelValue`. Drags are relative, so a press never jumps the handle; Shift adjusts finely and a double-click resets the ball or the slider. `ColorWheelValue.ToRgbOffset` turns a wheel into a per-channel offset, and applying it is left to you - **`Knob`** / **`KnobWithDrag`**: Dial-style knobs in several variants, ported to .NET from [ImGui-works/ImGui-knobs-dial-gauge-meter](https://github.com/imgui-works/imgui-knobs-dial-gauge-meter) - **`Rating`**: Interactive star rating that previews the value under the cursor before it is committed - **`Chip`** / **`ChipGroup`**: Pill-shaped filter or choice tags, filled when selected, and a wrapping single-select group of them diff --git a/README.md b/README.md index 64d592cb..c3a43eaf 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - **Application Foundation**: Complete application scaffolding with windowing, OpenGL rendering, font management, texture caching, dependency-free PNG/JPEG/BMP/TGA decoding, and DPI awareness via `ktsu.ImGui.App` - **PID Frame Limiting**: High-precision PID-controlled frame rate limiting with auto-tuning and adaptive throttling for focused, unfocused, idle, and minimized states -- **Custom Widgets**: Around sixty UI components via `ktsu.ImGui.Widgets` — controls (Switch, SegmentedControl, Stepper, RangeSlider, XYPad, Knob, Rating, Chip, PinInput, SearchBox with fuzzy matching), layout (DividerContainer, Grid, TabPanel, Card, Tree, ImageCanvas, PropertyGrid, overlays), feedback (RadialProgressBar with countdown/count-up timers, spinners, skeleton placeholders, badges), signal views (Histogram, FlameGraph, DbMeter, oscilloscope), code review (DiffView, unified or side by side with a checkbox per hunk), motion (tweens, springs, inertial scrolling, gesture detection), and callback-driven Sequencer, CurveEditor and BezierEditor +- **Custom Widgets**: Around sixty UI components via `ktsu.ImGui.Widgets` — controls (Switch, SegmentedControl, Stepper, RangeSlider, XYPad, lift/gamma/gain ColorWheels, Knob, Rating, Chip, PinInput, SearchBox with fuzzy matching), layout (DividerContainer, Grid, TabPanel, Card, Tree, ImageCanvas, PropertyGrid, overlays), feedback (RadialProgressBar with countdown/count-up timers, spinners, skeleton placeholders, badges), signal views (Histogram, FlameGraph, DbMeter, oscilloscope), code review (DiffView, unified or side by side with a checkbox per hunk), motion (tweens, springs, inertial scrolling, gesture detection), and callback-driven Sequencer, CurveEditor and BezierEditor - **Modal Dialogs**: Professional popup system with MessageOK, Prompt, InputString/Int/Float, FilesystemBrowser, and SearchableList via `ktsu.ImGui.Popups` - **Theming System**: 50+ built-in themes (Catppuccin, Tokyo Night, Gruvbox, Dracula, and more) with scoped styling, semantic text colors, button alignment, color palettes, and an interactive theme browser via `ktsu.ImGui.Styler` - **Node Graph Framework**: Attribute-based node declaration system with UI-agnostic `ktsu.NodeGraph` metadata library and ImNodes-based visual editor `ktsu.ImGui.NodeEditor` with physics-based layout @@ -41,7 +41,7 @@ Complete application scaffolding for Dear ImGui applications with windowing, ren [![NuGet](https://img.shields.io/nuget/v/ktsu.ImGui.Widgets?label=ktsu.ImGui.Widgets&logo=nuget)](https://nuget.org/packages/ktsu.ImGui.Widgets) -Rich collection of custom widgets, grouped by what they are for: input and controls (Switch, SegmentedControl, Stepper, RangeSlider, XYPad, Knob, Rating, Chip, PinInput, SearchBox, Combo), display and status (Avatar, Badge, ColorIndicator, Icon, Text, Image, PageIndicator, Tooltip, Breadcrumb), progress and loading (RadialProgressBar, BufferingBar, Spinner, skeleton placeholders), data and signals (Histogram, HandleTrack, CurveTrack, FlameGraph, DbMeter, Scope), code review (DiffView), layout and containers (DividerContainer, Grid, TabPanel, Card, Tree, ImageCanvas, PropertyGrid, OverlayHost, ScopedId, ScopedDisable), motion and gestures (Tween, Spring, Easing, InertialScroll, GestureDetector), callback-driven editors (Sequencer, CurveEditor, BezierEditor), and stateful dialogs. +Rich collection of custom widgets, grouped by what they are for: input and controls (Switch, SegmentedControl, Stepper, RangeSlider, XYPad, ColorWheel/LiftGammaGain, Knob, Rating, Chip, PinInput, SearchBox, Combo), display and status (Avatar, Badge, ColorIndicator, Icon, Text, Image, PageIndicator, Tooltip, Breadcrumb), progress and loading (RadialProgressBar, BufferingBar, Spinner, skeleton placeholders), data and signals (Histogram, HandleTrack, CurveTrack, FlameGraph, DbMeter, Scope), code review (DiffView), layout and containers (DividerContainer, Grid, TabPanel, Card, Tree, ImageCanvas, PropertyGrid, OverlayHost, ScopedId, ScopedDisable), motion and gestures (Tween, Spring, Easing, InertialScroll, GestureDetector), callback-driven editors (Sequencer, CurveEditor, BezierEditor), and stateful dialogs. ### ImGui.Popups - Modal Dialogs diff --git a/examples/ImGuiWidgetsDemo/ColorWheelDemo.cs b/examples/ImGuiWidgetsDemo/ColorWheelDemo.cs new file mode 100644 index 00000000..22835a53 --- /dev/null +++ b/examples/ImGuiWidgetsDemo/ColorWheelDemo.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Examples.Widgets; + +using System; +using System.Numerics; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.Probes; +using ktsu.ImGui.Widgets; + +/// Shows the lift, gamma and gain wheels grading a grey ramp. +internal static class ColorWheelDemo +{ + private const int RampSteps = 64; + + private static ColorWheelValue lift = ColorWheelValue.Neutral; + private static ColorWheelValue gamma = ColorWheelValue.Neutral; + private static ColorWheelValue gain = ColorWheelValue.Neutral; + + /// Gets the gain wheel's value. + internal static ColorWheelValue Gain => gain; + + /// Gets the lift wheel's value. + internal static ColorWheelValue Lift => lift; + + /// + /// Returns this section's state to its starting values. The demo keeps its state in statics, + /// which outlive a harness. + /// + internal static void ResetState() + { + lift = ColorWheelValue.Neutral; + gamma = ColorWheelValue.Neutral; + gain = ColorWheelValue.Neutral; + } + + public static void Show() + { + if (!DemoProbe.Header("Color wheels")) + { + return; + } + + ImGui.TextUnformatted("Lift, gamma and gain trackballs. Drag to push a wheel towards a colour, hold Shift for fine"); + ImGui.TextUnformatted("adjustment, and double-click a wheel to return it to neutral."); + ImGui.Separator(); + + ImGuiWidgets.LiftGammaGain("##grade", ref lift, ref gamma, ref gain); + + if (ImGui.Button("Reset all")) + { + ResetState(); + } + + ImGuiProbes.MarkItem("Reset all"); + + ImGui.Separator(); + ImGui.TextUnformatted("A grey ramp before and after the grade:"); + + // The widget only edits values; applying them is the demo's business. This is one common + // shape for it — lift raises the shadows, gamma bends the midtones, gain scales the + // highlights — and a real grader would pick its own. + Vector3 liftOffset = lift.ToRgbOffset() * 0.5f; + Vector3 gammaOffset = gamma.ToRgbOffset(); + Vector3 gainOffset = gain.ToRgbOffset(); + + float width = ImGui.GetContentRegionAvail().X; + width = MathF.Min(width, 480.0f); + DrawRamp("##rampBefore", width, x => new Vector3(x)); + DrawRamp("##rampAfter", width, x => Grade(new Vector3(x), liftOffset, gammaOffset, gainOffset)); + } + + /// Applies a lift, gamma and gain offset to one colour. + internal static Vector3 Grade(Vector3 input, Vector3 liftOffset, Vector3 gammaOffset, Vector3 gainOffset) + { + Vector3 lifted = input + (liftOffset * (Vector3.One - input)); + Vector3 gained = lifted * (Vector3.One + gainOffset); + + return new Vector3( + ApplyGamma(gained.X, gammaOffset.X), + ApplyGamma(gained.Y, gammaOffset.Y), + ApplyGamma(gained.Z, gammaOffset.Z)); + } + + private static float ApplyGamma(float value, float offset) + { + float exponent = 1.0f / MathF.Max(0.1f, 1.0f + offset); + return Math.Clamp(MathF.Pow(Math.Clamp(value, 0.0f, 1.0f), exponent), 0.0f, 1.0f); + } + + private static void DrawRamp(string id, float width, Func shade) + { + float height = ImGui.GetFrameHeight(); + Vector2 min = ImGui.GetCursorScreenPos(); + ImGui.Dummy(new Vector2(width, height)); + ImGuiProbes.MarkItem(id); + + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + float step = width / RampSteps; + for (int i = 0; i < RampSteps; i++) + { + Vector3 rgb = shade((i + 0.5f) / RampSteps); + Vector2 cellMin = new(min.X + (i * step), min.Y); + Vector2 cellMax = new(min.X + ((i + 1) * step) + 1.0f, min.Y + height); + drawList.AddRectFilled(cellMin, cellMax, ImGui.GetColorU32(new Vector4(rgb, 1.0f))); + } + } +} diff --git a/examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs b/examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs index f94ffa3e..770786f8 100644 --- a/examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs +++ b/examples/ImGuiWidgetsDemo/ImGuiWidgetsDemo.cs @@ -131,6 +131,7 @@ internal static void ResetState() propertySwatches.Clear(); propertySwatches.AddRange([Color.FromSrgb(0.9f, 0.3f, 0.3f, 1.0f), Color.FromSrgb(0.3f, 0.8f, 0.4f, 1.0f)]); DiffViewDemo.ResetState(); + ColorWheelDemo.ResetState(); } /// Gets the tags held by the property grid section's list row. @@ -459,6 +460,7 @@ private static void ShowWidgetDemos() ShowMobileContainersDemo(); ShowHistogramAndHandleTrackDemo(); ShowCurveTrackDemo(); + ColorWheelDemo.Show(); DiffViewDemo.Show(); } diff --git a/tests/ImGui.Widgets.Tests/ColorWheelStateTests.cs b/tests/ImGui.Widgets.Tests/ColorWheelStateTests.cs new file mode 100644 index 00000000..3899f6ea --- /dev/null +++ b/tests/ImGui.Widgets.Tests/ColorWheelStateTests.cs @@ -0,0 +1,395 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets.Tests; + +using System; +using System.Numerics; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests the geometry and drag behind ColorWheel, and the value it edits. All pure — no ImGui +/// context required. +/// +[TestClass] +public class ColorWheelStateTests +{ + private const float Tolerance = 1e-4f; + + private static float AngleOf(Vector2 disk) => MathF.Atan2(disk.Y, disk.X) * (180.0f / MathF.PI); + + private static float HueDistance(float a, float b) + { + float d = MathF.Abs(ColorWheelValue.WrapHue(a) - ColorWheelValue.WrapHue(b)); + return MathF.Min(d, 360.0f - d); + } + + [TestMethod] + public void ToDisk_PutsRedWhereAVectorscopeDoes() + { + Vector2 red = ImGuiWidgets.ColorWheelState.ToDisk(new ColorWheelValue(0.0f, 1.0f)); + + Assert.AreEqual(ImGuiWidgets.ColorWheelState.RedAngle, AngleOf(red), Tolerance); + Assert.AreEqual(1.0f, red.Length(), Tolerance); + } + + [TestMethod] + public void ToDisk_RunsTheHuesCounterClockwise() + { + // Red, then yellow a sixth of a turn further round, as on a vectorscope. + float red = AngleOf(ImGuiWidgets.ColorWheelState.ToDisk(new ColorWheelValue(0.0f, 1.0f))); + float yellow = AngleOf(ImGuiWidgets.ColorWheelState.ToDisk(new ColorWheelValue(60.0f, 1.0f))); + + Assert.AreEqual(60.0f, yellow - red, Tolerance); + } + + [TestMethod] + public void ToDisk_PlacesStrengthAsDistanceFromTheCentre() + { + Vector2 half = ImGuiWidgets.ColorWheelState.ToDisk(new ColorWheelValue(200.0f, 0.5f)); + + Assert.AreEqual(0.5f, half.Length(), Tolerance); + } + + [TestMethod] + public void ToDisk_ClampsStrengthToTheRim() + { + Assert.AreEqual(1.0f, ImGuiWidgets.ColorWheelState.ToDisk(new ColorWheelValue(10.0f, 4.0f)).Length(), Tolerance); + Assert.AreEqual(0.0f, ImGuiWidgets.ColorWheelState.ToDisk(new ColorWheelValue(10.0f, -1.0f)).Length(), Tolerance); + Assert.AreEqual(0.0f, ImGuiWidgets.ColorWheelState.ToDisk(new ColorWheelValue(10.0f, float.NaN)).Length(), Tolerance); + } + + [TestMethod] + public void FromDisk_RoundTripsEveryHue() + { + for (float hue = 0.0f; hue < 360.0f; hue += 15.0f) + { + ColorWheelValue value = new(hue, 0.75f); + + ColorWheelValue back = ImGuiWidgets.ColorWheelState.FromDisk(ImGuiWidgets.ColorWheelState.ToDisk(value), 0.0f); + + Assert.IsTrue(HueDistance(hue, back.Hue) < 1e-2f, $"Hue {hue} came back as {back.Hue}."); + Assert.AreEqual(0.75f, back.Strength, Tolerance); + } + } + + [TestMethod] + public void FromDisk_ReadsPastTheRimAsTheRim() + { + ColorWheelValue value = ImGuiWidgets.ColorWheelState.FromDisk(new Vector2(3.0f, 4.0f), 0.0f); + + Assert.AreEqual(1.0f, value.Strength, Tolerance); + } + + [TestMethod] + public void FromDisk_KeepsThePreviousHueAtTheCentre() + { + // The centre has no direction. Reporting red there would swing the hue of a handle dragged + // through the middle. + ColorWheelValue value = ImGuiWidgets.ColorWheelState.FromDisk(Vector2.Zero, 217.0f); + + Assert.AreEqual(217.0f, value.Hue, Tolerance); + Assert.AreEqual(0.0f, value.Strength, Tolerance); + } + + [TestMethod] + public void FromDisk_ReturnsHuesInsideTheWrappedRange() + { + for (int i = 0; i < 64; i++) + { + float angle = i / 64.0f * MathF.PI * 2.0f; + ColorWheelValue value = ImGuiWidgets.ColorWheelState.FromDisk(new Vector2(MathF.Cos(angle), MathF.Sin(angle)), 0.0f); + + Assert.IsTrue(value.Hue is >= 0.0f and < 360.0f, $"Angle {angle} gave hue {value.Hue}."); + } + } + + [TestMethod] + public void Drag_WithoutBeginChangesNothing() + { + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = new(40.0f, 0.3f); + + Assert.AreEqual(value, state.Drag(value, new Vector2(0.9f, 0.1f), fine: false)); + } + + [TestMethod] + public void Drag_PressDoesNotJumpTheHandle() + { + // A trackball grabs the handle where it is. Pressing on the far side of the wheel and + // holding still must leave the value exactly as it was. + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = new(123.0f, 0.4f); + + state.Begin(value, new Vector2(-0.8f, -0.2f), fine: false); + ColorWheelValue held = state.Drag(value, new Vector2(-0.8f, -0.2f), fine: false); + + Assert.AreEqual(value, held); + } + + [TestMethod] + public void Drag_MovesTheHandleByThePointersMovement() + { + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = ColorWheelValue.Neutral; + + state.Begin(value, new Vector2(0.5f, 0.5f), fine: false); + ColorWheelValue moved = state.Drag(value, new Vector2(0.5f, 0.8f), fine: false); + + Vector2 disk = ImGuiWidgets.ColorWheelState.ToDisk(moved); + Assert.AreEqual(0.0f, disk.X, Tolerance); + Assert.AreEqual(0.3f, disk.Y, Tolerance); + } + + [TestMethod] + public void Drag_FineAdjustmentScalesTheMovement() + { + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = ColorWheelValue.Neutral; + + state.Begin(value, Vector2.Zero, fine: true); + ColorWheelValue moved = state.Drag(value, new Vector2(0.4f, 0.0f), fine: true); + + Assert.AreEqual(0.4f * ImGuiWidgets.ColorWheelState.FineScale, moved.Strength, Tolerance); + } + + [TestMethod] + public void Drag_TogglingFineMidDragDoesNotJumpTheHandle() + { + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = ColorWheelValue.Neutral; + + state.Begin(value, Vector2.Zero, fine: false); + value = state.Drag(value, new Vector2(0.4f, 0.0f), fine: false); + Vector2 before = ImGuiWidgets.ColorWheelState.ToDisk(value); + + // Shift goes down with the pointer where it is: the handle must stay put. + ColorWheelValue after = state.Drag(value, new Vector2(0.4f, 0.0f), fine: true); + Assert.AreEqual(value, after); + + // And from there it moves at the fine rate. + after = state.Drag(after, new Vector2(0.4f, 0.4f), fine: true); + Vector2 disk = ImGuiWidgets.ColorWheelState.ToDisk(after); + Assert.AreEqual(before.X, disk.X, Tolerance); + Assert.AreEqual(before.Y + (0.4f * ImGuiWidgets.ColorWheelState.FineScale), disk.Y, Tolerance); + } + + [TestMethod] + public void Drag_StopsAtTheRim() + { + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = ColorWheelValue.Neutral; + + state.Begin(value, Vector2.Zero, fine: false); + value = state.Drag(value, new Vector2(5.0f, 0.0f), fine: false); + + Assert.AreEqual(1.0f, value.Strength, Tolerance); + } + + [TestMethod] + public void Drag_BackFromPastTheRimRespondsAtOnce() + { + // Overshooting the rim must not be remembered: turning back inward moves the handle + // straight away rather than after the pointer retraces the overshoot. + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = ColorWheelValue.Neutral; + + state.Begin(value, Vector2.Zero, fine: false); + value = state.Drag(value, new Vector2(5.0f, 0.0f), fine: false); + value = state.Drag(value, new Vector2(4.5f, 0.0f), fine: false); + + Assert.AreEqual(0.5f, value.Strength, Tolerance); + } + + [TestMethod] + public void Drag_ThroughTheCentreKeepsTheHue() + { + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = new(250.0f, 0.2f); + Vector2 start = ImGuiWidgets.ColorWheelState.ToDisk(value); + + state.Begin(value, start, fine: false); + value = state.Drag(value, Vector2.Zero, fine: false); + + Assert.AreEqual(0.0f, value.Strength, Tolerance); + Assert.AreEqual(250.0f, value.Hue, 1e-2f); + } + + [TestMethod] + public void End_StopsTheDrag() + { + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = ColorWheelValue.Neutral; + + state.Begin(value, Vector2.Zero, fine: false); + state.End(); + + Assert.IsFalse(state.IsDragging); + Assert.AreEqual(value, state.Drag(value, new Vector2(0.5f, 0.0f), fine: false)); + } + + [TestMethod] + public void Drag_KeepsTheMaster() + { + ImGuiWidgets.ColorWheelState state = new(); + ColorWheelValue value = new(0.0f, 0.0f, 0.4f); + + state.Begin(value, Vector2.Zero, fine: false); + value = state.Drag(value, new Vector2(0.3f, 0.0f), fine: false); + + Assert.AreEqual(0.4f, value.Master, Tolerance); + } + + [TestMethod] + public void DragMaster_PressDoesNotJumpTheSlider() + { + ImGuiWidgets.ColorWheelState state = new(); + + state.BeginMaster(0.2f, 0.9f, fine: false); + + Assert.AreEqual(0.2f, state.DragMaster(0.2f, 0.9f, fine: false)); + } + + [TestMethod] + public void DragMaster_MovesByThePointersMovement() + { + ImGuiWidgets.ColorWheelState state = new(); + + state.BeginMaster(0.0f, -0.5f, fine: false); + + Assert.AreEqual(0.3f, state.DragMaster(0.0f, -0.2f, fine: false), Tolerance); + } + + [TestMethod] + public void DragMaster_FineAdjustmentScalesTheMovement() + { + ImGuiWidgets.ColorWheelState state = new(); + + state.BeginMaster(0.0f, 0.0f, fine: true); + + Assert.AreEqual(-0.4f * ImGuiWidgets.ColorWheelState.FineScale, state.DragMaster(0.0f, -0.4f, fine: true), Tolerance); + } + + [TestMethod] + public void DragMaster_TogglingFineMidDragDoesNotJump() + { + ImGuiWidgets.ColorWheelState state = new(); + + state.BeginMaster(0.0f, 0.0f, fine: false); + float master = state.DragMaster(0.0f, 0.5f, fine: false); + + Assert.AreEqual(master, state.DragMaster(master, 0.5f, fine: true)); + Assert.AreEqual(master + (0.4f * ImGuiWidgets.ColorWheelState.FineScale), state.DragMaster(master, 0.9f, fine: true), Tolerance); + } + + [TestMethod] + public void DragMaster_StopsAtTheEndsAndTurnsBackAtOnce() + { + ImGuiWidgets.ColorWheelState state = new(); + + state.BeginMaster(0.0f, 0.0f, fine: false); + float master = state.DragMaster(0.0f, 3.0f, fine: false); + Assert.AreEqual(1.0f, master, Tolerance); + + master = state.DragMaster(master, 2.75f, fine: false); + Assert.AreEqual(0.75f, master, Tolerance); + } + + [TestMethod] + public void DragMaster_WithoutBeginOrAfterEndChangesNothing() + { + ImGuiWidgets.ColorWheelState state = new(); + Assert.AreEqual(0.1f, state.DragMaster(0.1f, 0.8f, fine: false)); + + state.BeginMaster(0.1f, 0.0f, fine: false); + state.EndMaster(); + + Assert.IsFalse(state.IsDraggingMaster); + Assert.AreEqual(0.1f, state.DragMaster(0.1f, 0.8f, fine: false)); + } + + [TestMethod] + public void DragMaster_AndTheBallDragIndependently() + { + ImGuiWidgets.ColorWheelState state = new(); + + state.BeginMaster(0.0f, 0.0f, fine: false); + + Assert.IsFalse(state.IsDragging, "Starting the slider started the ball."); + } + + [TestMethod] + public void ToRgbOffset_AddsTheMasterToEveryChannel() + { + Vector3 offset = new ColorWheelValue(0.0f, 0.0f, -0.25f).ToRgbOffset(); + + Assert.AreEqual(new Vector3(-0.25f), offset); + } + + [TestMethod] + public void ToRgbOffset_BalancePlusMasterSumsToThreeMasters() + { + Vector3 offset = new ColorWheelValue(75.0f, 0.6f, 0.2f).ToRgbOffset(); + + Assert.AreEqual(0.6f, offset.X + offset.Y + offset.Z, Tolerance); + } + + [TestMethod] + public void IsNeutral_NeedsBothTheBallAndTheMasterAtRest() + { + Assert.IsTrue(ColorWheelValue.Neutral.IsNeutral); + Assert.IsTrue(new ColorWheelValue(123.0f, 0.0f).IsNeutral, "A hue alone does nothing."); + Assert.IsFalse(new ColorWheelValue(0.0f, 0.1f).IsNeutral); + Assert.IsFalse(new ColorWheelValue(0.0f, 0.0f, 0.1f).IsNeutral); + } + + [TestMethod] + public void ToRgbOffset_IsZeroWhenNeutral() + { + Assert.AreEqual(Vector3.Zero, new ColorWheelValue(90.0f, 0.0f).ToRgbOffset()); + } + + [TestMethod] + public void ToRgbOffset_KeepsTheChannelAverage() + { + for (float hue = 0.0f; hue < 360.0f; hue += 20.0f) + { + Vector3 offset = new ColorWheelValue(hue, 1.0f).ToRgbOffset(); + + Assert.AreEqual(0.0f, offset.X + offset.Y + offset.Z, Tolerance, $"Hue {hue} moved the average."); + } + } + + [TestMethod] + public void ToRgbOffset_PushesTowardsTheHue() + { + Vector3 red = new ColorWheelValue(0.0f, 1.0f).ToRgbOffset(); + Vector3 blue = new ColorWheelValue(240.0f, 0.5f).ToRgbOffset(); + + Assert.AreEqual(2.0f / 3.0f, red.X, Tolerance); + Assert.AreEqual(-1.0f / 3.0f, red.Y, Tolerance); + Assert.AreEqual(1.0f / 3.0f, blue.Z, Tolerance); + Assert.IsTrue(blue.X < 0.0f && blue.Y < 0.0f); + } + + [TestMethod] + public void HueToRgb_HitsThePrimariesAndSecondaries() + { + Assert.AreEqual(new Vector3(1, 0, 0), ColorWheelValue.HueToRgb(0.0f)); + Assert.AreEqual(new Vector3(1, 1, 0), ColorWheelValue.HueToRgb(60.0f)); + Assert.AreEqual(new Vector3(0, 1, 0), ColorWheelValue.HueToRgb(120.0f)); + Assert.AreEqual(new Vector3(0, 1, 1), ColorWheelValue.HueToRgb(180.0f)); + Assert.AreEqual(new Vector3(0, 0, 1), ColorWheelValue.HueToRgb(240.0f)); + Assert.AreEqual(new Vector3(1, 0, 1), ColorWheelValue.HueToRgb(300.0f)); + Assert.AreEqual(new Vector3(1, 0, 0), ColorWheelValue.HueToRgb(360.0f)); + } + + [TestMethod] + public void WrapHue_WrapsIntoRange() + { + Assert.AreEqual(350.0f, ColorWheelValue.WrapHue(-10.0f), Tolerance); + Assert.AreEqual(10.0f, ColorWheelValue.WrapHue(730.0f), Tolerance); + Assert.AreEqual(0.0f, ColorWheelValue.WrapHue(-1e-8f), Tolerance); + Assert.AreEqual(0.0f, ColorWheelValue.WrapHue(float.NaN)); + } +} diff --git a/tests/ImGui.Widgets.UITests/ColorWheelTests.cs b/tests/ImGui.Widgets.UITests/ColorWheelTests.cs new file mode 100644 index 00000000..5f196eb3 --- /dev/null +++ b/tests/ImGui.Widgets.UITests/ColorWheelTests.cs @@ -0,0 +1,221 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.Widgets.UITests; + +using System; +using System.Numerics; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.App.Testing; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// Drives and on their own. +[TestClass] +public sealed class ColorWheelTests : WidgetTest +{ + private const string Label = "Balance"; + private const float Side = 160f; + + // Mirrors ColorWheelState, which is internal to the library and not visible from here. + private const float RedAngle = 103f; + private const float FineScale = 0.25f; + + private ColorWheelValue value = ColorWheelValue.Neutral; + private ColorWheelValue lift = ColorWheelValue.Neutral; + private ColorWheelValue gamma = ColorWheelValue.Neutral; + private ColorWheelValue gain = ColorWheelValue.Neutral; + + private void Draw() => ImGuiWidgets.ColorWheel(Label, ref value, Side); + + private void DrawSet() => ImGuiWidgets.LiftGammaGain("Grade", ref lift, ref gamma, ref gain, 120f); + + private void DragBy(string name, float fromX, float fromY, float dx, float dy) + { + Rectangle rect = RectOf(name); + float x = rect.MinX + (rect.Width * fromX); + float y = rect.MinY + (rect.Height * fromY); + Harness.Mouse.Drag(x, y, x + dx, y + dy); + Step(); + } + + [TestMethod] + public void ColorWheel_IsDrawnAndMarksItself() + { + Start(Draw); + + Assert.IsTrue(IsVisible(Label), "The wheel marked no probe item."); + AssertSomethingWasDrawn("the wheel"); + } + + [TestMethod] + public void ColorWheel_IsSquareAtTheRequestedSize() + { + Start(Draw); + + Rectangle rect = RectOf(Label); + + Assert.IsTrue(Math.Abs(rect.Width - Side) <= 2, $"The wheel reserved {rect.Width}px of width rather than {Side}."); + Assert.IsTrue(Math.Abs(rect.Height - Side) <= 2, $"The wheel reserved {rect.Height}px of height rather than {Side}."); + } + + [TestMethod] + public void ColorWheel_AClickDoesNotJumpTheHandle() + { + // A trackball grabs the handle where it is: pressing near the rim is not a push to the rim. + value = new ColorWheelValue(30f, 0.25f); + Start(Draw); + + ClickFraction(Label, 0.9f, 0.5f); + + Assert.AreEqual(new ColorWheelValue(30f, 0.25f), value); + } + + [TestMethod] + public void ColorWheel_DraggingPushesTheHandleTheWayThePointerWent() + { + Start(Draw); + + // Right, from anywhere on the wheel. Right is 103 degrees clockwise of red, which is a blue. + DragBy(Label, 0.3f, 0.3f, 40f, 0f); + + Assert.IsTrue(value.Strength > 0.2f, $"Strength stayed at {value.Strength} after a drag of a quarter of the wheel."); + float expected = ColorWheelValue.WrapHue(-RedAngle); + Assert.IsTrue(Math.Abs(value.Hue - expected) < 3f, $"A drag to the right gave hue {value.Hue}, not about {expected}."); + } + + [TestMethod] + public void ColorWheel_StrengthStopsAtTheRim() + { + Start(Draw); + + DragBy(Label, 0.5f, 0.5f, 0f, -400f); + + Assert.IsTrue(value.Strength is > 0.99f and <= 1f, $"Strength ran to {value.Strength} on a drag far past the rim."); + } + + [TestMethod] + public void ColorWheel_ShiftDragsFinely() + { + Start(Draw); + DragBy(Label, 0.5f, 0.5f, 40f, 0f); + float coarse = value.Strength; + + value = ColorWheelValue.Neutral; + HarnessKeyboard.KeyDown(ImGuiKey.ModShift); + Step(); + DragBy(Label, 0.5f, 0.5f, 40f, 0f); + HarnessKeyboard.KeyUp(ImGuiKey.ModShift); + Step(); + + Assert.IsTrue(value.Strength > 0f, "A fine drag did not move the handle at all."); + Assert.AreEqual(coarse * FineScale, value.Strength, 0.02f, "A fine drag did not move the handle a quarter as far."); + } + + [TestMethod] + public void ColorWheel_DoubleClickReturnsItToNeutral() + { + value = new ColorWheelValue(200f, 0.6f, 0.2f); + Start(Draw); + + Vector2 center = CenterOf(Label); + Harness.Mouse.Click(center.X, center.Y); + Harness.Mouse.Click(center.X, center.Y); + Step(); + + Assert.AreEqual(0f, value.Strength, $"Strength stayed at {value.Strength} after a double-click."); + Assert.AreEqual(200f, value.Hue, 1e-3f, "A reset threw away the direction the wheel was last pushed."); + Assert.AreEqual(0.2f, value.Master, 1e-4f, "Resetting the ball reset the master too."); + } + + [TestMethod] + public void ColorWheel_MasterSliderSitsUnderTheWheelAndMarksItself() + { + Start(Draw); + + Assert.IsTrue(IsVisible($"{Label}/master"), "The master slider marked no probe item."); + Rectangle wheel = RectOf(Label); + Rectangle master = RectOf($"{Label}/master"); + Assert.IsTrue(master.MinY >= wheel.MaxY, "The master slider is not below the wheel."); + Assert.IsTrue(Math.Abs(master.Width - wheel.Width) <= 2, $"The slider is {master.Width}px wide under a {wheel.Width}px wheel."); + } + + [TestMethod] + public void ColorWheel_DraggingTheMasterRaisesItAndLeavesTheBall() + { + value = new ColorWheelValue(60f, 0.3f); + Start(Draw); + + // A quarter of the bar's width is a quarter of the way along a range two wide. + DragBy($"{Label}/master", 0.2f, 0.5f, Side / 4f, 0f); + + Assert.AreEqual(0.5f, value.Master, 0.05f, "A quarter-width drag did not move the master by a half."); + Assert.AreEqual(0.3f, value.Strength, 1e-4f, "Dragging the master moved the ball."); + } + + [TestMethod] + public void ColorWheel_DoubleClickingTheMasterZeroesOnlyIt() + { + value = new ColorWheelValue(60f, 0.3f, -0.4f); + Start(Draw); + + Vector2 center = CenterOf($"{Label}/master"); + Harness.Mouse.Click(center.X, center.Y); + Harness.Mouse.Click(center.X, center.Y); + Step(); + + Assert.AreEqual(0f, value.Master, $"A double-click left the master at {value.Master}."); + Assert.AreEqual(0.3f, value.Strength, 1e-4f, "Resetting the master reset the ball too."); + } + + [TestMethod] + public void ColorWheel_RedrawsTheHandleWhereItWasMoved() + { + Start(Draw); + MoveAway(); + byte[] neutral = Snapshot(); + + value = new ColorWheelValue(120f, 0.8f); + Step(2); + MoveAway(); + + Assert.IsTrue(PixelsChangedSince(neutral) > 0, "The handle drew in the same place after the value moved."); + } + + [TestMethod] + public void LiftGammaGain_MarksAllThreeWheelsUnderItsLabel() + { + Start(DrawSet); + + Assert.IsTrue(IsVisible("Grade/Lift"), "The lift wheel marked no probe item."); + Assert.IsTrue(IsVisible("Grade/Gamma"), "The gamma wheel marked no probe item."); + Assert.IsTrue(IsVisible("Grade/Gain"), "The gain wheel marked no probe item."); + Assert.IsTrue(IsVisible("Grade/Gain/master"), "The gain wheel's master slider marked no probe item."); + } + + [TestMethod] + public void LiftGammaGain_LaysTheWheelsOutLeftToRight() + { + Start(DrawSet); + + Rectangle liftRect = RectOf("Grade/Lift"); + Rectangle gammaRect = RectOf("Grade/Gamma"); + Rectangle gainRect = RectOf("Grade/Gain"); + + Assert.IsTrue(liftRect.MaxX <= gammaRect.MinX && gammaRect.MaxX <= gainRect.MinX, "The three wheels overlap or are out of order."); + Assert.AreEqual(liftRect.MinY, gainRect.MinY, 1f, "The wheels are not on one row."); + } + + [TestMethod] + public void LiftGammaGain_DraggingOneWheelLeavesTheOthersAlone() + { + Start(DrawSet); + + DragBy("Grade/Gain", 0.5f, 0.5f, 0f, -30f); + + Assert.IsTrue(gain.Strength > 0f, "Dragging the gain wheel did not move it."); + Assert.IsTrue(lift.IsNeutral, $"Dragging gain moved lift to {lift}."); + Assert.IsTrue(gamma.IsNeutral, $"Dragging gain moved gamma to {gamma}."); + } +} diff --git a/tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs b/tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs index ccd0f076..88160df2 100644 --- a/tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs +++ b/tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs @@ -42,7 +42,7 @@ public sealed class WidgetsDemoUITests [ "Mobile - Form Controls", "Property Grid", "Knobs", "Radial Progress Bar", "Color Indicators", "Combo Boxes", "Text Utilities", "Scoped Utilities", "Tree View", - "Mobile - Decorators", "Mobile - Containers & Loaders", "Diff view", + "Mobile - Decorators", "Mobile - Containers & Loaders", "Color wheels", "Diff view", ]; private static readonly string[] AdvancedDemoSections = @@ -433,6 +433,30 @@ public void DiffView_DrawsAHunkPerViewAndTicksTheOneThatWasClicked() "Ticking a hunk's checkbox is the whole point of the widget, so it has to reach the caller's set."); } + [TestMethod] + public void ColorWheels_DraggingGainReachesTheDemoAndResetClearsIt() + { + OpenSection(WidgetDemosTab, "Color wheels"); + + Assert.IsTrue(IsVisible("##grade/Lift"), "The lift wheel was not drawn."); + Assert.IsTrue(IsVisible("##grade/Gamma"), "The gamma wheel was not drawn."); + Assert.IsTrue(IsVisible("##grade/Gain"), "The gain wheel was not drawn."); + + Rectangle gain = harness.Probe.Rect("##grade/Gain")!.Value; + float x = gain.MinX + (gain.Width / 2f); + float y = gain.MinY + (gain.Height / 2f); + harness.Mouse.Drag(x, y, x + 30f, y - 30f); + harness.Step(2); + + Assert.IsTrue(ColorWheelDemo.Gain.Strength > 0f, "Dragging the gain wheel did not reach the demo's value."); + Assert.IsTrue(ColorWheelDemo.Lift.IsNeutral, "Dragging the gain wheel moved lift."); + + harness.Click("Reset all"); + harness.Step(2); + + Assert.IsTrue(ColorWheelDemo.Gain.IsNeutral, "Reset all left the gain wheel pushed."); + } + [TestMethod] public void ImageCanvas_OffersFitAndOneToOne() { From 40cdb1900f7f49f382f11444f7a31434d60c7748 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:36:32 +0000 Subject: [PATCH 2/2] [patch] Compare colour wheel master values with a tolerance Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HEAQ6ynUNXdf4LpFq8o8MX --- ImGui.Widgets/ColorWheel.cs | 6 +++--- ImGui.Widgets/ColorWheelState.cs | 2 +- ImGui.Widgets/ColorWheelValue.cs | 9 ++++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ImGui.Widgets/ColorWheel.cs b/ImGui.Widgets/ColorWheel.cs index 49de1ff2..349a2ff1 100644 --- a/ImGui.Widgets/ColorWheel.cs +++ b/ImGui.Widgets/ColorWheel.cs @@ -232,7 +232,7 @@ internal static bool MasterSlider(ColorWheelState state, ref ColorWheelValue val if (ImGui.IsItemHovered() && ImGui.IsMouseDoubleClicked(ImGuiMouseButton.Left)) { - changed = value.Master != 0.0f; + changed = MathF.Abs(value.Master) > ColorWheelValue.MasterTolerance; value = value with { Master = 0.0f }; state.EndMaster(); } @@ -246,7 +246,7 @@ internal static bool MasterSlider(ColorWheelState state, ref ColorWheelValue val if (ImGui.IsItemActive()) { float next = state.DragMaster(value.Master, pointer, fine); - changed = next != value.Master; + changed = MathF.Abs(next - value.Master) > ColorWheelValue.MasterTolerance; value = value with { Master = next }; } else @@ -266,7 +266,7 @@ internal static bool MasterSlider(ColorWheelState state, ref ColorWheelValue val float master = float.IsFinite(value.Master) ? Math.Clamp(value.Master, -1.0f, 1.0f) : 0.0f; float valueX = centerX + (master * half); - if (master != 0.0f) + if (MathF.Abs(master) > ColorWheelValue.MasterTolerance) { uint fill = ImGui.GetColorU32(colors[(int)(hot ? ImGuiCol.SliderGrabActive : ImGuiCol.SliderGrab)]); drawList.AddRectFilled(new Vector2(MathF.Min(centerX, valueX), min.Y), new Vector2(MathF.Max(centerX, valueX), max.Y), fill); diff --git a/ImGui.Widgets/ColorWheelState.cs b/ImGui.Widgets/ColorWheelState.cs index c19c5420..f6e5f5fa 100644 --- a/ImGui.Widgets/ColorWheelState.cs +++ b/ImGui.Widgets/ColorWheelState.cs @@ -170,7 +170,7 @@ public float DragMaster(float current, float pointer, bool fine) AnchorMaster(master, pointer, fine); } - return MathF.Abs(master - current) <= 1e-7f ? current : master; + return MathF.Abs(master - current) <= ColorWheelValue.MasterTolerance ? current : master; } /// Ends the master slider's drag. diff --git a/ImGui.Widgets/ColorWheelValue.cs b/ImGui.Widgets/ColorWheelValue.cs index 8ebdc5c2..1dd8f124 100644 --- a/ImGui.Widgets/ColorWheelValue.cs +++ b/ImGui.Widgets/ColorWheelValue.cs @@ -21,11 +21,18 @@ namespace ktsu.ImGui.Widgets; /// public readonly record struct ColorWheelValue(float Hue, float Strength, float Master = 0.0f) { + /// How close to zero a master value has to be to count as zero. + /// + /// A millionth of the slider's range, which is well under a pixel of any bar that could be drawn, + /// so a master that reads as zero here also looks like zero on screen. + /// + internal const float MasterTolerance = 1e-6f; + /// Gets a wheel that does nothing: zero strength and master, pointing at red. public static ColorWheelValue Neutral { get; } = new(0.0f, 0.0f); /// Gets a value indicating whether the wheel has no effect. - public bool IsNeutral => Strength <= 0.0f && Master == 0.0f; + public bool IsNeutral => Strength <= 0.0f && MathF.Abs(Master) <= MasterTolerance; /// /// Returns the per-channel colour offset this wheel stands for: the colour of