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:
@@ -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>>()
|
||||
));
|
||||
|
||||
@@ -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("·");
|
||||
}
|
||||
Reference in New Issue
Block a user