Files
HellionChat/HellionChat/Ui/Components/Sidebar.cs
T
JonKazama-Hellion 1e8a60ac80 fix(sidebar): scale the drawn width without moving the stored value
The sidebar constants were raw pixels. At 150% display scaling the text grows,
the column does not, and the row contents stop fitting.

GetWidth and IsExpanded stay unscaled on purpose. The stored width and the
switch threshold are user settings in design pixels, and
SidebarModeAutoSwitchStep compares GetWidth's return value against the raw
bounds with exact equality -- scaling there would fail the step at anything
other than 100%. Scaling happens once, at the single draw call site.

RowHeight and the two hit widths now come from Metrics. The row internals read
GetContentRegionAvail, so they follow automatically and the hit-area split
thresholds stay proportional.

The not-ready branch is scaled too: a scale change triggers a font rebuild, so
that branch really is hit while GlobalScale is moving, and an unscaled width
there makes the sidebar jump.

The width slider referenced the bounds as literals. It now uses the constants,
so it cannot drift away from the clamp.

Known remainder, deliberate: SidebarAutoSwitchThresholdPx is compared against
real screen pixels while the columns now scale, so the switch point drifts at
high scaling. Scaling it would fail the same SelfTest. Noted for v1.11.0.
2026-08-17 23:48:36 +02:00

424 lines
17 KiB
C#

