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);