diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index f03c37a..247494f 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -134,6 +134,7 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService() )); + services.AddSingleton(_ => new Ui.Components.SymbolPicker()); services.AddSingleton(sp => new Integrations.FailedTellNotifier( sp.GetRequiredService>() )); diff --git a/HellionChat/Ui/Components/SymbolPicker.cs b/HellionChat/Ui/Components/SymbolPicker.cs new file mode 100644 index 0000000..04a6998 --- /dev/null +++ b/HellionChat/Ui/Components/SymbolPicker.cs @@ -0,0 +1,294 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text; +using Dalamud.Interface.Utility.Raii; + +namespace HellionChat.Ui.Components; + +// Popup picker for chat-input symbol insertion. Two tabs: +// PUA — Dalamud's SeIconChar enum (server-safe FFXIV glyphs) +// BMP — server-verified Unicode symbols (whitelist probed via /echo + /say) +// +// Render-only — the visibility toggle for the trigger button lives on the +// caller side (InputBar). 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); + + // FFXIV server-safe BMP symbols, verified via /echo + /say. Filtered + // ranges live in the v1.4.10 BMP-Whitelist Notes for the original probe; + // the list stays inline so the picker has no external lookup table. + 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 + // one this frame, or null otherwise. Caller splices the fragment into the + // chat-input buffer at the current cursor position. + public string? DrawAndConsume() + { + using var popup = ImRaii.Popup(PopupId); + if (!popup) + return null; + + string? inserted = null; + + 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) + { + 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; + + if ( + ImGui.Selectable( + icon.ToIconString(), + false, + ImGuiSelectableFlags.DontClosePopups, + new Vector2(24, 24) + ) + ) + { + inserted = icon.ToIconString(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(label); + + // Manual wrap — GetWindowContentRegionMax was deprecated in + // ImGui 1.92, so we compute the right edge ourselves. + 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 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); + + 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) + { + 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); + } +}