feat(style): add the segmented control
WindowTab picks its layout with two radio buttons that are one setting wearing two labels. A setting row knows one label and one control, so the choice was either two rows that misstate the relationship or a control that holds the whole choice. This is the latter, and it generalises to any small closed set. Segment bounds come from rounded edges rather than a per-segment width. At 201 pixels over two segments the naive form paints two 100.5px halves that land on the same physical column, which leaves a seam in the middle and a gap on the right. Deriving each edge from the run means segment i ends exactly where i+1 starts. Hover keys are derived per segment for the same reason the toggle derives its own: the caller has already spent its id on the enclosing row, and sharing it would tie the row highlight to whichever segment the mouse is over. Disabled is a parameter rather than a BeginDisabled scope because that alpha never reaches draw-list output. The invisible buttons are still submitted while disabled so item count and cursor advance do not change between the two states. MetricsMath gains Center, which is what CenterY always was underneath. Reusing CenterY to centre text horizontally would have read as a bug at every call site.
This commit is contained in:
@@ -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<string> 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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user