The header was the only place that knew a tab name can be a person. The sidebar, the tab strip and a pop-out's window title drew the same "Player@World" string untouched, so a screenshot of the default view still named the partner while the messages underneath were anonymised. Guarding one surface out of four guards nobody. The rule sits in one place now and all four read it. Names are replaced rather than blanked: a nameless tab in a strip of tabs is worse to use than a placeholder, and the sidebar has no room to explain itself. The salt is drawn fresh on every plugin load, the same reasoning the message path uses -- a stable label would let two screenshots taken weeks apart be tied together. The tab strip resolves once and both measures and draws that value. Measuring one string and drawing another would have sized every tab wrong the moment the mode came on, which is the kind of thing that looks like a layout bug and gets fixed in the wrong place.
190 lines
7.0 KiB
C#
190 lines
7.0 KiB
C#
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<Tab> 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];
|
|
// POP-1b: 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
|
|
);
|
|
}
|
|
}
|