i18n: the five spots the smoke test found, and a BOM in the export

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.
This commit is contained in:
2026-08-19 08:24:31 +02:00
parent fdb1a98519
commit e0c9efca05
31 changed files with 699 additions and 49 deletions
@@ -3,6 +3,7 @@ using System.Threading;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
@@ -153,7 +154,7 @@ internal sealed class LivePreviewPanel : IDisposable
// unchanged — but both paths now share one resolver. No truncation here:
// the preview draws a fixed, centred "«Champion» Preview" string.
var textAbgr = HonorificTitleColor.ResolveTitleAbgr(null, theme);
var title = "«Champion» Preview";
var title = HellionStrings.Settings_Preview_TitleMock;
var crownGlyph = FontAwesomeIcon.Crown.ToIconString();
// Crown is a FontAwesome glyph (matches the real header); measure + draw
@@ -196,7 +197,14 @@ internal sealed class LivePreviewPanel : IDisposable
// sat on different rows here, so the preview promised a layout the
// sidebar never delivered -- and then the sidebar caught up.
var borderAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Border);
ReadOnlySpan<string> labels = ["Linkshell", "Tell", "FC"];
// Real channel names, not literals: the preview claims to show what the
// sidebar will look like, and the sidebar is localised.
ReadOnlySpan<string> labels =
[
ChatType.Linkshell1.Name(),
ChatType.TellIncoming.Name(),
ChatType.FreeCompany.Name(),
];
for (var i = 0; i < 3; i++)
{
var rowMin = new Vector2(origin.X, origin.Y + i * rowHeight);
@@ -300,7 +308,7 @@ internal sealed class LivePreviewPanel : IDisposable
var pillMax = new Vector2(origin.X + pillWidth, max.Y - 4f);
draw.AddRectFilled(pillMin, pillMax, ColourUtil.RgbaToAbgr(theme.Colors.Primary), 6f);
var pillLabel = "Say";
var pillLabel = ChatType.Say.Name();
var pillLabelSize = ImGui.CalcTextSize(pillLabel);
var pillTextPos = new Vector2(
pillMin.X + ((pillMax.X - pillMin.X) - pillLabelSize.X) * 0.5f,
@@ -353,7 +361,12 @@ internal sealed class LivePreviewPanel : IDisposable
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
var pillY = origin.Y + (height - pillH) * 0.5f;
ReadOnlySpan<string> slots = ["Say", "open", "3 tabs"];
ReadOnlySpan<string> slots =
[
ChatType.Say.Name(),
HellionStrings.Settings_Preview_StatusOpen,
string.Format(HellionStrings.StatusBar_Tabs_Other, 3),
];
ReadOnlySpan<uint> dots =
[
theme.Colors.StatusSuccess,
@@ -9,22 +9,25 @@ namespace HellionChat.Ui.Components.Settings;
internal sealed class ThemePicker
{
private static readonly (string Category, string[] Slugs, bool DefaultExpanded)[] CategoryMap =
{
(
"Hellion Brand",
new[] { "hellion-arctic", "hellion-spectrum", "forge-merchantman" },
true
),
(
HellionStrings.Settings_Theme_Category_Cool,
new[] { "night-blue", "event-horizon", "indigo-violet", "crystal-nocturne" },
false
),
(HellionStrings.Settings_Theme_Category_Natural, new[] { "mint-grove" }, false),
(HellionStrings.Settings_Theme_Category_Classic, new[] { "chat2-classic" }, false),
(HellionStrings.Settings_Theme_Category_Retro, new[] { "synthwave-sunset" }, false),
};
// Built per call, not once. As a static readonly array the category names
// froze at whatever language the plugin started in -- a runtime switch
// relabelled the whole window except these five.
private static (string Category, string[] Slugs, bool DefaultExpanded)[] CategoryMap =>
[
(
"Hellion Brand",
new[] { "hellion-arctic", "hellion-spectrum", "forge-merchantman" },
true
),
(
HellionStrings.Settings_Theme_Category_Cool,
new[] { "night-blue", "event-horizon", "indigo-violet", "crystal-nocturne" },
false
),
(HellionStrings.Settings_Theme_Category_Natural, new[] { "mint-grove" }, false),
(HellionStrings.Settings_Theme_Category_Classic, new[] { "chat2-classic" }, false),
(HellionStrings.Settings_Theme_Category_Retro, new[] { "synthwave-sunset" }, false),
];
// T2 ThemePickerCategoryStep diffs this against ThemeRegistry.BuiltinSlugs
// to enforce coverage. Kept on the static map so the test does not pierce instance state.
+14 -4
View File
@@ -56,9 +56,16 @@ internal sealed class StatusBar
{
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")}";
? 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}";
}
@@ -66,7 +73,10 @@ internal sealed class StatusBar
{
if (count <= 0)
return string.Empty;
return $"{count} {(count == 1 ? "tell" : "tells")}";
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