From ced7ea0c55c3a0151b2c68e374162246168d7f8b Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 17:42:02 +0200 Subject: [PATCH] feat(style): headings by typography, surfaces by gradient Two rounds of screenshots said the same thing twice: the section headings read against blue themes and vanished against violet ones. The cause was that their only distinction from a normal row was a fill colour, and no fill works in every palette. Headings are typography now. Small caps with wide tracking, no plate, no bar, followed by a rule that fades out along its length instead of stopping dead. The weight comes from letterforms and whitespace, which look the same in every theme. 18px of air above each one does the grouping that the bar used to fake. ImGui has no letter-spacing, so DrawTrackedText renders one glyph at a time through a stack buffer. That technique is documented in Character Select+, whose Boutique style layer solves exactly this problem the same way. Row separators are off by default. A rule under every row turns a settings page into a ledger, and the hover fill already marks where a row starts and ends. Surfaces get vertical gradients, derived from a single theme tone rather than a hardcoded pair, so they follow whatever the active theme sets. Lerp rather than a brightness multiplier: these surfaces sit near black, where scaling a channel of 12 by 1.14 lands back on 13. That is what LerpTowardBlack is for. The selected segment finally uses DrawSlipPolygon, which has been sitting in the plugin unused since it was written, and its highlight is a second, shorter chamfer rather than a clipped gradient -- PushClipRect is rectangular and would have squared the cut corner straight back off. Reference note: Lightless and Umbra were read for approach only. Both are AGPL-3.0 and none of their code is here; a vertical gradient is common knowledge, their implementation of it is theirs. --- .../Ui/Components/Settings/ContentArea.cs | 19 ++- .../Ui/Components/Settings/SettingsPalette.cs | 1 - .../Ui/StyleEngine/DrawListExtensions.cs | 88 +++++++++++ .../Ui/StyleEngine/Widgets/SectionHeader.cs | 148 +++++++++--------- .../StyleEngine/Widgets/SegmentedControl.cs | 42 ++++- .../Ui/StyleEngine/Widgets/SettingRow.cs | 8 +- HellionChat/Util/ColourUtil.cs | 14 ++ 7 files changed, 233 insertions(+), 87 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/ContentArea.cs b/HellionChat/Ui/Components/Settings/ContentArea.cs index 4021c0d..ed72173 100644 --- a/HellionChat/Ui/Components/Settings/ContentArea.cs +++ b/HellionChat/Ui/Components/Settings/ContentArea.cs @@ -29,17 +29,28 @@ internal sealed class ContentArea // fills it before the scrollbar and border, and the window's own opacity // still applies on top. var colors = _themes.Active.Colors; - using var bg = ImRaii.PushColor( - ImGuiCol.ChildBg, - ColourUtil.RgbaToVector4(_resolver.Resolve(Token.SurfaceBase, colors)) - ); + // Transparent child, gradient painted underneath. ChildBg only takes a + // flat colour, and a flat pane is what made the window read as a form + // dump: nothing told the eye where the surface started. + using var bg = ImRaii.PushColor(ImGuiCol.ChildBg, 0u); using var child = ImRaii.Child("##settings-content", new Vector2(0, 0), true); if (!child.Success) { return; } + // GetWindowDrawList inside the child is the child's own list, so this + // lands behind its content without a channel split. + var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); + ImGui + .GetWindowDrawList() + .DrawVerticalGradient( + ImGui.GetWindowPos(), + ImGui.GetWindowPos() + ImGui.GetWindowSize(), + surface + ); + renderTab(activeTab); } } diff --git a/HellionChat/Ui/Components/Settings/SettingsPalette.cs b/HellionChat/Ui/Components/Settings/SettingsPalette.cs index b2eee56..c7c24da 100644 --- a/HellionChat/Ui/Components/Settings/SettingsPalette.cs +++ b/HellionChat/Ui/Components/Settings/SettingsPalette.cs @@ -47,7 +47,6 @@ internal sealed class SettingsPalette AccentAbgr = _palette.Abgr(Token.AccentPrimary, c), BorderAbgr = _palette.Abgr(Token.Border, c), HoverAbgr = _palette.Abgr(Token.SurfaceHover, c), - SurfaceAbgr = _palette.Abgr(Token.SurfaceRaised, c), }; internal SegmentedControlColors Segmented(ThemeColors c) => diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index c0824a4..cd050a2 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -54,6 +54,94 @@ internal static class DrawListExtensions ); } + // ImGui has no letter-spacing, so tracked text is drawn one glyph at a time + // with an extra gap between them. Small caps with wide tracking is what + // separates a heading from a label when both use the same font size -- and + // unlike colour, it reads the same in every theme. + // + // One char at a time through a stack buffer, so a heading costs no + // allocation per frame. + public static float DrawTrackedText( + this ImDrawListPtr dl, + Vector2 pos, + ReadOnlySpan text, + uint abgr, + float trackPx + ) + { + Span one = stackalloc char[1]; + var x = pos.X; + for (var i = 0; i < text.Length; i++) + { + one[0] = text[i]; + dl.AddText(new Vector2(x, pos.Y), abgr, one); + x += ImGui.CalcTextSize(one).X; + if (i < text.Length - 1) + x += trackPx; + } + + return x - pos.X; + } + + public static float MeasureTrackedText(ReadOnlySpan text, float trackPx) + { + Span one = stackalloc char[1]; + var w = 0f; + for (var i = 0; i < text.Length; i++) + { + one[0] = text[i]; + w += ImGui.CalcTextSize(one).X; + if (i < text.Length - 1) + w += trackPx; + } + + return w; + } + + // Vertical gradient from a single base colour, brightened at the top and + // darkened at the bottom. Deriving both ends from one tone keeps it working + // across every theme, where a hardcoded pair would only suit one of them. + // + // ImGui cannot put a gradient behind a child window -- ChildBg takes a flat + // colour -- so surfaces that want depth have to paint it themselves. + public static void DrawVerticalGradient( + this ImDrawListPtr dl, + Vector2 min, + Vector2 max, + uint baseAbgr, + float topLift = 0.14f, + float bottomDrop = 0.10f + ) + { + // Lerp, not a multiplier: these surfaces sit near black, and scaling a + // channel of 12 by 1.14 lands back on 13. + var top = ColourUtil.LerpTowardWhite(baseAbgr, topLift); + var bottom = ColourUtil.LerpTowardBlack(baseAbgr, bottomDrop); + dl.AddRectFilledMultiColor(min, max, top, top, bottom, bottom); + } + + // A rule that fades out along its length instead of stopping dead. A hard + // line boxes content in; a fading one suggests a boundary without drawing a + // wall, which is the whole difference between a heading and a header bar. + public static void DrawFadeRule( + this ImDrawListPtr dl, + Vector2 start, + float width, + uint abgr, + float thickness + ) + { + var transparent = abgr & 0x00FFFFFFu; + dl.AddRectFilledMultiColor( + start, + new Vector2(start.X + width, start.Y + thickness), + abgr, + transparent, + transparent, + abgr + ); + } + public static void DrawGlowBorder( this ImDrawListPtr dl, Vector2 min, diff --git a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs index 1189b8b..deb34d7 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs @@ -13,23 +13,27 @@ internal readonly record struct SectionHeaderColors public uint AccentAbgr { get; init; } public uint BorderAbgr { get; init; } public uint HoverAbgr { get; init; } - - // Own ground. Without it the heading floats between the rows at roughly - // their own weight and stops separating anything. - public uint SurfaceAbgr { get; init; } } internal readonly record struct SectionHeaderStyle { public SectionHeaderStyle() { } - public float PadY { get; init; } = 6f; - public float AccentBarWidth { get; init; } = 3f; - public float ChevronInset { get; init; } = 6f; + // Air above the heading, which is what actually groups the rows below it. + // A filled bar can be replaced by whitespace; whitespace cannot be replaced + // by a bar. + public float SpaceAbove { get; init; } = 18f; + public float SpaceBelow { get; init; } = 6f; + public float TrackPx { get; init; } = 1.6f; + public float ChevronGap { get; init; } = 7f; } -// Collapsible section heading. Replaces ImGui.CollapsingHeader, whose framed -// bar is the single most ImGui-looking element in the settings window. +// Collapsible section heading, drawn as typography rather than as a bar. +// +// The bar version failed a real test: it read fine against blue themes and +// disappeared against violet ones, because its only distinction from a normal +// row was a fill colour. Small caps with wide tracking carries the same weight +// in every palette, since the difference is shape, not hue. // // State lives here rather than in ImGui's per-window storage: that keys off the // label, so once the titles are localised the open/closed state would reset on @@ -44,14 +48,16 @@ internal static class SectionHeader string title, string? description, SectionHeaderColors colors, - bool defaultOpen = false, + bool defaultOpen = true, bool disabled = false, SectionHeaderStyle? styleOverride = null ) { var style = styleOverride ?? new SectionHeaderStyle(); var scale = Metrics.Scale; - var padY = style.PadY * scale; + var spaceAbove = style.SpaceAbove * scale; + var spaceBelow = style.SpaceBelow * scale; + var track = style.TrackPx * scale; if (!Open.TryGetValue(key, out var open)) { @@ -61,18 +67,19 @@ internal static class SectionHeader var origin = ImGui.GetCursorScreenPos(); var width = ImGui.GetContentRegionAvail().X; - var titleHeight = ImGui.GetTextLineHeight(); + var lineHeight = ImGui.GetTextLineHeight(); + var chevronWidth = lineHeight * 0.45f + style.ChevronGap * scale; - // The description starts after the chevron, so its wrap width is the - // remainder of the row. Measuring it matters more here than in a setting - // row: nothing clips this text, so an unreserved second line would draw - // straight over the border line and whatever the caller renders next. - var leadWidth = (style.AccentBarWidth + style.ChevronInset * 2f) * scale; - var descWrap = width - leadWidth; + var descWrap = width - chevronWidth; var descHeight = description is null ? 0f : ImGui.CalcTextSize(description, false, descWrap).Y; - var size = WidgetGeometry.SectionHeader(width, titleHeight, descHeight, padY, 1f * scale); + + // The rule sits on the baseline gap, not on its own row. + var size = new Vector2( + width, + spaceAbove + lineHeight + spaceBelow + descHeight + (description is null ? 0f : 2f) + ); ImGui.SetCursorScreenPos(origin); var clicked = ImGui.InvisibleButton($"##hellion-section-{key}", size) && !disabled; @@ -86,91 +93,88 @@ internal static class SectionHeader } // BeginDisabled only dims ImGui's own widgets, so a draw-list header - // would stay at full opacity while everything around it fades. The - // ThemePicker wraps two of its headers exactly like that. + // would stay at full opacity while everything around it fades. var alpha = disabled ? 0.5f : 1f; var dl = ImGui.GetWindowDrawList(); - var max = origin + size; + var textY = origin.Y + spaceAbove; - dl.AddRectFilled(origin, max, ColourUtil.ApplyAlpha(colors.SurfaceAbgr, alpha)); - - if (hoverAmount > 0f) - dl.AddRectFilled( - origin, - max, - ColourUtil.ApplyAlpha(colors.HoverAbgr, hoverAmount * alpha) - ); - - // Full height, not just the title line. Stopping at the title left the - // bar looking like a stray tick next to a two-line heading. - var barWidth = style.AccentBarWidth * scale; - dl.AddRectFilled( - origin, - new Vector2(origin.X + barWidth, max.Y), - ColourUtil.ApplyAlpha(colors.AccentAbgr, alpha) + // Hover brightens the heading itself. There is no plate to tint, and + // tinting the empty band above it would look like a stray selection. + var titleAbgr = ColourUtil.ApplyAlpha( + ColourUtil.Lerp(colors.TitleAbgr, colors.AccentAbgr, hoverAmount), + alpha ); - var textX = origin.X + leadWidth; - dl.PushClipRect(origin, max, true); - dl.AddText( - new Vector2(textX, origin.Y + padY), - ColourUtil.ApplyAlpha(colors.TitleAbgr, alpha), - title - ); - dl.PopClipRect(); - DrawChevron( dl, - new Vector2( - origin.X + barWidth + style.ChevronInset * scale, - origin.Y + padY + titleHeight * 0.5f - ), - titleHeight * 0.28f, + new Vector2(origin.X + lineHeight * 0.18f, textY + lineHeight * 0.5f), + lineHeight * 0.26f, open, - ColourUtil.ApplyAlpha(colors.TitleAbgr, alpha) + ColourUtil.ApplyAlpha(colors.AccentAbgr, alpha) ); + // Upper-cased for the tracking to land: wide spacing between lowercase + // letters reads as a rendering fault, between caps as deliberate. + var textX = origin.X + chevronWidth; + dl.PushClipRect(origin, origin + size, true); + var titleWidth = dl.DrawTrackedText( + new Vector2(textX, textY), + title.ToUpperInvariant(), + titleAbgr, + track + ); + + // Starts where the title ends and fades into nothing, so it reads as a + // continuation of the heading rather than as a box lid. + var ruleX = textX + titleWidth + 10f * scale; + var ruleWidth = origin.X + width - ruleX; + if (ruleWidth > 0f) + dl.DrawFadeRule( + new Vector2(ruleX, textY + lineHeight * 0.5f), + ruleWidth, + ColourUtil.ApplyAlpha(colors.BorderAbgr, alpha), + MathF.Max(1f, scale) + ); + if (description is not null) - { - dl.PushClipRect(origin, max, true); dl.AddText( ImGui.GetFont(), ImGui.GetFontSize(), - new Vector2(textX, origin.Y + padY + titleHeight), + new Vector2(textX, textY + lineHeight + 2f * scale), ColourUtil.ApplyAlpha(colors.DescriptionAbgr, alpha), description, descWrap ); - dl.PopClipRect(); - } - - dl.AddLine( - new Vector2(origin.X, max.Y - 1f * scale), - new Vector2(max.X, max.Y - 1f * scale), - ColourUtil.ApplyAlpha(colors.BorderAbgr, alpha), - 1f * scale - ); + dl.PopClipRect(); + // ItemSize, not SetCursorScreenPos: it advances the cursor AND extends + // CursorMaxPos, which is what the scrollbar measures. ImGui.SetCursorScreenPos(origin); ImGuiP.ItemSize(new Vector2(width, size.Y - ImGui.GetStyle().ItemSpacing.Y)); return open; } - private static void DrawChevron(ImDrawListPtr dl, Vector2 centre, float r, bool open, uint abgr) + private static void DrawChevron( + ImDrawListPtr dl, + Vector2 centre, + float radius, + bool open, + uint abgr + ) { if (open) dl.AddTriangleFilled( - centre + new Vector2(-r, -r * 0.5f), - centre + new Vector2(r, -r * 0.5f), - centre + new Vector2(0f, r * 0.7f), + new Vector2(centre.X - radius, centre.Y - radius * 0.5f), + new Vector2(centre.X + radius, centre.Y - radius * 0.5f), + new Vector2(centre.X, centre.Y + radius * 0.75f), abgr ); else dl.AddTriangleFilled( - centre + new Vector2(-r * 0.5f, -r), - centre + new Vector2(r * 0.7f, 0f), - centre + new Vector2(-r * 0.5f, r), + new Vector2(centre.X - radius * 0.5f, centre.Y - radius), + new Vector2(centre.X - radius * 0.5f, centre.Y + radius), + new Vector2(centre.X + radius * 0.75f, centre.Y), abgr ); } diff --git a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs index acbd32f..98e78a9 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs @@ -20,8 +20,12 @@ internal readonly record struct SegmentedControlStyle { public SegmentedControlStyle() { } - public float Rounding { get; init; } = 4f; + public float Rounding { get; init; } = 3f; public float Inset { get; init; } = 2f; + + // Chamfer on the selected segment. The plugin already had DrawSlipPolygon + // for exactly this cut and had never called it once. + public float Chamfer { get; init; } = 5f; } // One setting, n mutually exclusive choices, one control. A radio group spends @@ -65,11 +69,12 @@ internal static class SegmentedControl var alpha = disabled ? 0.5f : 1f; var dl = ImGui.GetWindowDrawList(); - dl.AddRectFilled( + dl.DrawVerticalGradient( origin, origin + new Vector2(width, height), ColourUtil.ApplyAlpha(colors.TrackAbgr, alpha), - rounding + topLift: 0.04f, + bottomDrop: 0.06f ); var picked = selected; @@ -91,12 +96,33 @@ internal static class SegmentedControl var isSelected = i == selected; if (isSelected) - dl.AddRectFilled( - segOrigin + new Vector2(inset, inset), - segOrigin + segSize - new Vector2(inset, inset), - ColourUtil.ApplyAlpha(colors.SelectedAbgr, alpha), - rounding + { + // Chamfered rather than rounded: the cut corner is the shape the + // rest of the plugin's HUD language uses, and it distinguishes + // the active segment by silhouette instead of by fill alone. + var min = segOrigin + new Vector2(inset, inset); + var max = segOrigin + segSize - new Vector2(inset, inset); + var fill = ColourUtil.ApplyAlpha(colors.SelectedAbgr, alpha); + var cham = style.Chamfer * scale; + dl.DrawSlipPolygon(min, max, ColourUtil.RgbaToAbgr(fill), cham); + + // Highlight as a second, shorter chamfered shape rather than a + // clipped gradient: PushClipRect is rectangular and would square + // the cut corner straight back off. Same silhouette, half the + // height, lifted toward white -- it reads as light from above + // without touching the outline. + dl.DrawSlipPolygon( + min, + new Vector2(max.X, min.Y + (max.Y - min.Y) * 0.5f), + ColourUtil.RgbaToAbgr( + ColourUtil.ApplyAlpha( + ColourUtil.LerpTowardWhite(fill, 0.16f), + 0.55f * alpha + ) + ), + cham ); + } else if (hoverAmount > 0f) dl.AddRectFilled( segOrigin + new Vector2(inset, inset), diff --git a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs index 6f4d1b3..1df7df2 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs @@ -18,10 +18,14 @@ internal readonly record struct SettingRowStyle { public SettingRowStyle() { } - public float PadY { get; init; } = 4f; + public float PadY { get; init; } = 7f; public float Gap { get; init; } = 12f; public float PreferredControlWidth { get; init; } = 200f; - public bool DrawSeparator { get; init; } = true; + + // Off by default. A rule under every row turns a settings page into a + // ledger; the hover fill already tells the reader where a row begins and + // ends, and it only appears where the pointer is. + public bool DrawSeparator { get; init; } } // Handed to the control callback. Widgets that respect SetNextItemWidth can diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 462ac5e..5c203a9 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -125,6 +125,20 @@ internal static class ColourUtil internal static uint OnColour(uint background, uint light, uint dark) => Luminance(background) > 0.5f ? dark : light; + // Mixes an ABGR colour's RGB channels toward black by factor t, alpha + // untouched. The counterpart to LerpTowardWhite, and the reason both exist + // rather than AdjustBrightness: this plugin's surfaces sit near black, where + // a multiplier has almost nothing to scale. 12 * 1.15 is still 13. + internal static uint LerpTowardBlack(uint abgr, float t) + { + t = Math.Clamp(t, 0f, 1f); + var a = (byte)((abgr >> 24) & 0xFFu); + var b = (byte)Math.Round(((abgr >> 16) & 0xFFu) * (1f - t)); + var g = (byte)Math.Round(((abgr >> 8) & 0xFFu) * (1f - t)); + var r = (byte)Math.Round((abgr & 0xFFu) * (1f - t)); + return ((uint)a << 24) | ((uint)b << 16) | ((uint)g << 8) | r; + } + internal static uint LerpTowardWhite(uint abgr, float t) { t = Math.Clamp(t, 0f, 1f);