fix(statusbar): size pills from the text and give every slot a fit check

Review of block E found the pill height locked to its design value.
WidgetGeometry.Pill discarded the measured text height, so a pill was always
22px times display scale -- and Config.FontSizeV2 does not feed into display
scale. At 18pt the 24px line no longer fit its 22px pill, at 20pt the text left
the reserved strip entirely. Both sizes are in the plugin's own font list.

Height is a floor now, and the text plus vertical padding wins when it is
taller. At the default 12.75pt the floor still applies, so nothing moves.

Only the right-hand version slot checked whether it had room. At 150% scaling
with the window at its 480px minimum -- which stays reachable, because the size
constraint is not scaled -- the counts and tells pills simply ran off the edge.
Every slot checks now.

Pill.Draw returns the size it drew, so the status bar no longer measures each
slot twice. That also removes the risk of the drawn and returned widths
drifting apart if a style override ever reaches only one of the two calls, and
it cuts the lock glyph from three font pushes per frame to one. A font push is
not free: it allocates a lock object and queues a deferred dispose.

Smaller items: the version string is built once instead of per frame, the glyph
cache from IconButton is now in Pill as well, and five metrics constants plus
two usings that lost their consumers in E1 and E2 are gone.
This commit is contained in:
2026-08-18 00:19:39 +02:00
parent 929188e5eb
commit d79188c7b0
4 changed files with 76 additions and 36 deletions
+36 -19
View File
@@ -3,8 +3,6 @@ using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.ManagedFontAtlas;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Themes;
@@ -27,7 +25,7 @@ internal sealed class StatusBar
// body height against this property, so the two drifting apart is the whole
// failure mode. Slots are pills now, and a pill is taller than a text line.
public static float Height =>
StyleEngine.Widgets.Pill.CalcSize("X", withDot: false).Y
StyleEngine.Widgets.Pill.CalcSize(string.Empty, withDot: false).Y
+ StyleEngine.Metrics.StatusTopSpacer * 2f;
private const long UpdateIntervalMs = 1000;
@@ -38,6 +36,10 @@ internal sealed class StatusBar
new StyleEngine.TokenResolver()
);
// Never changes at runtime; it used to be rebuilt on every frame.
private static readonly string VersionText =
$"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion";
private long _lastUpdateMs = -UpdateIntervalMs;
private string _cachedCountsText = string.Empty;
private string _cachedTellsText = string.Empty;
@@ -154,34 +156,49 @@ internal sealed class StatusBar
? HellionStrings.StatusBar_Privacy_Enabled
: HellionStrings.StatusBar_Privacy_Open;
// Every slot checks its own room. Only the right-hand one used to, so at
// 150% scaling with the window at its 480px minimum the counts and tells
// pills ran off the edge instead of dropping out.
var regionRight = origin.X + ImGui.GetContentRegionAvail().X;
var x = origin.X;
x += DrawSlot(new Vector2(x, top), channelName, pillFill, pillText, dotAbgr) + gap;
x +=
DrawSlot(
new Vector2(x, top),
privacyLabel,
pillFill,
pillText,
null,
(FontAwesomeIcon.Lock, _fonts.FontAwesome)
) + gap;
x += DrawSlot(new Vector2(x, top), _cachedCountsText, pillFill, mutedText, null) + gap;
if (!string.IsNullOrEmpty(_cachedTellsText))
bool Fits(string label, bool withDot, float iconWidth = 0f) =>
x + StyleEngine.Widgets.Pill.CalcSize(label, withDot, iconWidth: iconWidth).X
<= regionRight;
if (Fits(channelName, withDot: true))
x += DrawSlot(new Vector2(x, top), channelName, pillFill, pillText, dotAbgr) + gap;
var lockWidth = StyleEngine.Widgets.Pill.MeasureIcon(
FontAwesomeIcon.Lock,
_fonts.FontAwesome
);
if (Fits(privacyLabel, withDot: false, lockWidth))
x +=
DrawSlot(
new Vector2(x, top),
privacyLabel,
pillFill,
pillText,
null,
(FontAwesomeIcon.Lock, _fonts.FontAwesome)
) + gap;
if (Fits(_cachedCountsText, withDot: false))
x += DrawSlot(new Vector2(x, top), _cachedCountsText, pillFill, mutedText, null) + gap;
if (!string.IsNullOrEmpty(_cachedTellsText) && Fits(_cachedTellsText, withDot: false))
x += DrawSlot(new Vector2(x, top), _cachedTellsText, pillFill, pillText, null) + gap;
// Slot 5: version + brand, right-aligned. Dropped when the left-hand run
// would actually collide with it -- the old check compared against a flat
// 200px and never measured the left slots at all.
var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion";
var versionWidth = StyleEngine.Widgets.Pill.CalcSize(versionText, withDot: false).X;
var regionRight = origin.X + ImGui.GetContentRegionAvail().X;
var versionWidth = StyleEngine.Widgets.Pill.CalcSize(VersionText, withDot: false).X;
var leftRunEnd = x - gap;
if (regionRight - versionWidth - gap > leftRunEnd)
DrawSlot(
new Vector2(regionRight - versionWidth, top),
versionText,
VersionText,
pillFill,
mutedText,
null
-11
View File
@@ -26,9 +26,6 @@ internal static class Metrics
// --- Input bar ---
internal const float InputBarHeightRaw = 32f;
internal const float InputPillHeightRaw = 22f;
internal const float InputPillPaddingXRaw = 8f;
internal const float InputPillRoundingRaw = 6f;
internal const float InputQuickButtonsReserveRaw = 130f;
// --- Honorific header ---
@@ -44,8 +41,6 @@ internal static class Metrics
// --- Status bar ---
internal const float StatusBorderThicknessRaw = 1f;
internal const float StatusTopSpacerRaw = 2f;
internal const float StatusMinOtherSlotsWidthRaw = 200f;
internal const float StatusDotRadiusRaw = 4f;
// --- Message list ---
internal const float MessageDummyWidthRaw = 10f;
@@ -85,9 +80,6 @@ internal static class Metrics
internal static float SidebarGlyphInset => MetricsMath.Scale(SidebarGlyphInsetRaw, Scale);
internal static float InputBarHeight => MetricsMath.Scale(InputBarHeightRaw, Scale);
internal static float InputPillHeight => MetricsMath.Scale(InputPillHeightRaw, Scale);
internal static float InputPillPaddingX => MetricsMath.Scale(InputPillPaddingXRaw, Scale);
internal static float InputPillRounding => MetricsMath.Scale(InputPillRoundingRaw, Scale);
internal static float InputQuickButtonsReserve =>
MetricsMath.Scale(InputQuickButtonsReserveRaw, Scale);
@@ -106,9 +98,6 @@ internal static class Metrics
internal static float StatusBorderThickness =>
MetricsMath.Scale(StatusBorderThicknessRaw, Scale);
internal static float StatusTopSpacer => MathF.Round(StatusTopSpacerRaw * Scale);
internal static float StatusMinOtherSlotsWidth =>
MetricsMath.Scale(StatusMinOtherSlotsWidthRaw, Scale);
internal static float StatusDotRadius => MetricsMath.Scale(StatusDotRadiusRaw, Scale);
internal static float MessageDummyWidth => MetricsMath.Scale(MessageDummyWidthRaw, Scale);
+29 -5
View File
@@ -12,6 +12,10 @@ internal readonly record struct PillStyle
public float Height { get; init; } = 22f;
public float PaddingX { get; init; } = 8f;
// Only takes over once the text is taller than Height allows; at the default
// 12.75pt (17px line in a 22px pill) the floor still wins.
public float PaddingY { get; init; } = 2f;
public float Rounding { get; init; } = 6f;
public float DotRadius { get; init; } = 4f;
public float DotGap { get; init; } = 6f;
@@ -38,20 +42,39 @@ internal static class Pill
return WidgetGeometry.Pill(
ImGui.CalcTextSize(label),
style.PaddingX * scale,
style.PaddingY * scale,
style.Height * scale,
lead
);
}
// Measures a glyph in the icon font so a caller can feed iconWidth above
// without pushing the font twice.
// ToIconString allocates a fresh string on every call and holds no cache.
private static readonly Dictionary<FontAwesomeIcon, string> GlyphCache = [];
private static string Glyph(FontAwesomeIcon icon)
{
if (GlyphCache.TryGetValue(icon, out var s))
return s;
s = icon.ToIconString();
GlyphCache[icon] = s;
return s;
}
// Measures a glyph in the icon font. A font push is not free -- it allocates
// a lock object and queues a deferred dispose -- so callers should let Draw
// return the size rather than measuring alongside it.
internal static float MeasureIcon(FontAwesomeIcon icon, IFontHandle font)
{
using (font.Push())
return ImGui.CalcTextSize(icon.ToIconString()).X;
return ImGui.CalcTextSize(Glyph(icon)).X;
}
internal static void Draw(
// Returns the drawn size so a caller laying pills out in a row does not have
// to call CalcSize again -- doing so would repeat the text and icon
// measurement, and the two could drift apart if a style override is passed
// to only one of them.
internal static Vector2 Draw(
Vector2 origin,
string label,
uint fillAbgr,
@@ -87,7 +110,7 @@ internal static class Pill
{
using (glyph.Font.Push())
{
var text = glyph.Icon.ToIconString();
var text = Glyph(glyph.Icon);
var h = ImGui.CalcTextSize(text).Y;
dl.AddText(
new Vector2(textX, origin.Y + MetricsMath.CenterY(size.Y, h)),
@@ -102,5 +125,6 @@ internal static class Pill
// which display scaling does not feed into.
var textY = origin.Y + MetricsMath.CenterY(size.Y, ImGui.GetTextLineHeight());
dl.AddText(new Vector2(textX, textY), textAbgr, label);
return size;
}
}
+11 -1
View File
@@ -10,9 +10,19 @@ internal static class WidgetGeometry
{
private const float MinExtent = 1f;
internal static Vector2 Pill(Vector2 textSize, float paddingX, float height, float leadWidth)
// 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));
}