feat(ui): migrate SymbolPicker into the components layer

Lifted out of Ui/ into Ui/Components and registered as a DI singleton so
the new InputBar can consume it via constructor injection. PUA tab still
sources from SeIconChar, BMP tab keeps the server-verified whitelist
verbatim. The old Ui/SymbolPicker.cs stays in place until the cleanup
block removes the v1.5.6 file.
This commit is contained in:
2026-05-23 19:12:18 +02:00
parent eb95968750
commit bdfb1298ad
2 changed files with 295 additions and 0 deletions
+1
View File
@@ -134,6 +134,7 @@ internal static class PluginHostFactory
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(), sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
sp.GetRequiredService<FontManager>() sp.GetRequiredService<FontManager>()
)); ));
services.AddSingleton(_ => new Ui.Components.SymbolPicker());
services.AddSingleton(sp => new Integrations.FailedTellNotifier( services.AddSingleton(sp => new Integrations.FailedTellNotifier(
sp.GetRequiredService<ILogger<Integrations.FailedTellNotifier>>() sp.GetRequiredService<ILogger<Integrations.FailedTellNotifier>>()
)); ));
+294
View File
@@ -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<uint> _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<SeIconChar>())
{
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);
}
}