diff --git a/Source/IMaterialAcrylicPage.cs b/Source/IMaterialAcrylicPage.cs index cd88a20..a282372 100644 --- a/Source/IMaterialAcrylicPage.cs +++ b/Source/IMaterialAcrylicPage.cs @@ -2,5 +2,13 @@ namespace Celeste.Mod.MicroblocksQolUtils; internal interface IMaterialAcrylicPage { bool SuppressNormalRender { get; set; } + + /// + /// False while a foreground popup (string editor, key binding config) owns the screen: + /// the acrylic path renders the whole scene into a blurred backdrop, which would swallow + /// the popup. Falling back to the normal render keeps the popup on top. + /// + bool AllowsAcrylic { get; } + void RenderMaterialContent(bool acrylicActive); } diff --git a/Source/MaterialAcrylicRenderer.cs b/Source/MaterialAcrylicRenderer.cs index 2683bf7..79327ad 100644 --- a/Source/MaterialAcrylicRenderer.cs +++ b/Source/MaterialAcrylicRenderer.cs @@ -59,6 +59,7 @@ private static void RenderCore(On.Monocle.Engine.orig_RenderCore orig, Engine se if (rendering || failed || page is null + || !page.AllowsAcrylic || !settings.MaterialAcrylicBackground || Engine.Scene is not Scene) { orig(self); diff --git a/Source/MaterialChapterSelect.cs b/Source/MaterialChapterSelect.cs index acbb4a2..1ed08dd 100644 --- a/Source/MaterialChapterSelect.cs +++ b/Source/MaterialChapterSelect.cs @@ -56,6 +56,8 @@ public sealed class MaterialChapterSelect : Oui, IMaterialAcrylicPage { public bool SuppressNormalRender { get; set; } + public bool AllowsAcrylic => true; + internal static MaterialChapterSelect? ActivePage { get { if (Engine.Scene is not Overworld overworld) return null; diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs index 56f3ed8..add9cd0 100644 --- a/Source/MaterialModOptions.cs +++ b/Source/MaterialModOptions.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Reflection; using System.Runtime.CompilerServices; +using Celeste.Mod.Core; using Celeste.Mod.UI; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; @@ -52,6 +53,12 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage { private static bool hookFailed; private static readonly FieldInfo? textMenuWidthField = typeof(TextMenu).GetField("width", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo? stringValueChangeField = + typeof(OuiModOptionString).GetField("OnValueChange", + BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo? stringExitField = + typeof(OuiModOptionString).GetField("OnExit", + BindingFlags.Instance | BindingFlags.NonPublic); private readonly List tabs = []; private readonly List sourceTabs = []; @@ -90,9 +97,20 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage { private string settingSearchText = ""; private string imeText = ""; private SearchTarget searchTarget; + private readonly Dictionary stringSettings = []; + private TextEntryOverlay? textEntry; + private StringSettingEntry? inlineEditing; + private string inlineBuffer = ""; + private string inlineIme = ""; + private bool inlineInputSubscribed; + private float inlineError; + private bool externalOverlayOpen; + private int baseDepth; public bool SuppressNormalRender { get; set; } + public bool AllowsAcrylic => !externalOverlayOpen && textEntry is null; + internal static MaterialModOptions? ActivePage { get { if (Engine.Scene is not Overworld overworld) return null; @@ -104,6 +122,7 @@ internal static MaterialModOptions? ActivePage { public MaterialModOptions() { Tag = Tags.HUD | Tags.PauseUpdate | Tags.TransitionUpdate; + baseDepth = Depth; } private MaterialModOptions(Level level, int returnIndex, bool minimal, bool allowHudHide) : this() { @@ -112,6 +131,7 @@ private MaterialModOptions(Level level, int returnIndex, bool minimal, bool allo pauseMinimal = minimal; oldAllowHudHide = allowHudHide; Depth = -2_000_000; + baseDepth = Depth; } public static void Load() { @@ -155,6 +175,8 @@ public override IEnumerator Enter(Oui from) { public override IEnumerator Leave(Oui next) { display = false; SetSearchTarget(SearchTarget.None); + if (inlineEditing is not null) CommitInlineEdit(); + CloseTextEntry(); if (menu is not null) menu.Focused = false; SaveTabState(); for (float timer = 0f; timer < 0.16f; timer += Engine.RawDeltaTime) yield return null; @@ -177,6 +199,8 @@ public override void Added(Scene scene) { public override void Removed(Scene scene) { SetSearchTarget(SearchTarget.None); + EndInlineEdit(); + CloseTextEntry(); DetachMenu(); if (pauseLevel is not null) pauseLevel.AllowHudHide = oldAllowHudHide; tabViewport.Dispose(); @@ -194,6 +218,7 @@ public override void Update() { rowScroll.Update(MaxRowScroll(layout)); compositePopupScroll.Update(MaxCompositePopupScroll(layout)); ease = Calc.Approach(ease, display ? 1f : 0f, Engine.RawDeltaTime * 7.5f); + inlineError = Calc.Approach(inlineError, 0f, Engine.RawDeltaTime * 4f); inputDelay -= Engine.RawDeltaTime; UpdateInteractions(layout); @@ -206,6 +231,18 @@ public override void Update() { } if (menu is null) return; + if (textEntry is { Scene: null }) textEntry = null; + RefreshExternalOverlay(); + if (externalOverlayOpen || textEntry is not null) { + // A foreground popup (key/button config or the string editor) owns input now. + menu.Focused = false; + return; + } + if (inlineEditing is not null) { + UpdateInlineEdit(layout); + menu.Focused = false; + return; + } bool acceptsInput = pauseLevel is not null || Selected && Focused; if (acceptsInput && inputDelay <= 0f && dropdownItem is null && compositePopupItem is null && UpdateSearchInput(layout)) { @@ -296,6 +333,20 @@ public override void Update() { } } + if ((Input.MenuConfirm.Pressed + || MInput.Keyboard.Pressed(Keys.Enter) + || MInput.Keyboard.Pressed(Keys.Space)) + && menu.Current is { Disabled: false } stringItem + && TryGetStringSetting(stringItem, out StringSettingEntry? stringSetting)) { + // Keyboard/mouse users get the OS text input in place; only the gamepad + // (or a user who disabled "use keyboard for text input") needs the QWERTY. + OpenStringEditor(stringSetting!, + allowGameKeyboard: Input.MenuConfirm.Pressed + && !MInput.Keyboard.Pressed(Keys.Enter) + && !MInput.Keyboard.Pressed(Keys.Space)); + return; + } + UpdateMouse(layout); int previousSelection = menu.Selection; menu.Update(); @@ -342,6 +393,7 @@ private void BuildMenu(bool inGame) { naturalVisibility.Clear(); itemOwners.Clear(); favoriteKeys.Clear(); + stringSettings.Clear(); menu = OuiModOptions.CreateMenu(inGame, null!); menu.Active = false; menu.Visible = false; @@ -382,10 +434,139 @@ private void BuildMenu(bool inGame) { sourceTabs.Add(new ModTab("__empty", UiText("microblocks_qol_modoptions_empty", "没有可用设置"), "", [], pinnable: false)); } + IndexStringSettings(); IndexSourceItems(); RebuildTabs(); } + /// + /// Everest renders string settings as opaque buttons ("名称: 值") that open its own text + /// screen. Map those buttons back to their property so this page can show an inline text + /// field and drive the shared in-game entry prompt instead. + /// + private void IndexStringSettings() { + foreach (ModTab tab in sourceTabs) { + if (!tab.Pinnable) continue; + EverestModule? module = Everest.Modules.FirstOrDefault(candidate => + MetadataMatches(candidate, tab.Title)); + Type? type = module?.SettingsType; + object? settings = module?._Settings; + if (type is null || settings is null) continue; + + string typeName = type.Name.ToLowerInvariant(); + if (typeName.EndsWith("settings", StringComparison.Ordinal)) typeName = typeName[..^8]; + string dialogPrefix = $"modoptions_{typeName}_"; + List buttons = tab.Items.OfType().ToList(); + + foreach (PropertyInfo property in type.GetProperties()) { + if (!property.CanRead || !property.CanWrite) continue; + if (property.PropertyType != typeof(string)) continue; + if (property.GetCustomAttribute() != null) continue; + + string name = property.GetCustomAttribute()?.Name + ?? $"{dialogPrefix}{property.Name.ToLowerInvariant()}"; + name = (Dialog.Has(name) ? Dialog.Clean(name) : null) + ?? SpacedPascalCase(property.Name); + + TextMenu.Button? button = buttons.FirstOrDefault(candidate => + !stringSettings.ContainsKey(candidate) + && LabelStartsWith(candidate.Label, name)); + if (button is null) continue; + + int maxLength = Math.Max(1, + property.GetCustomAttribute()?.Max ?? 12); + int minLength = Math.Max(0, + property.GetCustomAttribute()?.Min ?? 1); + stringSettings[button] = new StringSettingEntry(name, button, + () => { + try { + return property.GetValue(settings) as string ?? ""; + } catch { + return ""; + } + }, + value => { + property.SetValue(settings, value); + button.Label = Relabel(button.Label, name, value); + }, + minLength, maxLength); + } + IndexManualStringButtons(buttons); + } + } + + /// + /// Mods such as MiaoNet build their string entries by hand, so there is no property to + /// match against. Recognise the common `Func<string>` getter / `Action<string>` + /// setter closure behind such buttons and only accept it when the label still carries + /// the current value (which keeps ordinary action buttons out). + /// + private void IndexManualStringButtons(List buttons) { + foreach (TextMenu.Button button in buttons) { + if (stringSettings.ContainsKey(button) || button.OnPressed?.Target is not { } closure) + continue; + Func? getter = null; + Action? setter = null; + foreach (FieldInfo field in closure.GetType().GetFields( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { + if (field.FieldType == typeof(Func)) + getter ??= field.GetValue(closure) as Func; + else if (field.FieldType == typeof(Action)) + setter ??= field.GetValue(closure) as Action; + } + if (getter is null || setter is null) continue; + string value; + try { + value = getter() ?? ""; + } catch { + continue; + } + if (!LabelCarriesValue(button.Label, value)) continue; + string displayName = DisplayNameFromLabel(button.Label, value); + Action apply = setter; + stringSettings[button] = new StringSettingEntry(displayName, button, + () => value, + newValue => { + apply(newValue); + value = newValue; + button.Label = Relabel(button.Label, displayName, newValue); + }, + 1, 128); + } + } + + private static bool LabelStartsWith(string label, string name) => + label.StartsWith(name + ": ", StringComparison.Ordinal) + || label.StartsWith(name + ":", StringComparison.Ordinal) + || string.Equals(label, name, StringComparison.Ordinal); + + private static bool LabelCarriesValue(string label, string value) { + if (value.Length == 0) return true; + if (label.EndsWith(value, StringComparison.Ordinal)) return true; + const int truncationLength = 24; + if (value.Length <= truncationLength) return false; + return label.EndsWith(value[..truncationLength] + "...", StringComparison.Ordinal); + } + + private static bool MetadataMatches(EverestModule module, string title) { + string? name = module.Metadata?.Name; + if (string.IsNullOrEmpty(name)) return false; + return string.Equals(name, title, StringComparison.Ordinal) + || title.StartsWith(name, StringComparison.Ordinal) + || name.StartsWith(title, StringComparison.Ordinal); + } + + private static string SpacedPascalCase(string value) { + System.Text.StringBuilder builder = new(); + for (int index = 0; index < value.Length; index++) { + char character = value[index]; + if (index > 0 && char.IsUpper(character) && char.IsLower(value[index - 1])) + builder.Append(' '); + builder.Append(character); + } + return builder.ToString(); + } + private void IndexSourceItems() { foreach (ModTab tab in sourceTabs) { int favoriteOrdinal = 0; @@ -486,6 +667,237 @@ private void DetachMenu() { menu = null; } + private void RefreshExternalOverlay() { + bool open = Scene is { } scene + && scene.Entities.Any(entity => entity is KeyboardConfigUI or ButtonConfigUI); + if (open == externalOverlayOpen) return; + externalOverlayOpen = open; + // The config screen is added to the same scene at depth 0. Put this page behind it + // instead of letting it cover the config screen (or the acrylic snapshot swallow it). + Depth = open ? 1 : baseDepth; + } + + private bool TryGetStringSetting(TextMenu.Item? item, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out StringSettingEntry? setting) { + setting = null; + return item is not null && stringSettings.TryGetValue(item, out setting); + } + + private void OpenStringEditor(StringSettingEntry setting, bool allowGameKeyboard) { + if (allowGameKeyboard && PreferGameKeyboard()) OpenTextEntry(setting); + else BeginInlineEdit(setting); + } + + /// + /// Mirror Everest's "use keyboard for text input" setting and the engine's last-input + /// flag: the QWERTY prompt is only needed when the gamepad drove the input (or when the + /// user explicitly turned physical-keyboard text input off). + /// + private static bool PreferGameKeyboard() { + try { + if (!CoreModule.Settings.UseKeyboardForTextInput) return true; + } catch { + // Core settings unavailable: fall back to the controller flag below. + } + return MInput.ControllerHasFocus; + } + + private void BeginInlineEdit(StringSettingEntry setting) { + if (inlineEditing is not null) EndInlineEdit(); + CloseDropdown(playSound: false); + CloseCompositePopup(playSound: false); + inlineEditing = setting; + inlineBuffer = setting.Value(); + inlineIme = ""; + inlineError = 0f; + Audio.Play("event:/ui/main/savefile_rename_start"); + SubscribeInlineInput(); + MaterialTextInputFocus.Focus(this); + if (menu is not null) menu.Focused = false; + } + + private void UpdateInlineEdit(ModOptionsLayout layout) { + if (MaterialTextInputFocus.Pressed(Keys.Escape) || Input.MenuCancel.Pressed) { + CancelInlineEdit(); + return; + } + if (MaterialTextInputFocus.Pressed(Keys.Enter)) { + CommitInlineEdit(); + return; + } + if (!MaterialTouch.PressedLeftButton) return; + // Clicking anywhere outside the edited field commits, like a desktop text box. + bool inside = false; + foreach (RowPlacement placement in RowPlacements(layout)) { + if (placement.Item != inlineEditing!.Item) continue; + inside = TextControlRect(placement.Rect).Contains(MaterialTouch.Position); + break; + } + if (!inside) CommitInlineEdit(); + } + + private void OnInlineInput(char character) { + if (inlineEditing is null) return; + if (character == '\b') { + if (inlineBuffer.Length > 0) inlineBuffer = inlineBuffer[..^1]; + } else if (!char.IsControl(character) && inlineBuffer.Length < inlineEditing.MaxLength) { + inlineBuffer += character; + } else { + return; + } + inlineIme = ""; + inlineError = 0f; + } + + private void OnInlineEditing(string? text, int start, int length) { + _ = start; + _ = length; + if (inlineEditing is not null) inlineIme = text ?? ""; + } + + private void CommitInlineEdit() { + StringSettingEntry? setting = inlineEditing; + if (setting is null) return; + if (!CommitStringSetting(setting, inlineBuffer)) { + inlineError = 1f; + Audio.Play("event:/ui/main/button_invalid"); + return; + } + EndInlineEdit(); + Audio.Play("event:/ui/main/button_select"); + } + + private void CancelInlineEdit() { + EndInlineEdit(); + Audio.Play("event:/ui/main/button_back"); + } + + private void EndInlineEdit() { + if (inlineEditing is null) return; + inlineEditing = null; + inlineBuffer = ""; + inlineIme = ""; + inlineError = 0f; + ReleaseInlineInput(); + if (menu is not null) menu.Focused = searchTarget == SearchTarget.None; + } + + private void SubscribeInlineInput() { + if (inlineInputSubscribed) return; + inlineInputSubscribed = true; + TextInput.OnInput += OnInlineInput; + TextInputEXT.TextEditing += OnInlineEditing; + } + + private void ReleaseInlineInput() { + if (!inlineInputSubscribed) return; + inlineInputSubscribed = false; + TextInput.OnInput -= OnInlineInput; + TextInputEXT.TextEditing -= OnInlineEditing; + MaterialTextInputFocus.Blur(this); + } + + private static void SetTextInputRectangle(MaterialRect rect) { + float xScale = Engine.ViewWidth / ScreenWidth; + float yScale = Engine.ViewHeight / ScreenHeight; + TextInputEXT.SetInputRectangle(new Rectangle( + (int)(rect.X * xScale), + (int)(rect.Y * yScale), + Math.Max(1, (int)(rect.Width * xScale)), + Math.Max(1, (int)(rect.Height * yScale)) + )); + } + + private void OpenTextEntry(StringSettingEntry setting) { + if (textEntry is not null || Scene is null || menu is null) return; + CloseDropdown(playSound: false); + CloseCompositePopup(playSound: false); + Audio.Play("event:/ui/main/savefile_rename_start"); + menu.Focused = false; + textEntry = new TextEntryOverlay( + setting.DisplayName, + setting.Value(), + setting.MaxLength, + setting.MinLength, + value => CommitStringSetting(setting, value), + () => { + textEntry = null; + if (menu is not null) menu.Focused = searchTarget == SearchTarget.None; + } + ); + Scene.Add(textEntry); + } + + private bool CommitStringSetting(StringSettingEntry setting, string value) { + try { + setting.Setter(value); + return true; + } catch (Exception exception) { + Logger.LogDetailed(exception, "MicroblocksQolUtils/MaterialModOptions"); + return false; + } + } + + private void CloseTextEntry() { + textEntry?.RemoveSelf(); + textEntry = null; + } + + /// + /// Everest (and mods that build their own entries, like MiaoNet) route string settings + /// through . Capture that request and show the shared + /// in-game prompt instead of switching to Everest's page. + /// + internal bool BeginStringEntry(OuiModOptionString optionString) { + if (menu is null || Scene is null || menu.Current is not { Disabled: false } item) return false; + if (!stringSettings.TryGetValue(item, out StringSettingEntry? entry)) { + string initial = optionString.StartingValue ?? ""; + string label = item is TextMenu.Button button + ? button.Label + : TryGetLabel(item, out string itemLabel) ? itemLabel : ""; + string displayName = DisplayNameFromLabel(label, initial); + Action? apply = stringValueChangeField?.GetValue(optionString) as Action; + string current = initial; + entry = new StringSettingEntry(displayName, item, + () => current, + value => { + current = value; + apply?.Invoke(value); + if (item is TextMenu.Button target) + target.Label = Relabel(target.Label, displayName, value); + }, + Math.Clamp(optionString.MinValueLength, 0, Math.Max(1, optionString.MaxValueLength)), + Math.Max(1, optionString.MaxValueLength)); + stringSettings[item] = entry; + } + // Init() accumulates an exit handler per press; these requests never enter the Oui. + stringValueChangeField?.SetValue(optionString, null); + stringExitField?.SetValue(optionString, null); + OpenStringEditor(entry, allowGameKeyboard: MInput.ControllerHasFocus); + return true; + } + + private static string DisplayNameFromLabel(string label, string value) { + string name = label; + if (value.Length > 0 && name.EndsWith(value, StringComparison.Ordinal)) { + name = name[..^value.Length]; + } else if (value.Length > 24 && name.EndsWith(value[..24] + "...", StringComparison.Ordinal)) { + name = name[..^(24 + 3)]; + } + name = name.TrimEnd().TrimEnd(':', ':'); + return name.Length > 0 ? name : "文本"; + } + + private static string Relabel(string label, string displayName, string value) { + if (label.StartsWith(displayName, StringComparison.Ordinal)) { + string rest = label[displayName.Length..].TrimStart(); + if (rest.Length == 0) return displayName + ": " + value; + char separator = rest[0]; + if (separator is ':' or ':') return $"{displayName}{separator} {value}"; + } + return $"{displayName}: {value}"; + } + private void RestoreTab(bool restoreSaved) { int restored = restoreSaved && savedTabId is not null ? tabs.FindIndex(tab => tab.Id == savedTabId) @@ -805,6 +1217,10 @@ private void UpdateMouse(ModOptionsLayout layout) { SetIntSliderFromMouse(item, slider, SliderControlRect(rect), mouse.X); } else if (IsCompositeMenu(item)) { OpenCompositePopup(item); + } else if (TryGetStringSetting(item, out StringSettingEntry? setting)) { + // Touch goes through the published "text" target (CeleMod shows the system + // keyboard); desktop mouse edits in place with the OS input. + if (!MaterialTouch.Connected) OpenStringEditor(setting!, allowGameKeyboard: false); } else { ActivateItem(item); } @@ -1034,6 +1450,17 @@ private void EnsureTabVisible(ModOptionsLayout layout) { } private void UpdateInteractions(ModOptionsLayout layout) { + if (textEntry is not null) { + MaterialTouch.Publish(this, textEntry.InteractionTargets(), TouchContext + ":text-entry"); + motion.Update([]); + return; + } + if (externalOverlayOpen) { + // The key/button config screen lives in the same scene and owns input. + MaterialTouch.Publish(this, [], TouchContext); + motion.Update([]); + return; + } List targets = [ new MaterialInteractionTarget("mod-options.search.tabs", layout.TabSearch, Focused: searchTarget == SearchTarget.Tabs, TouchKind: "text", TouchText: tabSearchText, @@ -1067,6 +1494,15 @@ private void UpdateInteractions(ModOptionsLayout layout) { if (placement.Rect.Bottom < layout.Rows.Y || placement.Rect.Y > layout.Rows.Bottom) continue; targets.Add(new MaterialInteractionTarget(ItemKey(placement.Item), placement.Rect, Focused: menu.Current == placement.Item)); + if (TryGetStringSetting(placement.Item, out StringSettingEntry? stringSetting)) { + // CeleMod turns this into the system soft keyboard on Android; desktop + // input never uses it (no touch bridge). + targets.Add(new MaterialInteractionTarget(ItemKey(placement.Item) + ".text", + TextControlRect(PrimaryRect(placement)), TouchKind: "text", + TouchText: stringSetting!.Value(), + TouchMaxLength: stringSetting.MaxLength, + TouchTextChanged: value => CommitStringSetting(stringSetting, value))); + } if (placement.Item is TextMenu.Slider) { targets.Add(new MaterialInteractionTarget(ItemKey(placement.Item) + ".touch-slider", SliderControlRect(PrimaryRect(placement)), TouchKind: "slider")); @@ -1253,6 +1689,13 @@ private bool RenderStandardItem(TextMenu.Item item, MaterialRect rect, MaterialP RenderIntSlider(slider, rect, palette, alpha, enabled); return true; } + if (TryGetStringSetting(item, out StringSettingEntry? stringSetting)) { + MaterialRect control = TextControlRect(rect); + RenderItemLabel(stringSetting!.DisplayName, rect, control.X - rect.X - 62f, labelColor, + alpha, hasDescription); + RenderStringControl(stringSetting, control, palette, alpha, enabled); + return true; + } if (item is TextMenu.Button button) { MaterialRect action = ActionControlRect(rect); RenderItemLabel(button.Label, rect, action.X - rect.X - 62f, labelColor, alpha, @@ -1370,6 +1813,49 @@ private static void RenderSliderTrack(MaterialRect track, float amount, Material palette.Primary * (alpha * (enabled ? 1f : 0.45f))); } + private void RenderStringControl(StringSettingEntry setting, MaterialRect control, + MaterialPalette palette, float alpha, bool enabled) { + bool editing = ReferenceEquals(setting, inlineEditing); + MaterialUi.RoundedRect(control.X, control.Y, control.Width, control.Height, 16f, + palette.Surface * (0.76f * alpha)); + MaterialUi.RoundedOutline(control.X, control.Y, control.Width, control.Height, 16f, + editing ? 2f : 1f, + (editing ? (inlineError > 0f ? Color.OrangeRed : palette.Primary) : palette.Outline) + * (editing ? alpha : alpha * 0.52f)); + string value = editing ? inlineBuffer + inlineIme : setting.Value(); + bool empty = value.Length == 0; + MaterialUiKit.Text(MaterialTextUtil.Ellipsize( + empty ? UiText("microblocks_qol_modoptions_text_placeholder", "点击输入") : value, + control.Width - 62f, 0.27f, UiFontWeight.Bold), + new Vector2(control.X + 15f, control.Center.Y), new Vector2(0f, 0.5f), + MaterialTextRole.Label, + enabled + ? empty ? palette.OnSurfaceVariant * 0.72f : palette.OnSurface + : palette.OnSurfaceVariant * 0.5f, + alpha, scaleOverride: 0.27f); + if (editing) { + if (Scene?.BetweenInterval(0.5f) == true) { + float width = SystemTtfFont.MeasureVisible( + MaterialTextUtil.Ellipsize(value, control.Width - 62f, 0.27f, + UiFontWeight.Bold), 0.27f, UiFontWeight.Bold).X; + float caretX = Math.Min(control.Right - 36f, control.X + 17f + width); + MaterialUi.Line(new Vector2(caretX, control.Y + 8f), + new Vector2(caretX, control.Bottom - 8f), 2f, palette.Primary); + } + SetTextInputRectangle(control); + } else { + MaterialUiKit.Icon("edit", new Vector2(control.Right - 26f, control.Center.Y), 20f, + enabled ? palette.Primary : palette.OnSurfaceVariant * 0.5f, alpha); + } + } + + private static MaterialRect TextControlRect(MaterialRect rect) => new( + rect.Right - 504f, + rect.Center.Y - 24f, + 480f, + 48f + ); + private static void RenderActionControl(MaterialRect control, string value, MaterialPalette palette, float alpha, bool enabled) { MaterialUi.RoundedRect(control.X, control.Y, control.Width, control.Height, 16f, @@ -1905,10 +2391,21 @@ private static IEnumerator DetourGotoRoutine(GotoRoutineOrig orig, Overworld sel && MicroblocksQolUtilsModule.Settings.ReplaceEverestModOptions && self.GetUI() is { } material) { next = material; + } else if (next is OuiModOptionString optionString + && MicroblocksQolUtilsModule.Settings.ReplaceEverestModOptions + && self.GetUI() is { } page + && page.Selected && page.Visible + && page.BeginStringEntry(optionString)) { + // Keep this page current and show the material text prompt instead. + return EmptyRoutine(); } return orig(self, next); } + private static IEnumerator EmptyRoutine() { + yield break; + } + private static bool TrySplitModuleHeader(string value, out string title, out string version) { const string marker = " | v."; int markerIndex = value.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase); @@ -2021,6 +2518,24 @@ private sealed class ModTab(string id, string title, string version, List getter, + Action setter, + int minLength, + int maxLength + ) { + public string DisplayName { get; } = displayName; + public TextMenu.Item Item { get; } = item; + public Func Getter { get; } = getter; + public Action Setter { get; } = setter; + public int MinLength { get; } = minLength; + public int MaxLength { get; } = maxLength; + + public string Value() => Getter(); + } + private sealed record OptionSnapshot( string Label, int Index, diff --git a/Source/QolSettingsOverlay.cs b/Source/QolSettingsOverlay.cs index 45b072a..c9cf30d 100644 --- a/Source/QolSettingsOverlay.cs +++ b/Source/QolSettingsOverlay.cs @@ -70,6 +70,8 @@ private enum RecorderSettingsSection { Automatic, DeathReplay, Quality, Storage, public bool SuppressNormalRender { get; set; } + public bool AllowsAcrylic => true; + public QolSettingsOverlay(Level level, int returnIndex, bool minimal, bool oldAllowHudHide) { this.level = level; this.returnIndex = returnIndex; @@ -108,7 +110,13 @@ public override void Update() { recordingRefreshTimer -= Engine.RawDeltaTime; if (IsRecorderTab && IsRecorderLibrary && recordingRefreshTimer <= 0f) RefreshRecordingFiles(); - if (bindingConfig is not null) return; + if (bindingConfig is not null) { + // The key/button config screen handles its own input; do not leave the hidden + // setting rows alive as touch targets underneath it. + MaterialTouch.Publish(this, [], TouchContext); + motion.Update([]); + return; + } OverlayLayout layout = OverlayLayout.Create(1f - Ease.CubeOut(ease)); rowScroll.Update(MaxRowScroll(layout)); diff --git a/Source/TextEntryOverlay.cs b/Source/TextEntryOverlay.cs new file mode 100644 index 0000000..4cbdcdb --- /dev/null +++ b/Source/TextEntryOverlay.cs @@ -0,0 +1,578 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using Monocle; + +namespace Celeste.Mod.MicroblocksQolUtils; + +/// +/// Full-screen in-game text entry prompt with a QWERTY keyboard, number and symbol pages. +/// Gamepad navigation keeps the directional + confirm flow, while mouse and touch type +/// directly on the tapped key (long-press on the space bar repeats spaces). +/// +internal sealed class TextEntryOverlay : Entity { + private const float PanelX = 260f; + private const float PanelY = 120f; + private const float PanelWidth = 1400f; + private const float PanelHeight = 720f; + private const float PanelInset = 40f; + private const float FieldOffsetY = 84f; + private const float FieldHeight = 88f; + private const float KeyboardOffsetY = 236f; + private const float KeyGap = 12f; + private const float KeyHeight = 104f; + private const float ActionHeight = 68f; + private const float ActionBottomMargin = 30f; + private const int Columns = 10; + private const float SpaceRepeatDelay = 0.36f; + private const float SpaceRepeatInterval = 0.08f; + + private static readonly string[] ActionLabels = ["取消", "空格", "退格", "接受"]; + private static readonly float[] ActionWeights = [1.4f, 3.4f, 1.4f, 1.5f]; + + private enum Page { + Letters, + Numbers, + Symbols + } + + private enum KeyAction { + Character, + Shift, + PageLetters, + PageNumbers, + PageSymbols, + Space, + Backspace, + Accept, + Cancel + } + + private readonly record struct Key(string Label, char Character, KeyAction Action, string Hint = ""); + + private readonly string title; + private readonly int maxLength; + private readonly int minLength; + private readonly Func commit; + private readonly Action closed; + private readonly MaterialPalette palette = MaterialPalette.FromSeed(new Color(126, 99, 184)); + + private string buffer; + private Page page = Page.Letters; + private bool upper; + private Key[][] rows = []; + private int cursorRow; + private int cursorColumn; + private int actionIndex; + private bool inActions; + private int hoverRow = -1; + private int hoverColumn = -1; + private int hoverAction = -1; + private float spaceHold; + private float spaceRepeat; + private float error; + private bool opened; + + public TextEntryOverlay( + string title, + string initial, + int maxLength, + int minLength, + Func commit, + Action closed + ) { + this.title = title; + this.maxLength = Math.Max(1, maxLength); + this.minLength = Math.Clamp(minLength, 0, this.maxLength); + this.commit = commit; + this.closed = closed; + // Keep an existing longer value intact; the limit only applies to new characters. + buffer = initial ?? ""; + RebuildRows(); + Tag = Tags.HUD | Tags.PauseUpdate; + Depth = -3_000_000; + } + + public string Buffer => buffer; + + public override void Added(Scene scene) { + base.Added(scene); + opened = true; + TextInput.OnInput += OnTextInput; + MaterialTextInputFocus.Focus(this); + } + + public override void Removed(Scene scene) { + TextInput.OnInput -= OnTextInput; + MaterialTextInputFocus.Blur(this); + base.Removed(scene); + } + + private void OnTextInput(char character) { + if (character == '\b') { + Backspace(); + return; + } + if (char.IsControl(character)) return; + Append(character); + } + + public override void Update() { + base.Update(); + error = Calc.Approach(error, 0f, Engine.RawDeltaTime * 4f); + if (!opened) return; + + if (Input.MenuCancel.Pressed || MaterialTextInputFocus.Pressed(Keys.Escape)) { + Cancel(); + return; + } + // A physical Enter accepts the text; the gamepad confirm types the focused key. + if (MaterialTextInputFocus.Pressed(Keys.Enter)) { + Accept(); + return; + } + + UpdateHover(); + if (Input.MenuConfirm.Pressed) { + if (inActions) RunAction(actionIndex); + else PressKey(rows[cursorRow][cursorColumn]); + return; + } + UpdateNavigation(); + UpdateLongPress(); + } + + private void UpdateHover() { + Vector2 mouse = MaterialTouch.Position; + hoverRow = RowAt(mouse); + hoverColumn = hoverRow >= 0 ? ColumnAt(hoverRow, mouse) : -1; + hoverAction = ActionAt(mouse); + + // Keep the gamepad focus stable while the pointer rests; it only follows the + // pointer when the pointer actually moves (or taps). + if (MaterialTouch.WasMoved || MaterialTouch.PressedLeftButton) { + if (hoverRow >= 0) { + inActions = false; + cursorRow = hoverRow; + cursorColumn = hoverColumn; + } else if (hoverAction >= 0) { + inActions = true; + actionIndex = hoverAction; + } + } + + if (!MaterialTouch.PressedLeftButton) return; + if (hoverRow >= 0) { + PressKey(rows[hoverRow][hoverColumn]); + } else if (hoverAction >= 0) { + RunAction(hoverAction); + } + } + + private void UpdateNavigation() { + bool left = Input.MenuLeft.Pressed || MaterialTextInputFocus.Pressed(Keys.Left); + bool right = Input.MenuRight.Pressed || MaterialTextInputFocus.Pressed(Keys.Right); + bool up = Input.MenuUp.Pressed || MaterialTextInputFocus.Pressed(Keys.Up); + bool down = Input.MenuDown.Pressed || MaterialTextInputFocus.Pressed(Keys.Down); + if (left) MoveHorizontal(-1); + else if (right) MoveHorizontal(1); + else if (up) MoveVertical(-1); + else if (down) MoveVertical(1); + } + + private void MoveHorizontal(int direction) { + if (inActions) { + actionIndex = Wrap(actionIndex + direction, ActionLabels.Length); + } else { + cursorColumn = Wrap(cursorColumn + direction, rows[cursorRow].Length); + } + Audio.Play("event:/ui/main/rollover_down"); + } + + private void MoveVertical(int direction) { + if (direction > 0) { + if (inActions) return; + if (cursorRow + 1 >= rows.Length) { + inActions = true; + actionIndex = Math.Clamp(cursorColumn * 4 / Math.Max(1, rows[^1].Length), 0, + ActionLabels.Length - 1); + } else { + cursorRow++; + cursorColumn = Math.Min(cursorColumn, rows[cursorRow].Length - 1); + } + } else { + if (inActions) { + inActions = false; + cursorRow = rows.Length - 1; + cursorColumn = Math.Clamp(actionIndex, 0, rows[cursorRow].Length - 1); + } else if (cursorRow > 0) { + cursorRow--; + cursorColumn = Math.Min(cursorColumn, rows[cursorRow].Length - 1); + } + } + Audio.Play("event:/ui/main/rollover_down"); + } + + private void UpdateLongPress() { + bool holdingSpace = (hoverAction == 1 && MaterialTouch.CheckLeftButton) + || (inActions && actionIndex == 1 && Input.MenuConfirm.Check); + if (!holdingSpace) { + spaceHold = 0f; + spaceRepeat = 0f; + return; + } + spaceHold += Engine.RawDeltaTime; + if (spaceHold < SpaceRepeatDelay) return; + spaceRepeat += Engine.RawDeltaTime; + while (spaceRepeat >= SpaceRepeatInterval) { + spaceRepeat -= SpaceRepeatInterval; + Append(' '); + } + } + + private void PressKey(Key key) { + switch (key.Action) { + case KeyAction.Character: + Append(key.Character); + break; + case KeyAction.Shift: + upper = !upper; + RebuildRows(); + Audio.Play("event:/ui/main/button_toggle_on"); + break; + case KeyAction.PageLetters: + case KeyAction.PageNumbers: + case KeyAction.PageSymbols: + page = key.Action switch { + KeyAction.PageLetters => Page.Letters, + KeyAction.PageNumbers => Page.Numbers, + _ => Page.Symbols + }; + RebuildRows(); + Audio.Play("event:/ui/main/button_toggle_on"); + break; + case KeyAction.Space: + Append(' '); + break; + case KeyAction.Backspace: + Backspace(); + break; + case KeyAction.Accept: + Accept(); + break; + case KeyAction.Cancel: + Cancel(); + break; + } + } + + private void RunAction(int index) { + switch (index) { + case 0: + Cancel(); + break; + case 1: + Append(' '); + break; + case 2: + Backspace(); + break; + case 3: + Accept(); + break; + } + } + + private void Append(char character) { + if (buffer.Length >= maxLength) { + Audio.Play("event:/ui/main/button_invalid"); + return; + } + buffer += character; + error = 0f; + Audio.Play("event:/ui/main/button_select"); + } + + private void Backspace() { + if (buffer.Length == 0) return; + buffer = buffer[..^1]; + error = 0f; + Audio.Play("event:/ui/main/button_back"); + } + + private void Accept() { + if (buffer.Length < minLength) { + error = 1f; + Audio.Play("event:/ui/main/button_invalid"); + return; + } + if (!commit(buffer)) { + error = 1f; + Audio.Play("event:/ui/main/button_invalid"); + return; + } + Close(); + } + + private void Cancel() { + Audio.Play("event:/ui/main/button_back"); + Close(); + } + + private void Close() { + if (!opened) return; + opened = false; + closed(); + RemoveSelf(); + } + + private void RebuildRows() { + rows = page switch { + Page.Letters => [ + Characters(upper ? "QWERTYUIOP" : "qwertyuiop", "1234567890"), + Characters(upper ? "ASDFGHJKL" : "asdfghjkl", "~@#!%&*()"), + [ + new Key(upper ? "⇩" : "⇧", '\0', KeyAction.Shift), + ..Characters(upper ? "ZXCVBNM" : "zxcvbnm", "`/-_:;?"), + new Key("123", '\0', KeyAction.PageNumbers), + new Key("#+=", '\0', KeyAction.PageSymbols) + ] + ], + Page.Numbers => [ + Characters("1234567890"), + Characters("-/:;()$&@\""), + [ + ..Characters(".,?!'"), + new Key("ABC", '\0', KeyAction.PageLetters), + new Key("#+=", '\0', KeyAction.PageSymbols) + ] + ], + _ => [ + Characters("[]{}#%^*+="), + Characters("_\\|~<>`\"'&"), + [ + ..Characters("@$:;,./?"), + new Key("ABC", '\0', KeyAction.PageLetters), + new Key("123", '\0', KeyAction.PageNumbers) + ] + ] + }; + cursorRow = Math.Clamp(cursorRow, 0, rows.Length - 1); + cursorColumn = Math.Clamp(cursorColumn, 0, rows[cursorRow].Length - 1); + inActions = false; + } + + private static Key[] Characters(string characters, string hints = "") { + Key[] keys = new Key[characters.Length]; + for (int index = 0; index < characters.Length; index++) { + char character = characters[index]; + string hint = index < hints.Length ? hints[index].ToString() : ""; + keys[index] = new Key(character.ToString(), character, KeyAction.Character, hint); + } + return keys; + } + + /// Touch targets published by the owning page while this prompt is open. + public IEnumerable InteractionTargets() { + for (int row = 0; row < rows.Length; row++) { + for (int column = 0; column < rows[row].Length; column++) { + yield return new MaterialInteractionTarget($"text-entry.key.{row}.{column}", + KeyRect(row, column), Focused: !inActions && cursorRow == row && cursorColumn == column); + } + } + for (int index = 0; index < ActionLabels.Length; index++) { + yield return new MaterialInteractionTarget($"text-entry.action.{index}", ActionRect(index), + Focused: inActions && actionIndex == index); + } + } + + public override void Render() { + Draw.Rect(0f, 0f, 1920f, 1080f, Color.Black * 0.7f); + MaterialRect panel = new(PanelX, PanelY, PanelWidth, PanelHeight); + MaterialUiKit.Surface(panel, 30f, palette); + MaterialUi.RoundedOutline(panel.X, panel.Y, panel.Width, panel.Height, 30f, 1f, + palette.Outline * 0.8f); + + MaterialUiKit.Text($"编辑 · {title}", new Vector2(panel.X + PanelInset, panel.Y + 30f), + Vector2.Zero, MaterialTextRole.Section, palette.OnSurface, 1f, scaleOverride: 0.38f); + MaterialUiKit.Text(PageHint(), + new Vector2(panel.Right - PanelInset, panel.Y + 38f), new Vector2(1f, 0f), + MaterialTextRole.Caption, palette.OnSurfaceVariant, 0.9f, scaleOverride: 0.26f); + + RenderField(panel); + RenderKeyboard(); + RenderActions(); + } + + private string PageHint() => page switch { + Page.Letters => "点按输入 · 长按空格连打 · 角标仅提示", + Page.Numbers => "数字页 · ABC 回字母 · #+= 换符号 · Esc 取消", + _ => "符号页 · ABC 回字母 · 123 换数字 · Esc 取消" + }; + + private void RenderField(MaterialRect panel) { + MaterialRect field = FieldRect(panel); + MaterialUi.RoundedRect(field.X, field.Y, field.Width, field.Height, 18f, + palette.SurfaceHighest); + MaterialUi.RoundedOutline(field.X, field.Y, field.Width, field.Height, 18f, + error > 0f ? 2f : 1f, + (error > 0f ? Color.OrangeRed : palette.Primary) * (error > 0f ? 1f : 0.7f)); + + string shown = buffer.Length == 0 + ? "输入内容…" + : EllipsizeFromLeft(buffer, 1120f, 0.36f, UiFontWeight.Bold); + Color color = buffer.Length == 0 ? palette.OnSurfaceVariant * 0.7f : palette.OnSurface; + MaterialUiKit.Text(shown, new Vector2(field.X + 24f, field.Center.Y), new Vector2(0f, 0.5f), + MaterialTextRole.Label, color, 1f, scaleOverride: 0.36f); + + if (Scene.BetweenInterval(0.5f)) { + float width = SystemTtfFont.MeasureVisible(shown, 0.36f, UiFontWeight.Bold).X; + float caretX = Math.Min(field.Right - 100f, field.X + 24f + width + 3f); + MaterialUi.Line(new Vector2(caretX, field.Y + 20f), + new Vector2(caretX, field.Bottom - 20f), 2.5f, palette.Primary); + } + + MaterialUiKit.Text($"{buffer.Length}/{maxLength}", + new Vector2(field.Right - 22f, field.Center.Y), new Vector2(1f, 0.5f), + MaterialTextRole.Caption, error > 0f ? Color.OrangeRed : palette.OnSurfaceVariant, + 1f, scaleOverride: 0.27f); + } + + private void RenderKeyboard() { + for (int row = 0; row < rows.Length; row++) { + Key[] keys = rows[row]; + float width = KeyWidth(); + float rowWidth = keys.Length * width + (keys.Length - 1) * KeyGap; + float x = PanelX + (PanelWidth - rowWidth) / 2f; + float y = KeyY(row); + for (int column = 0; column < keys.Length; column++) { + MaterialRect rect = new(x + column * (width + KeyGap), y, width, KeyHeight); + RenderKey(keys[column], rect, + !inActions && cursorRow == row && cursorColumn == column, + hoverRow == row && hoverColumn == column); + } + } + } + + private void RenderKey(Key key, MaterialRect rect, bool focused, bool hovered) { + bool activeToggle = key.Action == KeyAction.Shift && upper; + Color fill; + Color text; + if (hovered) { + fill = palette.Primary; + text = palette.OnPrimary; + } else if (activeToggle) { + fill = palette.Primary * 0.82f; + text = palette.OnPrimary; + } else if (focused) { + fill = palette.SurfaceHighest; + text = palette.Primary; + } else { + fill = palette.SurfaceHighest * 0.58f; + text = key.Action == KeyAction.Character ? palette.OnSurface : palette.OnSurfaceVariant; + } + MaterialUi.RoundedRect(rect.X, rect.Y, rect.Width, rect.Height, 14f, fill); + if (focused && !hovered) { + MaterialUi.RoundedOutline(rect.X, rect.Y, rect.Width, rect.Height, 14f, 2f, + palette.Primary); + } + if (key.Hint.Length > 0) { + MaterialUiKit.Text(key.Hint, new Vector2(rect.Right - 14f, rect.Y + 12f), + new Vector2(1f, 0f), MaterialTextRole.Caption, + (hovered ? palette.OnPrimary : palette.OnSurfaceVariant) * 0.72f, 1f, + scaleOverride: 0.19f); + } + MaterialUiKit.Text(key.Label, new Vector2(rect.Center.X, rect.Center.Y + 8f), + new Vector2(0.5f), MaterialTextRole.Label, text, 1f, + scaleOverride: key.Action == KeyAction.Character ? 0.36f : 0.24f); + } + + private void RenderActions() { + for (int index = 0; index < ActionLabels.Length; index++) { + MaterialRect button = ActionRect(index); + bool focused = inActions && actionIndex == index; + bool hovered = hoverAction == index; + bool danger = index == 0; + bool accept = index == 3; + Color fill = hovered || focused + ? danger ? new Color(198, 74, 84) : palette.Primary + : palette.SurfaceHighest; + Color text = hovered || focused + ? danger ? Color.White : palette.OnPrimary + : accept ? palette.Primary : palette.OnSurface; + MaterialUi.RoundedRect(button.X, button.Y, button.Width, button.Height, 18f, fill); + if (!hovered && !focused) { + MaterialUi.RoundedOutline(button.X, button.Y, button.Width, button.Height, 18f, + 1f, palette.Outline * 0.7f); + } + MaterialUiKit.Text(ActionLabels[index], button.Center, new Vector2(0.5f), + MaterialTextRole.Label, text, 1f, scaleOverride: 0.3f); + } + } + + private MaterialRect FieldRect(MaterialRect panel) => + new(panel.X + PanelInset, panel.Y + FieldOffsetY, panel.Width - PanelInset * 2f, + FieldHeight); + + private static float KeyWidth() => (PanelWidth - PanelInset * 2f - KeyGap * (Columns - 1)) + / Columns; + + private static float KeyY(int row) => PanelY + KeyboardOffsetY + row * (KeyHeight + KeyGap); + + private MaterialRect KeyRect(int row, int column) { + Key[] keys = rows[row]; + float width = KeyWidth(); + float rowWidth = keys.Length * width + (keys.Length - 1) * KeyGap; + float x = PanelX + (PanelWidth - rowWidth) / 2f; + return new MaterialRect(x + column * (width + KeyGap), KeyY(row), width, KeyHeight); + } + + private static float ActionWidth(int index) { + float total = ActionWeights.Sum(); + float available = PanelWidth - PanelInset * 2f - KeyGap * (ActionLabels.Length - 1); + return available * ActionWeights[index] / total; + } + + private static MaterialRect ActionRect(int index) { + float x = PanelX + PanelInset; + for (int previous = 0; previous < index; previous++) x += ActionWidth(previous) + KeyGap; + return new MaterialRect(x, PanelY + PanelHeight - ActionBottomMargin - ActionHeight, + ActionWidth(index), ActionHeight); + } + + private int RowAt(Vector2 point) { + for (int row = 0; row < rows.Length; row++) { + if (point.Y < KeyY(row) || point.Y > KeyY(row) + KeyHeight) continue; + return ColumnAt(row, point) >= 0 ? row : -1; + } + return -1; + } + + private int ColumnAt(int row, Vector2 point) { + Key[] keys = rows[row]; + for (int column = 0; column < keys.Length; column++) { + if (KeyRect(row, column).Contains(point)) return column; + } + return -1; + } + + private int ActionAt(Vector2 point) { + for (int index = 0; index < ActionLabels.Length; index++) { + if (ActionRect(index).Contains(point)) return index; + } + return -1; + } + + private static int Wrap(int value, int count) => + count <= 0 ? 0 : (value % count + count) % count; + + private static string EllipsizeFromLeft(string value, float maxWidth, float scale, + UiFontWeight weight) { + if (SystemTtfFont.MeasureVisible(value, scale, weight).X <= maxWidth) return value; + const string ellipsis = "…"; + for (int start = 1; start < value.Length; start++) { + string candidate = ellipsis + value[start..]; + if (SystemTtfFont.MeasureVisible(candidate, scale, weight).X <= maxWidth) return candidate; + } + return ellipsis; + } +}