diff --git a/Celeste.Mod.mm/Content/Dialog/English.txt b/Celeste.Mod.mm/Content/Dialog/English.txt index 0970adc0d..8f52bdd63 100755 --- a/Celeste.Mod.mm/Content/Dialog/English.txt +++ b/Celeste.Mod.mm/Content/Dialog/English.txt @@ -135,6 +135,8 @@ MODOPTIONS_COREMODULE_NOTLOADED_ASMLOADERROR= mod assembly failed to load MODOPTIONS_COREMODULE_YAMLERRORS= Some everest.yaml files could not be loaded. + MODOPTIONS_COREMODULE_SEARCHBOX_PLACEHOLDER= Press 'Tab' or 'Enter' to scroll to the next match + MODOPTIONS_VANILLATRISTATE_NEVER= OFF MODOPTIONS_VANILLATRISTATE_EVEREST= EVEREST MODOPTIONS_VANILLATRISTATE_ALWAYS= ALWAYS diff --git a/Celeste.Mod.mm/Content/Dialog/French.txt b/Celeste.Mod.mm/Content/Dialog/French.txt index ffc1bb4e7..1b82d99c2 100755 --- a/Celeste.Mod.mm/Content/Dialog/French.txt +++ b/Celeste.Mod.mm/Content/Dialog/French.txt @@ -118,6 +118,8 @@ MODOPTIONS_COREMODULE_NOTLOADED_ASMLOADERROR= échec de chargement de la DLL MODOPTIONS_COREMODULE_YAMLERRORS= Certains fichiers everest.yaml n'ont pas pu être chargés. + MODOPTIONS_COREMODULE_SEARCHBOX_PLACEHOLDER= Appuyez sur Tab ou Entrée pour lancer la recherche + MODOPTIONS_VANILLATRISTATE_NEVER= DÉSACTIVÉ MODOPTIONS_VANILLATRISTATE_EVEREST= EVEREST MODOPTIONS_VANILLATRISTATE_ALWAYS= TOUJOURS diff --git a/Celeste.Mod.mm/Mod/Core/CoreModule.cs b/Celeste.Mod.mm/Mod/Core/CoreModule.cs index c33ecfb27..9bad18c2a 100644 --- a/Celeste.Mod.mm/Mod/Core/CoreModule.cs +++ b/Celeste.Mod.mm/Mod/Core/CoreModule.cs @@ -254,8 +254,21 @@ public void CreatePauseMenuButtons(Level level, patch_TextMenu menu, bool minima level.Paused = true; TextMenu options = OuiModOptions.CreateMenu(true, LevelExt.PauseSnapshot); + Action startSearching = OuiModOptions.AddSearchBox(options); + + options.OnUpdate = () => { + if (options.Focused) { + if (Input.QuickRestart.Pressed) { + startSearching(); + } + } + }; options.OnESC = options.OnCancel = () => { + if (!options.Focused) { + return; + } + Audio.Play(SFX.ui_main_button_back); options.CloseAndRun(Everest.SaveSettings(), () => { level.Pause(returnIndex, minimal, false); @@ -271,6 +284,10 @@ public void CreatePauseMenuButtons(Level level, patch_TextMenu menu, bool minima }; options.OnPause = () => { + if (!options.Focused) { + return; + } + Audio.Play(SFX.ui_main_button_back); options.CloseAndRun(Everest.SaveSettings(), () => { level.Paused = false; diff --git a/Celeste.Mod.mm/Mod/UI/OuiModOptions.cs b/Celeste.Mod.mm/Mod/UI/OuiModOptions.cs index 4756adeea..7e564c7cd 100644 --- a/Celeste.Mod.mm/Mod/UI/OuiModOptions.cs +++ b/Celeste.Mod.mm/Mod/UI/OuiModOptions.cs @@ -1,6 +1,7 @@ using Celeste.Mod.Core; using FMOD.Studio; using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; using Monocle; using System; using System.Collections; @@ -27,6 +28,8 @@ public interface ISubmenu { } private int savedMenuIndex = -1; + private Action startSearching; + public OuiModOptions() { Instance = this; } @@ -176,6 +179,7 @@ private void ReloadMenu() { } menu = CreateMenu(false, null); + startSearching = AddSearchBox(menu, Overworld); if (selected >= 0) { menu.Selection = selected; @@ -185,6 +189,86 @@ private void ReloadMenu() { Scene.Add(menu); } + static public Action AddSearchBox(TextMenu menu, Overworld overworld = null) { + TextMenuExt.TextBox textBox = new(overworld) { + PlaceholderText = Dialog.Clean("MODOPTIONS_COREMODULE_SEARCHBOX_PLACEHOLDER") + }; + + TextMenuExt.Modal modal = new(textBox, absoluteX: null, absoluteY: 85); + menu.Add(modal); + menu.Add(new TextMenuExt.SearchToolTip()); + + Action searchNextMod(bool inReverse) => (TextMenuExt.TextBox textBox) => { + string searchTarget = textBox.Text.ToLower(); + List menuItems = ((patch_TextMenu) menu).Items; + + bool searchNextPredicate(TextMenu.Item item) { + string searchLabel = ((patch_TextMenu.patch_Item) item).SearchLabel(); + return item.Visible && item.Selectable && !item.Disabled && searchLabel != null && searchLabel.ToLower().Contains(searchTarget); + } + + + if (TextMenuExt.TextBox.WrappingLinearSearch(menuItems, searchNextPredicate, menu.Selection + (inReverse ? -1 : 1), inReverse, out int targetSelectionIndex)) { + if (targetSelectionIndex >= menu.Selection) { + Audio.Play(SFX.ui_main_roll_down); + } else { + Audio.Play(SFX.ui_main_roll_up); + } + + menu.Selection = targetSelectionIndex; + } else { + Audio.Play(SFX.ui_main_button_invalid); + } + }; + + void exitSearch(TextMenuExt.TextBox textBox) { + textBox.StopTyping(); + modal.Visible = false; + textBox.ClearText(); + } + + textBox.OnTextInputCharActions['\t'] = searchNextMod(false); + textBox.OnTextInputCharActions['\n'] = (_) => { }; + textBox.OnTextInputCharActions['\r'] = (textBox) => { + if (MInput.Keyboard.CurrentState.IsKeyDown(Keys.LeftShift) + || MInput.Keyboard.CurrentState.IsKeyDown(Keys.RightShift)) { + searchNextMod(true)(textBox); + } else { + searchNextMod(false)(textBox); + } + }; + textBox.OnTextInputCharActions['\b'] = (textBox) => { + if (textBox.DeleteCharacter()) { + Audio.Play(SFX.ui_main_rename_entry_backspace); + } else { + exitSearch(textBox); + Input.MenuCancel.ConsumePress(); + } + }; + + + textBox.AfterInputConsumed = () => { + if (textBox.Typing) { + if (Input.ESC.Pressed) { + exitSearch(textBox); + Input.ESC.ConsumePress(); + } else if (Input.MenuDown.Pressed) { + searchNextMod(false)(textBox); + } else if (Input.MenuUp.Pressed) { + searchNextMod(true)(textBox); + } + } + }; + + return () => { + // we want to ensure we don't open the search box while we are in a sub-menu + if (menu.Focused) { + modal.Visible = true; + textBox.StartTyping(); + } + }; + } + public override IEnumerator Enter(Oui from) { ReloadMenu(); @@ -233,6 +317,13 @@ public override void Update() { Overworld.Goto(); } + if (Selected && Focused) { + if (Input.QuickRestart.Pressed) { + startSearching?.Invoke(); + return; + } + } + base.Update(); } diff --git a/Celeste.Mod.mm/Mod/UI/OuiModToggler.cs b/Celeste.Mod.mm/Mod/UI/OuiModToggler.cs index 64e3bc015..02a4548de 100755 --- a/Celeste.Mod.mm/Mod/UI/OuiModToggler.cs +++ b/Celeste.Mod.mm/Mod/UI/OuiModToggler.cs @@ -360,50 +360,21 @@ protected override void addOptionsToMenu(patch_TextMenu menu) { modLoadingTask.Start(); } - private static bool WrappingLinearSearch(List items, Func predicate, int startIndex, bool inReverse, out int nextModIndex) { - int step = inReverse ? -1 : 1; - - if (startIndex > items.Count) { - nextModIndex = 0; - return false; - } - - for (int currentIndex = (startIndex + step) % items.Count; currentIndex != startIndex; currentIndex = (currentIndex + step) % items.Count) { - if (currentIndex < 0) { - currentIndex = items.Count - 1; - } - - if (predicate(items[currentIndex])) { - nextModIndex = currentIndex; - return true; - } - } - - nextModIndex = startIndex; - return predicate(items[nextModIndex]); - } - private void AddSearchBox(TextMenu menu) { TextMenuExt.TextBox textBox = new(Overworld) { PlaceholderText = Dialog.Clean("MODOPTIONS_MODTOGGLE_SEARCHBOX_PLACEHOLDER") }; - TextMenuExt.Modal modal = new(absoluteY: 85, textBox); + TextMenuExt.Modal modal = new(textBox, absoluteX: null, absoluteY: 85); menu.Add(modal); + menu.Add(new TextMenuExt.SearchToolTip()); startSearching = () => { modal.Visible = true; textBox.StartTyping(); - - if (((patch_TextMenu) menu).Items[menu.Selection] is patch_TextMenu.patch_Option currentOption - && modToggles.ContainsKey(currentOption.Label)) { - currentOption.UnselectedColor = currentOption.Container.HighlightColor; - } }; Action searchNextMod(bool inReverse) => (TextMenuExt.TextBox textBox) => { - updateHighlightedMods(); - string searchTarget = textBox.Text.ToLower(); List menuItems = ((patch_TextMenu) menu).Items; int currentSelection = menu.Selection; @@ -412,8 +383,7 @@ bool searchPredicate(TextMenu.Item item) => item is patch_TextMenu.patch_Option< && modToggles.ContainsKey(currentOption.Label) && currentOption.Label.ToLower().Contains(searchTarget); - if (WrappingLinearSearch(menuItems, searchPredicate, menu.Selection, inReverse, out int targetSelectionIndex)) { - + if (TextMenuExt.TextBox.WrappingLinearSearch(menuItems, searchPredicate, menu.Selection + (inReverse ? -1 : 1), inReverse, out int targetSelectionIndex)) { if (targetSelectionIndex >= menu.Selection) { Audio.Play(SFX.ui_main_roll_down); } else { @@ -421,9 +391,6 @@ bool searchPredicate(TextMenu.Item item) => item is patch_TextMenu.patch_Option< } menu.Selection = targetSelectionIndex; - if (menuItems[targetSelectionIndex] is patch_TextMenu.patch_Option currentOption) { - currentOption.UnselectedColor = currentOption.Container.HighlightColor; - } } else { Audio.Play(SFX.ui_main_button_invalid); } @@ -433,7 +400,6 @@ void exitSearch(TextMenuExt.TextBox textBox) { textBox.StopTyping(); modal.Visible = false; textBox.ClearText(); - updateHighlightedMods(); } textBox.OnTextInputCharActions['\t'] = searchNextMod(false); @@ -755,18 +721,6 @@ private bool modHasDependencies(string modFilename) { public override void Render() { base.Render(); - - if (modLoadingTask == null) { - MTexture searchIcon = GFX.Gui["menu/mapsearch"]; - - const float PREFERRED_ICON_X = 100f; - float spaceNearMenu = (Engine.Width - menu.Width) / 2; - float scaleFactor = Math.Min(spaceNearMenu / (PREFERRED_ICON_X + searchIcon.Width / 2), 1); - - Vector2 searchIconLocation = new(PREFERRED_ICON_X * scaleFactor, 952f); - searchIcon.DrawCentered(searchIconLocation, Color.White, scaleFactor); - Input.GuiKey(Input.FirstKey(Input.QuickRestart)).Draw(searchIconLocation, Vector2.Zero, Color.White, scaleFactor); - } } public override IEnumerator Leave(Oui next) { diff --git a/Celeste.Mod.mm/Mod/UI/TextMenuExt.cs b/Celeste.Mod.mm/Mod/UI/TextMenuExt.cs index 9bd222bf2..8d4d200ab 100644 --- a/Celeste.Mod.mm/Mod/UI/TextMenuExt.cs +++ b/Celeste.Mod.mm/Mod/UI/TextMenuExt.cs @@ -431,7 +431,7 @@ public override void Render(Vector2 position, bool highlighted) { ///

/// Currently does not support recursive submenus /// - public class SubMenu : TextMenu.Item { + public class SubMenu : patch_Item { public string Label; MTexture Icon; @@ -751,6 +751,10 @@ public void Exit() { Container.Focused = true; } + public override string SearchLabel() { + return Label; + } + #endregion #region TextMenu.Item @@ -902,7 +906,7 @@ private static void DrawIcon(Vector2 position, MTexture icon, Vector2 justify, b ///

/// Currently does not support recursive submenus /// - public class OptionSubMenu : TextMenu.Item { + public class OptionSubMenu : patch_Item { public string Label; MTexture Icon; @@ -1155,6 +1159,10 @@ public float GetYOffsetOf(TextMenu.Item item) { return offset - item.Height() * 0.5f - ItemSpacing; } + public override string SearchLabel() { + return Label; + } + #endregion #region TextMenu.Item @@ -1593,6 +1601,7 @@ public void StartTyping() { Audio.Play(SFX.ui_main_button_toggle_on); Typing = true; Container.Focused = false; + ((patch_TextMenu) Container).RenderAsFocused = true; previousEngineCommandsEnabled = Engine.Commands.Enabled; Engine.Commands.Enabled = false; @@ -1616,6 +1625,7 @@ public void StopTyping() { Audio.Play(SFX.ui_main_button_toggle_off); Typing = false; Container.Focused = true; + ((patch_TextMenu) Container).RenderAsFocused = false; TextBoxConsumedInput = false; MInput.Disabled = false; Engine.Commands.Enabled = previousEngineCommandsEnabled; @@ -1681,22 +1691,41 @@ public override void Update() { TextBoxConsumedInput = false; } + + private static int NegativeModulo(int number, int modulo) { + return (number % modulo + modulo) % modulo; + } + + public static bool WrappingLinearSearch(List items, Func predicate, int startIndex, bool inReverse, out int nextModIndex) { + int step = inReverse ? -1 : 1; + int targetIndex = NegativeModulo(startIndex - step, items.Count); + + for (int currentIndex = NegativeModulo(startIndex, items.Count); currentIndex != targetIndex; currentIndex = NegativeModulo(currentIndex + step, items.Count)) { + if (predicate(items[currentIndex])) { + nextModIndex = currentIndex; + return true; + } + } + + nextModIndex = startIndex; + return false; + } } public class Modal : patch_Item { public Color BoxBorderColor { get; set; } = Color.White; public Color BoxBackgroundColor { get; set; } = Color.Black * 0.8f; public int BorderThickness { get; set; } = 2; - public bool CenterItem { get; set; } = true; - - private readonly float absoluteY; + private readonly float? absoluteY; + private readonly float? absoluteX; private readonly TextMenu.Item item; - public Modal(float absoluteY, TextMenu.Item item) { + public Modal(TextMenu.Item item, float? absoluteX, float? absoluteY) { AboveAll = true; Visible = false; IncludeWidthInMeasurement = false; this.absoluteY = absoluteY; + this.absoluteX = absoluteX; this.item = item; } @@ -1723,11 +1752,34 @@ public override float Height() { } public override void Render(Vector2 position, bool highlighted) { + Vector2 renderPosition = new(absoluteX ?? position.X, absoluteY ?? position.Y); for (int i = 1; i <= BorderThickness; i++) { - Draw.HollowRect(position.X - i, absoluteY - i, item.Width + (2 * i), item.Height() + (2 * i), BoxBorderColor * Container.Alpha); + Draw.HollowRect(renderPosition.X - i, renderPosition.Y - i, item.Width + (2 * i), item.Height() + (2 * i), BoxBorderColor * Container.Alpha); } - item.Render(new Vector2(position.X, absoluteY), highlighted); + item.Render(renderPosition, highlighted); + } + } + + public class SearchToolTip : patch_Item { + public Vector2 preferredRenderLocation = new(100f, 952f); + + private readonly MTexture searchIcon = GFX.Gui["menu/mapsearch"]; + + public SearchToolTip() { + AboveAll = true; + Selectable = false; + IncludeWidthInMeasurement = false; + } + + public override bool AlwaysRender => true; + + public override void Render(Vector2 position, bool highlighted) { + float spaceNearMenu = (Engine.Width - Container.Width) / 2; + float scaleFactor = Math.Min(spaceNearMenu / (preferredRenderLocation.X + searchIcon.Width / 2), 1); + Vector2 searchIconLocation = new(preferredRenderLocation.X * scaleFactor, preferredRenderLocation.Y); + searchIcon.DrawCentered(searchIconLocation, Color.White, scaleFactor); + Input.GuiKey(Input.FirstKey(Input.QuickRestart)).Draw(searchIconLocation, Vector2.Zero, Color.White, scaleFactor); } } } diff --git a/Celeste.Mod.mm/Patches/TextMenu.cs b/Celeste.Mod.mm/Patches/TextMenu.cs index 8cdb7d8fd..4399d2e65 100644 --- a/Celeste.Mod.mm/Patches/TextMenu.cs +++ b/Celeste.Mod.mm/Patches/TextMenu.cs @@ -27,6 +27,11 @@ public class patch_TextMenu : TextMenu { private float height; private bool recalculatingSizeInBatchMode; + /// + /// Force the TextMenu to render as focused + /// + public bool RenderAsFocused = false; + /// /// The items contained in this menu. /// @@ -225,7 +230,7 @@ private bool renderItems(bool aboveAll) { Vector2 drawPosition = currentPosition + new Vector2(0f, itemHeight * 0.5f + item.SelectWiggler.Value * 8f); // skip rendering the option if it is off-screen. if (((patch_Item) item).AlwaysRender || (drawPosition.Y + itemHeight * 0.5f > 0 && drawPosition.Y - itemHeight * 0.5f < Engine.Height)) { - item.Render(drawPosition, Focused && Current == item); + item.Render(drawPosition, (Focused || RenderAsFocused) && Current == item); } } else { skippedItems = true; @@ -344,6 +349,13 @@ public override void Render(Vector2 position, bool highlighted) { } } + [MonoModPatch("Celeste.TextMenu/Option`1")] + public class SecondOptionPatch : patch_Item { + public override string SearchLabel() { + return ((Option) (object) this).Label; + } + } + public class patch_Option : Option { private float cachedRightWidth; private List cachedRightWidthContent; @@ -394,6 +406,10 @@ public class patch_Item : Item { /// Set this property to true to force the Item to render even when off-screen. /// public virtual bool AlwaysRender { get; } = false; + + public virtual string SearchLabel() { + return null; + } } public class patch_SubHeader : SubHeader { @@ -475,7 +491,7 @@ public void ctor(string label, List buttons) { } private int _MouseButtonsHash(int hash) { - foreach (patch_MInput.patch_MouseData.MouseButtons btn in ((patch_Binding)Binding).Mouse) { + foreach (patch_MInput.patch_MouseData.MouseButtons btn in ((patch_Binding) Binding).Mouse) { hash = hash * 31 + btn.GetHashCode(); } return hash; @@ -510,6 +526,12 @@ public void Append(List buttons) { } + public class patch_Button : patch_Item { + public override string SearchLabel() { + return ((Button) (object) this).Label; + } + } + } public static partial class TextMenuExt { @@ -563,7 +585,7 @@ public static void PatchTextMenuOptionColor(ILContext context, CustomAttribute a cursor.Next.OpCode = OpCodes.Ldfld; cursor.Next.Operand = f_UnselectedColor; } - + public static void PatchTextMenuSettingUpdate(ILContext il, CustomAttribute _) { MethodReference m_MouseButtonsHash = il.Method.DeclaringType.FindMethod("_MouseButtonsHash"); FieldReference f_Binding_Mouse = MonoModRule.Modder.FindType("Monocle.Binding").Resolve().FindField("Mouse");