2c64aaa251
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.
192 lines
6.7 KiB
C#
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("·");
|
|
}
|
|
}
|