feat: Add mod filter, sort menu and category counts to browsers

This commit is contained in:
VAC committed 2026-10-16 22:15:54 -03:00
1 parent 9de8112ce9
commit aae755913d
7 files changed
+248 -26

No files matched your search

+5 -1
View File
@@ -11,7 +11,7 @@ using TerraSharp.Features;
namespace TerraSharp.UI; namespace TerraSharp.UI;
public sealed class BuffBrowser() : GridBrowser("buffs", "Buff Browser", new Vector2(660, 160), 480, 540, "Search buffs by name or ID", "buffs", new IdIndex(() => BuffCount, Lang.GetBuffName), Categories) public sealed class BuffBrowser() : GridBrowser("buffs", "Buff Browser", new Vector2(660, 160), 480, 574, "Search buffs by name or ID", "buffs", new IdIndex(() => BuffCount, Lang.GetBuffName), Categories, [])
{ {
const string DurationSlider = "buffDuration"; const string DurationSlider = "buffDuration";
@@ -45,6 +45,10 @@ public sealed class BuffBrowser() : GridBrowser("buffs", "Buff Browser", new Vec
static bool IsMount(int type) => BuffID.Sets.MountType[type] >= 0; static bool IsMount(int type) => BuffID.Sets.MountType[type] >= 0;
#endif #endif
#if TMODLOADER
protected override string? ModOf(int type) => BuffLoader.GetBuff(type)?.Mod.DisplayNameClean;
#endif
public static string FormatTime(int ticks) public static string FormatTime(int ticks)
{ {
int seconds = ticks / 60; int seconds = ticks / 60;
+182 -14
View File
@@ -10,19 +10,50 @@ namespace TerraSharp.UI;
// Icon is a frame of Images/UI/Creative/Infinite_Icons; -1 draws the name instead. // Icon is a frame of Images/UI/Creative/Infinite_Icons; -1 draws the name instead.
public sealed record BrowserCategory(string Name, Func<int, bool> Fits, int Icon = -1, BrowserCategory[]? Subcategories = null); public sealed record BrowserCategory(string Name, Func<int, bool> Fits, int Icon = -1, BrowserCategory[]? Subcategories = null);
public abstract class GridBrowser(string id, string title, Vector2 position, int width, int height, string placeholder, string noun, IdIndex index, BrowserCategory[] categories) // A null order keeps ID order, with name-prefix matches first while searching.
public sealed record BrowserSort(string Name, Comparison<int>? Order);
public abstract class GridBrowser(string id, string title, Vector2 position, int width, int height, string placeholder, string noun, IdIndex index, BrowserCategory[] categories, BrowserSort[] sorts)
: Window(id, title, position, width, height) : Window(id, title, position, width, height)
{ {
const int SlotSize = 44, Gap = 4, Step = SlotSize + Gap, TabHeight = 32, SubTabHeight = 24, IconTabWidth = 38; enum Menu { None, Mod, Sort }
const int SlotSize = 44, Gap = 4, Step = SlotSize + Gap, TabHeight = 32, SubTabHeight = 24, IconTabWidth = 38, ToolbarHeight = 28, MenuRowHeight = 26, MenuRows = 10;
const float MenuScale = 0.78f;
readonly TextField _search = new(placeholder); readonly TextField _search = new(placeholder);
readonly Scroll _scroll = new(); readonly Scroll _scroll = new();
readonly Scroll _menuScroll = new();
string _lastQuery = ""; string _lastQuery = "";
int _category, _subcategory; int _category, _subcategory, _mod, _sort;
readonly BrowserCategory[]?[] _subTabs = categories.Select(category => category.Subcategories?.Prepend(new BrowserCategory("All", category.Fits)).ToArray()).ToArray(); readonly BrowserCategory[]?[] _subTabs = categories.Select(category => category.Subcategories?.Prepend(new BrowserCategory("All", category.Fits)).ToArray()).ToArray();
readonly BrowserSort[] _sorts = [new("ID", null), new("Name", (a, b) => string.Compare(index.Name(a), index.Name(b), StringComparison.OrdinalIgnoreCase)), .. sorts];
// Mod display name per id, null for vanilla.
string?[]? _sources;
(string? Source, string Name, int Count)[] _mods = [];
Func<int, bool>? _fits;
(int Category, int Subcategory, int Mod) _fitsKey;
readonly Dictionary<BrowserCategory, int> _counts = [];
(int Version, int Mod) _countsKey = (-1, -1);
Menu _menu;
Rectangle _menuAnchor;
bool _menuOpened;
protected IdIndex Index { get; } = index; protected IdIndex Index { get; } = index;
public override bool Open
{
get => base.Open;
set
{
base.Open = value;
_menu = Menu.None;
}
}
public sealed override void DrawBody(Rectangle body) public sealed override void DrawBody(Rectangle body)
{ {
if (Main.gameMenu) if (Main.gameMenu)
@@ -30,31 +61,170 @@ public abstract class GridBrowser(string id, string title, Vector2 position, int
Render.TextCentered(Widgets.SpriteBatch, "Enter a world to use this.", body, Widgets.Dim, 0.9f); Render.TextCentered(Widgets.SpriteBatch, "Enter a world to use this.", body, Widgets.Dim, 0.9f);
return; return;
} }
// An open menu takes all input until it closes.
bool blocked = Widgets.Blocked;
if (_menu != Menu.None)
Widgets.Blocked = true;
DrawContent(body); DrawContent(body);
Widgets.Blocked = blocked;
if (_menu != Menu.None)
DrawMenu(body);
} }
protected abstract void DrawContent(Rectangle body); protected abstract void DrawContent(Rectangle body);
protected abstract void DrawSlot(int id, Rectangle bounds, bool hot); protected abstract void DrawSlot(int id, Rectangle bounds, bool hot);
protected virtual string? ModOf(int id) => null;
protected void DrawSearchGrid(Rectangle body, int top, int bottom) protected void DrawSearchGrid(Rectangle body, int top, int bottom)
{ {
var field = new Rectangle(body.X, top, body.Width - 110, 36); LoadSources();
Widgets.Field(_search, field); Widgets.Field(_search, new Rectangle(body.X, top, body.Width, 36));
int gridTop = DrawCategories(body, top + 42); int toolbarY = top + 42;
Index.Filter(_search.Text, _subTabs[_category] is { } subTabs ? subTabs[_subcategory].Fits : categories[_category].Fits); int gridTop = DrawCategories(body, DrawToolbar(body, toolbarY));
Index.Filter(_search.Text, CurrentFits(), _sorts[_sort].Order);
if (_search.Text != _lastQuery) if (_search.Text != _lastQuery)
{ {
_lastQuery = _search.Text; _lastQuery = _search.Text;
_scroll.Reset(); _scroll.Reset();
} }
Render.TextRight(Widgets.SpriteBatch, $"{Index.Ids.Count} {noun}", new Rectangle(field.Right, top, 106, 36), Widgets.Dim, 0.8f); Render.TextRight(Widgets.SpriteBatch, $"{Index.Ids.Count} {noun}", new Rectangle(body.X, toolbarY, body.Width - 2, ToolbarHeight), Widgets.Dim, 0.8f);
var inner = new Rectangle(body.X, gridTop, body.Width, bottom - gridTop); var inner = new Rectangle(body.X, gridTop, body.Width, bottom - gridTop);
Widgets.Panel(inner, Widgets.InnerBg); Widgets.Panel(inner, Widgets.InnerBg);
DrawGrid(Index.Ids, new Rectangle(inner.X + 10, inner.Y + 10, inner.Width - 34, inner.Height - 20)); DrawGrid(Index.Ids, new Rectangle(inner.X + 10, inner.Y + 10, inner.Width - 34, inner.Height - 20));
} }
void LoadSources()
{
if (_sources is not null)
return;
int[] ids = [.. Index.All()];
_sources = new string?[ids.Length == 0 ? 0 : ids[ids.Length - 1] + 1];
Dictionary<string, int> counts = [];
foreach (int id in ids)
if (ModOf(id) is { } mod)
{
_sources[id] = mod;
counts[mod] = counts.TryGetValue(mod, out int count) ? count + 1 : 1;
}
if (counts.Count == 0)
return;
_mods = [(null, "All mods", ids.Length), (null, "Terraria", ids.Length - counts.Values.Sum()), .. counts.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase).Select(pair => ((string?)pair.Key, pair.Key, pair.Value))];
}
string? Source(int id) => id < _sources!.Length ? _sources[id] : null;
Func<int, bool> WithMod(Func<int, bool> fits)
{
if (_mod == 0)
return fits;
string? source = _mods[_mod].Source;
return id => Source(id) == source && fits(id);
}
// IdIndex.Filter compares delegates by reference, so reuse one per selection.
Func<int, bool> CurrentFits()
{
if (_fits is not null && _fitsKey == (_category, _subcategory, _mod))
return _fits;
_fitsKey = (_category, _subcategory, _mod);
BrowserCategory selected = _subTabs[_category] is { } subTabs ? subTabs[_subcategory] : categories[_category];
return _fits = WithMod(selected.Fits);
}
int CountOf(BrowserCategory tab)
{
if (_countsKey != (Index.Version, _mod))
{
_counts.Clear();
_countsKey = (Index.Version, _mod);
}
if (!_counts.TryGetValue(tab, out int count))
_counts[tab] = count = Index.Count(WithMod(tab.Fits));
return count;
}
int DrawToolbar(Rectangle body, int y)
{
int x = body.X;
if (_mods.Length > 0)
x = MenuButton(new Rectangle(x, y, 190, ToolbarHeight), Menu.Mod, "Images/UI/Bestiary/Button_Filtering", _mods[_mod].Name) + 6;
MenuButton(new Rectangle(x, y, 150, ToolbarHeight), Menu.Sort, "Images/UI/Bestiary/Button_Sorting", $"Sort: {_sorts[_sort].Name}");
return y + ToolbarHeight + 6;
}
int MenuButton(Rectangle bounds, Menu menu, string icon, string label)
{
bool open = _menu == menu;
if (Widgets.Button(bounds, "", open, MenuScale))
{
(_menu, _menuOpened, open) = (menu, true, true);
_menuScroll.Reset();
}
if (open)
_menuAnchor = bounds;
Widgets.Icon(GameTextures.Request(icon), new Rectangle(bounds.X + 5, bounds.Y + 4, bounds.Height - 8, bounds.Height - 8));
var text = new Rectangle(bounds.X + bounds.Height + 2, bounds.Y, bounds.Width - bounds.Height - 8, bounds.Height);
Render.TextMid(Widgets.SpriteBatch, Render.Ellipsize(label, MenuScale, text.Width), text, open || Widgets.Hot(bounds) ? Widgets.Yellow : Color.White, MenuScale);
return bounds.Right;
}
void DrawMenu(Rectangle body)
{
(string Name, string Detail)[] entries = _menu == Menu.Mod ? [.. _mods.Select(mod => (mod.Name, mod.Count.ToString()))] : [.. _sorts.Select(sort => (sort.Name, ""))];
int selected = _menu == Menu.Mod ? _mod : _sort;
int widest = (int)entries.Max(entry => Render.Measure(entry.Name, MenuScale).X + Render.Measure(entry.Detail, MenuScale).X);
int contentHeight = entries.Length * MenuRowHeight;
var popup = new Rectangle(_menuAnchor.X, _menuAnchor.Bottom + 2, Math.Max(_menuAnchor.Width, widest + 56), Math.Min(Math.Min(entries.Length, MenuRows) * MenuRowHeight + 8, body.Bottom - _menuAnchor.Bottom - 2));
bool scrolls = contentHeight > popup.Height - 8;
Widgets.Panel(popup, Widgets.WindowBg);
var area = new Rectangle(popup.X + 4, popup.Y + 4, popup.Width - 8 - (scrolls ? 20 : 0), popup.Height - 8);
int y0 = _menuScroll.Begin(area);
int picked = -1;
for (int i = 0; i < entries.Length; i++)
{
var row = new Rectangle(area.X, y0 + i * MenuRowHeight, area.Width, MenuRowHeight);
if (row.Bottom < area.Y || row.Y > area.Bottom)
continue;
bool hot = Widgets.Hot(row);
if (hot)
Render.Fill(Widgets.SpriteBatch, row, Widgets.ButtonHot);
var text = new Rectangle(row.X + 6, row.Y, row.Width - 12, row.Height);
Render.TextMid(Widgets.SpriteBatch, entries[i].Name, text, hot || i == selected ? Widgets.Yellow : Color.White, MenuScale);
Render.TextRight(Widgets.SpriteBatch, entries[i].Detail, text, Widgets.Dim, MenuScale);
if (hot && Widgets.Click)
picked = i;
}
_menuScroll.End(area, contentHeight);
if (picked >= 0)
{
if (_menu == Menu.Mod)
_mod = picked;
else
_sort = picked;
_menu = Menu.None;
_scroll.Reset();
Widgets.Sound(12);
}
else if (Widgets.Click && !_menuOpened && !Widgets.Hot(popup))
{
_menu = Menu.None;
}
_menuOpened = false;
}
int DrawCategories(Rectangle body, int y) int DrawCategories(Rectangle body, int y)
{ {
if (DrawTabs(body, categories, ref _category, y, TabHeight, 0.78f)) if (DrawTabs(body, categories, ref _category, y, TabHeight, 0.78f))
@@ -91,18 +261,16 @@ public abstract class GridBrowser(string id, string title, Vector2 position, int
changed = true; changed = true;
_scroll.Reset(); _scroll.Reset();
} }
if (!iconTab) if (Widgets.Hot(bounds))
Widgets.Tooltip = $"{tab.Name}: {CountOf(tab)} {noun}";
if (!iconTab || icons is null)
continue; continue;
if (icons is not null)
{
// Journey icon frames include a 2px separator on the right. // Journey icon frames include a 2px separator on the right.
Rectangle frame = GameTextures.Frame(icons, 11, 1, tab.Icon, 0); Rectangle frame = GameTextures.Frame(icons, 11, 1, tab.Icon, 0);
frame.Width -= 2; frame.Width -= 2;
Widgets.Icon(icons, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), frame, 1f, i == selected || Widgets.Hot(bounds) ? Color.White : Color.White * 0.7f); Widgets.Icon(icons, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), frame, 1f, i == selected || Widgets.Hot(bounds) ? Color.White : Color.White * 0.7f);
} }
if (Widgets.Hot(bounds))
Widgets.Tooltip = tab.Name;
}
return changed; return changed;
} }
+36 -7
View File
@@ -8,9 +8,12 @@ public sealed class IdIndex(Func<int> count, Func<int, string?> name)
string[]? _names; string[]? _names;
string? _lastQuery; string? _lastQuery;
Func<int, bool>? _lastFits; Func<int, bool>? _lastFits;
Comparison<int>? _lastOrder;
public List<int> Ids { get; } = []; public List<int> Ids { get; } = [];
public int Version { get; private set; }
string[] Names string[] Names
{ {
get get
@@ -27,23 +30,45 @@ public sealed class IdIndex(Func<int> count, Func<int, string?> name)
public string Name(int id) => id > 0 && id < Names.Length ? Names[id] : ""; public string Name(int id) => id > 0 && id < Names.Length ? Names[id] : "";
public void Refresh() => _lastQuery = null; public IEnumerable<int> All()
{
string[] names = Names;
for (int i = 1; i < names.Length; i++)
if (names[i].Length > 0)
yield return i;
}
public void Filter(string query, Func<int, bool> fits) public int Count(Func<int, bool> fits)
{
int total = 0;
foreach (int id in All())
if (fits(id))
total++;
return total;
}
public void Refresh()
{
_lastQuery = null;
Version++;
}
public void Filter(string query, Func<int, bool> fits, Comparison<int>? order)
{ {
query = query.Trim(); query = query.Trim();
if (query == _lastQuery && fits == _lastFits) if (query == _lastQuery && fits == _lastFits && order == _lastOrder)
return; return;
(_lastQuery, _lastFits) = (query, fits); (_lastQuery, _lastFits, _lastOrder) = (query, fits, order);
string[] names = Names; string[] names = Names;
Ids.Clear(); Ids.Clear();
if (query.Length == 0) if (query.Length == 0)
{ {
for (int i = 1; i < names.Length; i++) foreach (int i in All())
if (names[i].Length > 0 && fits(i)) if (fits(i))
Ids.Add(i); Ids.Add(i);
return;
} }
else
{
if (int.TryParse(query, out int id) && id > 0 && id < names.Length && names[id].Length > 0 && fits(id)) if (int.TryParse(query, out int id) && id > 0 && id < names.Length && names[id].Length > 0 && fits(id))
Ids.Add(id); Ids.Add(id);
foreach (bool prefixPass in (bool[])[true, false]) foreach (bool prefixPass in (bool[])[true, false])
@@ -54,4 +79,8 @@ public sealed class IdIndex(Func<int> count, Func<int, string?> name)
Ids.Add(i); Ids.Add(i);
} }
} }
if (order is not null)
Ids.Sort((a, b) => order(a, b) is var result and not 0 ? result : a.CompareTo(b));
}
} }
+1 -1
View File
@@ -3,7 +3,7 @@ using Terraria;
namespace TerraSharp.UI; namespace TerraSharp.UI;
public sealed class ItemBrowser() : ItemGrid("items", "Item Browser", new Vector2(660, 120), 534, 520, []) public sealed class ItemBrowser() : ItemGrid("items", "Item Browser", new Vector2(660, 120), 534, 554, [])
{ {
const int InventorySlots = 50; const int InventorySlots = 50;
+16 -1
View File
@@ -13,7 +13,7 @@ using Terraria.ModLoader;
namespace TerraSharp.UI; namespace TerraSharp.UI;
public abstract class ItemGrid(string id, string title, Vector2 position, int width, int height, BrowserCategory[] extra) : GridBrowser(id, title, position, width, height, "Search items by name or ID", "items", new IdIndex(() => ItemCount, Lang.GetItemNameValue), [.. Categories(), .. extra]) public abstract class ItemGrid(string id, string title, Vector2 position, int width, int height, BrowserCategory[] extra) : GridBrowser(id, title, position, width, height, "Search items by name or ID", "items", new IdIndex(() => ItemCount, Lang.GetItemNameValue), [.. Categories(), .. extra], Sorts)
{ {
#if TMODLOADER #if TMODLOADER
static int ItemCount => ItemLoader.ItemCount; static int ItemCount => ItemLoader.ItemCount;
@@ -36,6 +36,21 @@ public abstract class ItemGrid(string id, string title, Vector2 position, int wi
new(Language.GetTextValue("CreativePowers.TabMisc"), type => !Fits(Weapon, type) && !Filters.Any(entry => Fits(entry.Filter, type)), 5), new(Language.GetTextValue("CreativePowers.TabMisc"), type => !Fits(Weapon, type) && !Filters.Any(entry => Fits(entry.Filter, type)), 5),
]; ];
static readonly BrowserSort[] Sorts = [new("Rarity", (a, b) => RarityRank(b).CompareTo(RarityRank(a))), new("Value", (a, b) => (ContentSample(b)?.value ?? 0).CompareTo(ContentSample(a)?.value ?? 0))];
// Expert (-12) and Master (-13) rank above Purple (11); Quest and Gray stay at the bottom.
static int RarityRank(int type) => ContentSample(type)?.rare switch
{
-13 => 13,
-12 => 12,
{ } rare => rare,
null => int.MinValue,
};
#if TMODLOADER
protected override string? ModOf(int type) => ItemLoader.GetItem(type)?.Mod.DisplayNameClean;
#endif
static Item? ContentSample(int type) => ContentSamples.ItemsByType.TryGetValue(type, out Item? item) ? item : null; static Item? ContentSample(int type) => ContentSamples.ItemsByType.TryGetValue(type, out Item? item) ? item : null;
// Whips must be classified as summon weapons before checking melee. // Whips must be classified as summon weapons before checking melee.
+1 -1
View File
@@ -4,7 +4,7 @@ using Microsoft.Xna.Framework;
namespace TerraSharp.UI; namespace TerraSharp.UI;
// Edits a persisted set of item types, such as the auto sell list. // Edits a persisted set of item types, such as the auto sell list.
public sealed class ItemPicker(string id, string title, Vector2 position, HashSet<int> list) : ItemGrid(id, title, position, 534, 520, [new("Selected", list.Contains)]) public sealed class ItemPicker(string id, string title, Vector2 position, HashSet<int> list) : ItemGrid(id, title, position, 534, 554, [new("Selected", list.Contains)])
{ {
protected override void DrawContent(Rectangle body) protected override void DrawContent(Rectangle body)
{ {
+7 -1
View File
@@ -9,7 +9,7 @@ using TerraSharp.Features;
namespace TerraSharp.UI; namespace TerraSharp.UI;
public sealed class NpcBrowser() : GridBrowser("npcs", "NPC Spawner", new Vector2(660, 140), 532, 520, "Search NPCs by name or ID", "NPCs", new IdIndex(() => NpcCount, Lang.GetNPCNameValue), Categories) public sealed class NpcBrowser() : GridBrowser("npcs", "NPC Spawner", new Vector2(660, 140), 532, 554, "Search NPCs by name or ID", "NPCs", new IdIndex(() => NpcCount, Lang.GetNPCNameValue), Categories, Sorts)
{ {
const int Batch = 10; const int Batch = 10;
@@ -21,8 +21,14 @@ public sealed class NpcBrowser() : GridBrowser("npcs", "NPC Spawner", new Vector
static readonly BrowserCategory[] Categories = [new("All", NpcSpawner.Available), new("Bosses", type => NpcSpawner.Available(type) && IsBoss(type)), new("Enemies", type => NpcSpawner.Available(type) && Sample(type) is { friendly: false, townNPC: false } && !IsBoss(type) && !IsCritter(type)), new("Town", type => NpcSpawner.Available(type) && Sample(type) is { townNPC: true }), new("Critters", type => NpcSpawner.Available(type) && IsCritter(type))]; static readonly BrowserCategory[] Categories = [new("All", NpcSpawner.Available), new("Bosses", type => NpcSpawner.Available(type) && IsBoss(type)), new("Enemies", type => NpcSpawner.Available(type) && Sample(type) is { friendly: false, townNPC: false } && !IsBoss(type) && !IsCritter(type)), new("Town", type => NpcSpawner.Available(type) && Sample(type) is { townNPC: true }), new("Critters", type => NpcSpawner.Available(type) && IsCritter(type))];
static readonly BrowserSort[] Sorts = [new("Life", (a, b) => (Sample(b)?.lifeMax ?? 0).CompareTo(Sample(a)?.lifeMax ?? 0))];
bool _multiplayer; bool _multiplayer;
#if TMODLOADER
protected override string? ModOf(int type) => NPCLoader.GetNPC(type)?.Mod.DisplayNameClean;
#endif
static NPC? Sample(int type) => ContentSamples.NpcsByNetId.TryGetValue(type, out NPC? npc) ? npc : null; static NPC? Sample(int type) => ContentSamples.NpcsByNetId.TryGetValue(type, out NPC? npc) ? npc : null;
static bool IsBoss(int type) => Sample(type) is { boss: true }; static bool IsBoss(int type) => Sample(type) is { boss: true };