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("·"); +}