feat(honorific): wire title gate + colour + truncation, restore preview glyphs

This commit is contained in:
2026-06-15 14:26:22 +02:00
parent 8db2dee38c
commit fe766285ad
6 changed files with 107 additions and 27 deletions
@@ -5,11 +5,10 @@ namespace HellionChat.Integrations;
// Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll
// so HellionChat loads cleanly when Honorific is absent.
//
// Only Glow is rendered. Color3, GradientColourSet and GradientAnimationStyle
// are parsed but unused — the animated gradient lives entirely inside Honorific
// and is not exposed over IPC, so reproducing it here would mean shipping our
// own copy of Honorific's colour palette. The fields stay in the DTO so the
// JSON roundtrip remains lossless.
// Color is rendered in the header title slot (HonorificHeader). Glow, Color3,
// GradientColourSet and GradientAnimationStyle are parsed but not rendered —
// the animated gradient lives inside Honorific and is not exposed over IPC.
// The fields stay in the DTO so the JSON roundtrip remains lossless.
internal sealed record HonorificTitleData(
string? Title,
bool IsPrefix,
+2 -1
View File
@@ -166,7 +166,8 @@ internal static class PluginHostFactory
));
services.AddSingleton(sp => new Ui.Components.Settings.LivePreviewPanel(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
sp.GetRequiredService<FontManager>()
));
services.AddSingleton(sp => new Ui.Components.Settings.ThemeImportExportRow(
sp.GetRequiredService<ThemeRegistry>(),
+41 -4
View File
@@ -15,6 +15,12 @@ internal sealed class HonorificHeader
{
public const float Height = 30f;
// SelfTest observables — set on the real Draw path so a headless step can
// assert the gate/colour/truncation outcome instead of re-implementing it.
internal bool LastTitleRendered { get; private set; }
internal uint LastTitleColorAbgr { get; private set; }
internal string? LastRenderedTitle { get; private set; }
private readonly HonorificService _honorific;
private readonly FontManager _fonts;
private readonly ThemeRegistry _themes;
@@ -33,8 +39,15 @@ internal sealed class HonorificHeader
_resolver = resolver;
}
// Same singleton the AboutTab integrations section uses; lets a SelfTest
// drive the gate branches via HonorificService.TestOnly_SetState.
internal HonorificService GetServiceForSelfTest() => _honorific;
public void Draw(float maxWidth)
{
LastTitleRendered = false;
LastRenderedTitle = null;
// First-frame guard: components must not lay out before the atlas
// is finished or text metrics collapse into placeholder widths.
if (!_fonts.FontsReady)
@@ -58,11 +71,35 @@ internal sealed class HonorificHeader
dl.AddText(origin + new Vector2(0f, 8f), crownColor, crownGlyph);
}
var title = _honorific.IsAvailable ? _honorific.CurrentTitle?.Title : null;
if (!string.IsNullOrWhiteSpace(title))
// Gate the bracketed title through the 1.5.6 contract (toggle, IPC
// availability, IsOriginal, empty-title) — the crown above stays
// unconditional as the permanent brand anchor. NOTE divergence from
// 1.5.6: there a failed gate hid the whole slot incl. crown; here the
// crown persists by design.
if (
HonorificService.ShouldRenderSlot(
Plugin.Config.ShowHonorificTitleInHeader,
_honorific.IsAvailable,
_honorific.CurrentTitle
)
)
{
var titleColor = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
dl.AddText(origin + new Vector2(crownWidth + 6f, 8f), titleColor, $"«{title}»");
var current = _honorific.CurrentTitle!;
var titleColor = HonorificTitleColor.ResolveTitleAbgr(current.Color, theme);
LastTitleColorAbgr = titleColor;
// Budget the title against the row width. CalcTextSize inside
// TruncateToFitWidth measures the *Regular* font, so this must run
// OUTSIDE the FontAwesome.Push block above (crownWidth was measured
// inside it, which is correct).
var maxTitleWidth = maxWidth - crownWidth - 6f - 8f;
if (maxTitleWidth > 0f)
{
var rendered = StringUtil.TruncateToFitWidth($"«{current.Title}»", maxTitleWidth);
LastRenderedTitle = rendered;
dl.AddText(origin + new Vector2(crownWidth + 6f, 8f), titleColor, rendered);
LastTitleRendered = true;
}
}
// Reserve the row height even when no title rendered so the layout
@@ -0,0 +1,21 @@
using System.Numerics;
using HellionChat.Themes;
using HellionChat.Util;
namespace HellionChat.Ui.Components;
// Resolves the bracketed-title colour for the Honorific header, shared by the
// real header (HonorificHeader) and the settings theme preview (LivePreviewPanel)
// so the fallback never drifts between them. A title colour supplied by Honorific
// (0..1 normalised RGB over IPC) renders as-is; absent colour falls back to the
// theme's primary text. The Vector4ToRgba path clamps each component to [0,1] so
// an out-of-range value from the JSON IPC payload cannot wrap the byte cast.
internal static class HonorificTitleColor
{
internal static uint ResolveTitleAbgr(Vector3? color, Theme theme)
{
return color is { } c
? ColourUtil.RgbaToAbgr(ColourUtil.Vector4ToRgba(new Vector4(c, 1f)))
: ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
}
}
@@ -1,6 +1,7 @@
using System.Numerics;
using System.Threading;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
@@ -21,21 +22,21 @@ internal sealed class LivePreviewPanel : IDisposable
private const string MockTell = "Tell → Player: Hey, want to party?";
private const string MockFc = "FC: Welcome aboard.";
// FontAwesome is intentionally not pulled in — crown/cog render as Unicode
// glyphs in the default font so this panel stays DI-light (Step 2 scope).
private const string CrownGlyph = "♛";
private const string CogGlyph = "⚙";
// 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)
public LivePreviewPanel(ThemeRegistry themes, TokenResolver resolver, FontManager fonts)
{
_themes = themes;
_resolver = resolver;
_fonts = fonts;
_themes.OnEditingBufferChanged += OnBufferChanged;
Interlocked.Increment(ref InstanceCount);
}
@@ -124,7 +125,7 @@ internal sealed class LivePreviewPanel : IDisposable
ImGui.Dummy(new Vector2(width, height));
}
private static void DrawHonorificHeader(Theme theme)
private void DrawHonorificHeader(Theme theme)
{
const float height = 32f;
var draw = ImGui.GetWindowDrawList();
@@ -139,15 +140,30 @@ internal sealed class LivePreviewPanel : IDisposable
);
var crownAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Identity);
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
// 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 = "«Champion» Preview";
var crownSize = ImGui.CalcTextSize(CrownGlyph);
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 = crownSize.X + 4f + titleSize.X;
var totalWidth = crownWidth + 4f + titleSize.X;
var startX = origin.X + (width - totalWidth) * 0.5f;
var y = origin.Y + (height - titleSize.Y) * 0.5f;
draw.AddText(new Vector2(startX, y), crownAbgr, CrownGlyph);
draw.AddText(new Vector2(startX + crownSize.X + 4f, y), textAbgr, title);
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));
}
@@ -240,7 +256,7 @@ internal sealed class LivePreviewPanel : IDisposable
ImGui.Dummy(new Vector2(totalWidth, MiddleBandHeight));
}
private static void DrawInputBar(Theme theme)
private void DrawInputBar(Theme theme)
{
const float height = 24f;
const float pillWidth = 50f;
@@ -268,9 +284,16 @@ internal sealed class LivePreviewPanel : IDisposable
var phPos = new Vector2(pillMax.X + 6f, origin.Y + (height - phSize.Y) * 0.5f);
draw.AddText(phPos, ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), placeholder);
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);
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));
}
+1 -2
View File
@@ -34,8 +34,7 @@ internal static class StringUtil
// Returns the text unchanged when it already fits the width budget,
// otherwise the longest prefix plus a horizontal-ellipsis character that
// still fits. Used by the chat header Honorific title slot and reused by
// the chat-line truncation path in later cycles.
// still fits. Used by the HonorificHeader title slot (HonorificHeader.Draw).
public static string TruncateToFitWidth(string text, float maxWidth)
{
if (ImGui.CalcTextSize(text).X <= maxWidth)