Both clocks in the status bar, in the game's own LT/ST notation. Anyone agreeing on a time across regions reads them off one line instead of doing the arithmetic in their head. Server time is not computed here and must not be. Framework.GetServerTime() hands it over, so the plugin follows whatever Square Enix does with it -- a local UTC conversion would be right today and quietly wrong the day that stops holding. Umbra reads it the same way. The slot drops out on its own like every other one, and it is empty while logged out, because then there is no server to read a clock off. The header gives up its clock in exchange. With both times in the status bar it would have been the third copy of the same number on one screen, and the header's job is to answer where you are, not what time it is. Its title also moves down to the meta size. Tracked caps at body size read like a headline, and the header is meant to answer a question rather than announce one. That only works because the meta face now carries the full glyph range: it was built with an ASCII-sized one on the assumption it would only ever draw clocks and world names, and a single umlaut in a tab name would have broken it. All three delegate handles rasterise the full set now -- the honest cost of the change, and the reason the size distinction is worth having at all. Two things fell out along the way. The detail no longer needs a flag saying "draw me in the body face", because there is no glyph the meta face cannot reach. And a culture-pinning test lost its subject when the clock left, so it asserted nothing and is gone rather than repaired.
259 lines
10 KiB
C#
259 lines
10 KiB
C#
using System.Numerics;
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Interface;
|
|
using Dalamud.Interface.ManagedFontAtlas;
|
|
using HellionChat.Util;
|
|
|
|
namespace HellionChat.Ui.StyleEngine.Widgets;
|
|
|
|
// The band above the conversation: which channel you are in on the left, where
|
|
// you are and what time it is on the right.
|
|
//
|
|
// Set apart by small caps with wide tracking rather than by size. That is a
|
|
// deliberate departure from the mockup, which asks for one pixel smaller than
|
|
// body text: one pixel would cost a whole additional font handle at full glyph
|
|
// range, because tab names are free user input and can be CJK. Tracking carries
|
|
// the same weight in every palette and costs nothing.
|
|
//
|
|
// Which face draws what is not a style choice here, it is a constraint. The meta
|
|
// face has a glyph range of ASCII plus a middle dot, so only the world name and
|
|
// the clock can use it. The tab name and the translated "no world" stand-in go
|
|
// through the body face, or they come out as rows of question marks.
|
|
internal static class ChannelHeader
|
|
{
|
|
private const float InsetRaw = 14f;
|
|
private const float PadYRaw = 7f;
|
|
private const float TrackRaw = 1.8f;
|
|
private const float DetailTrackRaw = 0.9f;
|
|
private const float IconGapRaw = 8f;
|
|
|
|
// Measured, never a fixed 32px: the band has to hold a line of text, and the
|
|
// font comes from Config, which display scaling does not feed into.
|
|
internal static float Height =>
|
|
ImGui.GetTextLineHeight() + MathF.Round(PadYRaw * 2f * Metrics.Scale);
|
|
|
|
// The body face follows the same switch every other push site follows. Two
|
|
// settings decide it, and reading only one of them is how a window ends up
|
|
// half in the game font and half in the bundled one.
|
|
private static IFontHandle BodyFace(FontManager fonts) =>
|
|
Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont
|
|
? fonts.RegularFont!
|
|
: fonts.Axis;
|
|
|
|
// Same switch for the meta face. With the game font selected there is no
|
|
// stepped-down variant to fall back to, so the size distinction is simply
|
|
// dropped -- the same honest limitation the sender weight has.
|
|
private static IFontHandle MetaFace(FontManager fonts) =>
|
|
Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont ? fonts.MetaFont! : fonts.Axis;
|
|
|
|
internal static void Draw(
|
|
Tab tab,
|
|
ChannelHeaderMode mode,
|
|
FontManager fonts,
|
|
ChannelHeaderDetailParts detail,
|
|
float reservedBelow
|
|
)
|
|
{
|
|
// Every other drawing component gates on this. Without it the band is
|
|
// measured against whatever face happens to be active, and a handle that
|
|
// is not ready pushes nothing at all -- silently -- so the height, the
|
|
// drop-out rule and the baseline offset would all be wrong for those
|
|
// frames. This cycle made rebuilds more frequent, not less.
|
|
if (!fonts.FontsReady)
|
|
return;
|
|
|
|
var scale = Metrics.Scale;
|
|
var width = ImGui.GetContentRegionAvail().X;
|
|
var height = Height;
|
|
var origin = ImGui.GetCursorScreenPos();
|
|
|
|
var theme = Plugin.Instance.ThemeRegistry.Active;
|
|
var surface = theme.Colors.Surface;
|
|
var surfaceAbgr = ColourUtil.RgbaToAbgr(surface);
|
|
|
|
var body = BodyFace(fonts);
|
|
var meta = MetaFace(fonts);
|
|
|
|
var track = TrackRaw * scale;
|
|
var detailTrack = DetailTrackRaw * scale;
|
|
// Screenshot mode replaces a name that came from a conversation partner:
|
|
// AutoTellTabsService builds those as "Player@World", and drawing one in
|
|
// tracked caps above a log whose messages are anonymised would give away
|
|
// in the header exactly what the log is hiding.
|
|
//
|
|
// Same helper the sidebar, the tab strip and the pop-out title use, so
|
|
// one conversation shows the same placeholder everywhere rather than
|
|
// vanishing on one surface and staying put on three.
|
|
var namesAPartner = tab.NameCameFromPartner;
|
|
var shownName = TabDisplayName.Resolve(
|
|
tab.Name,
|
|
namesAPartner,
|
|
Plugin.Config.ScreenshotMode
|
|
);
|
|
|
|
// The tab icon for an auto-tell tab is derived from the partner and is
|
|
// stable across sessions, so it is three bits of linkable information on
|
|
// a picture meant to be shareable. The message path guards against
|
|
// exactly that by re-salting its name hashes on every plugin load.
|
|
var icon =
|
|
Plugin.Config.ScreenshotMode && namesAPartner
|
|
? Dalamud.Interface.FontAwesomeIcon.Envelope
|
|
: Components.Sidebar.ResolveTabIcon(tab);
|
|
var showName = mode is ChannelHeaderMode.Full;
|
|
|
|
// ToUpperInvariant allocates, so only where the name is actually drawn.
|
|
var name = showName ? shownName.ToUpperInvariant() : string.Empty;
|
|
|
|
Vector2 iconSize;
|
|
using (fonts.FontAwesome.Push())
|
|
iconSize = ImGui.CalcTextSize(icon.ToIconString());
|
|
|
|
var inset = InsetRaw * scale;
|
|
var iconRun = iconSize.X + IconGapRaw * scale;
|
|
|
|
var nameRun = 0f;
|
|
if (showName)
|
|
{
|
|
// Meta rather than body: tracked caps at body size read as loud as a
|
|
// headline, and the header is meant to answer a question, not
|
|
// announce one. Only possible because the meta face carries the full
|
|
// glyph range now -- tab names are free user input.
|
|
// Tab names are free user input, and an auto-tell name is
|
|
// "Firstname Lastname@World". Left alone it runs past the band and
|
|
// gets cut mid-glyph at the window edge; the drop-out rule only
|
|
// handles the two runs overlapping, not one of them overflowing.
|
|
var room = width - inset * 2f - iconRun;
|
|
using (meta.Push())
|
|
{
|
|
if (DrawListExtensions.MeasureTrackedText(name, track) > room)
|
|
name = StringUtil.TruncateToFitWidth(name, room);
|
|
|
|
nameRun = DrawListExtensions.MeasureTrackedText(name, track);
|
|
}
|
|
|
|
nameRun += iconRun;
|
|
}
|
|
|
|
float detailRun;
|
|
using (meta.Push())
|
|
detailRun = DrawListExtensions.MeasureTrackedText(detail.Where, detailTrack);
|
|
|
|
var plan = ChannelHeaderLayout.Plan(
|
|
showName ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly,
|
|
width - inset * 2f,
|
|
ImGui.GetContentRegionAvail().Y - height - reservedBelow,
|
|
nameRun,
|
|
detailRun,
|
|
scale
|
|
);
|
|
|
|
if (!plan.ShowHeader)
|
|
return;
|
|
|
|
var dl = ImGui.GetWindowDrawList();
|
|
var bottomRight = origin + new Vector2(width, height);
|
|
dl.AddRectFilled(origin, bottomRight, surfaceAbgr);
|
|
dl.AddLine(
|
|
new Vector2(origin.X, bottomRight.Y - 1f),
|
|
new Vector2(bottomRight.X, bottomRight.Y - 1f),
|
|
ColourUtil.RgbaToAbgr(theme.Colors.Border),
|
|
MathF.Max(1f, scale)
|
|
);
|
|
|
|
var textY = origin.Y + MathF.Round(PadYRaw * scale);
|
|
|
|
if (plan.ShowName)
|
|
{
|
|
// Converted first, then measured. EnsureContrast works in ABGR --
|
|
// handing it the theme's RGBA swaps red and blue on both arguments,
|
|
// so it measures a contrast that has nothing to do with what ends up
|
|
// on screen and returns a colour in the wrong order on top.
|
|
var accent = ColourUtil.EnsureContrast(
|
|
ColourUtil.RgbaToAbgr(theme.Colors.Accent),
|
|
surfaceAbgr,
|
|
4.5f
|
|
);
|
|
var x = origin.X + inset;
|
|
|
|
// Centred against the band, not aligned to the text baseline:
|
|
// FontAwesome is a fixed-width handle built at Dalamud's own size and
|
|
// does not follow Config.FontSizeV2, so the text line height would
|
|
// misplace it at any other body size. Same reasoning as the sidebar.
|
|
using (fonts.FontAwesome.Push())
|
|
dl.AddText(
|
|
new Vector2(x, origin.Y + MetricsMath.CenterY(height, iconSize.Y)),
|
|
accent,
|
|
icon.ToIconString()
|
|
);
|
|
|
|
x += iconSize.X + IconGapRaw * scale;
|
|
|
|
using (meta.Push())
|
|
dl.DrawTrackedText(
|
|
new Vector2(x, textY + DropFor(body, meta, scale)),
|
|
name,
|
|
accent,
|
|
track
|
|
);
|
|
}
|
|
|
|
if (plan.ShowDetail && detail.Where.Length > 0)
|
|
{
|
|
var muted = ColourUtil.EnsureContrast(
|
|
ColourUtil.RgbaToAbgr(theme.Colors.TextMuted),
|
|
surfaceAbgr,
|
|
4.5f
|
|
);
|
|
|
|
using (meta.Push())
|
|
dl.DrawTrackedText(
|
|
new Vector2(
|
|
bottomRight.X - inset - detailRun,
|
|
textY + DropFor(body, meta, scale)
|
|
),
|
|
detail.Where,
|
|
muted,
|
|
detailTrack
|
|
);
|
|
}
|
|
|
|
// ItemSize rather than a cursor move: it advances the cursor AND extends
|
|
// CursorMaxPos, which is what the enclosing layout measures.
|
|
ImGui.SetCursorScreenPos(origin);
|
|
ImGuiP.ItemSize(new Vector2(width, height - ImGui.GetStyle().ItemSpacing.Y));
|
|
}
|
|
|
|
internal static ChannelHeaderDetailParts CurrentDetail()
|
|
{
|
|
// IsValid guards the row reference, but the case that actually happens is
|
|
// subtler: logged out resolves to row zero, which exists and carries an
|
|
// empty name. Format treats blank as missing, which covers both.
|
|
var world = Plugin.PlayerState.HomeWorld.IsValid
|
|
? Plugin.PlayerState.HomeWorld.Value.Name.ExtractText()
|
|
: null;
|
|
|
|
return ChannelHeaderDetail.Format(
|
|
world,
|
|
Resources.HellionStrings.ChannelHeader_NotLoggedIn,
|
|
Plugin.Config.ScreenshotMode
|
|
);
|
|
}
|
|
|
|
// Zero whenever both runs use the same handle, which is the common case.
|
|
private static float DropFor(IFontHandle body, IFontHandle other, float scale)
|
|
{
|
|
if (ReferenceEquals(body, other))
|
|
return 0f;
|
|
|
|
float bodyAscent;
|
|
using (body.Push())
|
|
bodyAscent = ImGui.GetFont().Ascent;
|
|
|
|
float otherAscent;
|
|
using (other.Push())
|
|
otherAscent = ImGui.GetFont().Ascent;
|
|
|
|
return BaselineMath.OffsetFor(bodyAscent, otherAscent, scale);
|
|
}
|
|
}
|