feat(toptabs): draw tabs with an active underline instead of selectables

The strip was ImGui.Selectable sized to the bare text width, with a red dot
hanging off the item rect. Six things had to be rebuilt by hand, and the first
is the one that mattered most.

Selectable did have an active fill (ImGuiCol.Header, fed from the theme), so
this replaces a fill rather than adding a marker to something bare. The fill
stays and the accent underline comes on top -- swapping one for the other would
have made the active tab harder to spot.

Tabs now have their own height derived from the measured line height plus
padding, so the strip no longer collapses onto the text. Hover runs through
HoverState like the sidebar, and the label has three states: active and hovered
in Text, idle in TextMuted.

The unread marker is a count badge in AccentEmber, and its position had to be
recomputed: Selectable inflated its bounding box by half the item spacing on
every side, so reusing the old GetItemRectMin/Max maths against an
InvisibleButton would have made the marker jump.

Click semantics follow InvisibleButton's return value, which fires on release
like Selectable did. IsItemClicked would have fired on press, a silent
behaviour change.

The trailing Separator becomes a LineDivider, so both layout modes draw the
same rule.
This commit is contained in:
2026-08-17 23:56:29 +02:00
parent 5caa266287
commit e4659bc597
3 changed files with 124 additions and 27 deletions
+3 -1
View File
@@ -222,7 +222,9 @@ internal static class PluginHostFactory
sp.GetRequiredService<FontManager>()
));
services.AddSingleton(sp => new Ui.Components.TopTabBar(
sp.GetRequiredService<Ui.Windows.ChannelPopoutPool>()
sp.GetRequiredService<Ui.Windows.ChannelPopoutPool>(),
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
));
services.AddSingleton(sp => new Ui.Windows.MainWindow(
sp.GetRequiredService<Ui.Components.HonorificHeader>(),
+113 -26
View File
@@ -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<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++)
{
@@ -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
);
}
}
+8
View File
@@ -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);