From 0fc2512f3ef1eae05ea2118d87ce4770a017db79 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sat, 23 May 2026 19:35:50 +0200 Subject: [PATCH] feat(ui): rebuild StatusBar inside the components layer Same 1Hz-cached slot layout (channel indicator, privacy badge, counts, tells, version + brand) but ThemeRegistry and FontManager arrive via constructor injection rather than the Plugin static bridge, and Draw takes the active tab directly so the component does not have to reach back through Plugin.CurrentTab. Pure helpers (FormatCounts, FormatTells, AggregateForStatusBar) stay static so the build suite can pin them without an ImGui frame. The v1.5.6 Ui/StatusBar.cs stays in place until the cleanup block removes it. --- HellionChat/PluginHostFactory.cs | 4 + HellionChat/Ui/Components/StatusBar.cs | 193 +++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 HellionChat/Ui/Components/StatusBar.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 23bd483..ed5f2fa 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -141,6 +141,10 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService() )); + services.AddSingleton(sp => new Ui.Components.StatusBar( + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new Integrations.FailedTellNotifier( sp.GetRequiredService>() )); diff --git a/HellionChat/Ui/Components/StatusBar.cs b/HellionChat/Ui/Components/StatusBar.cs new file mode 100644 index 0000000..9342c5b --- /dev/null +++ b/HellionChat/Ui/Components/StatusBar.cs @@ -0,0 +1,193 @@ +using System.Globalization; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Code; +using HellionChat.Resources; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.Components; + +// Bottom status bar. Slots left to right: channel indicator, privacy badge, +// counts, tells (hidden at 0), version (right-aligned). Updates at 1Hz to +// keep the per-frame cost down on slow systems; format strings cache +// between updates and only recompute on the tick boundary. +internal sealed class StatusBar +{ + // DPI-aware bar height. A fixed pixel constant clipped at display + // scaling above 100% — GetTextLineHeightWithSpacing scales with the + // active ImGui font, the 2px spacer rounds against GlobalScale so the + // result lands on integer pixel boundaries. + public static float Height => + ImGui.GetTextLineHeightWithSpacing() + MathF.Round(2f * ImGuiHelpers.GlobalScale); + + private const long UpdateIntervalMs = 1000; + + private readonly ThemeRegistry _themes; + private readonly FontManager _fonts; + + private long _lastUpdateMs = -UpdateIntervalMs; + private string _cachedCountsText = string.Empty; + private string _cachedTellsText = string.Empty; + + public StatusBar(ThemeRegistry themes, FontManager fonts) + { + _themes = themes; + _fonts = fonts; + } + + // Pure string logic so the build suite can pin format edge cases + // (locale-sensitive k-suffix, singular/plural pivot) without ImGui. + public static string FormatCounts(int tabs, int messages) + { + var msgPart = + messages >= 1000 + ? string.Format(CultureInfo.InvariantCulture, "{0:0.0}k msg", messages / 1000.0) + : $"{messages} msg"; + var tabsPart = $"{tabs} {(tabs == 1 ? "tab" : "tabs")}"; + return $"{tabsPart} · {msgPart}"; + } + + public static string FormatTells(int count) + { + if (count <= 0) + return string.Empty; + return $"{count} {(count == 1 ? "tell" : "tells")}"; + } + + // Single-pass aggregator — same shape as the previous helper so the + // build-suite test continues to pin the contract. + internal static (int messages, int tells) AggregateForStatusBar(IList tabs) + { + int messages = 0, + tells = 0; + foreach (var t in tabs) + { + messages += t.Messages.Count; + if (t.IsTempTab) + tells++; + } + return (messages, tells); + } + + internal (string counts, string tells) SnapshotForTest( + long now, + int tabs, + int messages, + int tells + ) + { + UpdateCacheIfDue(now, tabs, messages, tells); + return (_cachedCountsText, _cachedTellsText); + } + + private void UpdateCacheIfDue(long now, int tabs, int messages, int tells) + { + if (now - _lastUpdateMs < UpdateIntervalMs) + return; + _cachedCountsText = FormatCounts(tabs, messages); + _cachedTellsText = FormatTells(tells); + _lastUpdateMs = now; + } + + public void Draw(Tab? activeTab) + { + if (!_fonts.FontsReady) + { + ImGui.Dummy(new Vector2(0, Height)); + return; + } + + var theme = _themes.Active; + var now = Environment.TickCount64; + if (now - _lastUpdateMs >= UpdateIntervalMs) + { + var (messages, tells) = AggregateForStatusBar(Plugin.Config.Tabs); + UpdateCacheIfDue(now, Plugin.Config.Tabs.Count, messages, tells); + } + + // Top border via DrawList — ImGui.Separator has too much padding for + // a tight bottom strip. + var cursorY = ImGui.GetCursorScreenPos().Y; + var winLeft = ImGui.GetWindowPos().X; + var winRight = winLeft + ImGui.GetWindowSize().X; + ImGui + .GetWindowDrawList() + .AddLine( + new Vector2(winLeft, cursorY), + new Vector2(winRight, cursorY), + ColourUtil.RgbaToAbgr(theme.Colors.Border), + 1f + ); + ImGui.Dummy(new Vector2(0, 2)); + + // Slot 1: active channel 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); + + // Slot 2: privacy badge + ImGui.SameLine(); + DrawSeparator(); + ImGui.SameLine(); + using (_fonts.FontAwesome.Push()) + ImGui.TextUnformatted(FontAwesomeIcon.Lock.ToIconString()); + ImGui.SameLine(); + 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); + + // Slot 4: tells (hidden at 0) + if (!string.IsNullOrEmpty(_cachedTellsText)) + { + ImGui.SameLine(); + DrawSeparator(); + ImGui.SameLine(); + ImGui.TextUnformatted(_cachedTellsText); + } + + // Slot 5: version + brand, right-aligned, muted. Hidden when the + // window cannot fit all five slots without overlap. + 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); + } + } + + 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) + ); + ImGui.Dummy(new Vector2(radius * 2 + 4, ImGui.GetTextLineHeight())); + } + + private static void DrawSeparator() => ImGui.TextDisabled("·"); +}