feat(statusbar): render slots as pills

Five slots drawn as flowing text with a TextDisabled interpunct between them.
They are pills now: channel with its status dot, privacy with its lock glyph,
counts, tells, and version right-aligned.

The composite texts stay composite. FormatCounts produces "5 tabs · 1.2k msg"
and the version slot "v1.10.0 · Hellion" as single strings, and splitting them
would have gained nothing while breaking their pinned format.

Height derives from the pill rather than sitting beside it as a second
constant. MainWindow reserves the body against this property, so the two
drifting apart is the entire failure mode here, and a pill is taller than a
bare text line.

The right-hand slot's fit check actually measures now. The old one compared the
region against a flat 200px and never looked at the left-hand slots at all, so
at the 480px minimum width it kept drawing the version while the left run
needed more room than was left -- the overlap predates this change.

Pill grew optional icon support for the privacy lock. Without it that glyph
would have been silently dropped in the move.

StatusBarCacheTests is re-enabled. It sat in the csproj Compile Remove block,
so the format contract this commit reshapes around had no live net at all. Two
of its cases construct StatusBar with null services, which is safe because
SnapshotForTest touches neither.
This commit is contained in:
2026-08-18 00:02:55 +02:00
parent f2dd49e454
commit a7ed8e1146
2 changed files with 115 additions and 63 deletions
+75 -58
View File
@@ -2,11 +2,13 @@ using System.Globalization;
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;
using HellionChat.Ui.StyleEngine;
using HellionChat.Util;
namespace HellionChat.Ui.Components;
@@ -21,13 +23,20 @@ internal sealed class StatusBar
// scaling above 100% — GetTextLineHeightWithSpacing scales with the
// active ImGui font, the 2px spacer rounds against GlobalScale so the
// result lands on integer pixel boundaries.
// Derived from the pill, never a second constant: MainWindow reserves the
// 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 =>
ImGui.GetTextLineHeightWithSpacing() + MathF.Round(2f * ImGuiHelpers.GlobalScale);
StyleEngine.Widgets.Pill.CalcSize("X", withDot: false).Y
+ StyleEngine.Metrics.StatusTopSpacer * 2f;
private const long UpdateIntervalMs = 1000;
private readonly ThemeRegistry _themes;
private readonly FontManager _fonts;
private readonly StyleEngine.Widgets.WidgetPalette _palette = new(
new StyleEngine.TokenResolver()
);
private long _lastUpdateMs = -UpdateIntervalMs;
private string _cachedCountsText = string.Empty;
@@ -111,83 +120,91 @@ internal sealed class StatusBar
// Top border via DrawList — ImGui.Separator has too much padding for
// a tight bottom strip.
var cursorY = ImGui.GetCursorScreenPos().Y;
var origin = ImGui.GetCursorScreenPos();
var winLeft = ImGui.GetWindowPos().X;
var winRight = winLeft + ImGui.GetWindowSize().X;
var palette = _palette;
var colors = theme.Colors;
ImGui
.GetWindowDrawList()
.AddLine(
new Vector2(winLeft, cursorY),
new Vector2(winRight, cursorY),
ColourUtil.RgbaToAbgr(theme.Colors.Border),
1f
new Vector2(winLeft, origin.Y),
new Vector2(winRight, origin.Y),
palette.Abgr(Token.Border, colors),
StyleEngine.Metrics.StatusBorderThickness
);
ImGui.Dummy(new Vector2(0, 2));
// Slot 1: active channel indicator
var pillFill = palette.Abgr(Token.SurfaceRaised, colors);
var pillText = palette.Abgr(Token.Text, colors);
var mutedText = palette.Abgr(Token.TextMuted, colors);
var gap = StyleEngine.Metrics.StatusTopSpacer * 3f;
var top = origin.Y + StyleEngine.Metrics.StatusTopSpacer;
// Slot 1: active channel. The dot doubles as the connection indicator.
var inputCh = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
var hasChannel = inputCh != InputChannel.Invalid;
var chatType = inputCh.ToChatType();
var channelName = hasChannel ? chatType.Name() : "—";
var dotColor = hasChannel ? theme.Colors.Primary : theme.Colors.TextMuted;
DrawDot(dotColor);
ImGui.SameLine();
ImGui.TextUnformatted(channelName);
var channelName = hasChannel ? inputCh.ToChatType().Name() : "—";
var dotAbgr = hasChannel
? palette.Abgr(Token.AccentPrimary, colors)
: palette.Abgr(Token.TextMuted, colors);
// Slot 2: privacy badge
ImGui.SameLine();
DrawSeparator();
ImGui.SameLine();
using (_fonts.FontAwesome.Push())
ImGui.TextUnformatted(FontAwesomeIcon.Lock.ToIconString());
ImGui.SameLine();
// Slot 2 label, resolved before measuring so the run width is exact.
var privacyLabel = Plugin.Config.PrivacyFilterEnabled
? HellionStrings.StatusBar_Privacy_Enabled
: HellionStrings.StatusBar_Privacy_Open;
ImGui.TextUnformatted(privacyLabel);
// Slot 3: counts
ImGui.SameLine();
DrawSeparator();
ImGui.SameLine();
ImGui.TextUnformatted(_cachedCountsText);
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;
// Slot 4: tells (hidden at 0)
if (!string.IsNullOrEmpty(_cachedTellsText))
{
ImGui.SameLine();
DrawSeparator();
ImGui.SameLine();
ImGui.TextUnformatted(_cachedTellsText);
}
x += DrawSlot(new Vector2(x, top), _cachedTellsText, pillFill, pillText, null) + gap;
// Slot 5: version + brand, right-aligned, muted. Hidden when the
// window cannot fit all five slots without overlap.
// 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 = ImGui.CalcTextSize(versionText).X;
var contentRegionMax = ImGui.GetContentRegionMax().X;
const float MinOtherSlotsWidth = 200f;
if (contentRegionMax - versionWidth > MinOtherSlotsWidth)
{
ImGui.SameLine(contentRegionMax - versionWidth);
using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted)))
ImGui.TextUnformatted(versionText);
}
}
var versionWidth = StyleEngine.Widgets.Pill.CalcSize(versionText, withDot: false).X;
var regionRight = origin.X + ImGui.GetContentRegionAvail().X;
var leftRunEnd = x - gap;
private static void DrawDot(uint rgba)
{
var pos = ImGui.GetCursorScreenPos();
const float radius = 4f;
ImGui
.GetWindowDrawList()
.AddCircleFilled(
new Vector2(pos.X + radius, pos.Y + ImGui.GetTextLineHeight() / 2f),
radius,
ColourUtil.RgbaToAbgr(rgba)
if (regionRight - versionWidth - gap > leftRunEnd)
DrawSlot(
new Vector2(regionRight - versionWidth, top),
versionText,
pillFill,
mutedText,
null
);
ImGui.Dummy(new Vector2(radius * 2 + 4, ImGui.GetTextLineHeight()));
ImGui.Dummy(new Vector2(0, Height));
}
private static void DrawSeparator() => ImGui.TextDisabled("·");
// Returns the slot width so the caller can run them left to right and know
// where the run ends.
private static float DrawSlot(
Vector2 origin,
string label,
uint fillAbgr,
uint textAbgr,
uint? dotAbgr,
(FontAwesomeIcon Icon, IFontHandle Font)? icon = null
)
{
StyleEngine.Widgets.Pill.Draw(origin, label, fillAbgr, textAbgr, dotAbgr, icon: icon);
var iconWidth = icon is { } ic
? StyleEngine.Widgets.Pill.MeasureIcon(ic.Icon, ic.Font)
: 0f;
return StyleEngine.Widgets.Pill.CalcSize(label, dotAbgr.HasValue, iconWidth: iconWidth).X;
}
}
+40 -5
View File
@@ -1,5 +1,7 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.ManagedFontAtlas;
using HellionChat.Util;
namespace HellionChat.Ui.StyleEngine.Widgets;
@@ -16,15 +18,23 @@ internal readonly record struct PillStyle
public bool Outlined { get; init; }
}
// Filled capsule with a label, optionally preceded by a status dot. Outlined is
// the variant that would otherwise have been a separate Chip widget.
// Filled capsule with a label, optionally preceded by a status dot or an icon.
// Outlined is the variant that would otherwise have been a separate Chip widget.
internal static class Pill
{
internal static Vector2 CalcSize(string label, bool withDot, PillStyle? styleOverride = null)
internal static Vector2 CalcSize(
string label,
bool withDot,
PillStyle? styleOverride = null,
float iconWidth = 0f
)
{
var style = styleOverride ?? new PillStyle();
var scale = Metrics.Scale;
var lead = withDot ? (style.DotRadius * 2f + style.DotGap) * scale : 0f;
if (iconWidth > 0f)
lead += iconWidth + style.DotGap * scale;
return WidgetGeometry.Pill(
ImGui.CalcTextSize(label),
style.PaddingX * scale,
@@ -33,18 +43,28 @@ internal static class Pill
);
}
// Measures a glyph in the icon font so a caller can feed iconWidth above
// without pushing the font twice.
internal static float MeasureIcon(FontAwesomeIcon icon, IFontHandle font)
{
using (font.Push())
return ImGui.CalcTextSize(icon.ToIconString()).X;
}
internal static void Draw(
Vector2 origin,
string label,
uint fillAbgr,
uint textAbgr,
uint? dotAbgr = null,
PillStyle? styleOverride = null
PillStyle? styleOverride = null,
(FontAwesomeIcon Icon, IFontHandle Font)? icon = null
)
{
var style = styleOverride ?? new PillStyle();
var scale = Metrics.Scale;
var size = CalcSize(label, dotAbgr.HasValue, style);
var iconWidth = icon is { } ic ? MeasureIcon(ic.Icon, ic.Font) : 0f;
var size = CalcSize(label, dotAbgr.HasValue, style, iconWidth);
var dl = ImGui.GetWindowDrawList();
var max = origin + size;
var rounding = style.Rounding * scale;
@@ -63,6 +83,21 @@ internal static class Pill
textX += r * 2f + style.DotGap * scale;
}
if (icon is { } glyph)
{
using (glyph.Font.Push())
{
var text = glyph.Icon.ToIconString();
var h = ImGui.CalcTextSize(text).Y;
dl.AddText(
new Vector2(textX, origin.Y + MetricsMath.CenterY(size.Y, h)),
textAbgr,
text
);
}
textX += iconWidth + style.DotGap * scale;
}
// Measured, not a frozen offset: the font comes from Config.FontSizeV2,
// which display scaling does not feed into.
var textY = origin.Y + MetricsMath.CenterY(size.Y, ImGui.GetTextLineHeight());