using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
using HellionChat.Util;
using Microsoft.Extensions.Logging;
namespace HellionChat.Ui.Components;
// Channel-list panel pinned to the left of the chat window. Auto-switches
// between an icon-only column (38px) and an expanded column (Config.SidebarWidth) once
// the outer window crosses Config.SidebarAutoSwitchThresholdPx. The
// pop-out affordance (hover button + right-click menu) routes through the
// injected ChannelPopoutPool via TryOpen, which reserves a slot and binds
// the tab to a pre-allocated pop-out window.
internal sealed class Sidebar
{
public const float IconOnlyWidth = 38f;
// B1-3a: expanded sidebar width is user-configurable (Config.SidebarWidth),
// clamped to these bounds (matches the ChannelsTab slider range). Replaces
// the old fixed 150px ExpandedWidth constant.
public const float MinSidebarWidth = 40f;
public const float MaxSidebarWidth = 300f;
private static float RowHeight => Metrics.SidebarRowHeight;
private static float PopOutHitWidth => Metrics.SidebarPopOutHitWidth;
private static float GreetedHitWidth => Metrics.SidebarGreetedHitWidth;
// B3-2 render observability: counts greeted glyphs actually drawn this frame.
// Incremented ONLY in the real glyph branch in DrawRow; reset at Draw start.
// The SelfTest reads it after driving the real Draw — no dead service roundtrip.
internal int LastRenderedGreetedGlyphCount;
internal int LastRenderedUnreadDotCount;
// B3-4 render observability: section headers actually drawn this frame.
// Incremented only in the real header branch; reset at Draw start.
internal int LastDrawnSectionHeaderCount;
// Inline mirror of the old TabIconMapping table so the Ui layer carries
// its own glyph lookup once the standalone file is removed.
private static readonly Dictionary<string, FontAwesomeIcon> IconByName = new(
StringComparer.OrdinalIgnoreCase
)
{
["comment"] = FontAwesomeIcon.Comment,
["comments"] = FontAwesomeIcon.Comments,
["cog"] = FontAwesomeIcon.Cog,
["users"] = FontAwesomeIcon.Users,
["user-friends"] = FontAwesomeIcon.UserFriends,
["link"] = FontAwesomeIcon.Link,
["envelope"] = FontAwesomeIcon.Envelope,
["clock"] = FontAwesomeIcon.Clock,
["hashtag"] = FontAwesomeIcon.Hashtag,
["star"] = FontAwesomeIcon.Star,
["heart"] = FontAwesomeIcon.Heart,
["bell"] = FontAwesomeIcon.Bell,
["bookmark"] = FontAwesomeIcon.Bookmark,
["flag"] = FontAwesomeIcon.Flag,
["fire"] = FontAwesomeIcon.Fire,
};
private readonly ThemeRegistry _themes;
private readonly TokenResolver _resolver;
private readonly FontManager _fonts;
private readonly ILogger<Sidebar> _logger;
private readonly Windows.ChannelPopoutPool _pool;
public Sidebar(
ThemeRegistry themes,
TokenResolver resolver,
FontManager fonts,
ILogger<Sidebar> logger,
Windows.ChannelPopoutPool pool
)
{
_themes = themes;
_resolver = resolver;
_fonts = fonts;
_logger = logger;
_pool = pool;
}
// Both stay unscaled. The stored width and the switch threshold are user
// settings in design pixels, and SidebarModeAutoSwitchStep compares this
// return value against the raw bounds. Display scaling is applied once, at
// the single draw call site below.
public bool IsExpanded(float windowWidth) =>
windowWidth >= Plugin.Config.SidebarAutoSwitchThresholdPx;
public float GetWidth(float windowWidth) =>
IsExpanded(windowWidth)
? Math.Clamp((float)Plugin.Config.SidebarWidth, MinSidebarWidth, MaxSidebarWidth)
: IconOnlyWidth;
// Factored click logic so the SelfTest exercises the real toggle, not a direct
// MarkGreeted call (which would be a dead path the render never takes).
internal void ToggleGreetedForSelfTest(Tab tab)
{
if (Plugin.Instance.AutoTellTabsService.IsGreeted(tab))
Plugin.Instance.AutoTellTabsService.UnmarkGreeted(tab);
else
Plugin.Instance.AutoTellTabsService.MarkGreeted(tab);
}
public void Draw(float windowWidth, IReadOnlyList<Tab> tabs, ref Tab? activeTab)
{
LastRenderedGreetedGlyphCount = 0;
LastRenderedUnreadDotCount = 0;
LastDrawnSectionHeaderCount = 0;
if (!_fonts.FontsReady)
{
// A scale change triggers a font rebuild, so this branch is really
// hit while GlobalScale is moving -- an unscaled width here makes
// the sidebar jump.
ImGui.Dummy(new Vector2(Metrics.SidebarIconOnlyWidth, 0));
return;
}
var expanded = IsExpanded(windowWidth);
var width = GetWidth(windowWidth) * Metrics.Scale;
using var child = ImRaii.Child("##hellion-sidebar", new Vector2(width, 0));
if (!child.Success)
return;
var theme = _themes.Active;
var accentRgba = _resolver.Resolve(Token.AccentPrimary, theme.Colors);
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
var mutedAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted);
var dimAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextDim);
var dangerAbgr = ColourUtil.RgbaToAbgr(theme.Colors.StatusDanger);
var dl = ImGui.GetWindowDrawList();
// B3-4 sectioned render order (1.5.6 parity): persistent → pinned
// TempTabs → unpinned TempTabs. Only the display sequence regroups;
// the tab list itself stays untouched and every row keeps its
// ORIGINAL list index for PushID, so an open context-menu popup
// stays bound to its tab when sectioning moves it visually.
var renderOrder = TabLifecycleHelpers.BuildRenderOrder(
tabs,
t => _pool.IsOpen(t.Identifier)
);
var pinnedHeaderRendered = false;
var unpinnedHeaderRendered = false;
foreach (var i in renderOrder)
{
var tab = tabs[i];
if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered)
{
DrawSectionHeader(
HellionStrings.PinTab_SectionHeader,
TabLifecycleHelpers.CountPinnedPool(tabs, t => _pool.IsOpen(t.Identifier))
);
pinnedHeaderRendered = true;
}
else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !unpinnedHeaderRendered)
{
DrawSectionHeader(
HellionStrings.AutoTellTabs_SectionHeader,
TabLifecycleHelpers.CountUnpinnedPool(tabs, t => _pool.IsOpen(t.Identifier))
);
unpinnedHeaderRendered = true;
}
DrawRow(
tab,
expanded,
accentRgba,
textAbgr,
mutedAbgr,
dimAbgr,
dangerAbgr,
dl,
ref activeTab
);
}
}
// Section transition marker (1.5.6 parity): the separator always renders,
// compact mode suppresses only the header text. Real cursor-advancing
// widgets on purpose — rows advance the cursor via InvisibleButton, so a
// drawlist-only header would overlap the next row.
private void DrawSectionHeader(string header, int count)
{
ImGui.Separator();
if (Plugin.Config.AutoTellTabsCompactDisplay)
return;
ImGui.TextDisabled($"{header} ({count})");
LastDrawnSectionHeaderCount++;
}
private void DrawRow(
Tab tab,
bool expanded,
uint accentRgba,
uint textAbgr,
uint mutedAbgr,
uint dimAbgr,
uint dangerAbgr,
ImDrawListPtr dl,
ref Tab? activeTab
)
{
// Identity, not position: ImGui keeps popup state across frames under this
// ID, so an index would re-bind an open context menu to a different tab as
// soon as the list shifts. String, not GetHashCode — hashes collide.
ImGui.PushID(tab.Identifier.ToString());
var origin = ImGui.GetCursorScreenPos();
var avail = ImGui.GetContentRegionAvail().X;
// Drop the row entirely when the sidebar is dragged below the width
// of a single hit target. ImGui's InvisibleButton asserts on a
// zero-width size, which crashes the whole window at min-drag.
if (avail < 2f)
{
ImGui.PopID();
return;
}
// 1.5.6 parity: greeted state dims the tab icon whenever the toggle is
// configured on. The clickable affordance additionally needs an expanded
// sidebar with room for a third hit area beside the pop-out slot — in
// icon-only or min-drag mode it is skipped entirely.
var greetedConfigured = tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle;
var showGreeted =
greetedConfigured && expanded && avail > GreetedHitWidth + PopOutHitWidth + 4f;
// Only split off a separate pop-out hit area when there's room for
// both buttons. Below that, the whole row stays as a single
// selectable strip without the pop-out affordance.
// A3: gate the pop-out affordance on the expanded sidebar too. In
// icon-only mode avail still clears the width threshold, which used to
// paint the pop-out glyph over the tab icon. The row stays a single
// selectable strip when collapsed; right-click pop-out is unaffected.
var hasPopOut = expanded && avail > PopOutHitWidth + 4f;
var tabHitWidth = hasPopOut ? avail - PopOutHitWidth : avail;
if (showGreeted)
{
// Greeted slot sits at the left edge (1.5.6 placement); the row
// button starts after it so the three hit areas never overlap.
tabHitWidth -= GreetedHitWidth;
ImGui.SetCursorScreenPos(origin + new Vector2(GreetedHitWidth, 0f));
}
ImGui.InvisibleButton("row", new Vector2(tabHitWidth, RowHeight));
var rowHovered = ImGui.IsItemHovered();
if (ImGui.IsItemClicked())
{
var previous = activeTab;
activeTab = tab;
TabLifecycleHelpers.OnTabActivated(tab, previous);
}
// GetID is seeded from the window's ID stack, so the same "row" literal
// stays distinct per window and per PushID'd tab. The old interpolated
// key allocated two strings per row per frame.
var hoverId = ImGui.GetID("row"u8);
var hoverAmount = StyleEngine.HoverState.Query(hoverId, rowHovered);
dl.DrawHoverSheen(
origin,
origin + new Vector2(avail, RowHeight),
accentRgba,
hoverAmount,
rowHovered
);
var icon = ResolveTabIcon(tab);
// Dim precedence (1.5.6): the active tab always keeps its regular
// color; only greeted, non-active tabs drop to TextDim.
var isCurrentTab = tab == activeTab;
var iconColor = textAbgr;
if (
!isCurrentTab
&& greetedConfigured
&& Plugin.Instance.AutoTellTabsService.IsGreeted(tab)
)
iconColor = dimAbgr;
// Icon and label shift right by the greeted slot when it is shown.
var contentX = showGreeted ? GreetedHitWidth : 0f;
using (_fonts.FontAwesome.Push())
{
var iconStr = icon.ToIconString();
dl.AddText(origin + new Vector2(10f + contentX, 8f), iconColor, iconStr);
// 1.5.6-parity unread dot, top-right of the icon. The active tab is
// zeroed every frame (MainWindow.Draw), so the dot never shows on the
// tab you're viewing; UnreadMode.None opts a tab out entirely.
if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0)
{
var iconRight = 10f + contentX + ImGui.CalcTextSize(iconStr).X;
dl.AddCircleFilled(origin + new Vector2(iconRight - 2f, 6f), 4f, dangerAbgr, 12);
LastRenderedUnreadDotCount++;
}
}
if (expanded)
dl.AddText(origin + new Vector2(32f + contentX, 8f), textAbgr, tab.Name);
TabContextMenu.Draw(tab, "ctx", _pool);
var popHovered = false;
if (hasPopOut)
{
ImGui.SameLine(0f, 0f);
ImGui.InvisibleButton("popout", new Vector2(PopOutHitWidth, RowHeight));
popHovered = ImGui.IsItemHovered();
if (ImGui.IsItemClicked())
_pool.TryOpen(tab);
}
if (hasPopOut && (rowHovered || popHovered))
{
using (_fonts.FontAwesome.Push())
{
var glyph = FontAwesomeIcon.ArrowUpRightFromSquare.ToIconString();
dl.AddText(origin + new Vector2(avail - PopOutHitWidth + 4f, 8f), mutedAbgr, glyph);
}
}
if (showGreeted)
{
// The hit area sits at the LEFT edge of the row, but the item must
// be submitted AFTER TabContextMenu.Draw — any interactive item
// between the row button and the popup call would steal the
// right-click trigger (B3-1 ordering constraint).
ImGui.SetCursorScreenPos(origin);
ImGui.InvisibleButton("greeted", new Vector2(GreetedHitWidth, RowHeight));
if (ImGui.IsItemClicked())
ToggleGreetedForSelfTest(tab);
// CheckCircle = greeted, plain Check = still pending (1.5.6 mapping).
var greetedGlyph = Plugin.Instance.AutoTellTabsService.IsGreeted(tab)
? FontAwesomeIcon.CheckCircle
: FontAwesomeIcon.Check;
using (_fonts.FontAwesome.Push())
dl.AddText(origin + new Vector2(4f, 8f), mutedAbgr, greetedGlyph.ToIconString());
LastRenderedGreetedGlyphCount++;
}
ImGui.PopID();
}
private static FontAwesomeIcon ResolveTabIcon(Tab tab)
{
if (
!string.IsNullOrWhiteSpace(tab.Icon) && IconByName.TryGetValue(tab.Icon, out var mapped)
)
return mapped;
// Auto-tell tabs always show the envelope, regardless of what their
// SelectedChannels filter is set to.
if (tab.IsTempTab)
return FontAwesomeIcon.Envelope;
// Channel-type fallback. Walk every selected key, not just the first,
// so a System tab that filters multiple system-flavoured ChatTypes
// still picks up fa-cog when one of the later keys carries the match.
// The Comment default only wins when every key falls into the
// generic-text bucket (Say / Yell / Shout etc.).
foreach (var chatType in tab.SelectedChannels.Keys)
{
var glyph = ResolveByChannelType(chatType);
if (glyph != FontAwesomeIcon.Comment)
return glyph;
}
// Last-resort name match for tabs that filter exotic ChatTypes the
// mapping above doesn't cover — keeps the System tab visually
// distinct even with a custom channel set.
if (tab.Name.Contains("system", StringComparison.OrdinalIgnoreCase))
return FontAwesomeIcon.Cog;
return FontAwesomeIcon.Comment;
}
private static FontAwesomeIcon ResolveByChannelType(ChatType type) =>
type switch
{
ChatType.TellIncoming or ChatType.TellOutgoing => FontAwesomeIcon.Envelope,
ChatType.FreeCompany
or ChatType.FreeCompanyAnnouncement
or ChatType.FreeCompanyLoginLogout => FontAwesomeIcon.Users,
ChatType.Linkshell1
or ChatType.Linkshell2
or ChatType.Linkshell3
or ChatType.Linkshell4
or ChatType.Linkshell5
or ChatType.Linkshell6
or ChatType.Linkshell7
or ChatType.Linkshell8
or ChatType.CrossLinkshell1
or ChatType.CrossLinkshell2
or ChatType.CrossLinkshell3
or ChatType.CrossLinkshell4
or ChatType.CrossLinkshell5
or ChatType.CrossLinkshell6
or ChatType.CrossLinkshell7
or ChatType.CrossLinkshell8 => FontAwesomeIcon.Link,
ChatType.Party or ChatType.CrossParty => FontAwesomeIcon.UserFriends,
ChatType.Alliance => FontAwesomeIcon.Users,
ChatType.NoviceNetwork or ChatType.NoviceNetworkSystem => FontAwesomeIcon.Users,
ChatType.PvpTeam or ChatType.PvpTeamAnnouncement or ChatType.PvpTeamLoginLogout =>
FontAwesomeIcon.Users,
ChatType.System
or ChatType.BattleSystem
or ChatType.GatheringSystem
or ChatType.Error
or ChatType.Notice
or ChatType.LootNotice
or ChatType.Echo => FontAwesomeIcon.Cog,
ChatType.CustomEmote or ChatType.StandardEmote => FontAwesomeIcon.Comments,
_ => FontAwesomeIcon.Comment,
};
}