diff --git a/HellionChat/Ui/Components/StatusBar.cs b/HellionChat/Ui/Components/StatusBar.cs index 3b867b8..4c561a8 100644 --- a/HellionChat/Ui/Components/StatusBar.cs +++ b/HellionChat/Ui/Components/StatusBar.cs @@ -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; + } } diff --git a/HellionChat/Ui/StyleEngine/Widgets/Pill.cs b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs index fbdfcac..f7cd511 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/Pill.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs @@ -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());