diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs new file mode 100644 index 0000000..53c75df --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -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; + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs new file mode 100644 index 0000000..3e6a302 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs @@ -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); + } +} diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 3d51571..a3ebd03 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -23,6 +23,8 @@ internal sealed class WidgetGalleryWindow : Window private readonly WidgetPalette _palette; private int _badgeCount = 3; + private Tab? _headerSample; + private int _headerMode; private bool _rowActive = true; private bool _toggleA = true; private bool _toggleB; @@ -54,6 +56,40 @@ internal sealed class WidgetGalleryWindow : Window DrawPillSection(c); DrawIconButtonSection(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)