From 42960f3f3cf7e70d4550b8be0b8dcbde1ebe17b5 Mon Sep 17 00:00:00 2001
From: std-external <201375710+std-external@users.noreply.github.com>
Date: Mon, 19 Oct 2026 23:48:40 +0800
Subject: [PATCH 1/5] modoptions: support string settings with in-game text
entry
Render string settings as inline text fields in the material mod options
page and edit them through a reusable full-screen entry prompt (character
grid, cancel/space/backspace/accept) that works with gamepad, touch, mouse
and physical keyboards.
Also stop the acrylic composition and input handling from covering the
Everest key/button config screens opened from this page: disable acrylic
while they are open, place the page behind them and ignore input.
---
Source/IMaterialAcrylicPage.cs | 8 +
Source/MaterialAcrylicRenderer.cs | 1 +
Source/MaterialChapterSelect.cs | 2 +
Source/MaterialModOptions.cs | 332 ++++++++++++++++++++++++
Source/QolSettingsOverlay.cs | 10 +-
Source/TextEntryOverlay.cs | 406 ++++++++++++++++++++++++++++++
6 files changed, 758 insertions(+), 1 deletion(-)
create mode 100644 Source/TextEntryOverlay.cs
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..4b4ddb8 100644
--- a/Source/MaterialModOptions.cs
+++ b/Source/MaterialModOptions.cs
@@ -52,6 +52,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 +96,15 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage {
private string settingSearchText = "";
private string imeText = "";
private SearchTarget searchTarget;
+ private readonly Dictionary stringSettings = [];
+ private TextEntryOverlay? textEntry;
+ 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 +116,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 +125,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 +169,7 @@ public override IEnumerator Enter(Oui from) {
public override IEnumerator Leave(Oui next) {
display = false;
SetSearchTarget(SearchTarget.None);
+ 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 +192,7 @@ public override void Added(Scene scene) {
public override void Removed(Scene scene) {
SetSearchTarget(SearchTarget.None);
+ CloseTextEntry();
DetachMenu();
if (pauseLevel is not null) pauseLevel.AllowHudHide = oldAllowHudHide;
tabViewport.Dispose();
@@ -206,6 +222,13 @@ 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;
+ }
bool acceptsInput = pauseLevel is not null || Selected && Focused;
if (acceptsInput && inputDelay <= 0f && dropdownItem is null && compositePopupItem is null
&& UpdateSearchInput(layout)) {
@@ -296,6 +319,15 @@ 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)) {
+ OpenTextEntry(stringSetting);
+ return;
+ }
+
UpdateMouse(layout);
int previousSelection = menu.Selection;
menu.Update();
@@ -342,6 +374,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 +415,128 @@ 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)
+ && candidate.Label.StartsWith(name, StringComparison.Ordinal));
+ 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,
+ () => property.GetValue(settings) as string ?? "",
+ 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 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 +637,110 @@ 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 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,
+ null,
+ 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);
+ OpenTextEntry(entry);
+ 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];
+ 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 +1060,8 @@ 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)) {
+ OpenTextEntry(setting!);
} else {
ActivateItem(item);
}
@@ -1034,6 +1291,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,
@@ -1253,6 +1521,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 +1645,34 @@ private static void RenderSliderTrack(MaterialRect track, float amount, Material
palette.Primary * (alpha * (enabled ? 1f : 0.45f)));
}
+ private static void RenderStringControl(StringSettingEntry setting, MaterialRect control,
+ MaterialPalette palette, float alpha, bool enabled) {
+ 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, 1f,
+ palette.Outline * (0.52f * alpha));
+ string value = 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);
+ 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 +2208,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 +2335,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..1fe93fb
--- /dev/null
+++ b/Source/TextEntryOverlay.cs
@@ -0,0 +1,406 @@
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Input;
+using Monocle;
+
+namespace Celeste.Mod.MicroblocksQolUtils;
+
+///
+/// Full-screen in-game text entry prompt (character grid + cancel / space / backspace / accept).
+/// Gamepad, mouse, touch and a physical keyboard all work, so string settings stay editable
+/// on platforms where no OS on-screen keyboard is available.
+///
+internal sealed class TextEntryOverlay : Entity {
+ public const string DefaultCharacters =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-=:~!@$%^&*_-#\"'()<>/\\|.,;?[]{}`";
+
+ private const float PanelX = 260f;
+ private const float PanelY = 110f;
+ private const float PanelWidth = 1400f;
+ private const float PanelHeight = 880f;
+ private const float PanelInset = 40f;
+ private const float FieldOffsetY = 84f;
+ private const float FieldHeight = 88f;
+ private const float GridTop = 300f;
+ private const float CellGap = 6f;
+ private const float ActionHeight = 66f;
+ private const float ActionBottomMargin = 30f;
+ private const int Columns = 12;
+
+ private static readonly string[] ActionLabels = ["取消", "空格", "退格", "接受"];
+
+ private readonly string title;
+ private readonly string characters;
+ 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 readonly int rows;
+
+ private string buffer;
+ private int cursor;
+ private int actionCursor;
+ private bool inActions;
+ private float error;
+ private bool opened;
+
+ public TextEntryOverlay(
+ string title,
+ string initial,
+ int maxLength,
+ int minLength,
+ string? characters,
+ Func commit,
+ Action closed
+ ) {
+ this.title = title;
+ this.maxLength = Math.Max(1, maxLength);
+ this.minLength = Math.Clamp(minLength, 0, this.maxLength);
+ this.characters = string.IsNullOrEmpty(characters) ? DefaultCharacters : characters;
+ this.commit = commit;
+ this.closed = closed;
+ // Keep an existing longer value intact; the limit only applies to new characters.
+ buffer = initial ?? "";
+ rows = Math.Max(1, (this.characters.Length + Columns - 1) / Columns);
+ 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 picks the highlighted cell.
+ if (MaterialTextInputFocus.Pressed(Keys.Enter)) {
+ Accept();
+ return;
+ }
+ if (Input.MenuConfirm.Pressed) {
+ Activate();
+ return;
+ }
+
+ 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) MoveCursor(-1);
+ else if (right) MoveCursor(1);
+ else if (up) MoveRow(-1);
+ else if (down) MoveRow(1);
+
+ UpdatePointer();
+ }
+
+ private void UpdatePointer() {
+ Vector2 mouse = MaterialTouch.Position;
+ bool moved = MaterialTouch.WasMoved;
+ bool clicked = MaterialTouch.PressedLeftButton;
+
+ int hoveredCell = CellAt(mouse);
+ int hoveredAction = ActionAt(mouse);
+ if (hoveredCell >= 0) {
+ inActions = false;
+ cursor = hoveredCell;
+ } else if (hoveredAction >= 0) {
+ inActions = true;
+ actionCursor = hoveredAction;
+ }
+
+ if (!clicked) return;
+ if (hoveredCell >= 0) Append(characters[hoveredCell]);
+ else if (hoveredAction >= 0) RunAction(hoveredAction);
+ else if (moved) Audio.Play("event:/ui/main/button_invalid");
+ }
+
+ private void MoveCursor(int direction) {
+ if (inActions) {
+ actionCursor = Wrap(actionCursor + direction, ActionLabels.Length);
+ Audio.Play("event:/ui/main/rollover_down");
+ return;
+ }
+ cursor = Wrap(cursor + direction, characters.Length);
+ Audio.Play("event:/ui/main/rollover_down");
+ }
+
+ private void MoveRow(int direction) {
+ int column = cursor % Columns;
+ if (direction > 0) {
+ if (inActions) return;
+ int next = cursor + Columns;
+ if (next >= characters.Length) {
+ inActions = true;
+ actionCursor = Math.Min(column, ActionLabels.Length - 1);
+ } else {
+ cursor = next;
+ }
+ } else {
+ if (inActions) {
+ inActions = false;
+ cursor = Math.Min(characters.Length - 1, (rows - 1) * Columns
+ + Math.Min(column, Columns - 1));
+ } else {
+ cursor = Math.Max(0, cursor - Columns);
+ }
+ }
+ Audio.Play("event:/ui/main/rollover_down");
+ }
+
+ private void Activate() {
+ if (!inActions) {
+ Append(characters[cursor]);
+ return;
+ }
+ RunAction(actionCursor);
+ }
+
+ 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();
+ }
+
+ /// Touch targets published by the owning page while this prompt is open.
+ public IEnumerable InteractionTargets() {
+ for (int index = 0; index < characters.Length; index++) {
+ yield return new MaterialInteractionTarget($"text-entry.char.{index}", CellRect(index),
+ Focused: !inActions && cursor == index);
+ }
+ for (int index = 0; index < ActionLabels.Length; index++) {
+ yield return new MaterialInteractionTarget($"text-entry.action.{index}", ActionRect(index),
+ Focused: inActions && actionCursor == 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("方向键选择字符 · 确认输入 · 取消返回",
+ new Vector2(panel.Right - PanelInset, panel.Y + 38f), new Vector2(1f, 0f),
+ MaterialTextRole.Caption, palette.OnSurfaceVariant, 0.9f, scaleOverride: 0.26f);
+
+ RenderField(panel);
+ RenderCharacters();
+ RenderActions();
+ }
+
+ 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;
+ float textY = field.Center.Y;
+ MaterialUiKit.Text(shown, new Vector2(field.X + 24f, textY), 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 RenderCharacters() {
+ for (int index = 0; index < characters.Length; index++) {
+ MaterialRect cell = CellRect(index);
+ bool focused = !inActions && cursor == index;
+ bool hovered = cell.Contains(MaterialTouch.Position);
+ if (focused || hovered) {
+ MaterialUi.RoundedRect(cell.X, cell.Y, cell.Width, cell.Height, 12f,
+ palette.Primary * (focused ? 0.92f : 0.26f));
+ } else {
+ MaterialUi.RoundedRect(cell.X, cell.Y, cell.Width, cell.Height, 12f,
+ palette.SurfaceHighest * 0.55f);
+ }
+ MaterialUiKit.Text(characters[index].ToString(), cell.Center, new Vector2(0.5f),
+ MaterialTextRole.Label, focused ? palette.OnPrimary : palette.OnSurface,
+ 1f, scaleOverride: 0.3f);
+ }
+ }
+
+ private void RenderActions() {
+ for (int index = 0; index < ActionLabels.Length; index++) {
+ MaterialRect button = ActionRect(index);
+ bool focused = inActions && actionCursor == index;
+ bool danger = index == 0;
+ Color fill = focused
+ ? danger ? new Color(198, 74, 84) : palette.Primary
+ : palette.SurfaceHighest;
+ MaterialUi.RoundedRect(button.X, button.Y, button.Width, button.Height, 18f, fill);
+ if (!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,
+ focused ? (danger ? Color.White : palette.OnPrimary) : palette.OnSurface,
+ 1f, scaleOverride: 0.3f);
+ }
+ }
+
+ private MaterialRect FieldRect(MaterialRect panel) =>
+ new(panel.X + PanelInset, panel.Y + FieldOffsetY, panel.Width - PanelInset * 2f, FieldHeight);
+
+ private MaterialRect GridRect(MaterialRect panel) {
+ float bottom = ActionRect(panel, 0).Y - 24f;
+ return new MaterialRect(panel.X + PanelInset, panel.Y + GridTop,
+ panel.Width - PanelInset * 2f, Math.Max(1f, bottom - (panel.Y + GridTop)));
+ }
+
+ private MaterialRect CellRect(int index) {
+ MaterialRect grid = GridRect(new MaterialRect(PanelX, PanelY, PanelWidth, PanelHeight));
+ float width = (grid.Width - CellGap * (Columns - 1)) / Columns;
+ float height = (grid.Height - CellGap * (rows - 1)) / rows;
+ int column = index % Columns;
+ int row = index / Columns;
+ return new MaterialRect(
+ grid.X + column * (width + CellGap),
+ grid.Y + row * (height + CellGap),
+ width,
+ Math.Min(64f, height)
+ );
+ }
+
+ private MaterialRect ActionRect(int index) => ActionRect(
+ new MaterialRect(PanelX, PanelY, PanelWidth, PanelHeight), index);
+
+ private static MaterialRect ActionRect(MaterialRect panel, int index) {
+ const float gap = 14f;
+ float width = (panel.Width - PanelInset * 2f - gap * (ActionLabels.Length - 1))
+ / ActionLabels.Length;
+ return new MaterialRect(
+ panel.X + PanelInset + index * (width + gap),
+ panel.Bottom - ActionBottomMargin - ActionHeight,
+ width,
+ ActionHeight
+ );
+ }
+
+ private int CellAt(Vector2 point) {
+ for (int index = 0; index < characters.Length; index++) {
+ if (CellRect(index).Contains(point)) return index;
+ }
+ 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;
+ }
+}
From 0743b5eee7f15155c85b9087c808c31af7803efa Mon Sep 17 00:00:00 2001
From: std-external <201375710+std-external@users.noreply.github.com>
Date: Mon, 19 Oct 2026 23:49:24 +0800
Subject: [PATCH 2/5] modoptions: tighten string entry label matching
---
Source/MaterialModOptions.cs | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs
index 4b4ddb8..f61082b 100644
--- a/Source/MaterialModOptions.cs
+++ b/Source/MaterialModOptions.cs
@@ -451,7 +451,7 @@ private void IndexStringSettings() {
TextMenu.Button? button = buttons.FirstOrDefault(candidate =>
!stringSettings.ContainsKey(candidate)
- && candidate.Label.StartsWith(name, StringComparison.Ordinal));
+ && LabelStartsWith(candidate.Label, name));
if (button is null) continue;
int maxLength = Math.Max(1,
@@ -510,6 +510,11 @@ private void IndexManualStringButtons(List buttons) {
}
}
+ 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;
From 6cde378b141b816b10f1e493640e4311e0b8b5c4 Mon Sep 17 00:00:00 2001
From: std-external <201375710+std-external@users.noreply.github.com>
Date: Mon, 19 Oct 2026 23:49:48 +0800
Subject: [PATCH 3/5] modoptions: harden string value access
---
Source/MaterialModOptions.cs | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs
index f61082b..a1d4ab3 100644
--- a/Source/MaterialModOptions.cs
+++ b/Source/MaterialModOptions.cs
@@ -459,7 +459,13 @@ private void IndexStringSettings() {
int minLength = Math.Max(0,
property.GetCustomAttribute()?.Min ?? 1);
stringSettings[button] = new StringSettingEntry(name, button,
- () => property.GetValue(settings) as string ?? "",
+ () => {
+ try {
+ return property.GetValue(settings) as string ?? "";
+ } catch {
+ return "";
+ }
+ },
value => {
property.SetValue(settings, value);
button.Label = Relabel(button.Label, name, value);
@@ -730,8 +736,11 @@ internal bool BeginStringEntry(OuiModOptionString optionString) {
private static string DisplayNameFromLabel(string label, string value) {
string name = label;
- if (value.Length > 0 && name.EndsWith(value, StringComparison.Ordinal))
+ 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 : "文本";
}
From 2d9090a2f496fe390d6597bc345f0e3b1412c374 Mon Sep 17 00:00:00 2001
From: std-external <201375710+std-external@users.noreply.github.com>
Date: Mon, 19 Oct 2026 23:58:09 +0800
Subject: [PATCH 4/5] modoptions: use a phone-style QWERTY layout for text
entry
Reorder the prompt keyboard to qwertyuiop / asdfghjkl / zxcvbnm with
shift and a 123/symbol page cycle, matching a phone keyboard. Tapping a
key with mouse or touch now types immediately, the highlight follows the
pointer, and holding the space bar repeats spaces. Gamepad navigation and
Enter/Esc behaviour are unchanged.
---
Source/MaterialModOptions.cs | 1 -
Source/TextEntryOverlay.cs | 441 ++++++++++++++++++++++++-----------
2 files changed, 305 insertions(+), 137 deletions(-)
diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs
index a1d4ab3..5b2fb41 100644
--- a/Source/MaterialModOptions.cs
+++ b/Source/MaterialModOptions.cs
@@ -675,7 +675,6 @@ private void OpenTextEntry(StringSettingEntry setting) {
setting.Value(),
setting.MaxLength,
setting.MinLength,
- null,
value => CommitStringSetting(setting, value),
() => {
textEntry = null;
diff --git a/Source/TextEntryOverlay.cs b/Source/TextEntryOverlay.cs
index 1fe93fb..d350861 100644
--- a/Source/TextEntryOverlay.cs
+++ b/Source/TextEntryOverlay.cs
@@ -5,42 +5,68 @@
namespace Celeste.Mod.MicroblocksQolUtils;
///
-/// Full-screen in-game text entry prompt (character grid + cancel / space / backspace / accept).
-/// Gamepad, mouse, touch and a physical keyboard all work, so string settings stay editable
-/// on platforms where no OS on-screen keyboard is available.
+/// 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 {
- public const string DefaultCharacters =
- "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-=:~!@$%^&*_-#\"'()<>/\\|.,;?[]{}`";
-
private const float PanelX = 260f;
- private const float PanelY = 110f;
+ private const float PanelY = 120f;
private const float PanelWidth = 1400f;
- private const float PanelHeight = 880f;
+ private const float PanelHeight = 720f;
private const float PanelInset = 40f;
private const float FieldOffsetY = 84f;
private const float FieldHeight = 88f;
- private const float GridTop = 300f;
- private const float CellGap = 6f;
- private const float ActionHeight = 66f;
+ 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 = 12;
+ 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,
+ CyclePage,
+ Space,
+ Backspace,
+ Accept,
+ Cancel
+ }
+
+ private readonly record struct Key(string Label, char Character, KeyAction Action, string Hint = "");
private readonly string title;
- private readonly string characters;
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 readonly int rows;
private string buffer;
- private int cursor;
- private int actionCursor;
+ 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;
@@ -49,19 +75,17 @@ public TextEntryOverlay(
string initial,
int maxLength,
int minLength,
- string? characters,
Func commit,
Action closed
) {
this.title = title;
this.maxLength = Math.Max(1, maxLength);
this.minLength = Math.Clamp(minLength, 0, this.maxLength);
- this.characters = string.IsNullOrEmpty(characters) ? DefaultCharacters : characters;
this.commit = commit;
this.closed = closed;
// Keep an existing longer value intact; the limit only applies to new characters.
buffer = initial ?? "";
- rows = Math.Max(1, (this.characters.Length + Columns - 1) / Columns);
+ RebuildRows();
Tag = Tags.HUD | Tags.PauseUpdate;
Depth = -3_000_000;
}
@@ -99,88 +123,142 @@ public override void Update() {
Cancel();
return;
}
- // A physical Enter accepts the text; the gamepad confirm picks the highlighted cell.
+ // 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) {
- Activate();
+ if (inActions) RunAction(actionIndex);
+ else PressKey(rows[cursorRow][cursorColumn]);
return;
}
-
- 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) MoveCursor(-1);
- else if (right) MoveCursor(1);
- else if (up) MoveRow(-1);
- else if (down) MoveRow(1);
-
- UpdatePointer();
+ UpdateNavigation();
+ UpdateLongPress();
}
- private void UpdatePointer() {
+ private void UpdateHover() {
Vector2 mouse = MaterialTouch.Position;
- bool moved = MaterialTouch.WasMoved;
- bool clicked = MaterialTouch.PressedLeftButton;
-
- int hoveredCell = CellAt(mouse);
- int hoveredAction = ActionAt(mouse);
- if (hoveredCell >= 0) {
- inActions = false;
- cursor = hoveredCell;
- } else if (hoveredAction >= 0) {
- inActions = true;
- actionCursor = hoveredAction;
+ 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);
}
+ }
- if (!clicked) return;
- if (hoveredCell >= 0) Append(characters[hoveredCell]);
- else if (hoveredAction >= 0) RunAction(hoveredAction);
- else if (moved) Audio.Play("event:/ui/main/button_invalid");
+ 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 MoveCursor(int direction) {
+ private void MoveHorizontal(int direction) {
if (inActions) {
- actionCursor = Wrap(actionCursor + direction, ActionLabels.Length);
- Audio.Play("event:/ui/main/rollover_down");
- return;
+ actionIndex = Wrap(actionIndex + direction, ActionLabels.Length);
+ } else {
+ cursorColumn = Wrap(cursorColumn + direction, rows[cursorRow].Length);
}
- cursor = Wrap(cursor + direction, characters.Length);
Audio.Play("event:/ui/main/rollover_down");
}
- private void MoveRow(int direction) {
- int column = cursor % Columns;
+ private void MoveVertical(int direction) {
if (direction > 0) {
if (inActions) return;
- int next = cursor + Columns;
- if (next >= characters.Length) {
+ if (cursorRow + 1 >= rows.Length) {
inActions = true;
- actionCursor = Math.Min(column, ActionLabels.Length - 1);
+ actionIndex = Math.Clamp(cursorColumn * 4 / Math.Max(1, rows[^1].Length), 0,
+ ActionLabels.Length - 1);
} else {
- cursor = next;
+ cursorRow++;
+ cursorColumn = Math.Min(cursorColumn, rows[cursorRow].Length - 1);
}
} else {
if (inActions) {
inActions = false;
- cursor = Math.Min(characters.Length - 1, (rows - 1) * Columns
- + Math.Min(column, Columns - 1));
- } else {
- cursor = Math.Max(0, cursor - Columns);
+ 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 Activate() {
- if (!inActions) {
- Append(characters[cursor]);
+ private void UpdateLongPress() {
+ bool holdingSpace = (hoverAction == 1 && MaterialTouch.CheckLeftButton)
+ || (inActions && actionIndex == 1 && Input.MenuConfirm.Check);
+ if (!holdingSpace) {
+ spaceHold = 0f;
+ spaceRepeat = 0f;
return;
}
- RunAction(actionCursor);
+ 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.CyclePage:
+ page = page switch {
+ Page.Letters => Page.Numbers,
+ Page.Numbers => Page.Symbols,
+ _ => Page.Letters
+ };
+ 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) {
@@ -243,15 +321,60 @@ private void Close() {
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.CyclePage)
+ ]
+ ],
+ Page.Numbers => [
+ Characters("1234567890"),
+ Characters("-/:;()$&@\""),
+ [
+ ..Characters(".,?!'"),
+ new Key("#+=", '\0', KeyAction.CyclePage)
+ ]
+ ],
+ _ => [
+ Characters("[]{}#%^*+="),
+ Characters("_\\|~<>`\"'"),
+ [
+ ..Characters("&@$:;,./?"),
+ new Key("abc", '\0', KeyAction.CyclePage)
+ ]
+ ]
+ };
+ 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 index = 0; index < characters.Length; index++) {
- yield return new MaterialInteractionTarget($"text-entry.char.{index}", CellRect(index),
- Focused: !inActions && cursor == index);
+ 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 && actionCursor == index);
+ Focused: inActions && actionIndex == index);
}
}
@@ -264,15 +387,21 @@ public override void Render() {
MaterialUiKit.Text($"编辑 · {title}", new Vector2(panel.X + PanelInset, panel.Y + 30f),
Vector2.Zero, MaterialTextRole.Section, palette.OnSurface, 1f, scaleOverride: 0.38f);
- MaterialUiKit.Text("方向键选择字符 · 确认输入 · 取消返回",
+ 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);
- RenderCharacters();
+ RenderKeyboard();
RenderActions();
}
+ private string PageHint() => page switch {
+ Page.Letters => "点按输入 · 长按空格连打 · Esc 取消",
+ Page.Numbers => "数字与符号 · 右下 #+= 换符号页 · Esc 取消",
+ _ => "符号页 · 点 abc 回到字母 · Esc 取消"
+ };
+
private void RenderField(MaterialRect panel) {
MaterialRect field = FieldRect(panel);
MaterialUi.RoundedRect(field.X, field.Y, field.Width, field.Height, 18f,
@@ -281,10 +410,11 @@ private void RenderField(MaterialRect panel) {
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);
+ string shown = buffer.Length == 0
+ ? "输入内容…"
+ : EllipsizeFromLeft(buffer, 1120f, 0.36f, UiFontWeight.Bold);
Color color = buffer.Length == 0 ? palette.OnSurfaceVariant * 0.7f : palette.OnSurface;
- float textY = field.Center.Y;
- MaterialUiKit.Text(shown, new Vector2(field.X + 24f, textY), new Vector2(0f, 0.5f),
+ 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)) {
@@ -300,85 +430,124 @@ private void RenderField(MaterialRect panel) {
1f, scaleOverride: 0.27f);
}
- private void RenderCharacters() {
- for (int index = 0; index < characters.Length; index++) {
- MaterialRect cell = CellRect(index);
- bool focused = !inActions && cursor == index;
- bool hovered = cell.Contains(MaterialTouch.Position);
- if (focused || hovered) {
- MaterialUi.RoundedRect(cell.X, cell.Y, cell.Width, cell.Height, 12f,
- palette.Primary * (focused ? 0.92f : 0.26f));
- } else {
- MaterialUi.RoundedRect(cell.X, cell.Y, cell.Width, cell.Height, 12f,
- palette.SurfaceHighest * 0.55f);
+ 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);
}
- MaterialUiKit.Text(characters[index].ToString(), cell.Center, new Vector2(0.5f),
- MaterialTextRole.Label, focused ? palette.OnPrimary : palette.OnSurface,
- 1f, scaleOverride: 0.3f);
}
}
+ private void RenderKey(Key key, MaterialRect rect, bool focused, bool hovered) {
+ bool activeToggle = key.Action switch {
+ KeyAction.Shift => upper,
+ KeyAction.CyclePage => page != Page.Letters,
+ _ => false
+ };
+ 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 && actionCursor == index;
+ bool focused = inActions && actionIndex == index;
+ bool hovered = hoverAction == index;
bool danger = index == 0;
- Color fill = focused
+ 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 (!focused) {
+ 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,
- focused ? (danger ? Color.White : palette.OnPrimary) : palette.OnSurface,
- 1f, scaleOverride: 0.3f);
+ 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 MaterialRect GridRect(MaterialRect panel) {
- float bottom = ActionRect(panel, 0).Y - 24f;
- return new MaterialRect(panel.X + PanelInset, panel.Y + GridTop,
- panel.Width - PanelInset * 2f, Math.Max(1f, bottom - (panel.Y + GridTop)));
- }
-
- private MaterialRect CellRect(int index) {
- MaterialRect grid = GridRect(new MaterialRect(PanelX, PanelY, PanelWidth, PanelHeight));
- float width = (grid.Width - CellGap * (Columns - 1)) / Columns;
- float height = (grid.Height - CellGap * (rows - 1)) / rows;
- int column = index % Columns;
- int row = index / Columns;
- return new MaterialRect(
- grid.X + column * (width + CellGap),
- grid.Y + row * (height + CellGap),
- width,
- Math.Min(64f, height)
- );
- }
-
- private MaterialRect ActionRect(int index) => ActionRect(
- new MaterialRect(PanelX, PanelY, PanelWidth, PanelHeight), index);
-
- private static MaterialRect ActionRect(MaterialRect panel, int index) {
- const float gap = 14f;
- float width = (panel.Width - PanelInset * 2f - gap * (ActionLabels.Length - 1))
- / ActionLabels.Length;
- return new MaterialRect(
- panel.X + PanelInset + index * (width + gap),
- panel.Bottom - ActionBottomMargin - ActionHeight,
- width,
- ActionHeight
- );
- }
-
- private int CellAt(Vector2 point) {
- for (int index = 0; index < characters.Length; index++) {
- if (CellRect(index).Contains(point)) return index;
+ 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;
}
From a151e73f92957d92f3471f44e51d62fb106ded35 Mon Sep 17 00:00:00 2001
From: std-external <201375710+std-external@users.noreply.github.com>
Date: Tue, 20 Oct 2026 00:06:42 +0800
Subject: [PATCH 5/5] modoptions: route text entry by the last input source
Keyboard and mouse now edit string settings inline through SDL text input
(the same path as vanilla's rename box) instead of opening the self-drawn
keyboard; touch keeps using CeleMod's "text" touch target, which raises
the Android system soft keyboard. The QWERTY prompt is reserved for the
gamepad (MInput.ControllerHasFocus) or when Everest's
UseKeyboardForTextInput is off.
Replace the cycling page key with explicit 123 / #+= / ABC keys so the
number and symbol pages are discoverable; the corner hints on the letter
keys stay visual only, like a phone keyboard.
---
Source/MaterialModOptions.cs | 188 +++++++++++++++++++++++++++++++++--
Source/TextEntryOverlay.cs | 41 ++++----
2 files changed, 201 insertions(+), 28 deletions(-)
diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs
index 5b2fb41..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;
@@ -98,6 +99,11 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage {
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;
@@ -169,6 +175,7 @@ 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();
@@ -192,6 +199,7 @@ 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;
@@ -210,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);
@@ -229,6 +238,11 @@ public override void Update() {
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)) {
@@ -324,7 +338,12 @@ public override void Update() {
|| MInput.Keyboard.Pressed(Keys.Space))
&& menu.Current is { Disabled: false } stringItem
&& TryGetStringSetting(stringItem, out StringSettingEntry? stringSetting)) {
- OpenTextEntry(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;
}
@@ -664,6 +683,131 @@ private bool TryGetStringSetting(TextMenu.Item? item,
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);
@@ -729,7 +873,7 @@ internal bool BeginStringEntry(OuiModOptionString optionString) {
// Init() accumulates an exit handler per press; these requests never enter the Oui.
stringValueChangeField?.SetValue(optionString, null);
stringExitField?.SetValue(optionString, null);
- OpenTextEntry(entry);
+ OpenStringEditor(entry, allowGameKeyboard: MInput.ControllerHasFocus);
return true;
}
@@ -1074,7 +1218,9 @@ private void UpdateMouse(ModOptionsLayout layout) {
} else if (IsCompositeMenu(item)) {
OpenCompositePopup(item);
} else if (TryGetStringSetting(item, out StringSettingEntry? setting)) {
- OpenTextEntry(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);
}
@@ -1348,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"));
@@ -1658,13 +1813,16 @@ private static void RenderSliderTrack(MaterialRect track, float amount, Material
palette.Primary * (alpha * (enabled ? 1f : 0.45f)));
}
- private static void RenderStringControl(StringSettingEntry setting, MaterialRect control,
+ 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, 1f,
- palette.Outline * (0.52f * alpha));
- string value = setting.Value();
+ 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,
@@ -1675,8 +1833,20 @@ private static void RenderStringControl(StringSettingEntry setting, MaterialRect
? empty ? palette.OnSurfaceVariant * 0.72f : palette.OnSurface
: palette.OnSurfaceVariant * 0.5f,
alpha, scaleOverride: 0.27f);
- MaterialUiKit.Icon("edit", new Vector2(control.Right - 26f, control.Center.Y), 20f,
- enabled ? palette.Primary : palette.OnSurfaceVariant * 0.5f, alpha);
+ 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(
diff --git a/Source/TextEntryOverlay.cs b/Source/TextEntryOverlay.cs
index d350861..4cbdcdb 100644
--- a/Source/TextEntryOverlay.cs
+++ b/Source/TextEntryOverlay.cs
@@ -38,7 +38,9 @@ private enum Page {
private enum KeyAction {
Character,
Shift,
- CyclePage,
+ PageLetters,
+ PageNumbers,
+ PageSymbols,
Space,
Backspace,
Accept,
@@ -237,11 +239,13 @@ private void PressKey(Key key) {
RebuildRows();
Audio.Play("event:/ui/main/button_toggle_on");
break;
- case KeyAction.CyclePage:
- page = page switch {
- Page.Letters => Page.Numbers,
- Page.Numbers => Page.Symbols,
- _ => Page.Letters
+ 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");
@@ -329,7 +333,8 @@ private void RebuildRows() {
[
new Key(upper ? "⇩" : "⇧", '\0', KeyAction.Shift),
..Characters(upper ? "ZXCVBNM" : "zxcvbnm", "`/-_:;?"),
- new Key("123", '\0', KeyAction.CyclePage)
+ new Key("123", '\0', KeyAction.PageNumbers),
+ new Key("#+=", '\0', KeyAction.PageSymbols)
]
],
Page.Numbers => [
@@ -337,15 +342,17 @@ private void RebuildRows() {
Characters("-/:;()$&@\""),
[
..Characters(".,?!'"),
- new Key("#+=", '\0', KeyAction.CyclePage)
+ new Key("ABC", '\0', KeyAction.PageLetters),
+ new Key("#+=", '\0', KeyAction.PageSymbols)
]
],
_ => [
Characters("[]{}#%^*+="),
- Characters("_\\|~<>`\"'"),
+ Characters("_\\|~<>`\"'&"),
[
- ..Characters("&@$:;,./?"),
- new Key("abc", '\0', KeyAction.CyclePage)
+ ..Characters("@$:;,./?"),
+ new Key("ABC", '\0', KeyAction.PageLetters),
+ new Key("123", '\0', KeyAction.PageNumbers)
]
]
};
@@ -397,9 +404,9 @@ public override void Render() {
}
private string PageHint() => page switch {
- Page.Letters => "点按输入 · 长按空格连打 · Esc 取消",
- Page.Numbers => "数字与符号 · 右下 #+= 换符号页 · Esc 取消",
- _ => "符号页 · 点 abc 回到字母 · Esc 取消"
+ Page.Letters => "点按输入 · 长按空格连打 · 角标仅提示",
+ Page.Numbers => "数字页 · ABC 回字母 · #+= 换符号 · Esc 取消",
+ _ => "符号页 · ABC 回字母 · 123 换数字 · Esc 取消"
};
private void RenderField(MaterialRect panel) {
@@ -447,11 +454,7 @@ private void RenderKeyboard() {
}
private void RenderKey(Key key, MaterialRect rect, bool focused, bool hovered) {
- bool activeToggle = key.Action switch {
- KeyAction.Shift => upper,
- KeyAction.CyclePage => page != Page.Letters,
- _ => false
- };
+ bool activeToggle = key.Action == KeyAction.Shift && upper;
Color fill;
Color text;
if (hovered) {