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:
@@ -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>(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user