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;
+ }
+}