diff --git a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs new file mode 100644 index 0000000..f04049a --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs @@ -0,0 +1,134 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct SegmentedControlColors +{ + public SegmentedControlColors() { } + + public uint TrackAbgr { get; init; } + public uint SelectedAbgr { get; init; } + public uint HoverAbgr { get; init; } + public uint LabelAbgr { get; init; } + public uint SelectedLabelAbgr { get; init; } + public uint BorderAbgr { get; init; } +} + +internal readonly record struct SegmentedControlStyle +{ + public SegmentedControlStyle() { } + + public float Rounding { get; init; } = 4f; + public float Inset { get; init; } = 2f; +} + +// One setting, n mutually exclusive choices, one control. A radio group spends +// a labelled row per option and still leaves the reader working out that the +// options belong together; this says it in the shape. +// +// Returns the index the user picked, or the current one when nothing changed, +// so callers can compare against the config value and save on difference. +internal static class SegmentedControl +{ + // Per-segment hover keys, derived rather than passed: the caller already + // spends its id on the enclosing row, and reusing it here would tie the + // row's highlight to whichever segment the mouse happens to be over. + private static uint AnimKey(uint id, int index) => + (id ^ (uint)(index + 1) * 0x85EBCA6Bu) * 2654435761u + 0x9E3779B9u; + + internal static int Draw( + uint id, + Vector2 origin, + float width, + ReadOnlySpan labels, + int selected, + SegmentedControlColors colors, + bool disabled = false, + SegmentedControlStyle? styleOverride = null + ) + { + if (labels.Length == 0) + return selected; + + var style = styleOverride ?? new SegmentedControlStyle(); + var scale = Metrics.Scale; + var rounding = style.Rounding * scale; + var inset = style.Inset * scale; + var height = ImGui.GetFrameHeight(); + var alpha = disabled ? 0.5f : 1f; + + var dl = ImGui.GetWindowDrawList(); + dl.AddRectFilled( + origin, + origin + new Vector2(width, height), + ColourUtil.ApplyAlpha(colors.TrackAbgr, alpha), + rounding + ); + + var picked = selected; + for (var i = 0; i < labels.Length; i++) + { + var (segX, segWidth) = WidgetGeometry.Segment(i, labels.Length, width); + var segOrigin = new Vector2(origin.X + segX, origin.Y); + var segSize = new Vector2(segWidth, height); + + // Submitted even while disabled, so the item count and the cursor + // behave identically in both states. Only the result is dropped. + ImGui.SetCursorScreenPos(segOrigin); + var clicked = ImGui.InvisibleButton($"##hellion-seg-{id}-{i}", segSize) && !disabled; + var hovered = ImGui.IsItemHovered() && !disabled; + var hoverAmount = HoverState.Query(AnimKey(id, i), hovered); + + if (clicked) + picked = i; + + var isSelected = i == selected; + if (isSelected) + dl.AddRectFilled( + segOrigin + new Vector2(inset, inset), + segOrigin + segSize - new Vector2(inset, inset), + ColourUtil.ApplyAlpha(colors.SelectedAbgr, alpha), + rounding + ); + else if (hoverAmount > 0f) + dl.AddRectFilled( + segOrigin + new Vector2(inset, inset), + segOrigin + segSize - new Vector2(inset, inset), + ColourUtil.ApplyAlpha(colors.HoverAbgr, hoverAmount * alpha), + rounding + ); + + // Clipped, not truncated: a translated label that outgrows its + // segment should lose its tail rather than bleed into the neighbour. + var label = labels[i]; + var textSize = ImGui.CalcTextSize(label); + dl.PushClipRect(segOrigin, segOrigin + segSize, true); + dl.AddText( + segOrigin + + new Vector2( + MetricsMath.Center(segWidth, textSize.X), + MetricsMath.Center(height, textSize.Y) + ), + ColourUtil.ApplyAlpha( + isSelected ? colors.SelectedLabelAbgr : colors.LabelAbgr, + alpha + ), + label + ); + dl.PopClipRect(); + } + + dl.AddRect( + origin, + origin + new Vector2(width, height), + ColourUtil.ApplyAlpha(colors.BorderAbgr, alpha), + rounding + ); + + return picked; + } + + internal static Vector2 CalcSize(float width) => new(width, ImGui.GetFrameHeight()); +} diff --git a/HellionChat/Util/MetricsMath.cs b/HellionChat/Util/MetricsMath.cs index afedc0d..8a2e41d 100644 --- a/HellionChat/Util/MetricsMath.cs +++ b/HellionChat/Util/MetricsMath.cs @@ -6,8 +6,11 @@ internal static class MetricsMath { internal static float Scale(float raw, float scale) => raw * scale; - // Vertical centering for text inside a fixed-height row. Frozen offsets keep - // their mis-centering at every other font size; a computed one does not. + // Centering an inner extent inside an outer one. Frozen offsets keep their + // mis-centering at every other font size; a computed one does not. + internal static float Center(float outer, float inner) => (outer - inner) * 0.5f; + + // Vertical centering for text inside a fixed-height row. internal static float CenterY(float rowHeight, float textHeight) => - (rowHeight - textHeight) * 0.5f; + Center(rowHeight, textHeight); } diff --git a/HellionChat/Util/WidgetGeometry.cs b/HellionChat/Util/WidgetGeometry.cs index f74aad7..e01f3ff 100644 --- a/HellionChat/Util/WidgetGeometry.cs +++ b/HellionChat/Util/WidgetGeometry.cs @@ -120,6 +120,28 @@ internal static class WidgetGeometry ); } + // One segment of an n-way control, as a left edge and a width. + // + // Derived from rounded edges rather than by multiplying a per-segment width: + // at width 201 over 2 segments the naive form paints two 100.5px halves that + // both land on the same physical pixel column, leaving a seam in the middle + // and a gap at the right edge. Edges first means segment i always ends + // exactly where segment i+1 begins, and the last one ends on the width. + // + // The one case where that tiling breaks is fewer pixels than segments, where + // the MinExtent clamp wins: a segment nobody can click is worse than a run + // that overshoots a control which is already unreadable at that size. + internal static (float X, float Width) Segment(int index, int count, float width) + { + if (count <= 0) + return (0f, MinExtent); + + var i = Math.Clamp(index, 0, count - 1); + var left = MathF.Round(width * i / count); + var right = MathF.Round(width * (i + 1) / count); + return (left, MathF.Max(MinExtent, right - left)); + } + // 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.