feat: Add mod filter, sort menu and category counts to browsers
This commit is contained in:
1 parent
9de8112ce9
commit
aae755913d
7 files changed
+248
-26
No files matched your search
@@ -11,7 +11,7 @@ using TerraSharp.Features;
|
||||
|
||||
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";
|
||||
|
||||
@@ -45,6 +45,10 @@ public sealed class BuffBrowser() : GridBrowser("buffs", "Buff Browser", new Vec
|
||||
static bool IsMount(int type) => BuffID.Sets.MountType[type] >= 0;
|
||||
#endif
|
||||
|
||||
#if TMODLOADER
|
||||
protected override string? ModOf(int type) => BuffLoader.GetBuff(type)?.Mod.DisplayNameClean;
|
||||
#endif
|
||||
|
||||
public static string FormatTime(int ticks)
|
||||
{
|
||||
int seconds = ticks / 60;
|
||||
|
||||
@@ -10,19 +10,50 @@ namespace TerraSharp.UI;
|
||||
// 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 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)
|
||||
{
|
||||
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 Scroll _scroll = new();
|
||||
readonly Scroll _menuScroll = new();
|
||||
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 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;
|
||||
|
||||
public override bool Open
|
||||
{
|
||||
get => base.Open;
|
||||
set
|
||||
{
|
||||
base.Open = value;
|
||||
_menu = Menu.None;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed override void DrawBody(Rectangle body)
|
||||
{
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
// An open menu takes all input until it closes.
|
||||
bool blocked = Widgets.Blocked;
|
||||
if (_menu != Menu.None)
|
||||
Widgets.Blocked = true;
|
||||
DrawContent(body);
|
||||
Widgets.Blocked = blocked;
|
||||
if (_menu != Menu.None)
|
||||
DrawMenu(body);
|
||||
}
|
||||
|
||||
protected abstract void DrawContent(Rectangle body);
|
||||
|
||||
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)
|
||||
{
|
||||
var field = new Rectangle(body.X, top, body.Width - 110, 36);
|
||||
Widgets.Field(_search, field);
|
||||
int gridTop = DrawCategories(body, top + 42);
|
||||
Index.Filter(_search.Text, _subTabs[_category] is { } subTabs ? subTabs[_subcategory].Fits : categories[_category].Fits);
|
||||
LoadSources();
|
||||
Widgets.Field(_search, new Rectangle(body.X, top, body.Width, 36));
|
||||
int toolbarY = top + 42;
|
||||
int gridTop = DrawCategories(body, DrawToolbar(body, toolbarY));
|
||||
Index.Filter(_search.Text, CurrentFits(), _sorts[_sort].Order);
|
||||
if (_search.Text != _lastQuery)
|
||||
{
|
||||
_lastQuery = _search.Text;
|
||||
_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);
|
||||
Widgets.Panel(inner, Widgets.InnerBg);
|
||||
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)
|
||||
{
|
||||
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;
|
||||
_scroll.Reset();
|
||||
}
|
||||
if (!iconTab)
|
||||
if (Widgets.Hot(bounds))
|
||||
Widgets.Tooltip = $"{tab.Name}: {CountOf(tab)} {noun}";
|
||||
if (!iconTab || icons is null)
|
||||
continue;
|
||||
if (icons is not null)
|
||||
{
|
||||
|
||||
// Journey icon frames include a 2px separator on the right.
|
||||
Rectangle frame = GameTextures.Frame(icons, 11, 1, tab.Icon, 0);
|
||||
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);
|
||||
}
|
||||
if (Widgets.Hot(bounds))
|
||||
Widgets.Tooltip = tab.Name;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ public sealed class IdIndex(Func<int> count, Func<int, string?> name)
|
||||
string[]? _names;
|
||||
string? _lastQuery;
|
||||
Func<int, bool>? _lastFits;
|
||||
Comparison<int>? _lastOrder;
|
||||
|
||||
public List<int> Ids { get; } = [];
|
||||
|
||||
public int Version { get; private set; }
|
||||
|
||||
string[] Names
|
||||
{
|
||||
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 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();
|
||||
if (query == _lastQuery && fits == _lastFits)
|
||||
if (query == _lastQuery && fits == _lastFits && order == _lastOrder)
|
||||
return;
|
||||
(_lastQuery, _lastFits) = (query, fits);
|
||||
(_lastQuery, _lastFits, _lastOrder) = (query, fits, order);
|
||||
string[] names = Names;
|
||||
Ids.Clear();
|
||||
if (query.Length == 0)
|
||||
{
|
||||
for (int i = 1; i < names.Length; i++)
|
||||
if (names[i].Length > 0 && fits(i))
|
||||
foreach (int i in All())
|
||||
if (fits(i))
|
||||
Ids.Add(i);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (int.TryParse(query, out int id) && id > 0 && id < names.Length && names[id].Length > 0 && fits(id))
|
||||
Ids.Add(id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (order is not null)
|
||||
Ids.Sort((a, b) => order(a, b) is var result and not 0 ? result : a.CompareTo(b));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using Terraria;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ using Terraria.ModLoader;
|
||||
|
||||
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
|
||||
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),
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
// Whips must be classified as summon weapons before checking melee.
|
||||
|
||||
@@ -4,7 +4,7 @@ using Microsoft.Xna.Framework;
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
// 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)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ using TerraSharp.Features;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 BrowserSort[] Sorts = [new("Life", (a, b) => (Sample(b)?.lifeMax ?? 0).CompareTo(Sample(a)?.lifeMax ?? 0))];
|
||||
|
||||
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 bool IsBoss(int type) => Sample(type) is { boss: true };
|
||||
|
||||
Reference in new issue
Block a user