Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `Waveform`, `SpectrumAnalyzer` over a caller-owned `SpectrumAnalyzerState`); 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`, `Waveform`, `SpectrumAnalyzer` over a caller-owned `SpectrumAnalyzerState`); 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`.
Expand Down
306 changes: 306 additions & 0 deletions ImGui.Widgets/ColorWheel.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides custom ImGui widgets.
/// </summary>
public static partial class ImGuiWidgets
{
/// <summary>
/// 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.
/// </summary>
/// <param name="label">A unique identifier for the wheel, used for the ImGui ID and the probe name. The master slider is probed as <c>label/master</c>.</param>
/// <param name="value">The wheel's hue, strength and master, updated while dragging.</param>
/// <param name="diameter">The wheel's diameter in pixels. Non-positive falls back to a default derived from the font.</param>
/// <returns><see langword="true"/> if the value changed this frame; otherwise <see langword="false"/>.</returns>
/// <remarks>
/// <para>
/// This is <see cref="XYPad"/> 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.
/// </para>
/// <para>
/// The slider under the ball is the master: an equal offset to every channel, from <c>-1</c> at
/// the left end to <c>1</c> 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.
/// </para>
/// <para>
/// 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 <see cref="ColorWheelValue.ToRgbOffset"/> and apply it however the
/// grade in hand calls for. See <see cref="LiftGammaGain"/> for the usual set of three.
/// </para>
/// </remarks>
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;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="label">A unique identifier for the set. It scopes the three wheels, which are probed as <c>label/Lift</c>, <c>label/Gamma</c> and <c>label/Gain</c>; it is not drawn.</param>
/// <param name="lift">The shadows wheel.</param>
/// <param name="gamma">The midtones wheel.</param>
/// <param name="gain">The highlights wheel.</param>
/// <param name="diameter">Each wheel's diameter in pixels. Non-positive falls back to a default derived from the font.</param>
/// <returns><see langword="true"/> if any of the three changed this frame; otherwise <see langword="false"/>.</returns>
/// <remarks>
/// Each wheel behaves exactly as <see cref="ColorWheel"/> does. The widget edits the three values
/// and nothing else: how each is applied to an image is left to the caller, as
/// <see cref="ColorWheelValue.ToRgbOffset"/> describes.
/// </remarks>
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<uint, ColorWheelState> States = [];

/// <summary>How many segments the hue ring is drawn in.</summary>
/// <remarks>Six degrees apiece, which is finer than the eye can pick out a step in hue at this size.</remarks>
private const int RingSegments = 60;

/// <summary>Converts a screen position to disk units: centre at the origin, rim at one, y up.</summary>
internal static Vector2 ToDiskUnits(Vector2 screen, Vector2 center, float travel) =>
new((screen.X - center.X) / travel, (center.Y - screen.Y) / travel);

/// <summary>Converts a position in disk units back to the screen.</summary>
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<Vector4> 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 = MathF.Abs(value.Master) > ColorWheelValue.MasterTolerance;
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 = MathF.Abs(next - value.Master) > ColorWheelValue.MasterTolerance;
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<Vector4> 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 (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);
}

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);
}
}
}
Loading
Loading