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; // Horizontal tab strip — the alternative MainWindow layout to the Sidebar. // Selection drives the same shared EnsureCurrentChannel path; pop-out is the // same pool.TryOpen affordance as the sidebar (right-click context menu). internal sealed class TopTabBar { private readonly Windows.ChannelPopoutPool _pool; private readonly ThemeRegistry _themes; private readonly WidgetPalette _palette; // 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) { _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++) { var tab = tabs[i]; // A popped-out tab is owned by its pop-out window, not the main // strip (1.5.6 exclusivity). Gate on the pool, not Tab.PopOut (stale flag). if (_pool.IsOpen(tab.Identifier)) continue; if (!firstDrawn) ImGui.SameLine(); firstDrawn = false; var selected = ReferenceEquals(tab, activeTab); var origin = ImGui.GetCursorScreenPos(); // 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; // Resolved once: measuring one string and drawing another would size // every tab wrong the moment screenshot mode is on. var label = Util.TabDisplayName.Resolve( tab.Name, tab.NameCameFromPartner, Plugin.Config.ScreenshotMode ); var unread = showUnread ? (int)Math.Min(tab.Unread, int.MaxValue) : 0; var badgeSize = showUnread ? Badge.CalcSize(unread, TabBadge) : Vector2.Zero; var width = ImGui.CalcTextSize(label).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 // 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; } DrawTab( dl, origin, size, label, selected, hoverAmount, surfaceActive, surfaceHover, accent, selected || hovered ? textAbgr : mutedAbgr, padX ); if (selected) LastRenderedUnderlineCount++; // 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 - Metrics.TopTabUnreadInset, origin.Y + MetricsMath.CenterY(size.Y, badgeSize.Y) ), unread, _palette.Abgr(Token.AccentEmber, colors), textAbgr, TabBadge ); TabContextMenu.Draw(tab, $"toptab_ctx_{tab.Identifier}", _pool); } 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 ); } }