From 0e470fcdced3a4bddbd2b7be83c37c2dc82ad4e2 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sat, 16 May 2026 09:27:58 +0200 Subject: [PATCH] feat(ui): SymbolPicker BMP tab and session-only recents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second tab exposes the server-verified BMP whitelist (round-tripped via /echo and /say in the v1.4.10 preflight). Recent-used row at the top floats the user's last sixteen picks across both tabs, move-to-front on reuse. Recents stay session-only by design — no Configuration touch, schema unchanged. --- HellionChat/Ui/SymbolPicker.cs | 183 ++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 4 deletions(-) diff --git a/HellionChat/Ui/SymbolPicker.cs b/HellionChat/Ui/SymbolPicker.cs index a252003..2eb16d0 100644 --- a/HellionChat/Ui/SymbolPicker.cs +++ b/HellionChat/Ui/SymbolPicker.cs @@ -7,7 +7,7 @@ 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) +// BMP — server-verified Unicode symbols (whitelist built 2026-05-16) // // Render-only — the Settings-Guard for showing the trigger button lives on // the caller side (ChatLogWindow). Recent-Used is session state by design. @@ -19,6 +19,114 @@ internal sealed class SymbolPicker private string _search = string.Empty; private readonly List _recentUsed = new(capacity: RecentCapacity); + // FFXIV server-safe BMP symbols, verified 2026-05-16 via /echo + /say + // round-trip across four probe rounds (140 candidates tested, 43 filtered). + // Range U+2694-26C4 (Misc Symbols Extended), U+2700+ (Dingbats Extended), + // diagonal arrows, vulgar fractions U+2153+, and chess pieces all get + // dropped by the server, so they're not exposed here. + // Source-of-truth for this list lives in + // Projekte/FFXIV/Hellion Chat/Cycles/v1.4.10 BMP-Whitelist Notes.md. + private static readonly (uint Codepoint, string Name)[] BmpWhitelist = new[] + { + (0x00A1u, "Inverted Exclamation"), + (0x00A2u, "Cent Sign"), + (0x00A3u, "Pound Sign"), + (0x00A4u, "Currency Sign"), + (0x00A5u, "Yen Sign"), + (0x00A7u, "Section Sign"), + (0x00A9u, "Copyright Sign"), + (0x00ABu, "Left Angle Quote"), + (0x00AEu, "Registered Sign"), + (0x00B0u, "Degree Sign"), + (0x00B1u, "Plus-Minus Sign"), + (0x00B6u, "Pilcrow Sign"), + (0x00BBu, "Right Angle Quote"), + (0x00BCu, "One Quarter"), + (0x00BDu, "One Half"), + (0x00BEu, "Three Quarters"), + (0x00BFu, "Inverted Question"), + (0x00D7u, "Multiplication Sign"), + (0x00F7u, "Division Sign"), + (0x0393u, "Greek Capital Gamma"), + (0x0394u, "Greek Capital Delta"), + (0x0398u, "Greek Capital Theta"), + (0x039Bu, "Greek Capital Lambda"), + (0x039Eu, "Greek Capital Xi"), + (0x03A0u, "Greek Capital Pi"), + (0x03A3u, "Greek Capital Sigma"), + (0x03A6u, "Greek Capital Phi"), + (0x03A8u, "Greek Capital Psi"), + (0x03A9u, "Greek Capital Omega"), + (0x03B1u, "Greek Small Alpha"), + (0x03B2u, "Greek Small Beta"), + (0x03B3u, "Greek Small Gamma"), + (0x03B4u, "Greek Small Delta"), + (0x03B5u, "Greek Small Epsilon"), + (0x03B6u, "Greek Small Zeta"), + (0x03B7u, "Greek Small Eta"), + (0x03B8u, "Greek Small Theta"), + (0x03B9u, "Greek Small Iota"), + (0x03BAu, "Greek Small Kappa"), + (0x03BBu, "Greek Small Lambda"), + (0x03BCu, "Greek Small Mu"), + (0x03BDu, "Greek Small Nu"), + (0x03BEu, "Greek Small Xi"), + (0x03BFu, "Greek Small Omicron"), + (0x03C0u, "Greek Small Pi"), + (0x03C1u, "Greek Small Rho"), + (0x03C3u, "Greek Small Sigma"), + (0x03C4u, "Greek Small Tau"), + (0x03C5u, "Greek Small Upsilon"), + (0x03C6u, "Greek Small Phi"), + (0x03C7u, "Greek Small Chi"), + (0x03C8u, "Greek Small Psi"), + (0x03C9u, "Greek Small Omega"), + (0x2013u, "En Dash"), + (0x2014u, "Em Dash"), + (0x2020u, "Dagger"), + (0x2021u, "Double Dagger"), + (0x2026u, "Horizontal Ellipsis"), + (0x203Bu, "Reference Mark"), + (0x20ACu, "Euro Sign"), + (0x2122u, "Trade Mark Sign"), + (0x2190u, "Leftwards Arrow"), + (0x2191u, "Upwards Arrow"), + (0x2192u, "Rightwards Arrow"), + (0x2193u, "Downwards Arrow"), + (0x21D2u, "Rightwards Double Arrow"), + (0x21D4u, "Left Right Double Arrow"), + (0x2202u, "Partial Differential"), + (0x2207u, "Nabla"), + (0x2211u, "Summation"), + (0x221Au, "Square Root"), + (0x221Eu, "Infinity"), + (0x222Bu, "Integral"), + (0x2260u, "Not Equal To"), + (0x25A0u, "Black Square"), + (0x25A1u, "White Square"), + (0x25B2u, "Black Up Triangle"), + (0x25B3u, "White Up Triangle"), + (0x25BCu, "Black Down Triangle"), + (0x25C6u, "Black Diamond"), + (0x25C7u, "White Diamond"), + (0x25CBu, "White Circle"), + (0x25CFu, "Black Circle"), + (0x2600u, "Black Sun With Rays"), + (0x2601u, "Cloud"), + (0x2602u, "Umbrella"), + (0x2603u, "Snowman"), + (0x2605u, "Black Star"), + (0x2606u, "White Star"), + (0x2640u, "Female Sign"), + (0x2642u, "Male Sign"), + (0x2660u, "Black Spade Suit"), + (0x2661u, "White Heart Suit"), + (0x2663u, "Black Club Suit"), + (0x2665u, "Black Heart Suit"), + (0x266Au, "Eighth Note"), + (0x2713u, "Check Mark"), + }; + public void OpenPopup() => ImGui.OpenPopup(PopupId); // Returns the inserted codepoint as a string fragment if the user clicked @@ -34,6 +142,29 @@ internal sealed class SymbolPicker string? inserted = null; + // Recent-Used-Row sits above the tabs so both PUA and BMP picks share + // one fast-access strip. Session-only by design (see TrackRecent). + if (_recentUsed.Count > 0) + { + ImGui.TextDisabled("Recent"); + ImGui.SameLine(); + foreach (var codepoint in _recentUsed) + { + var glyph = char.ConvertFromUtf32((int)codepoint); + if (ImGui.Selectable( + glyph, + false, + ImGuiSelectableFlags.DontClosePopups, + new Vector2(20, 20))) + { + inserted = glyph; + } + ImGui.SameLine(); + } + ImGui.NewLine(); + ImGui.Separator(); + } + using (var tabs = ImRaii.TabBar("##symbolpicker-tabs")) { if (tabs) @@ -103,9 +234,53 @@ internal sealed class SymbolPicker return inserted; } - // Task 5 wires the BMP whitelist; the stub keeps the popup contract intact - // until then. - private string? DrawBmpTab() => null; + private string? DrawBmpTab() + { + using var tab = ImRaii.TabItem("Symbols"); + if (!tab) + return null; + + ImGui.InputTextWithHint("##bmp-search", "Search by name (e.g. Heart)", ref _search, 64); + + string? inserted = null; + + if (ImGui.BeginChild("##bmp-grid", new Vector2(0, 280), false)) + { + var query = _search; + foreach (var (codepoint, name) in BmpWhitelist) + { + if (query.Length > 0 + && name.IndexOf(query, StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + + var glyph = char.ConvertFromUtf32((int)codepoint); + if (ImGui.Selectable( + glyph, + false, + ImGuiSelectableFlags.DontClosePopups, + new Vector2(24, 24))) + { + inserted = glyph; + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(name); + + // Same manually-wrapping pattern as DrawPuaTab — modern API + // since GetWindowContentRegionMax was deprecated in ImGui 1.92. + 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; + } private void TrackRecent(string fragment) {