From 6ab2e9cece44fbd359b2a9bc63f0c64ca3e25dbb Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sat, 23 May 2026 18:30:29 +0200 Subject: [PATCH] feat(ui): add Sidebar component with width auto-switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel-list panel for the chat window's left side. Auto-switches between icon-only (38px) and expanded (150px) based on Config.SidebarAutoSwitchThresholdPx. Each row renders a FontAwesome tab icon, an expanded-mode name label, a hover-sheen sweep keyed on the tab identifier, and a pop-out affordance — both the trailing hover button and the right-click context menu route through a log stub until the channel popout pool comes online. Glyph table is inlined so the Ui layer carries its own lookup after the standalone mapping file is removed. --- HellionChat/PluginHostFactory.cs | 6 + HellionChat/Ui/Components/Sidebar.cs | 180 +++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 HellionChat/Ui/Components/Sidebar.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index ec87080..1a2bc62 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -123,6 +123,12 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService() )); + services.AddSingleton(sp => new Ui.Components.Sidebar( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>() + )); services.AddSingleton(sp => new Integrations.FailedTellNotifier( sp.GetRequiredService>() )); diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs new file mode 100644 index 0000000..e1f1e68 --- /dev/null +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -0,0 +1,180 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +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 (150px) once +// the outer window crosses Config.SidebarAutoSwitchThresholdPx. The +// pop-out trigger is wired later (channel-popout cycle); the hover button +// and right-click menu route through a log stub for now so the discovery +// affordance is already in place. +internal sealed class Sidebar +{ + public const float IconOnlyWidth = 38f; + public const float ExpandedWidth = 150f; + + private const float RowHeight = 32f; + private const float PopOutHitWidth = 22f; + + // 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 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 _logger; + + public Sidebar( + ThemeRegistry themes, + TokenResolver resolver, + FontManager fonts, + ILogger logger + ) + { + _themes = themes; + _resolver = resolver; + _fonts = fonts; + _logger = logger; + } + + public bool IsExpanded(float windowWidth) => + windowWidth >= Plugin.Config.SidebarAutoSwitchThresholdPx; + + public float GetWidth(float windowWidth) => + IsExpanded(windowWidth) ? ExpandedWidth : IconOnlyWidth; + + public void Draw(float windowWidth, IList tabs, ref Tab? activeTab) + { + if (!_fonts.FontsReady) + { + ImGui.Dummy(new Vector2(IconOnlyWidth, 0)); + return; + } + + var expanded = IsExpanded(windowWidth); + var width = expanded ? ExpandedWidth : IconOnlyWidth; + 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 dl = ImGui.GetWindowDrawList(); + + for (var i = 0; i < tabs.Count; i++) + DrawRow(tabs[i], i, expanded, accentRgba, textAbgr, mutedAbgr, dl, ref activeTab); + } + + private void DrawRow( + Tab tab, + int index, + bool expanded, + uint accentRgba, + uint textAbgr, + uint mutedAbgr, + ImDrawListPtr dl, + ref Tab? activeTab + ) + { + ImGui.PushID(index); + + var origin = ImGui.GetCursorScreenPos(); + var avail = ImGui.GetContentRegionAvail().X; + var tabHitWidth = MathF.Max(0f, avail - PopOutHitWidth); + + // Tab hit area sits left of the pop-out button so the two never + // steal each other's clicks. + ImGui.InvisibleButton("row", new Vector2(tabHitWidth, RowHeight)); + var rowHovered = ImGui.IsItemHovered(); + if (ImGui.IsItemClicked()) + activeTab = tab; + + dl.DrawHoverSheen( + origin, + origin + new Vector2(avail, RowHeight), + accentRgba, + $"sidebar.tab.{tab.Identifier}", + rowHovered + ); + + var icon = ResolveTabIcon(tab); + using (_fonts.FontAwesome.Push()) + dl.AddText(origin + new Vector2(10f, 8f), textAbgr, icon.ToIconString()); + + if (expanded) + dl.AddText(origin + new Vector2(32f, 8f), textAbgr, tab.Name); + + if (ImGui.BeginPopupContextItem("ctx")) + { + if (ImGui.MenuItem("Pop Out")) + LogPopOutStub(tab); + ImGui.EndPopup(); + } + + ImGui.SameLine(0f, 0f); + ImGui.InvisibleButton("popout", new Vector2(PopOutHitWidth, RowHeight)); + var popHovered = ImGui.IsItemHovered(); + if (ImGui.IsItemClicked()) + LogPopOutStub(tab); + + if (rowHovered || popHovered) + { + using (_fonts.FontAwesome.Push()) + { + var glyph = FontAwesomeIcon.ArrowUpRightFromSquare.ToIconString(); + dl.AddText(origin + new Vector2(avail - PopOutHitWidth + 4f, 8f), mutedAbgr, glyph); + } + } + + ImGui.PopID(); + } + + private static FontAwesomeIcon ResolveTabIcon(Tab tab) + { + if ( + !string.IsNullOrWhiteSpace(tab.Icon) && IconByName.TryGetValue(tab.Icon, out var mapped) + ) + return mapped; + return FontAwesomeIcon.Comment; + } + + private void LogPopOutStub(Tab tab) + { + // The channel-popout pool is built in a later cycle; logging here + // keeps the trigger visible without faking the routing. + _logger.LogInformation( + "Pop-out requested for tab {Identifier} ({Name}); routing arrives later.", + tab.Identifier, + tab.Name + ); + } +}