Reported from a real pass through the window. The theme categories were a static readonly array, so the five names froze at whatever language the plugin started in and a runtime switch relabelled the entire window except them. Same shape as the layout labels earlier in this cycle; this one got missed because replacing the literals with resource lookups looks finished until you actually switch. The status bar built its counts from English literals -- tab, tabs, msg, tell, tells -- and the privacy pill said "Privacy-First" in all 25 files. The thousands separator follows the user's culture now too, so German reads 1,2k rather than 1.2k. The live preview claims to show what the window will look like. It was showing English channel names next to a translated placeholder, which is worse than either. Channel labels come from ChatType.Name() now and the status slots share the strings with the real status bar. The four mock chat lines stay English on the earlier decision. And the export wrote a byte order mark. Encoding.UTF8 emits one, and a leading U+FEFF makes the JSON invalid for every strict parser -- confirmed against a real export from the game, where python's json.load refused the file. CSV keeps its BOM, because without one Excel guesses the codepage and mangles every non-ASCII name. The self-test that was supposed to catch that read the file with File.ReadAllText, which strips a BOM while detecting the encoding. It reads bytes now. The status bar tests asserted English literals and started failing on a German machine -- they pin a fixed culture now instead of inheriting the locale of whoever runs them.
238 lines
8.8 KiB
C#
238 lines
8.8 KiB
C#
using System.Globalization;
|
|
using System.Numerics;
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Interface;
|
|
using Dalamud.Interface.ManagedFontAtlas;
|
|
using HellionChat.Code;
|
|
using HellionChat.Resources;
|
|
using HellionChat.Themes;
|
|
using HellionChat.Ui.StyleEngine;
|
|
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.
|
|
// Derived from the pill, never a second constant: MainWindow reserves the
|
|
// body height against this property, so the two drifting apart is the whole
|
|
// failure mode. Slots are pills now, and a pill is taller than a text line.
|
|
public static float Height =>
|
|
StyleEngine.Widgets.Pill.CalcSize(string.Empty, withDot: false).Y
|
|
+ StyleEngine.Metrics.StatusTopSpacer * 2f;
|
|
|
|
private const long UpdateIntervalMs = 1000;
|
|
|
|
private readonly ThemeRegistry _themes;
|
|
private readonly FontManager _fonts;
|
|
private readonly StyleEngine.Widgets.WidgetPalette _palette = new(
|
|
new StyleEngine.TokenResolver()
|
|
);
|
|
|
|
// Never changes at runtime; it used to be rebuilt on every frame.
|
|
private static readonly string VersionText =
|
|
$"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion";
|
|
|
|
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.CurrentCulture,
|
|
HellionStrings.StatusBar_MessagesThousands,
|
|
messages / 1000.0
|
|
)
|
|
: string.Format(HellionStrings.StatusBar_Messages, messages);
|
|
var tabsPart = string.Format(
|
|
tabs == 1 ? HellionStrings.StatusBar_Tabs_One : HellionStrings.StatusBar_Tabs_Other,
|
|
tabs
|
|
);
|
|
return $"{tabsPart} · {msgPart}";
|
|
}
|
|
|
|
public static string FormatTells(int count)
|
|
{
|
|
if (count <= 0)
|
|
return string.Empty;
|
|
return string.Format(
|
|
count == 1 ? HellionStrings.StatusBar_Tells_One : HellionStrings.StatusBar_Tells_Other,
|
|
count
|
|
);
|
|
}
|
|
|
|
// 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(IReadOnlyList<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, IReadOnlyList<Tab> tabs)
|
|
{
|
|
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(tabs);
|
|
UpdateCacheIfDue(now, tabs.Count, messages, tells);
|
|
}
|
|
|
|
// Top border via DrawList — ImGui.Separator has too much padding for
|
|
// a tight bottom strip.
|
|
var origin = ImGui.GetCursorScreenPos();
|
|
var winLeft = ImGui.GetWindowPos().X;
|
|
var winRight = winLeft + ImGui.GetWindowSize().X;
|
|
var palette = _palette;
|
|
var colors = theme.Colors;
|
|
|
|
ImGui
|
|
.GetWindowDrawList()
|
|
.AddLine(
|
|
new Vector2(winLeft, origin.Y),
|
|
new Vector2(winRight, origin.Y),
|
|
palette.Abgr(Token.Border, colors),
|
|
StyleEngine.Metrics.StatusBorderThickness
|
|
);
|
|
|
|
var pillFill = palette.Abgr(Token.SurfaceRaised, colors);
|
|
var pillText = palette.Abgr(Token.Text, colors);
|
|
var mutedText = palette.Abgr(Token.TextMuted, colors);
|
|
var gap = StyleEngine.Metrics.StatusTopSpacer * 3f;
|
|
var top = origin.Y + StyleEngine.Metrics.StatusTopSpacer;
|
|
|
|
// Slot 1: active channel. The dot doubles as the connection indicator.
|
|
var inputCh = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
|
|
var hasChannel = inputCh != InputChannel.Invalid;
|
|
var channelName = hasChannel ? inputCh.ToChatType().Name() : "—";
|
|
var dotAbgr = hasChannel
|
|
? palette.Abgr(Token.AccentPrimary, colors)
|
|
: palette.Abgr(Token.TextMuted, colors);
|
|
|
|
// Slot 2 label, resolved before measuring so the run width is exact.
|
|
var privacyLabel = Plugin.Config.PrivacyFilterEnabled
|
|
? HellionStrings.StatusBar_Privacy_Enabled
|
|
: HellionStrings.StatusBar_Privacy_Open;
|
|
|
|
// Every slot checks its own room. Only the right-hand one used to, so at
|
|
// 150% scaling with the window at its 480px minimum the counts and tells
|
|
// pills ran off the edge instead of dropping out.
|
|
var regionRight = origin.X + ImGui.GetContentRegionAvail().X;
|
|
var x = origin.X;
|
|
|
|
bool Fits(string label, bool withDot, float iconWidth = 0f) =>
|
|
x + StyleEngine.Widgets.Pill.CalcSize(label, withDot, iconWidth: iconWidth).X
|
|
<= regionRight;
|
|
|
|
if (Fits(channelName, withDot: true))
|
|
x += DrawSlot(new Vector2(x, top), channelName, pillFill, pillText, dotAbgr) + gap;
|
|
var lockWidth = StyleEngine.Widgets.Pill.MeasureIcon(
|
|
FontAwesomeIcon.Lock,
|
|
_fonts.FontAwesome
|
|
);
|
|
if (Fits(privacyLabel, withDot: false, lockWidth))
|
|
x +=
|
|
DrawSlot(
|
|
new Vector2(x, top),
|
|
privacyLabel,
|
|
pillFill,
|
|
pillText,
|
|
null,
|
|
(FontAwesomeIcon.Lock, _fonts.FontAwesome)
|
|
) + gap;
|
|
|
|
if (Fits(_cachedCountsText, withDot: false))
|
|
x += DrawSlot(new Vector2(x, top), _cachedCountsText, pillFill, mutedText, null) + gap;
|
|
|
|
if (!string.IsNullOrEmpty(_cachedTellsText) && Fits(_cachedTellsText, withDot: false))
|
|
x += DrawSlot(new Vector2(x, top), _cachedTellsText, pillFill, pillText, null) + gap;
|
|
|
|
// Slot 5: version + brand, right-aligned. Dropped when the left-hand run
|
|
// would actually collide with it -- the old check compared against a flat
|
|
// 200px and never measured the left slots at all.
|
|
var versionWidth = StyleEngine.Widgets.Pill.CalcSize(VersionText, withDot: false).X;
|
|
var leftRunEnd = x - gap;
|
|
|
|
if (regionRight - versionWidth - gap > leftRunEnd)
|
|
DrawSlot(
|
|
new Vector2(regionRight - versionWidth, top),
|
|
VersionText,
|
|
pillFill,
|
|
mutedText,
|
|
null
|
|
);
|
|
|
|
ImGui.Dummy(new Vector2(0, Height));
|
|
}
|
|
|
|
// Returns the slot width so the caller can run them left to right and know
|
|
// where the run ends.
|
|
private static float DrawSlot(
|
|
Vector2 origin,
|
|
string label,
|
|
uint fillAbgr,
|
|
uint textAbgr,
|
|
uint? dotAbgr,
|
|
(FontAwesomeIcon Icon, IFontHandle Font)? icon = null
|
|
)
|
|
{
|
|
StyleEngine.Widgets.Pill.Draw(origin, label, fillAbgr, textAbgr, dotAbgr, icon: icon);
|
|
var iconWidth = icon is { } ic
|
|
? StyleEngine.Widgets.Pill.MeasureIcon(ic.Icon, ic.Font)
|
|
: 0f;
|
|
return StyleEngine.Widgets.Pill.CalcSize(label, dotAbgr.HasValue, iconWidth: iconWidth).X;
|
|
}
|
|
}
|