diff --git a/src/TerraSharp/UI/BuffBrowser.cs b/src/TerraSharp/UI/BuffBrowser.cs index eb714ed..7fe6ca0 100644 --- a/src/TerraSharp/UI/BuffBrowser.cs +++ b/src/TerraSharp/UI/BuffBrowser.cs @@ -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; diff --git a/src/TerraSharp/UI/GridBrowser.cs b/src/TerraSharp/UI/GridBrowser.cs index 3d0cd1b..e9aff0c 100644 --- a/src/TerraSharp/UI/GridBrowser.cs +++ b/src/TerraSharp/UI/GridBrowser.cs @@ -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 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? 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? _fits; + (int Category, int Subcategory, int Mod) _fitsKey; + readonly Dictionary _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 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 WithMod(Func 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 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,17 +261,15 @@ public abstract class GridBrowser(string id, string title, Vector2 position, int changed = true; _scroll.Reset(); } - if (!iconTab) - 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; + Widgets.Tooltip = $"{tab.Name}: {CountOf(tab)} {noun}"; + if (!iconTab || icons is null) + continue; + + // 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); } return changed; } diff --git a/src/TerraSharp/UI/IdIndex.cs b/src/TerraSharp/UI/IdIndex.cs index 304fe19..add60cf 100644 --- a/src/TerraSharp/UI/IdIndex.cs +++ b/src/TerraSharp/UI/IdIndex.cs @@ -8,9 +8,12 @@ public sealed class IdIndex(Func count, Func name) string[]? _names; string? _lastQuery; Func? _lastFits; + Comparison? _lastOrder; public List Ids { get; } = []; + public int Version { get; private set; } + string[] Names { get @@ -27,31 +30,57 @@ public sealed class IdIndex(Func count, Func name) public string Name(int id) => id > 0 && id < Names.Length ? Names[id] : ""; - public void Refresh() => _lastQuery = null; + public IEnumerable 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 fits) + public int Count(Func 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 fits, Comparison? 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; } - 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]) - for (int i = 1; i < names.Length; i++) - { - int at = names[i].IndexOf(query, StringComparison.OrdinalIgnoreCase); - if ((prefixPass ? at == 0 : at > 0) && i != id && fits(i)) - Ids.Add(i); - } + 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]) + for (int i = 1; i < names.Length; i++) + { + int at = names[i].IndexOf(query, StringComparison.OrdinalIgnoreCase); + if ((prefixPass ? at == 0 : at > 0) && i != id && fits(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)); } } diff --git a/src/TerraSharp/UI/ItemBrowser.cs b/src/TerraSharp/UI/ItemBrowser.cs index ee1a5bd..3051dc1 100644 --- a/src/TerraSharp/UI/ItemBrowser.cs +++ b/src/TerraSharp/UI/ItemBrowser.cs @@ -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; diff --git a/src/TerraSharp/UI/ItemGrid.cs b/src/TerraSharp/UI/ItemGrid.cs index 4009dad..46c8209 100644 --- a/src/TerraSharp/UI/ItemGrid.cs +++ b/src/TerraSharp/UI/ItemGrid.cs @@ -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. diff --git a/src/TerraSharp/UI/ItemPicker.cs b/src/TerraSharp/UI/ItemPicker.cs index 32b6352..b6f7d86 100644 --- a/src/TerraSharp/UI/ItemPicker.cs +++ b/src/TerraSharp/UI/ItemPicker.cs @@ -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 list) : ItemGrid(id, title, position, 534, 520, [new("Selected", list.Contains)]) +public sealed class ItemPicker(string id, string title, Vector2 position, HashSet list) : ItemGrid(id, title, position, 534, 554, [new("Selected", list.Contains)]) { protected override void DrawContent(Rectangle body) { diff --git a/src/TerraSharp/UI/NpcBrowser.cs b/src/TerraSharp/UI/NpcBrowser.cs index c1b4b36..62e6f4a 100644 --- a/src/TerraSharp/UI/NpcBrowser.cs +++ b/src/TerraSharp/UI/NpcBrowser.cs @@ -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 };