diff --git a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs index cc76eb9..d380a2f 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs @@ -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 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; } } diff --git a/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs index 69d8fd0..5575771 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs @@ -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, diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index c83e37d..3e3a143 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -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(); diff --git a/HellionChat/Util/WidgetGeometry.cs b/HellionChat/Util/WidgetGeometry.cs index e657870..f74aad7 100644 --- a/HellionChat/Util/WidgetGeometry.cs +++ b/HellionChat/Util/WidgetGeometry.cs @@ -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); }