feat(style): add the setting-row and toggle-switch widgets

Two composites for the settings window, both built on the primitives from
v1.10.0 rather than beside them.

SettingRow puts the label left and the control right-aligned. ImGui does it the
other way round, control first and label trailing, which is a large part of why
the settings window reads as a form dump rather than a settings page. The
control arrives as a callback so one row covers toggles, sliders, combos and
buttons.

The cursor advance in SettingRow is the part that needed care. It has to happen
after the callback, because ItemSize overwrites CursorPos outright when the
control is submitted -- advancing first would simply be undone. But it must not
be an ImGui.Dummy: that submits an item and would replace the control as
g.LastItemData, silently disabling every IsItemDeactivatedAfterEdit save
throttle in the window. SetCursorScreenPos moves the cursor without touching
last-item state.

ToggleSwitch is a capsule with a gliding knob, driven by HoverState so it
animates instead of snapping. Unlike a slider there is no throttle to preserve:
a checkbox commits on the click itself. The caller owns the hit area, so a
settings row can make the whole row clickable, label included.

Named ToggleSwitch, not Toggle: Dalamud's Window base class already has a
Toggle() method, and the plain name collides in every window that uses the
widget.

Geometry lives in WidgetGeometry as usual, with nine new cases pinning the
right-alignment split, the narrow-row fallback (the label yields before the
control does), and that the knob stays inside its capsule at both ends.
ColourUtil gains a two-colour Lerp for the track crossfade.
This commit is contained in:
2026-08-18 11:37:11 +02:00
parent 5b51f7dcfd
commit 86992c70f2
5 changed files with 332 additions and 0 deletions
@@ -0,0 +1,107 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using HellionChat.Util;
namespace HellionChat.Ui.StyleEngine.Widgets;
internal readonly record struct SettingRowColors
{
public SettingRowColors() { }
public uint LabelAbgr { get; init; }
public uint DescriptionAbgr { get; init; }
public uint SurfaceHoverAbgr { get; init; }
public uint BorderAbgr { get; init; }
}
internal readonly record struct SettingRowStyle
{
public SettingRowStyle() { }
public float PadY { get; init; } = 4f;
public float Gap { get; init; } = 12f;
public float PreferredControlWidth { get; init; } = 200f;
public bool DrawSeparator { get; init; }
}
// Label left, control right-aligned. ImGui puts the control first and the label
// after it, which is why the settings window reads as a form dump rather than a
// settings page.
//
// The control comes in as a callback so one row type covers toggles, sliders,
// combos and buttons.
internal static class SettingRow
{
internal static void Draw(
uint id,
string label,
string? description,
SettingRowColors colors,
Action drawControl,
SettingRowStyle? styleOverride = null
)
{
var style = styleOverride ?? new SettingRowStyle();
var scale = Metrics.Scale;
var padY = style.PadY * scale;
var gap = style.Gap * scale;
var origin = ImGui.GetCursorScreenPos();
var width = ImGui.GetContentRegionAvail().X;
var lineHeight = ImGui.GetFrameHeight();
var descHeight = description is null ? 0f : ImGui.GetTextLineHeight();
var size = WidgetGeometry.SettingRow(width, lineHeight, descHeight, padY);
var (labelWidth, controlX, controlWidth) = WidgetGeometry.SettingRowSplit(
width,
style.PreferredControlWidth * scale,
gap
);
var hovered =
ImGui.IsMouseHoveringRect(origin, origin + size)
&& ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem);
var hoverAmount = HoverState.Query(id, hovered);
// Chrome first, all of it draw-list only: nothing here may submit an
// item, because IsItemDeactivatedAfterEdit inside the callback has to
// see the control as the last submitted item.
Row.Draw(
origin,
size,
new RowVisualState
{
IsActive = false,
HoverAmount = hoverAmount,
SurfaceHoverAbgr = colors.SurfaceHoverAbgr,
SurfaceActiveAbgr = colors.SurfaceHoverAbgr,
AccentAbgr = colors.BorderAbgr,
BorderAbgr = colors.BorderAbgr,
},
new RowStyle { AccentBarWidth = 0f, DrawSeparator = style.DrawSeparator }
);
// Clipped to the label column: a long label would otherwise run underneath
// the control.
var dl = ImGui.GetWindowDrawList();
dl.PushClipRect(origin, new Vector2(origin.X + labelWidth, origin.Y + size.Y), true);
dl.AddText(new Vector2(origin.X, origin.Y + padY), colors.LabelAbgr, label);
if (description is not null)
dl.AddText(
new Vector2(origin.X, origin.Y + padY + lineHeight),
colors.DescriptionAbgr,
description
);
dl.PopClipRect();
ImGui.SetCursorScreenPos(new Vector2(origin.X + controlX, origin.Y + padY));
ImGui.SetNextItemWidth(controlWidth);
drawControl();
// Advance with SetCursorScreenPos, never Dummy. Dummy submits an item,
// which would replace the control as g.LastItemData and silently
// disable every IsItemDeactivatedAfterEdit save throttle in the window.
// (ItemSize also overwrites CursorPos outright, so advancing before the
// callback would be undone by the callback itself.)
ImGui.SetCursorScreenPos(new Vector2(origin.X, origin.Y + size.Y));
}
}
@@ -0,0 +1,70 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using HellionChat.Util;
namespace HellionChat.Ui.StyleEngine.Widgets;
internal readonly record struct ToggleSwitchColors
{
public ToggleSwitchColors() { }
public uint TrackOffAbgr { get; init; }
public uint TrackOnAbgr { get; init; }
public uint KnobAbgr { get; init; }
}
internal readonly record struct ToggleSwitchStyle
{
public ToggleSwitchStyle() { }
// Capsule width as a multiple of its height.
public float WidthFactor { get; init; } = 1.9f;
}
// Sliding-knob switch. Unlike a slider there is no save throttle to preserve:
// a checkbox commits on the click itself, so nothing here can break the
// persistence path.
//
// The caller owns the hit area. In a settings row the whole row is clickable,
// label included, and the widget only knows where to paint.
internal static class ToggleSwitch
{
internal static Vector2 CalcSize(ToggleSwitchStyle? styleOverride = null)
{
var style = styleOverride ?? new ToggleSwitchStyle();
var (size, _, _) = WidgetGeometry.Toggle(ImGui.GetFrameHeight(), style.WidthFactor, 0f);
return size;
}
internal static void Draw(
uint id,
Vector2 origin,
bool value,
ToggleSwitchColors colors,
ToggleSwitchStyle? styleOverride = null
)
{
var style = styleOverride ?? new ToggleSwitchStyle();
// Held state, so the knob glides instead of snapping. Query only marks;
// HoverState.BeginFrame does the advancing.
var amount = HoverState.Query(id, value);
var (size, knobR, knobX) = WidgetGeometry.Toggle(
ImGui.GetFrameHeight(),
style.WidthFactor,
amount
);
var dl = ImGui.GetWindowDrawList();
var max = origin + size;
var track = ColourUtil.Lerp(colors.TrackOffAbgr, colors.TrackOnAbgr, amount);
dl.AddRectFilled(origin, max, track, size.Y * 0.5f);
dl.AddCircleFilled(
new Vector2(origin.X + knobX, origin.Y + size.Y * 0.5f),
knobR,
colors.KnobAbgr,
16
);
}
}
@@ -24,6 +24,8 @@ internal sealed class WidgetGalleryWindow : Window
private int _badgeCount = 3;
private bool _rowActive = true;
private bool _toggleA = true;
private bool _toggleB;
internal WidgetGalleryWindow(Plugin plugin, TokenResolver resolver)
: base("Widget Gallery###hellion-widget-gallery")
@@ -44,6 +46,8 @@ internal sealed class WidgetGalleryWindow : Window
ImGui.Separator();
DrawRowSection(c);
DrawToggleSection(c);
DrawSettingRowSection(c);
DrawBadgeSection(c);
DrawPillSection(c);
DrawIconButtonSection(c);
@@ -97,6 +101,79 @@ internal sealed class WidgetGalleryWindow : Window
ImGui.Spacing();
}
private void DrawToggleSection(ThemeColors c)
{
ImGui.TextUnformatted("Toggle");
var colors = new ToggleSwitchColors
{
TrackOffAbgr = _palette.Abgr(Token.SurfaceRaised, c),
TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c),
KnobAbgr = _palette.Abgr(Token.Text, c),
};
var size = ToggleSwitch.CalcSize();
var gap = 10f * Metrics.Scale;
for (var i = 0; i < 2; i++)
{
var origin = ImGui.GetCursorScreenPos();
var id = ImGui.GetID($"gallery.toggle.{i}");
if (ImGui.InvisibleButton($"##gallery-toggle-{i}", size))
{
if (i == 0)
_toggleA = !_toggleA;
else
_toggleB = !_toggleB;
}
ToggleSwitch.Draw(id, origin, i == 0 ? _toggleA : _toggleB, colors);
if (i == 0)
ImGui.SameLine(0f, gap);
}
ImGui.Spacing();
}
private void DrawSettingRowSection(ThemeColors c)
{
ImGui.TextUnformatted("SettingRow");
var colors = new SettingRowColors
{
LabelAbgr = _palette.Abgr(Token.Text, c),
DescriptionAbgr = _palette.Abgr(Token.TextMuted, c),
SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, c),
BorderAbgr = _palette.Abgr(Token.Border, c),
};
SettingRow.Draw(
ImGui.GetID("gallery.settingrow.plain"),
"A plain option",
null,
colors,
() => ImGui.Checkbox("##gallery-sr-a", ref _toggleA)
);
SettingRow.Draw(
ImGui.GetID("gallery.settingrow.described"),
"With a description",
"The second line explains what the control above actually does.",
colors,
() => ImGui.Checkbox("##gallery-sr-b", ref _toggleB)
);
SettingRow.Draw(
ImGui.GetID("gallery.settingrow.long"),
"A deliberately very long label that has to be clipped somewhere",
null,
colors,
() => ImGui.SliderInt("##gallery-sr-c", ref _badgeCount, 0, 150)
);
ImGui.Spacing();
}
private void DrawBadgeSection(ThemeColors c)
{
ImGui.TextUnformatted("Badge");
+15
View File
@@ -108,6 +108,21 @@ internal static class ColourUtil
// going fully saturated (effect level stays "subtle"). RGB-only on
// purpose -- DrawHoverSheen owns the alpha falloff.
// TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTintTests.cs
// Mixes two ABGR colours channel by channel, alpha included. Used where a
// widget crossfades between two theme slots rather than toward a constant.
internal static uint Lerp(uint fromAbgr, uint toAbgr, float t)
{
t = Math.Clamp(t, 0f, 1f);
uint Mix(int shift)
{
var a = (byte)((fromAbgr >> shift) & 0xFFu);
var b = (byte)((toAbgr >> shift) & 0xFFu);
return (uint)Math.Round(a + (b - a) * t) & 0xFFu;
}
return (Mix(24) << 24) | (Mix(16) << 16) | (Mix(8) << 8) | Mix(0);
}
internal static uint LerpTowardWhite(uint abgr, float t)
{
t = Math.Clamp(t, 0f, 1f);
+63
View File
@@ -48,6 +48,69 @@ internal static class WidgetGeometry
return Clamp(new Vector2(width, height));
}
// Label on the left, control right-aligned. The control keeps its preferred
// width unless the row is too narrow, in which case the label yields first:
// a clipped label is readable, a clipped slider is not usable.
internal static (float LabelWidth, float ControlX, float ControlWidth) SettingRowSplit(
float rowWidth,
float preferredControlWidth,
float gap
)
{
var control = MathF.Min(preferredControlWidth, MathF.Max(MinExtent, rowWidth - gap));
var labelWidth = MathF.Max(MinExtent, rowWidth - control - gap);
return (labelWidth, rowWidth - control, control);
}
internal static Vector2 SettingRow(
float rowWidth,
float lineHeight,
float descriptionHeight,
float padY
)
{
var height = lineHeight + padY * 2f;
if (descriptionHeight > 0f)
height += descriptionHeight;
return Clamp(new Vector2(rowWidth, height));
}
internal static Vector2 SectionHeader(
float width,
float titleHeight,
float descriptionHeight,
float padY,
float lineThickness
)
{
var height = titleHeight + padY * 2f + lineThickness;
if (descriptionHeight > 0f)
height += descriptionHeight + padY;
return Clamp(new Vector2(width, height));
}
// Capsule plus knob. Height drives everything: the font comes from
// Config.FontSizeV2, which display scaling does not feed into, so a fixed
// capsule would shrink against a larger label.
internal static (Vector2 Size, float KnobRadius, float KnobX) Toggle(
float height,
float widthFactor,
float value
)
{
var h = MathF.Max(MinExtent, height);
var w = MathF.Max(h, h * widthFactor);
var r = h * 0.5f - 1f;
var travel = w - (r + 1f) * 2f;
return (
new Vector2(w, h),
MathF.Max(MinExtent, r),
r + 1f + travel * Math.Clamp(value, 0f, 1f)
);
}
// Sum of pill widths plus the gap between them. Used by the status bar to
// decide whether the right-hand slot still fits, replacing a fixed 200px
// guess that never measured the left-hand slots at all.