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.
This commit is contained in:
2026-05-23 19:35:50 +02:00
parent 36afce21e5
commit 0fc2512f3e
2 changed files with 197 additions and 0 deletions
+4
View File
@@ -141,6 +141,10 @@ internal static class PluginHostFactory
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
));
services.AddSingleton(sp => new Ui.Components.StatusBar(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<FontManager>()
));
services.AddSingleton(sp => new Integrations.FailedTellNotifier(
sp.GetRequiredService<ILogger<Integrations.FailedTellNotifier>>()
));
+193
View File
@@ -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<Tab> 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("·");
}