fix(style): make the setting row usable for the widgets that need it most

Review of the two new widgets found four things that would all have landed on
the first real tab.

SettingRow had no hit area and no return value, so "the whole row is clickable,
label included" -- which is the point of pairing it with a switch -- was not
reachable. The label half is now an InvisibleButton and Draw returns whether it
was clicked.

SetNextItemWidth is a silent no-op for Checkbox, RadioButton and
InvisibleButton: they size themselves from GetFrameHeight and never call
CalcItemWidth. So exactly the controls the settings tabs are full of would have
sat at the left edge of the control column, 200px from where the row promised
to put them. The callback now receives a context with AlignRight for widgets
that know their own size.

The switch and the row shared a HoverState key. An enabled switch would have
kept its row permanently highlighted, and hovering a disabled row would have
slid its knob to "on" -- the widget lying about its own value. ToggleSwitch
derives its animation key now, so a caller cannot collide even by passing the
same id to both.

The cursor advance moves from SetCursorScreenPos to ImGuiP.ItemSize. Both
advance the cursor, but only ItemSize is guaranteed to extend CursorMaxPos,
which is what the scrollbar measures. SetCursorScreenPos happens to do it on
ImGui 1.88, which is what Dalamud ships -- upstream removed that in 1.92 and
asserts on the pattern instead. ItemSize does not touch g.LastItemData, so the
save throttles stay intact.

Two smaller ones: the label column no longer collapses to a single pixel on a
narrow row (it stops at 60 and the control shrinks instead), and the toggle
geometry clamps its radius once rather than deriving travel from an unclamped
value. Descriptions wrap now instead of being cut at the column edge.

