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.
163 lines
5.7 KiB
C#
163 lines
5.7 KiB
C#
using System.Numerics;
|
|
|
|
namespace HellionChat.Util;
|
|
|
|
// Size arithmetic for the drawn widgets, split from the widgets themselves so
|
|
// the build suite can pin it: the widgets need ImGui.CalcTextSize, this does
|
|
// not. Every result is clamped to a positive size -- ImGui asserts on a
|
|
// zero-sized InvisibleButton and takes the whole window down with it.
|
|
internal static class WidgetGeometry
|
|
{
|
|
private const float MinExtent = 1f;
|
|
|
|
// minHeight is a floor, not the answer: a pill whose height ignored the text
|
|
// would clip it as soon as the user picks a larger body font, which does not
|
|
// feed into display scaling.
|
|
internal static Vector2 Pill(
|
|
Vector2 textSize,
|
|
float paddingX,
|
|
float paddingY,
|
|
float minHeight,
|
|
float leadWidth
|
|
)
|
|
{
|
|
var width = textSize.X + paddingX * 2f + leadWidth;
|
|
var height = MathF.Max(minHeight, textSize.Y + paddingY * 2f);
|
|
return Clamp(new Vector2(width, height));
|
|
}
|
|
|
|
internal static Vector2 Badge(Vector2 textSize, float paddingX, float height)
|
|
{
|
|
// Round badges look wrong when a single digit makes them narrower than
|
|
// they are tall, so a badge is at least a circle.
|
|
var width = MathF.Max(textSize.X + paddingX * 2f, height);
|
|
return Clamp(new Vector2(width, height));
|
|
}
|
|
|
|
internal static Vector2 IconButton(float width, float height) =>
|
|
Clamp(new Vector2(width, height));
|
|
|
|
internal static Vector2 LineDivider(
|
|
float width,
|
|
float lineThickness,
|
|
float padY,
|
|
float labelHeight
|
|
)
|
|
{
|
|
var height = lineThickness + padY * 2f + labelHeight;
|
|
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.
|
|
private const float MinLabelWidth = 60f;
|
|
|
|
internal static (float LabelWidth, float ControlX, float ControlWidth) SettingRowSplit(
|
|
float rowWidth,
|
|
float preferredControlWidth,
|
|
float 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);
|
|
}
|
|
|
|
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)
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
internal static float SlotRunWidth(ReadOnlySpan<float> widths, float gap)
|
|
{
|
|
if (widths.Length == 0)
|
|
return 0f;
|
|
|
|
var total = 0f;
|
|
foreach (var w in widths)
|
|
total += w;
|
|
|
|
return total + gap * (widths.Length - 1);
|
|
}
|
|
|
|
private static Vector2 Clamp(Vector2 size) =>
|
|
new(MathF.Max(MinExtent, size.X), MathF.Max(MinExtent, size.Y));
|
|
}
|