Files
HellionChat/HellionChat/Ui/StatusBar.cs
T
JonKazama-Hellion 2c64aaa251 fix(statusbar): make height DPI-aware via GetTextLineHeightWithSpacing
Replace the fixed 22px const Height with a computed property that bakes
in the ImGui font line height plus a GlobalScale-rounded 2px spacer.
The constant clipped the bottom bar on Windows display-scaling >100%
because ImGui rendered the actual font taller than 22px; the bar then
got pushed off the window edge.

ChatLogWindow.cs:423 reservation drops the explicit +2 because the
spacer now lives inside Height. Same idiom as the v1.4.6 F7.2 underline
pill in ChatLogWindow.cs:1639-1653.

v1.4.8 B1. Coverage via in-game smoke on Windows (Jin) and Linux/Wayland
in Task 9 -- DrawList-coupled, no Build-Suite test.
2026-05-13 22:42:40 +02:00

192 lines
6.7 KiB
C#

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.Util;
namespace HellionChat.Ui;
// Bottom status bar. Slots left to right: channel indicator, privacy badge,
// counts, tells (hidden at 0), version (right-aligned). Updates at 1Hz;
// format strings are cached between updates.
internal sealed class StatusBar
{
// DPI-aware bar height. The previous fixed 22px constant clipped on
// Windows display-scaling >100% because ImGui renders the font bigger
// than the reservation. GetTextLineHeightWithSpacing scales with the
// current ImGui font; the 2px spacer is GlobalScale-rounded to stay
// on integer pixel boundaries (same idiom as v1.4.6 F7.2 underline-pill
// in ChatLogWindow.cs:1639-1653).
public static float Height =>
ImGui.GetTextLineHeightWithSpacing() + MathF.Round(2f * ImGuiHelpers.GlobalScale);
private const long UpdateIntervalMs = 1000;
// Initially outdated so the first frame always computes fresh.
private long _lastUpdateMs = -UpdateIntervalMs;
private string _cachedCountsText = string.Empty;
private string _cachedTellsText = string.Empty;
// Pure string logic, testable without ImGui init.
public static string FormatCounts(int tabs, int messages)
{
// InvariantCulture so locale doesn't affect the format (e.g. de_DE "1,2k").
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}";
}
// Pure string logic, testable without ImGui init. Returns empty string at 0 tells.
public static string FormatTells(int count)
{
if (count <= 0)
return string.Empty;
return $"{count} {(count == 1 ? "tell" : "tells")}";
}
// Single-pass replacement for a LINQ Sum+Count pair. Pure helper for unit testing.
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);
}
// Test hook to verify cache logic without a real time source.
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(Plugin plugin)
{
var theme = plugin.ThemeRegistry.Active;
var now = Environment.TickCount64;
if (now - _lastUpdateMs >= UpdateIntervalMs)
{
var (messages, tells) = AggregateForStatusBar(Plugin.Config.Tabs);
UpdateCacheIfDue(now, Plugin.Config.Tabs.Count, messages, tells);
}
// Border top via DrawList -- ImGui.Separator has too much padding.
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 = plugin.CurrentTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
var hasChannel = inputCh != InputChannel.Invalid;
var chatType = inputCh.ToChatType();
var channelName = hasChannel ? chatType.Name() : "—";
var channelColor = hasChannel
? (plugin.Functions.Chat.GetChannelColor(chatType) ?? theme.Colors.TextMuted)
: theme.Colors.TextMuted;
DrawDot(channelColor);
ImGui.SameLine();
ImGui.TextUnformatted(channelName);
// Slot 2: privacy badge
ImGui.SameLine();
DrawSeparator();
ImGui.SameLine();
using (plugin.FontManager.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, right-aligned, muted. Hidden when the window is
// too narrow to fit all five slots — the other four need ~200 px
// before the version text starts clipping into them.
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("·");
}
}