The gallery gained the pairing both widgets exist for, plus a style-override
variant -- the combination that would have exposed all of this.
This commit is contained in:
2026-08-18 11:59:52 +02:00
parent 86992c70f2
commit cbc4b17e1e
4 changed files with 123 additions and 36 deletions
@@ -21,23 +21,41 @@ internal readonly record struct SettingRowStyle
public float PadY { get; init; } = 4f;
public float Gap { get; init; } = 12f;
public float PreferredControlWidth { get; init; } = 200f;
public bool DrawSeparator { get; init; }
public bool DrawSeparator { get; init; } = true;
}
// Handed to the control callback. Widgets that respect SetNextItemWidth can
// ignore it; the ones that do not -- Checkbox, RadioButton, InvisibleButton --
// need AlignRight to land where the row promised.
internal readonly record struct SettingRowContext
{
public Vector2 ControlOrigin { get; init; }
public float ControlWidth { get; init; }
public float ControlHeight { get; init; }
public float HoverAmount { get; init; }
// Right edge of the control column, vertically centred.
public Vector2 AlignRight(Vector2 size) =>
new(
ControlOrigin.X + ControlWidth - size.X,
ControlOrigin.Y + MetricsMath.CenterY(ControlHeight, size.Y)
);
}
// 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.
// after it, which is a large part of 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.
// Returns true when the row itself was clicked outside the control column, so a
// caller can make the whole row toggle its setting.
internal static class SettingRow
{
internal static void Draw(
internal static bool Draw(
uint id,
string label,
string? description,
SettingRowColors colors,
Action drawControl,
Action<SettingRowContext> drawControl,
SettingRowStyle? styleOverride = null
)
{
@@ -57,14 +75,19 @@ internal static class SettingRow
gap
);
var hovered =
ImGui.IsMouseHoveringRect(origin, origin + size)
&& ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem);
// The label half is the hit area: the control column submits its own
// item and would fight with a button underneath it.
ImGui.SetCursorScreenPos(origin);
var labelClicked = ImGui.InvisibleButton(
$"##hellion-srow-{id}",
new Vector2(labelWidth, size.Y)
);
var hovered = ImGui.IsMouseHoveringRect(origin, origin + size) && ImGui.IsWindowHovered();
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.
// Chrome first, all of it draw-list only: nothing between here and the
// callback may submit an item, or IsItemDeactivatedAfterEdit inside the
// callback would no longer see the control as the last item.
Row.Draw(
origin,
size,
@@ -80,28 +103,44 @@ internal static class SettingRow
new RowStyle { AccentBarWidth = 0f, DrawSeparator = style.DrawSeparator }
);
// Clipped to the label column: a long label would otherwise run underneath
// the control.
// Clipped to the label column so a long label cannot run under the
// control. The description wraps instead of being cut.
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(
ImGui.GetFont(),
ImGui.GetFontSize(),
new Vector2(origin.X, origin.Y + padY + lineHeight),
colors.DescriptionAbgr,
description
description,
labelWidth
);
dl.PopClipRect();
ImGui.SetCursorScreenPos(new Vector2(origin.X + controlX, origin.Y + padY));
var controlOrigin = new Vector2(origin.X + controlX, origin.Y + padY);
ImGui.SetCursorScreenPos(controlOrigin);
ImGui.SetNextItemWidth(controlWidth);
drawControl();
drawControl(
new SettingRowContext
{
ControlOrigin = controlOrigin,
ControlWidth = controlWidth,
ControlHeight = lineHeight,
HoverAmount = hoverAmount,
}
);
// 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));
// ItemSize, not SetCursorScreenPos: it advances the cursor AND extends
// CursorMaxPos, which is what the scrollbar measures. SetCursorScreenPos
// still does the latter on ImGui 1.88, but upstream removed that in 1.92
// and asserts on it instead. And not Dummy, which would submit an item
// and replace the control as g.LastItemData, silently disabling every
// IsItemDeactivatedAfterEdit save throttle in the window.
ImGui.SetCursorScreenPos(origin);
ImGuiP.ItemSize(new Vector2(width, size.Y - ImGui.GetStyle().ItemSpacing.Y));
return labelClicked;
}
}
@@ -29,6 +29,12 @@ internal readonly record struct ToggleSwitchStyle
// label included, and the widget only knows where to paint.
internal static class ToggleSwitch
{
// The animation rides HoverState, but under a derived key: a caller that
// passes the same id to SettingRow and here would otherwise OR the two
// together -- an enabled switch would keep its row permanently highlighted,
// and hovering a disabled row would slide its knob to "on".
private static uint AnimKey(uint id) => id * 2654435761u + 0x9E3779B9u;
internal static Vector2 CalcSize(ToggleSwitchStyle? styleOverride = null)
{
var style = styleOverride ?? new ToggleSwitchStyle();
@@ -48,7 +54,7 @@ internal static class ToggleSwitch
// Held state, so the knob glides instead of snapping. Query only marks;
// HoverState.BeginFrame does the advancing.
var amount = HoverState.Query(id, value);
var amount = HoverState.Query(AnimKey(id), value);
var (size, knobR, knobX) = WidgetGeometry.Toggle(
ImGui.GetFrameHeight(),
style.WidthFactor,
+43 -10
View File
@@ -147,20 +147,42 @@ internal sealed class WidgetGalleryWindow : Window
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)
);
var switchColors = new ToggleSwitchColors
{
TrackOffAbgr = _palette.Abgr(Token.SurfaceRaised, c),
TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c),
KnobAbgr = _palette.Abgr(Token.Text, c),
};
// The pairing both widgets exist for: switch inside a row, whole label
// half clickable.
var idA = ImGui.GetID("gallery.settingrow.switch");
if (
SettingRow.Draw(
idA,
"A switch in a row",
null,
colors,
ctx =>
{
var size = ToggleSwitch.CalcSize();
var pos = ctx.AlignRight(size);
ImGui.SetCursorScreenPos(pos);
if (ImGui.InvisibleButton("##gallery-sr-switch", size))
_toggleA = !_toggleA;
ToggleSwitch.Draw(idA, pos, _toggleA, switchColors);
}
)
)
_toggleA = !_toggleA;
SettingRow.Draw(
ImGui.GetID("gallery.settingrow.described"),
"With a description",
"The second line explains what the control above actually does.",
"The second line wraps rather than being cut, so a real settings "
+ "explanation actually fits into the label column.",
colors,
() => ImGui.Checkbox("##gallery-sr-b", ref _toggleB)
_ => ImGui.Checkbox("##gallery-sr-b", ref _toggleB)
);
SettingRow.Draw(
@@ -168,7 +190,18 @@ internal sealed class WidgetGalleryWindow : Window
"A deliberately very long label that has to be clipped somewhere",
null,
colors,
() => ImGui.SliderInt("##gallery-sr-c", ref _badgeCount, 0, 150)
_ => ImGui.SliderInt("##gallery-sr-c", ref _badgeCount, 0, 150)
);
// No separator, narrow control column: the variants a settings tab will
// actually reach for.
SettingRow.Draw(
ImGui.GetID("gallery.settingrow.styled"),
"Style override",
null,
colors,
_ => ImGui.SliderInt("##gallery-sr-d", ref _badgeCount, 0, 150),
new SettingRowStyle { DrawSeparator = false, PreferredControlWidth = 90f }
);
ImGui.Spacing();
+10 -1
View File
@@ -51,13 +51,22 @@ internal static class WidgetGeometry
// 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.
private const float MinLabelWidth = 60f;
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 available = MathF.Max(MinExtent, rowWidth - gap);
var control = MathF.Min(preferredControlWidth, available);
// The label yields first, but only down to MinLabelWidth. A one-pixel
// label is not yielding, it is gone.
if (rowWidth - control - gap < MinLabelWidth)
control = MathF.Max(MinExtent, MathF.Min(control, available - MinLabelWidth));
var labelWidth = MathF.Max(MinExtent, rowWidth - control - gap);
return (labelWidth, rowWidth - control, control);
}