Files
HellionChat/HellionChat/Util/SearchSelector.cs
T
JonKazama-Hellion af7c757e63 fix(ui): ImGui ID collisions and missing popup-open trigger
- Util/SearchSelector.cs ImRaii.PushId(id) collapsed every row in the
  filtered list to the same ImGui ID, leaving the ID stack ambiguous
  for click resolution. Mix the row index into the pushed id so every
  Selectable has a distinct ImGui identifier
- Ui/SettingsTabs/Chat.cs blocked-emote add-button never opened the
  selector popup because SearchSelector.SelectorPopup is wrapped in
  ImRaii.ContextPopupItem (right-click semantics). Detect the
  IsItemClicked() event after the button and call ImGui.OpenPopup
  explicitly so left-click opens the picker too
2026-05-03 22:05:57 +02:00

168 lines
5.9 KiB
C#

using System.Numerics;
using Dalamud.Interface.Utility;
using Dalamud.Bindings.ImGui;
using System.Collections;
using Dalamud.Interface.Utility.Raii;
namespace HellionChat.Util;
// Modified from: https://github.com/UnknownX7/Hypostasis/blob/master/ImGui/ExcelSheet.cs
public static class SearchSelector
{
private static string[]? FilteredSearchSheet;
private static string SheetSearchText = null!;
private static string PrevSearchId = null!;
private static Type PrevSearchType = null!;
public record SelectorOptions
{
public Func<string, string> FormatRow { get; init; } = row => row.ToString();
public Func<string, string, bool>? SearchPredicate { get; init; } = null;
public Func<string, bool, bool>? DrawSelectable { get; init; } = null;
public string[] FilteredSheet { get; init; } = [];
public Vector2? Size { get; init; } = null;
}
public record SelectorPopupOptions: SelectorOptions
{
public ImGuiPopupFlags PopupFlags { get; init; } = ImGuiPopupFlags.None;
public bool CloseOnSelection { get; init; } = false;
public Func<string, bool> IsSelected { get; init; } = _ => false;
}
private static void SearchInput(string id, IEnumerable<string> filteredSheet, Func<string, string, bool> searchPredicate)
{
if (ImGui.IsWindowAppearing() && ImGui.IsWindowFocused() && !ImGui.IsAnyItemActive())
{
if (id != PrevSearchId)
{
if (typeof(string) != PrevSearchType)
{
SheetSearchText = string.Empty;
PrevSearchType = typeof(string);
}
FilteredSearchSheet = null;
PrevSearchId = id;
}
ImGui.SetKeyboardFocusHere(0);
}
if (ImGui.InputTextWithHint("##ExcelSheetSearch", "Search", ref SheetSearchText, 128, ImGuiInputTextFlags.AutoSelectAll))
FilteredSearchSheet = null;
FilteredSearchSheet ??= filteredSheet.Where(s => searchPredicate(s, SheetSearchText)).ToArray();
}
public static bool SelectorPopup(string id, out string selected, SelectorPopupOptions? options = null, bool close = false)
{
options ??= new SelectorPopupOptions();
var sheet = options.FilteredSheet;
selected = string.Empty;
if (close)
return false;
ImGui.SetNextWindowSize(options.Size ?? new Vector2(0, 250 * ImGuiHelpers.GlobalScale));
using var popup = ImRaii.ContextPopupItem(id, options.PopupFlags);
if (!popup.Success)
return false;
SearchInput(id, sheet, options.SearchPredicate ?? ((row, s) => options.FormatRow(row).Contains(s, StringComparison.CurrentCultureIgnoreCase)));
using var child = ImRaii.Child("SearchList", Vector2.Zero, true);
if (!child.Success)
return false;
var ret = false;
var drawSelectable = options.DrawSelectable ?? ((row, selected) => ImGui.Selectable(options.FormatRow(row), selected));
using (var clipper = new ListClipper(FilteredSearchSheet!.Length))
{
foreach (var i in clipper.Rows)
{
var searched = FilteredSearchSheet[i];
// Mix the row index into the ImGui ID so each Selectable in
// the loop has a distinct ID — using the same id for every
// row collapsed all rows to a single ID-stack entry and made
// selection ambiguous.
using var pushedId = ImRaii.PushId($"{id}##{i}");
if (!drawSelectable(searched, options.IsSelected(searched)))
continue;
selected = searched;
ret = true;
}
}
// ImGui issue #273849, children keep popups from closing automatically
if (ret && options.CloseOnSelection)
ImGui.CloseCurrentPopup();
return ret;
}
}
public unsafe class ListClipper : IEnumerable<(int, int)>, IDisposable
{
private ImGuiListClipperPtr Clipper;
private readonly int CurrentRows;
private readonly int CurrentColumns;
private readonly bool TwoDimensional;
private readonly int ItemRemainder;
public int FirstRow { get; private set; } = -1;
public int CurrentRow { get; private set; }
public int DisplayEnd => Clipper.DisplayEnd;
public IEnumerable<int> Rows
{
get
{
while (Clipper.Step()) // Supposedly this calls End()
{
if (Clipper.ItemsHeight > 0 && FirstRow < 0)
FirstRow = (int)(ImGui.GetScrollY() / Clipper.ItemsHeight);
for (var i = Clipper.DisplayStart; i < Clipper.DisplayEnd; i++)
{
CurrentRow = i;
yield return TwoDimensional ? i : i * CurrentColumns;
}
}
}
}
private IEnumerable<int> Columns
{
get
{
var cols = (ItemRemainder == 0 || CurrentRows != DisplayEnd || CurrentRow != DisplayEnd - 1) ? CurrentColumns : ItemRemainder;
for (var j = 0; j < cols; j++)
yield return j;
}
}
public ListClipper(int items, int cols = 1, bool twoD = false, float itemHeight = 0)
{
TwoDimensional = twoD;
CurrentColumns = cols;
CurrentRows = TwoDimensional ? items : (int)MathF.Ceiling((float)items / CurrentColumns);
ItemRemainder = !TwoDimensional ? items % CurrentColumns : 0;
Clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
Clipper.Begin(CurrentRows, itemHeight);
}
public IEnumerator<(int, int)> GetEnumerator() => (from i in Rows from j in Columns select (i, j)).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public void Dispose()
{
Clipper.Destroy(); // This also calls End() but I'm calling it anyway just in case
GC.SuppressFinalize(this);
}
}