feat(chat): a header that says which channel you are in
Small caps with wide tracking, not a smaller size. The mockup asks for one pixel below body text, and one pixel would have cost a whole additional font handle at full glyph range -- tab names are free user input and can be CJK. Tracking reads the same in every palette and costs nothing, which is the same argument that settled the section headings in the settings window. Only the world and the clock use the meta face. Its glyph range is ASCII plus a middle dot, so anything that gets translated has to stay on the body face. Two things it will not do. It drops the tab name where one is already on screen: a pop-out with its title bar on carries the name in the title, and the top-tab strip carries it too. Repeating it one line below is the exact defect that got an earlier header row removed, and the comment left behind at that removal is what made this rule. And it disappears entirely below a minimum message-area height -- the window minimum is 260px, already shared with the honorific header, the input row and the status bar, and all of it scales together. A header that leaves two readable lines is worse than no header. Both decisions are arithmetic and sit in ChannelHeaderLayout with tests. The gallery gets an entry despite the header needing a tab and the font handles, because that window exists precisely because pieces once shipped without a call site.
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using Dalamud.Bindings.ImGui;
|
||||||
|
using Dalamud.Interface;
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// The tab name draws in the body face for the same reason. Only the world and the
|
||||||
|
// clock use the meta face, whose glyph range is ASCII plus a middle dot.
|
||||||
|
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);
|
||||||
|
|
||||||
|
internal static void Draw(Tab tab, ChannelHeaderMode mode, FontManager fonts, string detail)
|
||||||
|
{
|
||||||
|
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 name = tab.Name.ToUpperInvariant();
|
||||||
|
var track = TrackRaw * scale;
|
||||||
|
var detailTrack = DetailTrackRaw * scale;
|
||||||
|
|
||||||
|
// Measure before deciding, the way the status bar does. Icon and gap are
|
||||||
|
// part of the name run because they are dropped together with it.
|
||||||
|
float nameRun;
|
||||||
|
using (fonts.RegularFont!.Push())
|
||||||
|
nameRun = DrawListExtensions.MeasureTrackedText(name, track);
|
||||||
|
|
||||||
|
var iconWidth = MeasureIcon(fonts, Components.Sidebar.ResolveTabIcon(tab));
|
||||||
|
nameRun += iconWidth + IconGapRaw * scale;
|
||||||
|
|
||||||
|
float detailRun;
|
||||||
|
using (fonts.MetaFont!.Push())
|
||||||
|
detailRun = DrawListExtensions.MeasureTrackedText(detail, detailTrack);
|
||||||
|
|
||||||
|
var inset = InsetRaw * scale;
|
||||||
|
var plan = ChannelHeaderLayout.Plan(
|
||||||
|
mode,
|
||||||
|
width - inset * 2f,
|
||||||
|
ImGui.GetContentRegionAvail().Y - height,
|
||||||
|
nameRun,
|
||||||
|
detailRun
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!plan.ShowHeader)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dl = ImGui.GetWindowDrawList();
|
||||||
|
var bottomRight = origin + new Vector2(width, height);
|
||||||
|
dl.AddRectFilled(origin, bottomRight, ColourUtil.RgbaToAbgr(surface));
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var accent = ColourUtil.RgbaToAbgr(
|
||||||
|
ColourUtil.EnsureContrast(theme.Colors.Accent, surface, 4.5f)
|
||||||
|
);
|
||||||
|
var x = origin.X + inset;
|
||||||
|
|
||||||
|
using (fonts.FontAwesome.Push())
|
||||||
|
{
|
||||||
|
var glyph = Components.Sidebar.ResolveTabIcon(tab).ToIconString();
|
||||||
|
dl.AddText(new Vector2(x, textY), accent, glyph);
|
||||||
|
x += ImGui.CalcTextSize(glyph).X + IconGapRaw * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (fonts.RegularFont.Push())
|
||||||
|
dl.DrawTrackedText(new Vector2(x, textY), name, accent, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.ShowDetail)
|
||||||
|
{
|
||||||
|
var muted = ColourUtil.RgbaToAbgr(
|
||||||
|
ColourUtil.EnsureContrast(theme.Colors.TextMuted, surface, 4.5f)
|
||||||
|
);
|
||||||
|
|
||||||
|
// The meta face is smaller, so it would hang from the top edge
|
||||||
|
// without the drop -- ImGui aligns by top, not by baseline.
|
||||||
|
float bodyAscent;
|
||||||
|
using (fonts.RegularFont.Push())
|
||||||
|
bodyAscent = ImGui.GetFont().Ascent;
|
||||||
|
|
||||||
|
float metaAscent;
|
||||||
|
using (fonts.MetaFont.Push())
|
||||||
|
metaAscent = ImGui.GetFont().Ascent;
|
||||||
|
|
||||||
|
var drop = BaselineMath.OffsetFor(bodyAscent, metaAscent, scale);
|
||||||
|
|
||||||
|
using (fonts.MetaFont.Push())
|
||||||
|
dl.DrawTrackedText(
|
||||||
|
new Vector2(bottomRight.X - inset - detailRun, textY + drop),
|
||||||
|
detail,
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float MeasureIcon(FontManager fonts, FontAwesomeIcon icon)
|
||||||
|
{
|
||||||
|
using (fonts.FontAwesome.Push())
|
||||||
|
return ImGui.CalcTextSize(icon.ToIconString()).X;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
namespace HellionChat.Ui.StyleEngine.Widgets;
|
||||||
|
|
||||||
|
internal enum ChannelHeaderMode
|
||||||
|
{
|
||||||
|
// Sidebar layout: nothing else on screen carries the tab name.
|
||||||
|
Full,
|
||||||
|
|
||||||
|
// Top-tab layout, and pop-outs with their title bar on. The name is already
|
||||||
|
// one line up; repeating it there is the defect that got an earlier header
|
||||||
|
// row removed.
|
||||||
|
DetailOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct ChannelHeaderPlan(bool ShowHeader, bool ShowName, bool ShowDetail);
|
||||||
|
|
||||||
|
// TEST-MIRROR: Ui/ChannelHeaderLayoutTests.cs
|
||||||
|
//
|
||||||
|
// Two decisions, both arithmetic, both kept out of the draw call.
|
||||||
|
//
|
||||||
|
// Horizontally this follows the status bar: measure each part, then leave out
|
||||||
|
// what will not fit rather than letting it run off the edge. The name has
|
||||||
|
// priority over the trailing detail -- it is the reason the header exists.
|
||||||
|
//
|
||||||
|
// Vertically it can decide to disappear entirely, which the status bar never
|
||||||
|
// does. The window minimum is 260px tall and already shared with the honorific
|
||||||
|
// header, the input row and the status bar, and all of it scales together. A
|
||||||
|
// header that leaves two readable lines of chat is worse than no header.
|
||||||
|
internal static class ChannelHeaderLayout
|
||||||
|
{
|
||||||
|
// Below this the message area stops being a conversation and starts being a
|
||||||
|
// peephole. Four body lines at a typical scale.
|
||||||
|
internal const float MinMessageAreaHeight = 90f;
|
||||||
|
|
||||||
|
internal static ChannelHeaderPlan Plan(
|
||||||
|
ChannelHeaderMode mode,
|
||||||
|
float availableWidth,
|
||||||
|
float availableHeight,
|
||||||
|
float nameWidth,
|
||||||
|
float detailWidth
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (availableHeight < MinMessageAreaHeight)
|
||||||
|
return new ChannelHeaderPlan(false, false, false);
|
||||||
|
|
||||||
|
var showName = mode is ChannelHeaderMode.Full;
|
||||||
|
var used = showName ? nameWidth : 0f;
|
||||||
|
var showDetail = used + detailWidth <= availableWidth;
|
||||||
|
|
||||||
|
return new ChannelHeaderPlan(true, showName, showDetail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ internal sealed class WidgetGalleryWindow : Window
|
|||||||
private readonly WidgetPalette _palette;
|
private readonly WidgetPalette _palette;
|
||||||
|
|
||||||
private int _badgeCount = 3;
|
private int _badgeCount = 3;
|
||||||
|
private Tab? _headerSample;
|
||||||
|
private int _headerMode;
|
||||||
private bool _rowActive = true;
|
private bool _rowActive = true;
|
||||||
private bool _toggleA = true;
|
private bool _toggleA = true;
|
||||||
private bool _toggleB;
|
private bool _toggleB;
|
||||||
@@ -54,6 +56,40 @@ internal sealed class WidgetGalleryWindow : Window
|
|||||||
DrawPillSection(c);
|
DrawPillSection(c);
|
||||||
DrawIconButtonSection(c);
|
DrawIconButtonSection(c);
|
||||||
DrawDividerSection(c);
|
DrawDividerSection(c);
|
||||||
|
DrawChannelHeaderSection(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlike every other entry here the header is not a stateless primitive -- it
|
||||||
|
// needs a tab and the font handles. It is in the gallery anyway: this window
|
||||||
|
// exists because three style-engine pieces once shipped with no call site at
|
||||||
|
// all, and a header nobody can look at outside a live chat is exactly how
|
||||||
|
// that happens again.
|
||||||
|
private void DrawChannelHeaderSection(ThemeColors c)
|
||||||
|
{
|
||||||
|
ImGui.TextUnformatted("ChannelHeader");
|
||||||
|
|
||||||
|
var fonts = _plugin.FontManager;
|
||||||
|
if (fonts is null || !fonts.FontsReady)
|
||||||
|
{
|
||||||
|
ImGui.TextDisabled("fonts not ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_headerSample ??= new Tab { Name = "General", Icon = "comment" };
|
||||||
|
|
||||||
|
ImGui.RadioButton("full##hdr", ref _headerMode, 0);
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.RadioButton("detail only##hdr", ref _headerMode, 1);
|
||||||
|
|
||||||
|
ImGui.SliderFloat("sender weight##hdr", ref FontManager.SenderWeight, 1.0f, 1.6f, "%.2f");
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("apply##hdr-weight"))
|
||||||
|
fonts.RebuildDelegateFonts();
|
||||||
|
|
||||||
|
var mode = _headerMode == 0 ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly;
|
||||||
|
ChannelHeader.Draw(_headerSample, mode, fonts, "Ravana · 14:32");
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawRowSection(ThemeColors c)
|
private void DrawRowSection(ThemeColors c)
|
||||||
|
|||||||
Reference in New Issue
Block a user