diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index c657aae..9faa784 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -32,8 +32,10 @@ internal sealed class Sidebar private static float PopOutHitWidth => Metrics.SidebarPopOutHitWidth; private static float GreetedHitWidth => Metrics.SidebarGreetedHitWidth; - // Counts rows that got the active surface this frame. At most one, but not - // exactly one: PickMainActiveTab returns null when every tab is popped out. + // Counts rows that got the active surface this frame. At most one, with two + // exceptions: zero when every tab is popped out (PickMainActiveTab returns + // null), and two in the frame a click lands on a row drawn after the + // previously active one -- that row was still active when it was painted. internal int LastRenderedActiveSurfaceCount { get; private set; } // B3-2 render observability: counts greeted glyphs actually drawn this frame. @@ -237,7 +239,9 @@ internal sealed class Sidebar // icon-only or min-drag mode it is skipped entirely. var greetedConfigured = tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle; var showGreeted = - greetedConfigured && expanded && avail > GreetedHitWidth + PopOutHitWidth + 4f; + greetedConfigured + && expanded + && avail > GreetedHitWidth + PopOutHitWidth + Metrics.SidebarHitSlack; // Only split off a separate pop-out hit area when there's room for // both buttons. Below that, the whole row stays as a single @@ -246,7 +250,7 @@ internal sealed class Sidebar // icon-only mode avail still clears the width threshold, which used to // paint the pop-out glyph over the tab icon. The row stays a single // selectable strip when collapsed; right-click pop-out is unaffected. - var hasPopOut = expanded && avail > PopOutHitWidth + 4f; + var hasPopOut = expanded && avail > PopOutHitWidth + Metrics.SidebarHitSlack; var tabHitWidth = hasPopOut ? avail - PopOutHitWidth : avail; if (showGreeted) { @@ -284,6 +288,14 @@ internal sealed class Sidebar LastRenderedActiveSurfaceCount++; var colors = _themes.Active.Colors; + + // Row fills follow the window's own opacity. Theme surfaces are fully + // opaque and the window is translucent by default (0.85 focused, 0.65 + // not), so unscaled fills would sit on top as solid blocks. + var opacity = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootWindow) + ? Plugin.Config.WindowOpacity + : Plugin.Config.WindowOpacityInactive; + Row.Draw( origin, new Vector2(avail, RowHeight), @@ -291,11 +303,16 @@ internal sealed class Sidebar { IsActive = isActiveRow, HoverAmount = hoverAmount, - SurfaceAbgr = _palette.Abgr(Token.SurfaceBase, colors), - SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, colors), - SurfaceActiveAbgr = _palette.Abgr(Token.SurfaceActive, colors), + SurfaceHoverAbgr = ColourUtil.ApplyAlpha( + _palette.Abgr(Token.SurfaceHover, colors), + opacity + ), + SurfaceActiveAbgr = ColourUtil.ApplyAlpha( + _palette.Abgr(Token.SurfaceActive, colors), + opacity + ), AccentAbgr = _palette.Abgr(Token.AccentPrimary, colors), - BorderAbgr = _palette.Abgr(Token.Border, colors), + BorderAbgr = ColourUtil.ApplyAlpha(_palette.Abgr(Token.Border, colors), opacity), } ); @@ -326,9 +343,18 @@ internal sealed class Sidebar float iconRight; using (_fonts.FontAwesome.Push()) { + // Measured inside the push: FontAwesome is a fixed-width icon handle + // that does not follow Config.FontSizeV2, so the text font's line + // height would misplace the glyph at any other body size. var iconStr = icon.ToIconString(); - dl.AddText(origin + new Vector2(iconInset + contentX, contentY), iconColor, iconStr); - iconRight = iconInset + contentX + ImGui.CalcTextSize(iconStr).X; + var iconSize = ImGui.CalcTextSize(iconStr); + dl.AddText( + origin + + new Vector2(iconInset + contentX, MetricsMath.CenterY(RowHeight, iconSize.Y)), + iconColor, + iconStr + ); + iconRight = iconInset + contentX + iconSize.X; } if (expanded) @@ -342,16 +368,34 @@ internal sealed class Sidebar { var unread = (int)Math.Min(tab.Unread, int.MaxValue); var badgeSize = Badge.CalcSize(unread); - var badgeX = expanded - ? avail - PopOutHitWidth - badgeSize.X - 4f * scale - : iconRight - badgeSize.X * 0.5f; + var accentAbgr = _palette.Abgr(Token.AccentEmber, colors); + var slack = Metrics.SidebarHitSlack; + var reserved = hasPopOut ? PopOutHitWidth : 0f; + var badgeX = avail - reserved - badgeSize.X - slack; + + // The count only fits where it can sit clear of the icon. At the + // default sidebar width of 44 it cannot, so a plain dot takes over + // rather than the badge landing on the glyph. + if (badgeX >= iconRight + slack) + { + Badge.Draw( + origin + new Vector2(badgeX, MetricsMath.CenterY(RowHeight, badgeSize.Y)), + unread, + accentAbgr, + textAbgr + ); + } + else + { + var r = Metrics.SidebarUnreadRadius; + dl.AddCircleFilled( + origin + new Vector2(iconRight - r * 0.5f, RowHeight * 0.5f - r), + r, + accentAbgr, + 12 + ); + } - Badge.Draw( - origin + new Vector2(badgeX, MetricsMath.CenterY(RowHeight, badgeSize.Y)), - unread, - _palette.Abgr(Token.AccentEmber, colors), - textAbgr - ); LastRenderedUnreadDotCount++; } diff --git a/HellionChat/Ui/Components/TabContextMenu.cs b/HellionChat/Ui/Components/TabContextMenu.cs index 27e1556..24af76b 100644 --- a/HellionChat/Ui/Components/TabContextMenu.cs +++ b/HellionChat/Ui/Components/TabContextMenu.cs @@ -41,14 +41,12 @@ internal static class TabContextMenu } // The sidebar pushes ItemSpacing to zero so its rows sit flush, and style - // vars are a global stack the popup inherits. Without restoring a normal - // spacing here the menu entries would touch each other. + // vars are a global stack the popup inherits. Reading GetStyle() here + // would read that zero back, so the popup sets its own spacing outright + // -- including X, which HelpMarker's SameLine depends on. using var spacing = ImRaii.PushStyle( ImGuiStyleVar.ItemSpacing, - ImGui.GetStyle().ItemSpacing with - { - Y = MathF.Max(ImGui.GetStyle().ItemSpacing.Y, 4f * ImGuiHelpers.GlobalScale), - } + new System.Numerics.Vector2(8f, 4f) * Ui.StyleEngine.Metrics.Scale ); // Rename: focus the field the first frame the popup appears. diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index 5a36776..2411aa8 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -16,8 +16,13 @@ internal sealed class TopTabBar private readonly ThemeRegistry _themes; private readonly WidgetPalette _palette; - // Render observability, mirroring the sidebar counters: at most one tab - // carries the active underline. Zero is valid -- every tab can be popped out. + // Slightly smaller than the sidebar default: the strip is horizontal, so + // every pixel of badge width costs a tab. + private static readonly BadgeStyle TabBadge = new() { Height = 12f, PaddingX = 3f }; + + // Render observability. At most one underline per frame, except in the frame + // a click lands: a tab drawn before the clicked one was still the active tab + // when it was painted, so that frame legitimately shows two. internal int LastRenderedUnderlineCount { get; private set; } public TopTabBar(Windows.ChannelPopoutPool pool, ThemeRegistry themes, TokenResolver resolver) @@ -58,8 +63,22 @@ internal sealed class TopTabBar var selected = ReferenceEquals(tab, activeTab); var origin = ImGui.GetCursorScreenPos(); - var width = ImGui.CalcTextSize(tab.Name).X + padX * 2f; - var size = new Vector2(width, height); + + // The badge has to be part of the width, not painted over it. Padding + // alone is 10px and the badge is at least 14 wide, so placing it in + // the trailing padding covered the label on every tab that had one. + var showUnread = + !ReferenceEquals(tab, activeTab) + && tab.UnreadMode != UnreadMode.None + && tab.Unread > 0; + var unread = showUnread ? (int)Math.Min(tab.Unread, int.MaxValue) : 0; + var badgeSize = showUnread ? Badge.CalcSize(unread, TabBadge) : Vector2.Zero; + + var width = + ImGui.CalcTextSize(tab.Name).X + + padX * 2f + + (showUnread ? badgeSize.X + Metrics.TopTabUnreadInset : 0f); + var size = WidgetGeometry.IconButton(width, height); // The ### keeps the ImGui id stable across a rename; without it the // context menu loses its binding the moment the label changes. Built @@ -96,24 +115,20 @@ internal sealed class TopTabBar if (selected) LastRenderedUnderlineCount++; - // 1.5.6-parity unread marker. Gated on the POST-click selection so - // clicking a tab clears it the same frame, like the sidebar. - if ( - !ReferenceEquals(tab, activeTab) - && tab.UnreadMode != UnreadMode.None - && tab.Unread > 0 - ) - { - var unread = (int)Math.Min(tab.Unread, int.MaxValue); - var badgeSize = Badge.CalcSize(unread); - var inset = Metrics.TopTabUnreadInset; + // Clicking a tab clears its marker in the same frame, like the + // sidebar: showUnread was resolved before the click was handled, so + // re-check against the post-click selection. + if (showUnread && !ReferenceEquals(tab, activeTab)) Badge.Draw( - new Vector2(origin.X + size.X - badgeSize.X - inset, origin.Y + inset), + new Vector2( + origin.X + size.X - badgeSize.X - Metrics.TopTabUnreadInset, + origin.Y + MetricsMath.CenterY(size.Y, badgeSize.Y) + ), unread, _palette.Abgr(Token.AccentEmber, colors), - textAbgr + textAbgr, + TabBadge ); - } TabContextMenu.Draw(tab, $"toptab_ctx_{tab.Identifier}", _pool); } diff --git a/HellionChat/Ui/StyleEngine/Metrics.cs b/HellionChat/Ui/StyleEngine/Metrics.cs index 5a1ef30..af4f4e5 100644 --- a/HellionChat/Ui/StyleEngine/Metrics.cs +++ b/HellionChat/Ui/StyleEngine/Metrics.cs @@ -37,7 +37,6 @@ internal static class Metrics internal const float HonorificBracketGapRaw = 6f; // --- Top tab bar --- - internal const float TopTabUnreadRadiusRaw = 3.5f; internal const float TopTabUnreadInsetRaw = 4f; internal const float TopTabPaddingXRaw = 10f; internal const float TopTabUnderlineRaw = 2f; @@ -96,7 +95,6 @@ internal static class Metrics internal static float HonorificInset => MetricsMath.Scale(HonorificInsetRaw, Scale); internal static float HonorificBracketGap => MetricsMath.Scale(HonorificBracketGapRaw, Scale); - internal static float TopTabUnreadRadius => MetricsMath.Scale(TopTabUnreadRadiusRaw, Scale); internal static float TopTabUnreadInset => MetricsMath.Scale(TopTabUnreadInsetRaw, Scale); internal static float TopTabPaddingX => MetricsMath.Scale(TopTabPaddingXRaw, Scale); internal static float TopTabUnderline => MetricsMath.Scale(TopTabUnderlineRaw, Scale); diff --git a/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs index 2bd23b8..5139dca 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs @@ -51,9 +51,14 @@ internal static class IconButton var clamped = WidgetGeometry.IconButton(size.X, size.Y); var origin = ImGui.GetCursorScreenPos(); - ImGui.InvisibleButton($"##hellion-iconbtn-{id}", clamped); + // PushID over an interpolated label: the string version allocated once + // per button per frame, which is the pattern the sidebar just removed + // from its hover keys. + ImGui.PushID((int)id); + ImGui.InvisibleButton("##b"u8, clamped); var hovered = ImGui.IsItemHovered(); var clicked = ImGui.IsItemClicked(); + ImGui.PopID(); var amount = HoverState.Query(id, hovered); var dl = ImGui.GetWindowDrawList(); diff --git a/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs b/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs index 6302de2..7f6dab1 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs @@ -23,7 +23,9 @@ internal static class LineDivider { var style = styleOverride ?? new LineDividerStyle(); var scale = Metrics.Scale; - var labelHeight = label is null ? 0f : ImGui.GetTextLineHeight(); + // Caption height plus its own bottom padding: without the second padY + // the next row starts one pixel under the text. + var labelHeight = label is null ? 0f : ImGui.GetTextLineHeight() + style.PadY * scale; return WidgetGeometry.LineDivider( ImGui.GetContentRegionAvail().X, style.Thickness * scale, diff --git a/HellionChat/Ui/StyleEngine/Widgets/Row.cs b/HellionChat/Ui/StyleEngine/Widgets/Row.cs index 2762d7a..42cebde 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/Row.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/Row.cs @@ -1,5 +1,6 @@ using System.Numerics; using Dalamud.Bindings.ImGui; +using HellionChat.Util; namespace HellionChat.Ui.StyleEngine.Widgets; @@ -12,7 +13,6 @@ internal readonly record struct RowVisualState public bool IsActive { get; init; } public float HoverAmount { get; init; } - public uint SurfaceAbgr { get; init; } public uint SurfaceHoverAbgr { get; init; } public uint SurfaceActiveAbgr { get; init; } public uint AccentAbgr { get; init; } @@ -46,11 +46,19 @@ internal static class Row var dl = ImGui.GetWindowDrawList(); var max = origin + size; - var baseAbgr = state.IsActive ? state.SurfaceActiveAbgr : state.SurfaceAbgr; - dl.AddRectFilled(origin, max, baseAbgr); + // Idle rows draw no fill at all. GlobalStyleScope zeroes ChildBg below + // full window opacity so WindowBg alone carries the coverage, and the + // default is 0.85 -- an opaque fill per row would make the sidebar a + // solid block inside a translucent window. + if (state.IsActive) + dl.AddRectFilled(origin, max, state.SurfaceActiveAbgr); if (state.HoverAmount > 0f) - dl.AddRectFilled(origin, max, ScaleAlpha(state.SurfaceHoverAbgr, state.HoverAmount)); + dl.AddRectFilled( + origin, + max, + ColourUtil.ApplyAlpha(state.SurfaceHoverAbgr, state.HoverAmount) + ); if (state.IsActive && style.AccentBarWidth > 0f) { @@ -66,10 +74,4 @@ internal static class Row Metrics.Scale ); } - - private static uint ScaleAlpha(uint abgr, float factor) - { - var a = (uint)Math.Clamp(MathF.Round(((abgr >> 24) & 0xFF) * factor), 0f, 255f); - return (abgr & 0x00FFFFFFu) | (a << 24); - } } diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 3663d4e..075c273 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -78,7 +78,6 @@ internal sealed class WidgetGalleryWindow : Window { IsActive = _rowActive && i == 1, HoverAmount = HoverState.Query(id, hovered), - SurfaceAbgr = _palette.Abgr(Token.SurfaceBase, c), SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, c), SurfaceActiveAbgr = _palette.Abgr(Token.SurfaceActive, c), AccentAbgr = _palette.Abgr(Token.AccentPrimary, c),