From abbbf95002fde2a44d8f72afce760e8f8bcf7e35 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sat, 16 May 2026 01:11:12 +0200 Subject: [PATCH] feat(ui): add SymbolPicker popup with FFXIV icon tab New popup attached to the chat input lets the user browse and insert Dalamud SeIconChar glyphs (161 PUA codepoints, server-safe by design). Search field filters by enum name. Multi-insert keeps the popup open until the user clicks elsewhere. BMP tab follows in the next commit. --- HellionChat/Ui/ChatLogWindow.cs | 27 +++++++ HellionChat/Ui/SymbolPicker.cs | 124 ++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 HellionChat/Ui/SymbolPicker.cs diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs index a240da9..6710716 100644 --- a/HellionChat/Ui/ChatLogWindow.cs +++ b/HellionChat/Ui/ChatLogWindow.cs @@ -40,6 +40,7 @@ public sealed class ChatLogWindow : Window private readonly CommandWrapper _clearHellionCommand; private readonly CommandWrapper _hellionCommand; + private readonly SymbolPicker _symbolPicker; internal bool ScreenshotMode; private string Salt { get; } @@ -129,6 +130,8 @@ public sealed class ChatLogWindow : Window _clearHellionCommand.Execute += ClearLog; _hellionCommand.Execute += ToggleChat; + _symbolPicker = new SymbolPicker(); + Plugin.ClientState.Login += Login; Plugin.ClientState.Logout += Logout; @@ -792,6 +795,30 @@ public sealed class ChatLogWindow : Window ) inputColour = ecColour; + // Symbol-picker trigger sits left of the channel indicator. ImRaii.Popup + // inside DrawAndConsume pins to the last rendered item, so the call MUST + // run immediately after this IconButton — placing it after the channel + // picker below would pin the popup under the wrong widget. + if (ImGuiUtil.IconButton( + FontAwesomeIcon.Smile, + "symbol-picker-trigger", + "Insert symbol or FFXIV icon")) + { + _symbolPicker.OpenPopup(); + } + var insertedSymbol = _symbolPicker.DrawAndConsume(); + if (insertedSymbol is not null) + { + // Same cursor-aware splice idiom as the AutoComplete commit path at + // ChatLogWindow.cs:2487-2493. Clamp because CursorPos can drift if + // the user mutates Chat while the popup is open. + var pos = Math.Clamp(CursorPos, 0, Chat.Length); + Chat = Chat[..pos] + insertedSymbol + Chat[pos..]; + Activate = true; + ActivatePos = pos + insertedSymbol.Length; + } + ImGui.SameLine(); + var beforeIcon = ImGui.GetCursorPos(); var tintSelector = Plugin.Config.ColorSelectedInputChannelButton && inputColour.HasValue; diff --git a/HellionChat/Ui/SymbolPicker.cs b/HellionChat/Ui/SymbolPicker.cs new file mode 100644 index 0000000..a252003 --- /dev/null +++ b/HellionChat/Ui/SymbolPicker.cs @@ -0,0 +1,124 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text; +using Dalamud.Interface.Utility.Raii; + +namespace HellionChat.Ui; + +// Popup picker for chat-input symbol insertion. Two tabs: +// PUA — Dalamud's SeIconChar enum (161 server-safe FFXIV glyphs) +// BMP — server-verified Unicode symbols (whitelist built 2026-05-XX) +// +// Render-only — the Settings-Guard for showing the trigger button lives on +// the caller side (ChatLogWindow). Recent-Used is session state by design. +internal sealed class SymbolPicker +{ + private const string PopupId = "HellionSymbolPicker"; + private const int RecentCapacity = 16; + + private string _search = string.Empty; + private readonly List _recentUsed = new(capacity: RecentCapacity); + + public void OpenPopup() => ImGui.OpenPopup(PopupId); + + // Returns the inserted codepoint as a string fragment if the user clicked + // one this frame, or null otherwise. Caller splices the fragment into the + // chat-input buffer at the current cursor position. + public string? DrawAndConsume() + { + // ImRaii.Popup mirrors ChatLogWindow.cs:823 / :2380, PayloadHandler.cs:68 + // — auto-EndPopup via using-Dispose. + using var popup = ImRaii.Popup(PopupId); + if (!popup) + return null; + + string? inserted = null; + + using (var tabs = ImRaii.TabBar("##symbolpicker-tabs")) + { + if (tabs) + { + inserted = DrawPuaTab() ?? inserted; + inserted = DrawBmpTab() ?? inserted; + } + } + + if (inserted is not null) + TrackRecent(inserted); + + return inserted; + } + + private string? DrawPuaTab() + { + using var tab = ImRaii.TabItem("FFXIV Icons"); + if (!tab) + return null; + + ImGui.InputTextWithHint("##pua-search", "Search by name (e.g. HighQuality)", ref _search, 64); + + string? inserted = null; + + if (ImGui.BeginChild("##pua-grid", new Vector2(0, 280), false)) + { + var query = _search; + foreach (var icon in Enum.GetValues()) + { + var label = icon.ToString(); + if (query.Length > 0 + && label.IndexOf(query, StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + + // ToIconString() returns the single-codepoint string ready for + // ImGui rendering (Dalamud SeIconCharExtensions.cs:25). Tooltip + // carries the enum name so users can discover what each glyph + // means. + if (ImGui.Selectable( + icon.ToIconString(), + false, + ImGuiSelectableFlags.DontClosePopups, + new Vector2(24, 24))) + { + inserted = icon.ToIconString(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(label); + + // Manually-wrapping pattern from imgui_demo.cpp on modern API. + // GetWindowContentRegionMax is obsolete since ImGui 1.92 + // (imgui.h:565); HellionChat uses GetContentRegionAvail + // throughout (e.g. ChatLogWindow.cs:840). Same modern idiom. + var style = ImGui.GetStyle(); + var lastItemX2 = ImGui.GetItemRectMax().X; + var availableRightX = + ImGui.GetCursorScreenPos().X + ImGui.GetContentRegionAvail().X; + if (lastItemX2 + style.ItemSpacing.X + 24f < availableRightX) + ImGui.SameLine(); + } + } + ImGui.EndChild(); + + return inserted; + } + + // Task 5 wires the BMP whitelist; the stub keeps the popup contract intact + // until then. + private string? DrawBmpTab() => null; + + private void TrackRecent(string fragment) + { + if (string.IsNullOrEmpty(fragment) || fragment.Length > 4) + return; + + var codepoint = (uint)char.ConvertToUtf32(fragment, 0); + + // Move-to-front so the head stays the freshest pick. + _recentUsed.RemoveAll(c => c == codepoint); + _recentUsed.Insert(0, codepoint); + + if (_recentUsed.Count > RecentCapacity) + _recentUsed.RemoveAt(_recentUsed.Count - 1); + } +}