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:
@@ -21,23 +21,41 @@ internal readonly record struct SettingRowStyle
|
|||||||
public float PadY { get; init; } = 4f;
|
public float PadY { get; init; } = 4f;
|
||||||
public float Gap { get; init; } = 12f;
|
public float Gap { get; init; } = 12f;
|
||||||
public float PreferredControlWidth { get; init; } = 200f;
|
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
|
// 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
|
// after it, which is a large part of why the settings window reads as a form
|
||||||
// settings page.
|
// dump rather than a settings page.
|
||||||
//
|
//
|
||||||
// The control comes in as a callback so one row type covers toggles, sliders,
|
// Returns true when the row itself was clicked outside the control column, so a
|
||||||
// combos and buttons.
|
// caller can make the whole row toggle its setting.
|
||||||
internal static class SettingRow
|
internal static class SettingRow
|
||||||
{
|
{
|
||||||
internal static void Draw(
|
internal static bool Draw(
|
||||||
uint id,
|
uint id,
|
||||||
string label,
|
string label,
|
||||||
string? description,
|
string? description,
|
||||||
SettingRowColors colors,
|
SettingRowColors colors,
|
||||||
Action drawControl,
|
Action<SettingRowContext> drawControl,
|
||||||
SettingRowStyle? styleOverride = null
|
SettingRowStyle? styleOverride = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -57,14 +75,19 @@ internal static class SettingRow
|
|||||||
gap
|
gap
|
||||||
);
|
);
|
||||||
|
|
||||||
var hovered =
|
// The label half is the hit area: the control column submits its own
|
||||||
ImGui.IsMouseHoveringRect(origin, origin + size)
|
// item and would fight with a button underneath it.
|
||||||
&& ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem);
|
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);
|
var hoverAmount = HoverState.Query(id, hovered);
|
||||||
|
|
||||||
// Chrome first, all of it draw-list only: nothing here may submit an
|
// Chrome first, all of it draw-list only: nothing between here and the
|
||||||
// item, because IsItemDeactivatedAfterEdit inside the callback has to
|
// callback may submit an item, or IsItemDeactivatedAfterEdit inside the
|
||||||
// see the control as the last submitted item.
|
// callback would no longer see the control as the last item.
|
||||||
Row.Draw(
|
Row.Draw(
|
||||||
origin,
|
origin,
|
||||||
size,
|
size,
|
||||||
@@ -80,28 +103,44 @@ internal static class SettingRow
|
|||||||
new RowStyle { AccentBarWidth = 0f, DrawSeparator = style.DrawSeparator }
|
new RowStyle { AccentBarWidth = 0f, DrawSeparator = style.DrawSeparator }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clipped to the label column: a long label would otherwise run underneath
|
// Clipped to the label column so a long label cannot run under the
|
||||||
// the control.
|
// control. The description wraps instead of being cut.
|
||||||
var dl = ImGui.GetWindowDrawList();
|
var dl = ImGui.GetWindowDrawList();
|
||||||
dl.PushClipRect(origin, new Vector2(origin.X + labelWidth, origin.Y + size.Y), true);
|
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);
|
dl.AddText(new Vector2(origin.X, origin.Y + padY), colors.LabelAbgr, label);
|
||||||
if (description is not null)
|
if (description is not null)
|
||||||
dl.AddText(
|
dl.AddText(
|
||||||
|
ImGui.GetFont(),
|
||||||
|
ImGui.GetFontSize(),
|
||||||
new Vector2(origin.X, origin.Y + padY + lineHeight),
|
new Vector2(origin.X, origin.Y + padY + lineHeight),
|
||||||
colors.DescriptionAbgr,
|
colors.DescriptionAbgr,
|
||||||
description
|
description,
|
||||||
|
labelWidth
|
||||||
);
|
);
|
||||||
dl.PopClipRect();
|
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);
|
ImGui.SetNextItemWidth(controlWidth);
|
||||||
drawControl();
|
drawControl(
|
||||||
|
new SettingRowContext
|
||||||
|
{
|
||||||
|
ControlOrigin = controlOrigin,
|
||||||
|
ControlWidth = controlWidth,
|
||||||
|
ControlHeight = lineHeight,
|
||||||
|
HoverAmount = hoverAmount,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Advance with SetCursorScreenPos, never Dummy. Dummy submits an item,
|
// ItemSize, not SetCursorScreenPos: it advances the cursor AND extends
|
||||||
// which would replace the control as g.LastItemData and silently
|
// CursorMaxPos, which is what the scrollbar measures. SetCursorScreenPos
|
||||||
// disable every IsItemDeactivatedAfterEdit save throttle in the window.
|
// still does the latter on ImGui 1.88, but upstream removed that in 1.92
|
||||||
// (ItemSize also overwrites CursorPos outright, so advancing before the
|
// and asserts on it instead. And not Dummy, which would submit an item
|
||||||
// callback would be undone by the callback itself.)
|
// and replace the control as g.LastItemData, silently disabling every
|
||||||
ImGui.SetCursorScreenPos(new Vector2(origin.X, origin.Y + size.Y));
|
// 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.
|
// label included, and the widget only knows where to paint.
|
||||||
internal static class ToggleSwitch
|
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)
|
internal static Vector2 CalcSize(ToggleSwitchStyle? styleOverride = null)
|
||||||
{
|
{
|
||||||
var style = styleOverride ?? new ToggleSwitchStyle();
|
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;
|
// Held state, so the knob glides instead of snapping. Query only marks;
|
||||||
// HoverState.BeginFrame does the advancing.
|
// HoverState.BeginFrame does the advancing.
|
||||||
var amount = HoverState.Query(id, value);
|
var amount = HoverState.Query(AnimKey(id), value);
|
||||||
var (size, knobR, knobX) = WidgetGeometry.Toggle(
|
var (size, knobR, knobX) = WidgetGeometry.Toggle(
|
||||||
ImGui.GetFrameHeight(),
|
ImGui.GetFrameHeight(),
|
||||||
style.WidthFactor,
|
style.WidthFactor,
|
||||||
|
|||||||
@@ -147,20 +147,42 @@ internal sealed class WidgetGalleryWindow : Window
|
|||||||
BorderAbgr = _palette.Abgr(Token.Border, c),
|
BorderAbgr = _palette.Abgr(Token.Border, c),
|
||||||
};
|
};
|
||||||
|
|
||||||
SettingRow.Draw(
|
var switchColors = new ToggleSwitchColors
|
||||||
ImGui.GetID("gallery.settingrow.plain"),
|
{
|
||||||
"A plain option",
|
TrackOffAbgr = _palette.Abgr(Token.SurfaceRaised, c),
|
||||||
null,
|
TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c),
|
||||||
colors,
|
KnobAbgr = _palette.Abgr(Token.Text, c),
|
||||||
() => ImGui.Checkbox("##gallery-sr-a", ref _toggleA)
|
};
|
||||||
);
|
|
||||||
|
// 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(
|
SettingRow.Draw(
|
||||||
ImGui.GetID("gallery.settingrow.described"),
|
ImGui.GetID("gallery.settingrow.described"),
|
||||||
"With a description",
|
"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,
|
colors,
|
||||||
() => ImGui.Checkbox("##gallery-sr-b", ref _toggleB)
|
_ => ImGui.Checkbox("##gallery-sr-b", ref _toggleB)
|
||||||
);
|
);
|
||||||
|
|
||||||
SettingRow.Draw(
|
SettingRow.Draw(
|
||||||
@@ -168,7 +190,18 @@ internal sealed class WidgetGalleryWindow : Window
|
|||||||
"A deliberately very long label that has to be clipped somewhere",
|
"A deliberately very long label that has to be clipped somewhere",
|
||||||
null,
|
null,
|
||||||
colors,
|
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();
|
ImGui.Spacing();
|
||||||
|
|||||||
@@ -51,13 +51,22 @@ internal static class WidgetGeometry
|
|||||||
// Label on the left, control right-aligned. The control keeps its preferred
|
// 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:
|
// 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.
|
// 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(
|
internal static (float LabelWidth, float ControlX, float ControlWidth) SettingRowSplit(
|
||||||
float rowWidth,
|
float rowWidth,
|
||||||
float preferredControlWidth,
|
float preferredControlWidth,
|
||||||
float gap
|
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);
|
var labelWidth = MathF.Max(MinExtent, rowWidth - control - gap);
|
||||||
return (labelWidth, rowWidth - control, control);
|
return (labelWidth, rowWidth - control, control);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user