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.
420 lines
16 KiB
C#
420 lines
16 KiB
C#
using System.Numerics;
|
|
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;
|
|
using HellionChat.Util;
|
|
|
|
namespace HellionChat.Ui.Components.Settings;
|
|
|
|
internal sealed class LivePreviewPanel : IDisposable
|
|
{
|
|
// The preview reads the same tokens the real chrome does. Copying their lerp
|
|
// formulas here is how it drifted out of sync in the first place.
|
|
private static readonly StyleEngine.TokenResolver Tokens = new();
|
|
|
|
private static uint Abgr(StyleEngine.Token token, ThemeColors colors) =>
|
|
ColourUtil.RgbaToAbgr(Tokens.Resolve(token, colors));
|
|
|
|
// Static counter for S5 reload-stress verification: after 10 reloads the
|
|
// counter must read 0 (plugin disabled) or 1 (plugin enabled). Anything
|
|
// higher signals a Dispose skip and a subscriber leak against ThemeRegistry.
|
|
internal static int InstanceCount;
|
|
|
|
// Plan-mandated mock strings — international tester-ready, do not localise.
|
|
private const string MockSystem = "System: Connection established";
|
|
private const string MockSay = "Say: Hello, world!";
|
|
private const string MockTell = "Tell → Player: Hey, want to party?";
|
|
private const string MockFc = "FC: Welcome aboard.";
|
|
|
|
// Crown/cog render via the FontAwesome font (FontManager) so the preview
|
|
// matches the real header glyphs; the bundled text font has no crown glyph.
|
|
|
|
private const float MiddleBandHeight = 220f;
|
|
private const float SidebarWidth = 70f;
|
|
|
|
private readonly ThemeRegistry _themes;
|
|
private readonly TokenResolver _resolver;
|
|
private readonly FontManager _fonts;
|
|
|
|
public LivePreviewPanel(ThemeRegistry themes, TokenResolver resolver, FontManager fonts)
|
|
{
|
|
_themes = themes;
|
|
_resolver = resolver;
|
|
_fonts = fonts;
|
|
_themes.OnEditingBufferChanged += OnBufferChanged;
|
|
Interlocked.Increment(ref InstanceCount);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_themes.OnEditingBufferChanged -= OnBufferChanged;
|
|
Interlocked.Decrement(ref InstanceCount);
|
|
}
|
|
|
|
private void OnBufferChanged()
|
|
{
|
|
// Visual repaint already runs per frame via Draw(); hook reserved for
|
|
// future telemetry or invalidation. Keep empty in v1.7.0.
|
|
}
|
|
|
|
public void Draw()
|
|
{
|
|
using var child = ImRaii.Child("##settings-live-preview", new Vector2(280, 0), true);
|
|
if (!child.Success)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var theme = _themes.EditingThemeBuffer ?? _themes.Active;
|
|
|
|
DrawBrandBar(theme);
|
|
DrawHonorificHeader(theme);
|
|
// Sidebar paints the left strip without advancing the cursor; the
|
|
// MessageList paints the right strip and reserves the full band.
|
|
DrawSidebar(theme);
|
|
DrawMessageList(theme);
|
|
DrawInputBar(theme);
|
|
DrawStatusBar(theme);
|
|
}
|
|
|
|
private static void DrawBrandBar(Theme theme)
|
|
{
|
|
const float height = 24f;
|
|
var draw = ImGui.GetWindowDrawList();
|
|
var origin = ImGui.GetCursorScreenPos();
|
|
var width = ImGui.GetContentRegionAvail().X;
|
|
var max = new Vector2(origin.X + width, origin.Y + height);
|
|
|
|
// Horizontal gradient split into three rects so all four primary
|
|
// slots (PrimaryDark/Primary/PrimaryLight/PrimaryGlow) drive the bar.
|
|
var pdAbgr = ColourUtil.RgbaToAbgr(theme.Colors.PrimaryDark);
|
|
var pAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Primary);
|
|
var plAbgr = ColourUtil.RgbaToAbgr(theme.Colors.PrimaryLight);
|
|
var pgAbgr = ColourUtil.RgbaToAbgr(theme.Colors.PrimaryGlow);
|
|
|
|
var third = width / 3f;
|
|
draw.AddRectFilledMultiColor(
|
|
origin,
|
|
new Vector2(origin.X + third, max.Y),
|
|
pdAbgr,
|
|
pAbgr,
|
|
pAbgr,
|
|
pdAbgr
|
|
);
|
|
draw.AddRectFilledMultiColor(
|
|
new Vector2(origin.X + third, origin.Y),
|
|
new Vector2(origin.X + 2 * third, max.Y),
|
|
pAbgr,
|
|
plAbgr,
|
|
plAbgr,
|
|
pAbgr
|
|
);
|
|
draw.AddRectFilledMultiColor(
|
|
new Vector2(origin.X + 2 * third, origin.Y),
|
|
max,
|
|
plAbgr,
|
|
pgAbgr,
|
|
pgAbgr,
|
|
plAbgr
|
|
);
|
|
|
|
var label = "HellionChat";
|
|
var textSize = ImGui.CalcTextSize(label);
|
|
var textPos = new Vector2(
|
|
origin.X + (width - textSize.X) * 0.5f,
|
|
origin.Y + (height - textSize.Y) * 0.5f
|
|
);
|
|
draw.AddText(textPos, ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), label);
|
|
|
|
ImGui.Dummy(new Vector2(width, height));
|
|
}
|
|
|
|
private void DrawHonorificHeader(Theme theme)
|
|
{
|
|
const float height = 32f;
|
|
var draw = ImGui.GetWindowDrawList();
|
|
var origin = ImGui.GetCursorScreenPos();
|
|
var width = ImGui.GetContentRegionAvail().X;
|
|
|
|
draw.AddLine(
|
|
origin,
|
|
new Vector2(origin.X + width, origin.Y),
|
|
ColourUtil.RgbaToAbgr(theme.Colors.Border),
|
|
1f
|
|
);
|
|
|
|
var crownAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Identity);
|
|
// Shared fallback path with the real header (Weiche 3). The mock has no
|
|
// Honorific colour, so this resolves to TextPrimary today — visually
|
|
// 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 = HellionStrings.Settings_Preview_TitleMock;
|
|
var crownGlyph = FontAwesomeIcon.Crown.ToIconString();
|
|
|
|
// Crown is a FontAwesome glyph (matches the real header); measure + draw
|
|
// it inside the FontAwesome push, the title stays in the default font.
|
|
float crownWidth;
|
|
using (_fonts.FontAwesome.Push())
|
|
{
|
|
crownWidth = ImGui.CalcTextSize(crownGlyph).X;
|
|
}
|
|
var titleSize = ImGui.CalcTextSize(title);
|
|
var totalWidth = crownWidth + 4f + titleSize.X;
|
|
var startX = origin.X + (width - totalWidth) * 0.5f;
|
|
var y = origin.Y + (height - titleSize.Y) * 0.5f;
|
|
using (_fonts.FontAwesome.Push())
|
|
{
|
|
draw.AddText(new Vector2(startX, y), crownAbgr, crownGlyph);
|
|
}
|
|
draw.AddText(new Vector2(startX + crownWidth + 4f, y), textAbgr, title);
|
|
|
|
ImGui.Dummy(new Vector2(width, height));
|
|
}
|
|
|
|
private static void DrawSidebar(Theme theme)
|
|
{
|
|
// Paints the left strip of the horizontal middle band. MessageList
|
|
// consumes the cursor reservation for the full band height.
|
|
var draw = ImGui.GetWindowDrawList();
|
|
var origin = ImGui.GetCursorScreenPos();
|
|
var rowHeight = MiddleBandHeight / 3f;
|
|
var surface = ColourUtil.RgbaToAbgr(theme.Colors.Surface);
|
|
var surfaceHover = ColourUtil.RgbaToAbgr(theme.Colors.SurfaceHover);
|
|
|
|
var surfaceActive = Abgr(StyleEngine.Token.SurfaceActive, theme.Colors);
|
|
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
|
|
var primaryAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Primary);
|
|
var accentAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Accent);
|
|
|
|
// Row 0 is the active one and carries BOTH the raised surface and the
|
|
// accent bar, the way the real sidebar draws it. Until v1.10.0 those two
|
|
// 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);
|
|
// 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);
|
|
var rowMax = new Vector2(origin.X + SidebarWidth, rowMin.Y + rowHeight);
|
|
var isActive = i == 0;
|
|
|
|
draw.AddRectFilled(rowMin, rowMax, isActive ? surfaceActive : surface);
|
|
|
|
if (isActive)
|
|
draw.AddRectFilled(rowMin, new Vector2(rowMin.X + 2f, rowMax.Y), primaryAbgr);
|
|
|
|
draw.AddLine(
|
|
new Vector2(rowMin.X, rowMax.Y - 1f),
|
|
new Vector2(rowMax.X, rowMax.Y - 1f),
|
|
borderAbgr,
|
|
1f
|
|
);
|
|
|
|
var labelSize = ImGui.CalcTextSize(labels[i]);
|
|
var textPos = new Vector2(rowMin.X + 6f, rowMin.Y + (rowHeight - labelSize.Y) * 0.5f);
|
|
draw.AddText(textPos, textAbgr, labels[i]);
|
|
|
|
if (i == 1)
|
|
{
|
|
// Unread marker: a rounded count badge in Accent, not a square.
|
|
var badgeH = MathF.Min(rowHeight - 4f, 14f);
|
|
var badgeW = badgeH * 1.4f;
|
|
var badgeMin = new Vector2(
|
|
rowMax.X - badgeW - 4f,
|
|
rowMin.Y + (rowHeight - badgeH) * 0.5f
|
|
);
|
|
var badgeMax = badgeMin + new Vector2(badgeW, badgeH);
|
|
draw.AddRectFilled(
|
|
badgeMin,
|
|
badgeMax,
|
|
(accentAbgr & 0x00FFFFFFu) | 0x38000000u,
|
|
badgeH * 0.5f
|
|
);
|
|
draw.AddRect(badgeMin, badgeMax, accentAbgr, badgeH * 0.5f);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void DrawMessageList(Theme theme)
|
|
{
|
|
var draw = ImGui.GetWindowDrawList();
|
|
var origin = ImGui.GetCursorScreenPos();
|
|
var totalWidth = ImGui.GetContentRegionAvail().X;
|
|
var listOrigin = new Vector2(origin.X + SidebarWidth, origin.Y);
|
|
var listWidth = totalWidth - SidebarWidth;
|
|
var max = new Vector2(listOrigin.X + listWidth, listOrigin.Y + MiddleBandHeight);
|
|
|
|
draw.AddRectFilled(listOrigin, max, ColourUtil.RgbaToAbgr(theme.Colors.WindowBg));
|
|
|
|
var padMin = new Vector2(listOrigin.X + 2f, max.Y - 6f);
|
|
draw.AddRectFilled(
|
|
padMin,
|
|
new Vector2(max.X - 2f, max.Y - 2f),
|
|
ColourUtil.RgbaToAbgr(theme.Colors.FrameBg)
|
|
);
|
|
|
|
ReadOnlySpan<(string Text, uint Rgba)> rows =
|
|
[
|
|
(MockSystem, theme.Colors.TextMuted),
|
|
(MockSay, theme.Colors.TextPrimary),
|
|
(MockTell, theme.Colors.StatusInfo),
|
|
(MockFc, theme.Colors.StatusSuccess),
|
|
];
|
|
|
|
var lineHeight = ImGui.GetTextLineHeightWithSpacing();
|
|
for (var i = 0; i < rows.Length; i++)
|
|
{
|
|
var pos = new Vector2(listOrigin.X + 6f, listOrigin.Y + 6f + i * lineHeight);
|
|
draw.AddText(pos, ColourUtil.RgbaToAbgr(rows[i].Rgba), rows[i].Text);
|
|
}
|
|
|
|
draw.AddLine(
|
|
new Vector2(origin.X, max.Y - 1f),
|
|
new Vector2(origin.X + totalWidth, max.Y - 1f),
|
|
ColourUtil.RgbaToAbgr(theme.Colors.Border),
|
|
1f
|
|
);
|
|
|
|
// Reserve the full middle-band height (sidebar overlays into the
|
|
// same vertical span and does not advance the cursor itself).
|
|
ImGui.Dummy(new Vector2(totalWidth, MiddleBandHeight));
|
|
}
|
|
|
|
private void DrawInputBar(Theme theme)
|
|
{
|
|
const float height = 24f;
|
|
const float pillWidth = 50f;
|
|
var draw = ImGui.GetWindowDrawList();
|
|
var origin = ImGui.GetCursorScreenPos();
|
|
var width = ImGui.GetContentRegionAvail().X;
|
|
var max = new Vector2(origin.X + width, origin.Y + height);
|
|
|
|
draw.AddRectFilled(origin, max, ColourUtil.RgbaToAbgr(theme.Colors.FrameBg));
|
|
|
|
var pillMin = new Vector2(origin.X + 4f, origin.Y + 4f);
|
|
var pillMax = new Vector2(origin.X + pillWidth, max.Y - 4f);
|
|
draw.AddRectFilled(pillMin, pillMax, ColourUtil.RgbaToAbgr(theme.Colors.Primary), 6f);
|
|
|
|
var pillLabel = ChatType.Say.Name();
|
|
var pillLabelSize = ImGui.CalcTextSize(pillLabel);
|
|
var pillTextPos = new Vector2(
|
|
pillMin.X + ((pillMax.X - pillMin.X) - pillLabelSize.X) * 0.5f,
|
|
pillMin.Y + ((pillMax.Y - pillMin.Y) - pillLabelSize.Y) * 0.5f
|
|
);
|
|
draw.AddText(pillTextPos, ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), pillLabel);
|
|
|
|
var placeholder = HellionStrings.Settings_Preview_TypeAMessage;
|
|
var phSize = ImGui.CalcTextSize(placeholder);
|
|
var phPos = new Vector2(pillMax.X + 6f, origin.Y + (height - phSize.Y) * 0.5f);
|
|
draw.AddText(phPos, ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), placeholder);
|
|
|
|
var cogGlyph = FontAwesomeIcon.Cog.ToIconString();
|
|
using (_fonts.FontAwesome.Push())
|
|
{
|
|
var cogSize = ImGui.CalcTextSize(cogGlyph);
|
|
var cogPos = new Vector2(
|
|
max.X - cogSize.X - 6f,
|
|
origin.Y + (height - cogSize.Y) * 0.5f
|
|
);
|
|
draw.AddText(cogPos, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted), cogGlyph);
|
|
}
|
|
|
|
ImGui.Dummy(new Vector2(width, height));
|
|
}
|
|
|
|
// Mirrors the real status bar: a top rule, then pill-shaped slots, the last
|
|
// one right-aligned. The status colours ride along as slot dots so a theme
|
|
// still shows what it does to them.
|
|
private static void DrawStatusBar(Theme theme)
|
|
{
|
|
const float height = 24f;
|
|
const float pillH = 18f;
|
|
const float padX = 6f;
|
|
const float gap = 6f;
|
|
var draw = ImGui.GetWindowDrawList();
|
|
var origin = ImGui.GetCursorScreenPos();
|
|
var width = ImGui.GetContentRegionAvail().X;
|
|
var max = new Vector2(origin.X + width, origin.Y + height);
|
|
|
|
draw.AddRectFilled(origin, max, ColourUtil.RgbaToAbgr(theme.Colors.ChildBg));
|
|
draw.AddLine(
|
|
origin,
|
|
new Vector2(max.X, origin.Y),
|
|
ColourUtil.RgbaToAbgr(theme.Colors.Border),
|
|
1f
|
|
);
|
|
|
|
var fill = Abgr(StyleEngine.Token.SurfaceRaised, theme.Colors);
|
|
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
|
|
var pillY = origin.Y + (height - pillH) * 0.5f;
|
|
|
|
ReadOnlySpan<string> slots =
|
|
[
|
|
ChatType.Say.Name(),
|
|
HellionStrings.Settings_Preview_StatusOpen,
|
|
string.Format(HellionStrings.StatusBar_Tabs_Other, 3),
|
|
];
|
|
ReadOnlySpan<uint> dots =
|
|
[
|
|
theme.Colors.StatusSuccess,
|
|
theme.Colors.StatusWarning,
|
|
theme.Colors.StatusDanger,
|
|
];
|
|
|
|
var x = origin.X + padX;
|
|
for (var i = 0; i < slots.Length; i++)
|
|
{
|
|
var labelSize = ImGui.CalcTextSize(slots[i]);
|
|
var slotW = labelSize.X + padX * 2f + 10f;
|
|
var slotMin = new Vector2(x, pillY);
|
|
var slotMax = new Vector2(x + slotW, pillY + pillH);
|
|
|
|
draw.AddRectFilled(slotMin, slotMax, fill, pillH * 0.5f);
|
|
draw.AddCircleFilled(
|
|
new Vector2(x + padX + 2f, pillY + pillH * 0.5f),
|
|
2.5f,
|
|
ColourUtil.RgbaToAbgr(dots[i]),
|
|
10
|
|
);
|
|
draw.AddText(
|
|
new Vector2(x + padX + 10f, pillY + (pillH - labelSize.Y) * 0.5f),
|
|
textAbgr,
|
|
slots[i]
|
|
);
|
|
|
|
x += slotW + gap;
|
|
}
|
|
|
|
var label = "preview";
|
|
var versionSize = ImGui.CalcTextSize(label);
|
|
var versionW = versionSize.X + padX * 2f;
|
|
var versionMin = new Vector2(max.X - versionW - padX, pillY);
|
|
draw.AddRectFilled(
|
|
versionMin,
|
|
versionMin + new Vector2(versionW, pillH),
|
|
fill,
|
|
pillH * 0.5f
|
|
);
|
|
draw.AddText(
|
|
new Vector2(versionMin.X + padX, pillY + (pillH - versionSize.Y) * 0.5f),
|
|
ColourUtil.RgbaToAbgr(theme.Colors.TextMuted),
|
|
label
|
|
);
|
|
|
|
ImGui.Dummy(new Vector2(width, height));
|
|
}
|
|
}
|