diff --git a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs new file mode 100644 index 0000000..cc76eb9 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs @@ -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)); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs new file mode 100644 index 0000000..69d8fd0 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs @@ -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 + ); + } +} diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 075c273..c83e37d 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -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"); diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 51a8305..8a2a89a 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -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); diff --git a/HellionChat/Util/WidgetGeometry.cs b/HellionChat/Util/WidgetGeometry.cs index 1c0ec02..e657870 100644 --- a/HellionChat/Util/WidgetGeometry.cs +++ b/HellionChat/Util/WidgetGeometry.cs @@ -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.