diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 794d132..5960b90 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -222,7 +222,9 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.TopTabBar( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Windows.MainWindow( sp.GetRequiredService(), diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index 59e10c9..5a36776 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -1,5 +1,8 @@ using System.Numerics; using Dalamud.Bindings.ImGui; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Ui.StyleEngine.Widgets; using HellionChat.Util; namespace HellionChat.Ui.Components; @@ -10,14 +13,36 @@ namespace HellionChat.Ui.Components; internal sealed class TopTabBar { private readonly Windows.ChannelPopoutPool _pool; + private readonly ThemeRegistry _themes; + private readonly WidgetPalette _palette; - public TopTabBar(Windows.ChannelPopoutPool pool) + // Render observability, mirroring the sidebar counters: at most one tab + // carries the active underline. Zero is valid -- every tab can be popped out. + internal int LastRenderedUnderlineCount { get; private set; } + + public TopTabBar(Windows.ChannelPopoutPool pool, ThemeRegistry themes, TokenResolver resolver) { _pool = pool; + _themes = themes; + _palette = new WidgetPalette(resolver); } public void Draw(IReadOnlyList tabs, ref Tab? activeTab) { + LastRenderedUnderlineCount = 0; + + var colors = _themes.Active.Colors; + var surfaceActive = _palette.Abgr(Token.SurfaceActive, colors); + var surfaceHover = _palette.Abgr(Token.SurfaceHover, colors); + var accent = _palette.Abgr(Token.AccentPrimary, colors); + var textAbgr = _palette.Abgr(Token.Text, colors); + var mutedAbgr = _palette.Abgr(Token.TextMuted, colors); + var borderAbgr = _palette.Abgr(Token.Border, colors); + + var height = Metrics.TopTabHeight; + var padX = Metrics.TopTabPaddingX; + var dl = ImGui.GetWindowDrawList(); + var firstDrawn = true; for (var i = 0; i < tabs.Count; i++) { @@ -32,48 +57,110 @@ internal sealed class TopTabBar firstDrawn = false; var selected = ReferenceEquals(tab, activeTab); - // Size the selectable to its own label width. A zero width makes ImGui - // stretch the selectable's box to the full remaining window width - // (imgui_widgets.cpp:7378), so in this SameLine row every tab overlaps - // into one giant bar and clicking never lands on the intended tab. - var tabWidth = ImGui.CalcTextSize(tab.Name).X; - if ( - ImGui.Selectable( - $"{tab.Name}###hellion_toptab_{tab.Identifier}", - selected, - ImGuiSelectableFlags.None, - new Vector2(tabWidth, 0) - ) - ) + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.CalcTextSize(tab.Name).X + padX * 2f; + var size = new Vector2(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 + // once and reused for the hover key, since GetID hashes the same + // string the button registers under. + var buttonId = $"###hellion_toptab_{tab.Identifier}"; + var hoverId = ImGui.GetID(buttonId); + var pressed = ImGui.InvisibleButton(buttonId, size); + var hovered = ImGui.IsItemHovered(); + var hoverAmount = HoverState.Query(hoverId, hovered); + + if (pressed) { var previous = activeTab; activeTab = tab; TabLifecycleHelpers.OnTabActivated(tab, previous); + selected = true; } - // 1.5.6-parity unread dot at the item's top-right. Gate on the - // POST-click selection (not the frame-start 'selected') so clicking a - // tab suppresses its dot the same frame, like the sidebar. The active - // tab is also zeroed every frame (MainWindow.Draw). + DrawTab( + dl, + origin, + size, + tab.Name, + selected, + hoverAmount, + surfaceActive, + surfaceHover, + accent, + selected || hovered ? textAbgr : mutedAbgr, + padX + ); + + 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 max = ImGui.GetItemRectMax(); - var min = ImGui.GetItemRectMin(); - var danger = ColourUtil.RgbaToAbgr( - Plugin.Instance.ThemeRegistry.Active.Colors.StatusDanger + var unread = (int)Math.Min(tab.Unread, int.MaxValue); + var badgeSize = Badge.CalcSize(unread); + var inset = Metrics.TopTabUnreadInset; + Badge.Draw( + new Vector2(origin.X + size.X - badgeSize.X - inset, origin.Y + inset), + unread, + _palette.Abgr(Token.AccentEmber, colors), + textAbgr ); - ImGui - .GetWindowDrawList() - .AddCircleFilled(new Vector2(max.X - 4f, min.Y + 4f), 3.5f, danger, 12); } TabContextMenu.Draw(tab, $"toptab_ctx_{tab.Identifier}", _pool); } - ImGui.Separator(); + LineDivider.Draw(null, borderAbgr, mutedAbgr); + } + + // Keeps the fill Selectable used to provide (ImGuiCol.Header) and adds the + // underline on top. Dropping the fill for the underline alone would make the + // active tab harder to spot, not easier. + private static void DrawTab( + ImDrawListPtr dl, + Vector2 origin, + Vector2 size, + string label, + bool selected, + float hoverAmount, + uint surfaceActive, + uint surfaceHover, + uint accent, + uint labelAbgr, + float padX + ) + { + var max = origin + size; + + if (selected) + dl.AddRectFilled(origin, max, surfaceActive); + + if (hoverAmount > 0f) + { + var a = (uint) + Math.Clamp(MathF.Round(((surfaceHover >> 24) & 0xFF) * hoverAmount), 0f, 255f); + dl.AddRectFilled(origin, max, (surfaceHover & 0x00FFFFFFu) | (a << 24)); + } + + if (selected) + { + var thickness = Metrics.TopTabUnderline; + dl.AddRectFilled(new Vector2(origin.X, max.Y - thickness), max, accent); + } + + var textSize = ImGui.CalcTextSize(label); + dl.AddText( + new Vector2(origin.X + padX, origin.Y + MetricsMath.CenterY(size.Y, textSize.Y)), + labelAbgr, + label + ); } } diff --git a/HellionChat/Ui/StyleEngine/Metrics.cs b/HellionChat/Ui/StyleEngine/Metrics.cs index 8ed96f0..849d45c 100644 --- a/HellionChat/Ui/StyleEngine/Metrics.cs +++ b/HellionChat/Ui/StyleEngine/Metrics.cs @@ -39,6 +39,8 @@ internal static class Metrics // --- Top tab bar --- internal const float TopTabUnreadRadiusRaw = 3.5f; internal const float TopTabUnreadInsetRaw = 4f; + internal const float TopTabPaddingXRaw = 10f; + internal const float TopTabUnderlineRaw = 2f; // --- Status bar --- internal const float StatusBorderThicknessRaw = 1f; @@ -94,6 +96,12 @@ internal static class Metrics 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); + + // Measured, not scaled: the tab has to fit the text, and the font comes from + // Config.FontSizeV2 which display scaling does not feed into. + internal static float TopTabHeight => ImGui.GetTextLineHeight() + MathF.Round(10f * Scale); internal static float StatusBorderThickness => MetricsMath.Scale(StatusBorderThicknessRaw, Scale);