add very scuffed pagination of game settings

This commit is contained in:
neilmjain committed 2026-10-14 17:26:00 -04:00
1 parent 1c8f7318d3
commit 1e453cf6af
3 files changed
+662 -3

No files matched your search

+455 -3
View File
@@ -27,6 +27,7 @@ namespace MiraAPI.Patches;
[HarmonyPatch]
public static class RoleGuidePatches
{
private const int GeneralTabIndex = 1;
private const int RolesTabIndex = 2;
private const int ModifiersTabIndex = 3;
private const float MinGuideScaleFactor = 0.5f;
@@ -174,6 +175,7 @@ public static class RoleGuidePatches
_advancedInfoTabScroller = null!;
_currentAdvancedTabObject = null!;
_loadingSpinner = null;
ResetGeneralState();
SearchIconButton = null!;
SearchSortingOrderButton = null!;
SearchSortingGroupButton = null!;
@@ -468,6 +470,7 @@ public static class RoleGuidePatches
.First(x => x.name.Contains("BG_Gradient")).transform.localPosition = new Vector3(0, -0.62f, -5);
_modifiersScroller = modifiersTab.GetComponent<Scroller>();
instance.settingsTabs.Add(modifiersTab);
InitializeGeneralPanel(instance);
var wikiTab = Object.Instantiate(
instance.settingsTabs[RolesTabIndex],
@@ -1051,6 +1054,12 @@ public static class RoleGuidePatches
private static void RefreshActiveTab(bool scrollToTop)
{
if (IsGeneralTabActive)
{
RefreshGeneralTab(scrollToTop);
return;
}
StartPopulate();
if (scrollToTop)
{
@@ -1079,6 +1088,12 @@ public static class RoleGuidePatches
}
_appliedSearch = text;
if (IsGeneralTabActive)
{
RefreshGeneralTab(true);
yield break;
}
if (TryRefreshSearch(text))
{
yield break;
@@ -1186,6 +1201,12 @@ public static class RoleGuidePatches
public static void SwitchFilter()
{
if (IsGeneralTabActive)
{
ChangeGeneralPage(-1);
return;
}
var stepUp = (SortingFilter)((int)_sortFilter + 1);
if (Enum.IsDefined(stepUp))
{
@@ -1232,6 +1253,11 @@ public static class RoleGuidePatches
public static void SwitchMethod()
{
if (IsGeneralTabActive)
{
return;
}
var stepUp = (SortingMethod)((int)_sortMethod + 1);
if (Enum.IsDefined(stepUp))
{
@@ -1266,6 +1292,12 @@ public static class RoleGuidePatches
public static void SwitchGrouping()
{
if (IsGeneralTabActive)
{
ChangeGeneralPage(1);
return;
}
var stepUp = (SortingGroups)((int)_sortGrouping + 1);
if (Enum.IsDefined(stepUp))
{
@@ -1405,8 +1437,8 @@ public static class RoleGuidePatches
_titleText.text = MiraLocaleManager.Get("MiraApi.Wiki.PlayersTab");
_titleIcon.sprite = MiraAssets.IconPlayerList.LoadAsset();
break;
case 1:
_titleText.text = MiraLocaleManager.Get("MiraApi.Wiki.SettingsTab");
case GeneralTabIndex:
_titleText.text = GetGeneralTitle();
_titleIcon.sprite = MiraAssets.IconSettings.LoadAsset();
break;
case RolesTabIndex:
@@ -1428,6 +1460,15 @@ public static class RoleGuidePatches
_advancedInfoTabScroller?.gameObject.SetActive(false);
SearchComponentContainer?.SetActive(true);
if (tabIndex == GeneralTabIndex)
{
EnterGeneralTab();
}
else
{
LeaveGeneralTab();
}
UpdateSpinner();
}
@@ -1474,6 +1515,7 @@ public static class RoleGuidePatches
{
instance.settingsTabs[RolesTabIndex].SetActive(false);
_modifiersScroller?.gameObject.SetActive(false);
LeaveGeneralTab();
if (_currentAdvancedTabObject)
{
_currentAdvancedTabObject.SetActive(false);
@@ -1680,6 +1722,416 @@ public static class RoleGuidePatches
{
return customRole.RoleFactionTitle;
}
// ---- General (non-role) option pages: paginated General tab + cross-page search ----
private static Scroller? _generalScroller;
private static GameObject? _generalNoResults;
private static readonly List<GeneralRow> GeneralRows = [];
private static GameObject? _generalRowTemplate;
private static Vector3 _generalRowOrigin;
private static Vector3 _generalRowColumnStep;
private static float _generalRowHeight = -0.65f; // negative: rows go down
private static int _generalColumns = 2;
private static float _generalVisibleHeight;
private static bool _generalScrollerFailed;
/// <summary>One vanilla-looking settings row (label box + value box) that is reused between pages.</summary>
private sealed class GeneralRow(GameObject root, TextMeshPro label, TextMeshPro value)
{
public GameObject Root { get; } = root;
public TextMeshPro Label { get; } = label;
public TextMeshPro Value { get; } = value;
}
private static List<GuideOptionPage> _generalPages = [];
private static int _generalPage; // 0 = vanilla settings, 1..N = _generalPages[page - 1]
private static bool _generalSearching;
// Saved state of the sort buttons so they can be restored when leaving the General tab.
private static bool _sortButtonsRepurposed;
private static string _savedFilterLabel = string.Empty;
private static string _savedGroupLabel = string.Empty;
private static Vector3 _savedFilterIconScale = Vector3.one;
private static Vector3 _savedGroupIconScale = Vector3.one;
private static bool IsGeneralTabActive =>
_uiInitialized && MatchInfoGuide.Instance && MatchInfoGuide.Instance.activeTabIndex == GeneralTabIndex;
private static void ResetGeneralState()
{
_generalScroller = null;
_generalNoResults = null;
GeneralRows.Clear();
_generalRowTemplate = null;
_generalScrollerFailed = false;
_generalPages = [];
_generalPage = 0;
_generalSearching = false;
_sortButtonsRepurposed = false;
}
private static void InitializeGeneralPanel(MatchInfoGuide instance)
{
_generalNoResults = Object.Instantiate(
instance.rolesEnabledMessage,
instance.rolesEnabledMessage.transform.parent,
true);
_generalNoResults.name = "GeneralNoResultsFound";
_generalNoResults.SetActive(false);
SetNoResultsText(_generalNoResults);
}
/// <summary>
/// Clones the vanilla settings tab the first time it is needed, so extra pages are drawn with the exact same
/// rows, fonts, masking and scroll area as the vanilla list. Done lazily because the vanilla rows are only
/// created when the guide first opens. The clone is NOT added to <c>settingsTabs</c>; its visibility is managed
/// here so the guide's tab count and tab buttons stay untouched.
/// </summary>
private static bool EnsureGeneralScroller(MatchInfoGuide guide)
{
if (_generalScroller != null && _generalRowTemplate)
{
return true;
}
if (_generalScrollerFailed)
{
return false;
}
var vanillaTab = guide.settingsTabs[GeneralTabIndex];
var vanillaScroller = vanillaTab.GetComponent<Scroller>() ?? vanillaTab.GetComponentInChildren<Scroller>(true);
if (!vanillaScroller || vanillaScroller.Inner.childCount < 2)
{
return false; // vanilla rows not built yet; try again next refresh
}
// Read the vanilla grid: first row, second entry (same row => 2 columns), third entry (next row).
var vInner = vanillaScroller.Inner;
var p0 = vInner.GetChild(0).localPosition;
var p1 = vInner.GetChild(1).localPosition;
var sameRow = Mathf.Abs(p1.y - p0.y) < 0.01f;
var vanillaCount = vInner.childCount;
_generalColumns = sameRow ? 2 : 1;
_generalRowOrigin = p0;
_generalRowColumnStep = sameRow ? new Vector3(p1.x - p0.x, 0f, 0f) : Vector3.zero;
if (sameRow)
{
_generalRowHeight = vanillaCount >= 3 ? vInner.GetChild(2).localPosition.y - p0.y : -0.65f;
}
else
{
_generalRowHeight = p1.y - p0.y;
}
if (Mathf.Abs(_generalRowHeight) < 0.05f)
{
_generalRowHeight = -0.65f;
}
// Self-calibrate the visible height from how the vanilla list was bounded.
var vanillaRows = Mathf.CeilToInt(vanillaCount / (float)_generalColumns);
var visible = (vanillaRows * Mathf.Abs(_generalRowHeight)) - vanillaScroller.ContentYBounds.max;
_generalVisibleHeight = visible > 0.1f && visible < vanillaRows * Mathf.Abs(_generalRowHeight)
? visible
: 3.3f * Mathf.Abs(_generalRowHeight);
var clone = Object.Instantiate(vanillaTab, vanillaTab.transform.parent);
clone.name = "GeneralOptionsPanel";
var cloneScroller = clone.GetComponent<Scroller>() ?? clone.GetComponentInChildren<Scroller>(true);
var cInner = cloneScroller.Inner;
// Keep the first row as an inactive prototype, delete the rest.
var template = cInner.GetChild(0).gameObject;
for (var i = cInner.childCount - 1; i >= 1; i--)
{
var child = cInner.GetChild(i).gameObject;
child.SetActive(false);
child.Destroy();
}
template.SetActive(false);
_generalRowTemplate = template;
_generalScroller = cloneScroller;
clone.SetActive(false);
return true;
}
private static GeneralRow? CreateGeneralRow()
{
if (_generalScroller == null || !_generalRowTemplate)
{
return null;
}
var go = Object.Instantiate(_generalRowTemplate!, _generalScroller.Inner);
go.name = "GeneralOptionRow";
// The game would re-translate these and overwrite our text.
foreach (var translator in go.GetComponentsInChildren<TextTranslatorTMP>(true))
{
translator.Destroy();
}
// Label is the left-most text, value is the right-most (same as the vanilla entries).
var texts = go.GetComponentsInChildren<TextMeshPro>(true).OrderBy(t => t.transform.position.x).ToArray();
if (texts.Length < 2)
{
Warning("Vanilla settings row has fewer than two text fields; cannot clone it for the general pages.");
go.Destroy();
_generalScrollerFailed = true;
return null;
}
return new GeneralRow(go, texts[0], texts[^1]);
}
private static void EnterGeneralTab()
{
if (!_uiInitialized)
{
return;
}
RefreshGeneralTab(true);
}
private static void LeaveGeneralTab()
{
if (_generalScroller != null)
{
_generalScroller.gameObject.SetActive(false);
}
if (_generalNoResults)
{
_generalNoResults!.SetActive(false);
}
RestoreSortButtons();
}
private static void ChangeGeneralPage(int delta)
{
var total = _generalPages.Count + 1;
if (total <= 1)
{
return;
}
_generalPage = (_generalPage + delta + total) % total;
RefreshGeneralTab(true);
}
/// <summary>
/// Rebuilds pages from live option data and redraws the General tab.
/// Pages are rebuilt every time because values, group visibility and the gamemode can change.
/// </summary>
private static void RefreshGeneralTab(bool scrollToTop)
{
var guide = MatchInfoGuide.Instance;
if (!_uiInitialized || !guide)
{
return;
}
_generalPages = GuideOptionPages.BuildPages(guide.GetBoolString);
if (_generalPage > _generalPages.Count)
{
_generalPage = 0;
}
var vanillaTab = guide.settingsTabs[GeneralTabIndex];
// No MiraAPI general options in this match: leave the vanilla tab exactly as it always was.
if (_generalPages.Count == 0)
{
_generalSearching = false;
_generalScroller?.gameObject.SetActive(false);
_generalNoResults?.SetActive(false);
vanillaTab.SetActive(true);
RestoreSortButtons();
UpdateGeneralTitle();
return;
}
var searchText = GetSearchText().Trim();
_generalSearching = searchText.Length > 0;
if (_generalSearching)
{
ShowGeneralEntries(GuideOptionPages.Search(_generalPages, searchText), true, scrollToTop);
vanillaTab.SetActive(false);
RepurposeSortButtons(false);
}
else if (_generalPage == 0)
{
_generalScroller?.gameObject.SetActive(false);
_generalNoResults?.SetActive(false);
vanillaTab.SetActive(true);
RepurposeSortButtons(true);
}
else
{
ShowGeneralEntries(_generalPages[_generalPage - 1].Entries, false, scrollToTop);
vanillaTab.SetActive(false);
RepurposeSortButtons(true);
}
UpdateGeneralTitle();
}
private static void ShowGeneralEntries(IReadOnlyList<GuideOptionEntry> entries, bool showGroup, bool scrollToTop)
{
var guide = MatchInfoGuide.Instance;
if (!guide || !EnsureGeneralScroller(guide))
{
// Could not clone the vanilla rows: fall back to the plain vanilla tab rather than a blank page.
guide?.settingsTabs[GeneralTabIndex].SetActive(true);
return;
}
while (GeneralRows.Count < entries.Count)
{
var row = CreateGeneralRow();
if (row == null)
{
break;
}
GeneralRows.Add(row);
}
for (var i = 0; i < GeneralRows.Count; i++)
{
var row = GeneralRows[i];
if (i >= entries.Count)
{
row.Root.SetActive(false);
continue;
}
var entry = entries[i];
row.Label.text = showGroup ? $"<size=60%>{entry.GroupName}</size>\n{entry.Title}" : entry.Title;
row.Value.text = entry.Value;
row.Root.transform.localPosition = _generalRowOrigin +
(_generalRowColumnStep * (i % _generalColumns)) +
new Vector3(0f, _generalRowHeight * (i / _generalColumns), 0f);
row.Root.SetActive(true);
}
var rows = Mathf.CeilToInt(Mathf.Min(entries.Count, GeneralRows.Count) / (float)_generalColumns);
_generalScroller!.gameObject.SetActive(true);
_generalScroller.SetYBoundsMax(Mathf.Clamp((rows * Mathf.Abs(_generalRowHeight)) - _generalVisibleHeight, 0f, 999f));
_generalNoResults?.SetActive(entries.Count == 0);
if (scrollToTop)
{
_generalScroller.ScrollToTop();
}
}
private static string GetGeneralTitle()
{
var baseTitle = MiraLocaleManager.Get("MiraApi.Wiki.SettingsTab");
if (!_uiInitialized || _generalPages.Count == 0)
{
return baseTitle;
}
if (_generalSearching)
{
return $"{baseTitle}: {MiraLocaleManager.Get("MiraApi.Wiki.SearchResults", "Search Results")}";
}
var counter = $"({_generalPage + 1}/{_generalPages.Count + 1})";
return _generalPage == 0
? $"{baseTitle} {counter}"
: $"{baseTitle}: {_generalPages[_generalPage - 1].Title} {counter}";
}
private static void UpdateGeneralTitle()
{
if (_titleText && IsGeneralTabActive)
{
_titleText.text = GetGeneralTitle();
}
}
/// <summary>
/// Turns the Filter button into "previous page" and the Grouping button into "next page",
/// and hides the Order button (it only sorts roles/modifiers).
/// </summary>
/// <param name="paginationVisible">False while searching, since search results are a single page.</param>
private static void RepurposeSortButtons(bool paginationVisible)
{
if (!SearchSortingFilterButton || !SearchSortingGroupButton || !SearchSortingOrderButton)
{
return;
}
if (!_sortButtonsRepurposed)
{
_savedFilterLabel = FilterSortingTmp.text;
_savedGroupLabel = GroupSortingTmp.text;
_savedFilterIconScale = _searchSortingFilterIdle.transform.localScale;
_savedGroupIconScale = _searchSortingGroupIdle.transform.localScale;
SetPaginationSprites(_searchSortingFilterIdle, _searchSortingFilterHover, true);
SetPaginationSprites(_searchSortingGroupIdle, _searchSortingGroupHover, false);
FilterSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.PrevPage", "Prev");
GroupSortingTmp.text = MiraLocaleManager.Get("MiraApi.Wiki.NextPage", "Next");
_sortButtonsRepurposed = true;
}
SearchSortingOrderButton.gameObject.SetActive(false);
SearchSortingFilterButton.gameObject.SetActive(paginationVisible);
SearchSortingGroupButton.gameObject.SetActive(paginationVisible);
}
private static void SetPaginationSprites(SpriteRenderer idle, SpriteRenderer hover, bool flip)
{
var idleSprite = MiraAssets.NextButton.LoadAsset();
var hoverSprite = MiraAssets.NextButtonActive.LoadAsset();
// Match the on-screen size of the sort icon being replaced, since the arrow art differs in size.
var currentHeight = idle.sprite ? idle.sprite.bounds.size.y * idle.transform.localScale.y : 0f;
var newHeight = idleSprite.bounds.size.y;
var scale = currentHeight > 0f && newHeight > 0f ? currentHeight / newHeight : 1f;
idle.sprite = idleSprite;
hover.sprite = hoverSprite;
idle.flipX = hover.flipX = flip;
idle.transform.localScale = hover.transform.localScale = new Vector3(scale, scale, 1f);
}
private static void RestoreSortButtons()
{
if (!_sortButtonsRepurposed)
{
return;
}
_sortButtonsRepurposed = false;
if (!SearchSortingFilterButton || !SearchSortingGroupButton || !SearchSortingOrderButton)
{
return;
}
_searchSortingFilterIdle.sprite = GetSortFilterSprite(_sortFilter, false);
_searchSortingFilterHover.sprite = GetSortFilterSprite(_sortFilter, true);
_searchSortingGroupIdle.sprite = GetSortGroupSprite(_sortGrouping, false);
_searchSortingGroupHover.sprite = GetSortGroupSprite(_sortGrouping, true);
_searchSortingFilterIdle.flipX = _searchSortingFilterHover.flipX = false;
_searchSortingGroupIdle.flipX = _searchSortingGroupHover.flipX = false;
_searchSortingFilterIdle.transform.localScale =
_searchSortingFilterHover.transform.localScale = _savedFilterIconScale;
_searchSortingGroupIdle.transform.localScale =
_searchSortingGroupHover.transform.localScale = _savedGroupIconScale;
FilterSortingTmp.text = _savedFilterLabel;
GroupSortingTmp.text = _savedGroupLabel;
SearchSortingOrderButton.gameObject.SetActive(true);
SearchSortingFilterButton.gameObject.SetActive(true);
SearchSortingGroupButton.gameObject.SetActive(true);
}
}
public enum SortingGroups
@@ -1700,4 +2152,4 @@ public enum SortingFilter
EnabledOnly,
DisabledOnly,
AllRoles,
}
}
+4
View File
@@ -60,5 +60,9 @@ Outlive the timer or eliminate the crew.</string>
<string name="MiraApi.Wiki.FilterLabel">Filter</string>
<string name="MiraApi.Wiki.GroupingLabel">Grouping</string>
<string name="MiraApi.Wiki.OrderLabel">Order</string>
<string name="MiraApi.Wiki.VanillaSettings">Vanilla</string>
<string name="MiraApi.Wiki.SearchResults">Search Results</string>
<string name="MiraApi.Wiki.PreviousPage">Previous</string>
<string name="MiraApi.Wiki.NextPage">Next</string>
<string name="MiraApi.Wiki.NoResultsFound">No Results Found</string>
</resources>
+203
View File
@@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using MiraAPI.GameModes;
using MiraAPI.GameOptions;
using MiraAPI.GameOptions.OptionTypes;
using MiraAPI.PluginLoading;
using MiraAPI.Translation;
using Reactor.Utilities;
namespace MiraAPI.Utilities;
internal sealed record GuideOptionEntry(string Title, string Value, string GroupName, string ModAbbreviation);
internal sealed class GuideOptionPage(AbstractOptionGroup group, string modAbbreviation, IReadOnlyList<GuideOptionEntry> entries)
{
public AbstractOptionGroup Group { get; } = group;
public string Title { get; } = group.GroupName.Translate();
public string ModAbbreviation { get; } = modAbbreviation;
public IReadOnlyList<GuideOptionEntry> Entries { get; } = entries;
}
internal static class GuideOptionPages
{
private static readonly Regex TrailingZeroRegex = new(@"\.0+(?!\d)", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
public static List<GuideOptionPage> BuildPages(Func<bool, string> formatBool)
{
var pages = new List<GuideOptionPage>();
var mode = CustomGameModeManager.ActiveMode;
// Same plugin set the in-game options menu lists (skips mods that hide themselves from it).
foreach (var plugin in MiraPluginManager.Instance.PluginsWithOptionsOrGameModes
.OrderBy(p => p.MiraPlugin.GetAbbreviatedModName(), StringComparer.OrdinalIgnoreCase))
{
var modAbbreviation = plugin.MiraPlugin.GetAbbreviatedModName();
var groups = plugin.InternalOptionGroups
.Where(g => IsGeneralGroup(g, mode) && SafeGroupVisible(g))
.OrderBy(g => g.GroupPriority)
.ThenBy(g => g.GroupName, StringComparer.OrdinalIgnoreCase);
foreach (var group in groups)
{
var groupName = group.GroupName.Translate();
var entries = new List<GuideOptionEntry>();
foreach (var option in group.Options)
{
if (!SafeOptionVisible(option))
{
continue;
}
var entry = TryCreateEntry(option, groupName, modAbbreviation, formatBool);
if (entry != null)
{
entries.Add(entry);
}
}
if (entries.Count > 0)
{
pages.Add(new GuideOptionPage(group, modAbbreviation, entries));
}
}
}
return pages;
}
public static List<GuideOptionEntry> Search(IEnumerable<GuideOptionPage> pages, string query)
{
query = query.Trim();
if (query.Length == 0)
{
return [];
}
const StringComparison cmp = StringComparison.OrdinalIgnoreCase;
return pages
.SelectMany(p => p.Entries)
.Select(e => (Entry: e, Rank: Rank(e, query, cmp)))
.Where(x => x.Rank < int.MaxValue)
.OrderBy(x => x.Rank)
.Select(x => x.Entry)
.ToList();
}
private static int Rank(GuideOptionEntry entry, string query, StringComparison cmp)
{
if (entry.Title.Equals(query, cmp))
{
return 0;
}
if (entry.Title.Contains(query, cmp))
{
return 1;
}
if (entry.GroupName.Contains(query, cmp) || entry.ModAbbreviation.Contains(query, cmp))
{
return 2;
}
return entry.Value.Contains(query, cmp) ? 3 : int.MaxValue;
}
private static bool IsGeneralGroup(AbstractOptionGroup group, AbstractGameMode? mode)
{
if (group.ParentMenu is not (MenuCategory.Game or MenuCategory.CustomOne or MenuCategory.CustomTwo))
{
return false;
}
if (group.OptionableType == null)
{
return true;
}
return mode != null && typeof(AbstractGameMode).IsAssignableFrom(group.OptionableType) &&
mode.GetType().IsAssignableTo(group.OptionableType);
}
private static bool SafeGroupVisible(AbstractOptionGroup group)
{
try
{
return group.GroupVisible();
}
catch (Exception e)
{
Logger<MiraApiPlugin>.Warning($"GroupVisible threw for {group.GetType().Name}: {e.Message}");
return false;
}
}
private static bool SafeOptionVisible(IModdedOption option)
{
try
{
return option.Visible();
}
catch (Exception e)
{
Logger<MiraApiPlugin>.Warning($"Visible threw for option '{option.Title}': {e.Message}");
return false;
}
}
private static GuideOptionEntry? TryCreateEntry(
IModdedOption option,
string groupName,
string modAbbreviation,
Func<bool, string> formatBool)
{
try
{
var title = TranslationController.Instance.GetString(option.StringName);
var value = FormatValue(option, formatBool);
return new GuideOptionEntry(title, value, groupName, modAbbreviation);
}
catch (Exception e)
{
Logger<MiraApiPlugin>.Warning($"Could not format option '{option.Title}' for the guide: {e.Message}");
return null;
}
}
private static string FormatValue(IModdedOption option, Func<bool, string> formatBool)
{
switch (option)
{
case ModdedToggleOption toggle:
return formatBool(toggle.Value);
case ModdedEnumOption enumOption:
return MiraLocaleManager.Get(
enumOption.Values[enumOption.Value],
enumOption.Values[enumOption.Value]);
case ModdedNumberOption number:
if (number is { NegativeWordValue: not "#", Value: -1 })
{
return number.NegativeWordValue;
}
if (number is { ZeroWordValue: not "#", Value: 0 })
{
return number.ZeroWordValue;
}
return TrailingZeroRegex.Replace(number.Data.GetValueString(number.Value), string.Empty);
default:
return option.Data.Type == global::OptionTypes.Checkbox
? formatBool(Math.Abs(option.GetFloatData() - 1f) < 0.0001f)
: option.Data.GetValueString(option.GetFloatData());
}
}
}