diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs index a86c66d..914b994 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; @@ -59,6 +60,12 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage, IMaterialAcr BindingFlags.Instance | BindingFlags.NonPublic); private static readonly FieldInfo? intSliderMaximumField = typeof(TextMenuExt.IntSlider).GetField("max", 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 = []; @@ -84,7 +91,7 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage, IMaterialAcr private float inputDelay; internal bool AcceptsTouch => Visible && Active && display && inputDelay <= 0 && ease > 0.99f && (pauseLevel is not null || Selected && Focused); - private string TouchContext => $"mods:{selectedTab}:{tabSearchText}:{settingSearchText}:{dropdownItem?.GetHashCode()}:{compositePopupItem?.GetHashCode()}"; + private string TouchContext => $"mods:{selectedTab}:{tabSearch.Text}:{settingSearch.Text}:{dropdownItem?.GetHashCode()}:{compositePopupItem?.GetHashCode()}"; private CloseDestination closeDestination; private bool saveStarted; private string? savedTabId; @@ -98,13 +105,24 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage, IMaterialAcr private float sliderAudioCooldown; private int dropdownHighlight; private int dropdownFirstVisible; - private string tabSearchText = ""; - private string settingSearchText = ""; + private readonly TextEditBuffer tabSearch = new("", SearchTextLimit); + private readonly TextEditBuffer settingSearch = new("", SearchTextLimit); + private readonly TextKeyRepeat searchLeftRepeat = new(); + private readonly TextKeyRepeat searchRightRepeat = new(); private string imeText = ""; private SearchTarget searchTarget; private bool configOverlayOpen; private readonly List externalOverlays = []; private readonly List<(Entity Entity, bool Visible)> hiddenOverlays = []; + private readonly Dictionary stringSettings = []; + private TextEntryOverlay? textEntry; + private StringSettingEntry? inlineEditing; + private TextEditBuffer? inlineText; + private string inlineIme = ""; + private bool inlineInputSubscribed; + private float inlineError; + private readonly TextKeyRepeat inlineLeftRepeat = new(); + private readonly TextKeyRepeat inlineRightRepeat = new(); public bool SuppressNormalRender { get; set; } @@ -113,7 +131,9 @@ internal static MaterialModOptions? ActivePage { if (Engine.Scene is not Overworld overworld) return null; MaterialModOptions? page = overworld.Current as MaterialModOptions ?? overworld.Next as MaterialModOptions; - return page is { Visible: true } ? page : null; + // The text prompt is a separate foreground entity; keep the normal render path + // so the acrylic snapshot does not swallow it. + return page is { Visible: true, textEntry: null } ? page : null; } } @@ -170,6 +190,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; @@ -192,6 +214,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(); @@ -210,6 +234,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; sliderAudioCooldown = Math.Max(0f, sliderAudioCooldown - Engine.RawDeltaTime); UpdateInteractions(layout); @@ -233,6 +258,17 @@ public override void Update() { menu.Focused = false; return; } + if (textEntry is { Scene: null }) textEntry = null; + if (textEntry is not null) { + // The string prompt is a foreground entity and 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)) { @@ -328,6 +364,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; + } + bool rangeHandled = HandleRangeKeyboard(); UpdateMouse(layout); int previousSelection = menu.Selection; @@ -377,6 +427,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; @@ -417,10 +468,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; @@ -500,7 +680,7 @@ private void ToggleFavorite(TextMenu.Item item) { : currentTabId; RebuildTabs(destination, remove ? null : item); if (tabs[selectedTab].Id != destination && currentTabId == "__favorites" && remove) { - tabSearchText = ""; + tabSearch.SetText(""); RebuildTabs(sourceTabId, item); } rowScroll.Reset(); @@ -521,6 +701,269 @@ private void DetachMenu() { menu = null; } + 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; + inlineText = new TextEditBuffer(setting.Value(), setting.MaxLength); + inlineIme = ""; + inlineError = 0f; + inlineLeftRepeat.Reset(); + inlineRightRepeat.Reset(); + 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 (inlineText is null) { + CancelInlineEdit(); + return; + } + if (MaterialTextInputFocus.Pressed(Keys.Escape) || Input.MenuCancel.Pressed) { + CancelInlineEdit(); + return; + } + if (MaterialTextInputFocus.Pressed(Keys.Enter)) { + CommitInlineEdit(); + return; + } + bool extend = MaterialTextInputFocus.ShiftDown; + if (TextEditShortcuts.Handle(inlineText)) { + inlineIme = ""; + inlineError = 0f; + } else { + // Left/right repeat while held, like a native text box; Home/End/Delete are single-shot. + bool left = inlineLeftRepeat.Update(MaterialTextInputFocus.Down(Keys.Left), + Engine.RawDeltaTime); + bool right = inlineRightRepeat.Update(MaterialTextInputFocus.Down(Keys.Right), + Engine.RawDeltaTime); + if (left) inlineText.MoveLeft(extend); + else if (right) inlineText.MoveRight(extend); + else if (MaterialTextInputFocus.Pressed(Keys.Home)) inlineText.MoveHome(extend); + else if (MaterialTextInputFocus.Pressed(Keys.End)) inlineText.MoveEnd(extend); + else if (MaterialTextInputFocus.Pressed(Keys.Delete)) inlineText.Delete(); + } + + if (!MaterialTouch.PressedLeftButton) return; + MaterialRect? field = null; + foreach (RowPlacement placement in RowPlacements(layout)) { + if (placement.Item != inlineEditing!.Item) continue; + field = TextControlRect(placement.Rect); + break; + } + if (field is not { } control) { + CommitInlineEdit(); + return; + } + if (!control.Contains(MaterialTouch.Position)) { + CommitInlineEdit(); + return; + } + // Click inside the field moves the caret to the nearest character boundary. + TextEditGeometry.TextWindow window = TextEditGeometry.Window(inlineText, + control.Width - 62f, 0.27f, UiFontWeight.Bold); + inlineText.SetCursor(TextEditGeometry.IndexFromX(inlineText, + MaterialTouch.Position.X - (control.X + 15f), window, 0.27f, UiFontWeight.Bold), extend); + inlineIme = ""; + } + + private void OnInlineInput(char character) { + if (inlineEditing is null || inlineText is null) return; + bool changed; + if (character == '\b') { + changed = inlineText.Backspace(); + } else if (!char.IsControl(character)) { + changed = inlineText.Insert(character); + } else { + return; + } + if (!changed) { + Audio.Play("event:/ui/main/button_invalid"); + 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 (inlineText is null || !CommitStringSetting(setting, inlineText.Text)) { + 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; + inlineText = null; + inlineIme = ""; + inlineError = 0f; + inlineLeftRepeat.Reset(); + inlineRightRepeat.Reset(); + 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) @@ -610,13 +1053,11 @@ private bool UpdateSearchInput(ModOptionsLayout layout) { Vector2 mouse = MaterialTouch.Position; if (MaterialTouch.PressedLeftButton) { if (layout.TabSearch.Contains(mouse)) { - SetSearchTarget(SearchTarget.Tabs); - Audio.Play("event:/ui/main/button_select"); + FocusSearch(SearchTarget.Tabs, layout.TabSearch, mouse); return true; } if (layout.SettingSearch.Contains(mouse)) { - SetSearchTarget(SearchTarget.Settings); - Audio.Play("event:/ui/main/button_select"); + FocusSearch(SearchTarget.Settings, layout.SettingSearch, mouse); return true; } } @@ -642,15 +1083,56 @@ private bool UpdateSearchInput(ModOptionsLayout layout) { SetSearchTarget(SearchTarget.None); return false; } + TextEditBuffer buffer = SearchBuffer(searchTarget); + if (TextEditShortcuts.Handle(buffer)) { + imeText = ""; + SearchChanged(); + return true; + } + UpdateSearchCaret(buffer); return true; } + /// Clicking a search box focuses it and drops the caret at the nearest character. + private void FocusSearch(SearchTarget target, MaterialRect rect, Vector2 mouse) { + SetSearchTarget(target); + TextEditBuffer buffer = SearchBuffer(target); + TextEditGeometry.TextWindow window = TextEditGeometry.Window(buffer, rect.Width - 74f, + 0.28f, UiFontWeight.Regular); + buffer.SetCursor(TextEditGeometry.IndexFromX(buffer, mouse.X - (rect.X + 54f), window, + 0.28f, UiFontWeight.Regular), MaterialTextInputFocus.ShiftDown); + Audio.Play("event:/ui/main/button_select"); + } + + private TextEditBuffer SearchBuffer(SearchTarget target) => + target == SearchTarget.Tabs ? tabSearch : settingSearch; + + private void UpdateSearchCaret(TextEditBuffer buffer) { + bool shift = MaterialTextInputFocus.ShiftDown; + // Left/right repeat while held, like a native text box; Home/End/Delete stay single-shot. + bool left = searchLeftRepeat.Update(MaterialTextInputFocus.Down(Keys.Left), + Engine.RawDeltaTime); + bool right = searchRightRepeat.Update(MaterialTextInputFocus.Down(Keys.Right), + Engine.RawDeltaTime); + if (left) buffer.MoveLeft(shift); + else if (right) buffer.MoveRight(shift); + else if (MaterialTextInputFocus.Pressed(Keys.Home)) buffer.MoveHome(shift); + else if (MaterialTextInputFocus.Pressed(Keys.End)) buffer.MoveEnd(shift); + else if (MaterialTextInputFocus.Pressed(Keys.Delete)) buffer.Delete(); + } + private void SetSearchTarget(SearchTarget target) { if (searchTarget == target) return; bool wasFocused = searchTarget != SearchTarget.None; bool isFocused = target != SearchTarget.None; searchTarget = target; imeText = ""; + searchLeftRepeat.Reset(); + searchRightRepeat.Reset(); + if (wasFocused && !isFocused) { + tabSearch.CollapseSelection(); + settingSearch.CollapseSelection(); + } if (!wasFocused && isFocused) { TextInput.OnInput += OnTextInput; TextInputEXT.TextEditing += OnTextEditing; @@ -664,17 +1146,11 @@ private void SetSearchTarget(SearchTarget target) { private void OnTextInput(char character) { if (searchTarget == SearchTarget.None) return; - ref string value = ref searchTarget == SearchTarget.Tabs - ? ref tabSearchText - : ref settingSearchText; - if (character == '\b') { - if (value.Length == 0) return; - value = value[..^1]; - } else if (!char.IsControl(character) && value.Length < SearchTextLimit) { - value += character; - } else { - return; - } + TextEditBuffer buffer = SearchBuffer(searchTarget); + bool changed = character == '\b' + ? buffer.Backspace() + : !char.IsControl(character) && buffer.Insert(character); + if (!changed) return; imeText = ""; SearchChanged(); } @@ -701,7 +1177,7 @@ private void SearchChanged() { private List FilteredTabIndices() { List result = []; - string search = tabSearchText.Trim(); + string search = tabSearch.Text.Trim(); for (int index = 0; index < tabs.Count; index++) { ModTab tab = tabs[index]; if (search.Length == 0 @@ -714,7 +1190,7 @@ private List FilteredTabIndices() { } private bool MatchesSettingSearch(ModTab tab, TextMenu.Item item) { - string search = settingSearchText.Trim(); + string search = settingSearch.Text.Trim(); if (search.Length == 0 || item is TextMenuExt.EaseInSubHeaderExt) return true; if (TryGetSearchLabel(item, out string label) && label.Contains(search, StringComparison.CurrentCultureIgnoreCase)) return true; @@ -950,6 +1426,10 @@ private void UpdateMouse(ModOptionsLayout layout) { } } 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); } @@ -1264,12 +1744,17 @@ private void UpdateInteractions(ModOptionsLayout layout) { MaterialTouch.Suppress(this, motion, TouchContext); return; } + if (textEntry is not null) { + MaterialTouch.Publish(this, textEntry.InteractionTargets(), TouchContext + ":text-entry"); + motion.Update([]); + return; + } List targets = [ new MaterialInteractionTarget("mod-options.search.tabs", layout.TabSearch, - Focused: searchTarget == SearchTarget.Tabs, TouchKind: "text", TouchText: tabSearchText, + Focused: searchTarget == SearchTarget.Tabs, TouchKind: "text", TouchText: tabSearch.Text, TouchMaxLength: SearchTextLimit, TouchTextChanged: value => TouchSearch(SearchTarget.Tabs, value)), new MaterialInteractionTarget("mod-options.search.settings", layout.SettingSearch, - Focused: searchTarget == SearchTarget.Settings, TouchKind: "text", TouchText: settingSearchText, + Focused: searchTarget == SearchTarget.Settings, TouchKind: "text", TouchText: settingSearch.Text, TouchMaxLength: SearchTextLimit, TouchTextChanged: value => TouchSearch(SearchTarget.Settings, value)) ]; // Modal popups own their entire input surface. Their existing hit testing @@ -1297,6 +1782,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 (TryGetOption(placement.Item, out OptionSnapshot touchOption) && touchOption.Kind == OptionKind.Range) { // Only real ranges get a slider gesture; an enum is also a TextMenu.Slider @@ -1316,15 +1810,15 @@ private void UpdateInteractions(ModOptionsLayout layout) { private void TouchSearch(SearchTarget target, string value) { SetSearchTarget(target); - if (target == SearchTarget.Tabs) tabSearchText = value; - else settingSearchText = value; + if (target == SearchTarget.Tabs) tabSearch.SetText(value); + else settingSearch.SetText(value); SearchChanged(); SetSearchTarget(SearchTarget.None); } private void RenderTabs(ModOptionsLayout layout, MaterialPalette palette, float alpha) { MaterialSplitPageChrome.RenderNavigationSurface(layout.Navigation, palette, alpha); - RenderSearchBox(layout.TabSearch, tabSearchText, searchTarget == SearchTarget.Tabs, + RenderSearchBox(layout.TabSearch, tabSearch, searchTarget == SearchTarget.Tabs, UiText("microblocks_qol_modoptions_search_tabs", "搜索模组…"), "mod-options.search.tabs", palette, alpha); MaterialUiKit.Text(UiText("microblocks_qol_modoptions_mods", "模组与分类"), @@ -1382,7 +1876,7 @@ private void RenderRows(ModOptionsLayout layout, MaterialPalette palette, float MaterialUiKit.Chip(tab.Version, new Vector2(layout.SettingSearch.X - 16f, layout.ContentHeader.Y + 13f), palette, selected: false, alpha); } - RenderSearchBox(layout.SettingSearch, settingSearchText, searchTarget == SearchTarget.Settings, + RenderSearchBox(layout.SettingSearch, settingSearch, searchTarget == SearchTarget.Settings, UiText("microblocks_qol_modoptions_search_settings", "搜索当前设置…"), "mod-options.search.settings", palette, alpha); @@ -1436,7 +1930,7 @@ private void RenderRows(ModOptionsLayout layout, MaterialPalette palette, float if (!renderedAny) { string empty = FilteredTabIndices().Count == 0 ? UiText("microblocks_qol_modoptions_no_tabs", "没有匹配的模组") - : string.IsNullOrWhiteSpace(settingSearchText) + : string.IsNullOrWhiteSpace(settingSearch.Text) ? UiText("microblocks_qol_modoptions_empty", "没有可用设置") : UiText("microblocks_qol_modoptions_no_settings", "没有匹配的设置"); MaterialUiKit.Text(empty, @@ -1485,6 +1979,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, @@ -1617,6 +2118,61 @@ 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)); + float textWidth = control.Width - 62f; + string shown; + bool empty; + Color textColor; + if (editing && inlineText is not null) { + TextEditGeometry.TextWindow window = TextEditGeometry.Window(inlineText, textWidth, + 0.27f, UiFontWeight.Bold); + int caret = Math.Clamp(window.CaretOffset, 0, window.Text.Length); + string before = window.Text[..caret]; + string after = window.Text[caret..]; + RenderTextSelection(inlineText, window, control.X + 15f, control.Center.Y, palette, + alpha, 0.27f, UiFontWeight.Bold); + shown = before + inlineIme + after; + empty = shown.Length == 0; + textColor = palette.OnSurface; + if (Scene?.BetweenInterval(0.5f) == true) { + float caretWidth = SystemTtfFont.MeasureVisible(before + inlineIme, 0.27f, + UiFontWeight.Bold).X; + float caretX = Math.Min(control.Right - 36f, control.X + 15f + caretWidth); + MaterialUi.Line(new Vector2(caretX, control.Y + 8f), + new Vector2(caretX, control.Bottom - 8f), 2f, palette.Primary); + } + SetTextInputRectangle(control); + } else { + shown = setting.Value(); + empty = shown.Length == 0; + textColor = empty ? palette.OnSurfaceVariant * 0.72f : palette.OnSurface; + MaterialUiKit.Icon("edit", new Vector2(control.Right - 26f, control.Center.Y), 20f, + enabled ? palette.Primary : palette.OnSurfaceVariant * 0.5f, alpha); + } + MaterialUiKit.Text(MaterialTextUtil.Ellipsize( + empty ? UiText("microblocks_qol_modoptions_text_placeholder", "点击输入") : shown, + textWidth, 0.27f, UiFontWeight.Bold), + new Vector2(control.X + 15f, control.Center.Y), new Vector2(0f, 0.5f), + MaterialTextRole.Label, + enabled ? textColor : textColor * 0.45f, + alpha, scaleOverride: 0.27f); + } + + 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, @@ -1649,8 +2205,8 @@ private static void RenderDescription(string text, MaterialRect rect, MaterialPa } } - private void RenderSearchBox(MaterialRect rect, string value, bool focused, string placeholder, - string interactionKey, MaterialPalette palette, float alpha) { + private void RenderSearchBox(MaterialRect rect, TextEditBuffer buffer, bool focused, + string placeholder, string interactionKey, MaterialPalette palette, float alpha) { MaterialUi.RoundedRect(rect.X, rect.Y, rect.Width, rect.Height, rect.Height / 2f, (focused ? palette.SurfaceHighest : palette.SurfaceHigh) * (0.96f * alpha)); motion.RenderStateLayer(interactionKey, rect, rect.Height / 2f, palette.Primary, alpha); @@ -1660,27 +2216,52 @@ private void RenderSearchBox(MaterialRect rect, string value, bool focused, stri } MaterialUiKit.Icon("search", new Vector2(rect.X + 27f, rect.Center.Y), 22f, focused ? palette.Primary : palette.OnSurfaceVariant, alpha); - string shown = value + (focused ? imeText : ""); - string text = shown.Length == 0 ? placeholder : shown; - Color color = shown.Length == 0 ? palette.OnSurfaceVariant * 0.64f : palette.OnSurface; + float textWidth = rect.Width - 74f; Vector2 textPosition = new(rect.X + 54f, rect.Center.Y); - string visibleText = MaterialTextUtil.Ellipsize(text, rect.Width - 74f, 0.28f); - SystemTtfFont.DrawVisual(visibleText, textPosition, new Vector2(0f, 0.5f), 0.28f, color * alpha); + string before = ""; + string after = buffer.Text; + if (focused) { + TextEditGeometry.TextWindow window = TextEditGeometry.Window(buffer, textWidth, 0.28f, + UiFontWeight.Regular); + int caret = Math.Clamp(window.CaretOffset, 0, window.Text.Length); + before = window.Text[..caret]; + after = window.Text[caret..]; + RenderTextSelection(buffer, window, textPosition.X, rect.Center.Y, palette, alpha, + 0.28f, UiFontWeight.Regular); + } + string shown = before + (focused ? imeText : "") + after; + bool empty = shown.Length == 0; + string text = empty ? placeholder : shown; + Color color = empty ? palette.OnSurfaceVariant * 0.64f : palette.OnSurface; + SystemTtfFont.DrawVisual(MaterialTextUtil.Ellipsize(text, textWidth, 0.28f), textPosition, + new Vector2(0f, 0.5f), 0.28f, color * alpha); if (focused && Scene.BetweenInterval(0.5f)) { - string visibleInput = MaterialTextUtil.Ellipsize(shown, rect.Width - 74f, 0.28f); - float caretX = textPosition.X + SystemTtfFont.MeasureVisible(visibleInput, 0.28f).X + 2f; + float caretX = Math.Min(rect.Right - 12f, textPosition.X + + SystemTtfFont.MeasureVisible(before + imeText, 0.28f).X + 2f); MaterialUi.Line(new Vector2(caretX, rect.Y + 9f), new Vector2(caretX, rect.Bottom - 9f), 2f, palette.Primary * alpha); } if (!focused) return; - 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)) - )); + SetTextInputRectangle(rect); + } + + /// + /// Draws the selection highlight behind a text run. tells where the + /// visible slice starts so a selection that scrolled out of the field is clipped correctly. + /// + private static void RenderTextSelection(TextEditBuffer buffer, TextEditGeometry.TextWindow window, + float textX, float centerY, MaterialPalette palette, float alpha, float scale, + UiFontWeight weight) { + if (!buffer.HasSelection) return; + int from = Math.Clamp(buffer.SelectionStart - window.Start, 0, window.Text.Length); + int to = Math.Clamp(buffer.SelectionStart + buffer.SelectionLength - window.Start, 0, + window.Text.Length); + if (to <= from) return; + float left = textX + SystemTtfFont.MeasureVisible(window.Text[..from], scale, weight).X; + float right = textX + SystemTtfFont.MeasureVisible(window.Text[..to], scale, weight).X; + if (right - left <= 0.5f) return; + MaterialUi.RoundedRect(left, centerY - 11f, right - left, 22f, 4f, + palette.Primary * (alpha * 0.30f)); } private void RenderDropdown(ModOptionsLayout layout, MaterialPalette palette, float alpha) { @@ -2179,10 +2760,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); @@ -2295,6 +2887,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 readonly record struct OptionSnapshot( string Label, OptionKind Kind, diff --git a/Source/MaterialTextInputFocus.cs b/Source/MaterialTextInputFocus.cs index ef8c8ef..0dab463 100644 --- a/Source/MaterialTextInputFocus.cs +++ b/Source/MaterialTextInputFocus.cs @@ -62,6 +62,12 @@ public static bool Pressed(Keys key) => key != Keys.None && rawCurrentState.IsKeyDown(key) && rawPreviousState.IsKeyUp(key); + public static bool Down(Keys key) => key != Keys.None && rawCurrentState.IsKeyDown(key); + + public static bool ControlDown => Down(Keys.LeftControl) || Down(Keys.RightControl); + + public static bool ShiftDown => Down(Keys.LeftShift) || Down(Keys.RightShift); + public static Keys[] GetPressedKeys() => rawCurrentState.GetPressedKeys(); private static void KeyboardUpdate( diff --git a/Source/TextEditBuffer.cs b/Source/TextEditBuffer.cs new file mode 100644 index 0000000..fc5bb78 --- /dev/null +++ b/Source/TextEditBuffer.cs @@ -0,0 +1,131 @@ +using System.Text; + +namespace Celeste.Mod.MicroblocksQolUtils; + +/// +/// Editable single-line text with a caret and a selection anchor. The value is kept verbatim +/// (spaces stay spaces); width measurement and the visible window live in +/// so this type can be exercised without the game assemblies. +/// +internal sealed class TextEditBuffer { + public string Text { get; private set; } + public int Cursor { get; private set; } + public int Anchor { get; private set; } + public int MaxLength { get; } + + public TextEditBuffer(string value, int maxLength) { + Text = value ?? ""; + MaxLength = Math.Max(1, maxLength); + Cursor = Text.Length; + Anchor = Cursor; + } + + public int SelectionStart => Math.Min(Cursor, Anchor); + public int SelectionLength => Math.Abs(Cursor - Anchor); + public bool HasSelection => Cursor != Anchor; + public string SelectedText => Text.Substring(SelectionStart, SelectionLength); + + public void SetText(string? value) { + string text = value ?? ""; + Text = text.Length > MaxLength ? text[..MaxLength] : text; + Cursor = Text.Length; + Anchor = Cursor; + } + + public bool Insert(char character) => InsertText(character.ToString()); + + /// + /// Inserts at the caret, replacing the selection. Clipboard input + /// can carry line breaks and tabs, so control characters are dropped first: this is a + /// single-line field. Returns true when the value actually changed. + /// + public bool InsertText(string? value) { + value = SingleLine(value); + if (value.Length == 0) return false; + int room = MaxLength - (Text.Length - SelectionLength); + if (room <= 0) return false; + if (value.Length > room) value = value[..room]; + int start = SelectionStart; + Text = string.Concat(Text.AsSpan(0, start), value, Text.AsSpan(start + SelectionLength)); + Cursor = start + value.Length; + Anchor = Cursor; + return true; + } + + /// Strips characters that a single-line field cannot show (CR/LF/TAB and friends). + public static string SingleLine(string? value) { + if (string.IsNullOrEmpty(value)) return ""; + StringBuilder builder = new(value.Length); + foreach (char character in value) { + if (!char.IsControl(character)) builder.Append(character); + } + return builder.ToString(); + } + + public bool Backspace() { + if (HasSelection) return DeleteSelection(); + if (Cursor <= 0) return false; + Text = Text.Remove(Cursor - 1, 1); + Cursor--; + Anchor = Cursor; + return true; + } + + public bool Delete() { + if (HasSelection) return DeleteSelection(); + if (Cursor >= Text.Length) return false; + Text = Text.Remove(Cursor, 1); + return true; + } + + /// Removes the selected range; the caret ends up where the selection started. + public bool DeleteSelection() { + if (!HasSelection) return false; + int start = SelectionStart; + Text = Text.Remove(start, SelectionLength); + Cursor = start; + Anchor = start; + return true; + } + + public void SelectAll() { + Anchor = 0; + Cursor = Text.Length; + } + + public void CollapseSelection() { + Cursor = SelectionStart; + Anchor = Cursor; + } + + public void MoveLeft(bool extend = false) { + if (!extend && HasSelection) { + Cursor = SelectionStart; + Anchor = Cursor; + return; + } + MoveTo(Cursor - 1, extend); + } + + public void MoveRight(bool extend = false) { + if (!extend && HasSelection) { + Cursor = SelectionStart + SelectionLength; + Anchor = Cursor; + return; + } + MoveTo(Cursor + 1, extend); + } + + public void MoveHome(bool extend = false) => MoveTo(0, extend); + + public void MoveEnd(bool extend = false) => MoveTo(Text.Length, extend); + + public void SetCursor(int index, bool extend = false) => MoveTo(index, extend); + + private void MoveTo(int index, bool extend) { + index = Math.Clamp(index, 0, Text.Length); + if (extend && !HasSelection) Anchor = Cursor; + Cursor = index; + if (!extend) Anchor = index; + } +} diff --git a/Source/TextEditGeometry.cs b/Source/TextEditGeometry.cs new file mode 100644 index 0000000..500404c --- /dev/null +++ b/Source/TextEditGeometry.cs @@ -0,0 +1,52 @@ +using Microsoft.Xna.Framework; + +namespace Celeste.Mod.MicroblocksQolUtils; + +/// +/// Width measurement for : every position comes from the TTF font, +/// so CJK/full-width glyphs count twice and the caret never drifts half a cell. +/// +internal static class TextEditGeometry { + public static float CaretWidth(TextEditBuffer buffer, float scale, UiFontWeight weight) => + SystemTtfFont.MeasureVisible(buffer.Text[..buffer.Cursor], scale, weight).X; + + /// + /// Window of the text around the caret, plus the index where the window starts and the + /// caret offset inside it. Used to draw fields that are narrower than the value without + /// the caret drifting off-screen. + /// + public static TextWindow Window(TextEditBuffer buffer, float maxWidth, float scale, + UiFontWeight weight) { + string text = buffer.Text; + int start = 0; + while (start < buffer.Cursor + && SystemTtfFont.MeasureVisible(text[start..buffer.Cursor], scale, weight).X + > maxWidth - 10f) { + start++; + } + string shown = start >= text.Length ? "" : text[start..]; + if (SystemTtfFont.MeasureVisible(shown, scale, weight).X > maxWidth) { + shown = MaterialTextUtil.Ellipsize(shown, maxWidth, scale, weight); + } + return new TextWindow(shown, start, Math.Clamp(buffer.Cursor - start, 0, shown.Length)); + } + + /// Maps a local x offset inside a to a caret index. + public static int IndexFromX(TextEditBuffer buffer, float localX, TextWindow window, float scale, + UiFontWeight weight) { + string text = buffer.Text; + int end = window.Start + window.Text.Length; + int best = window.Start; + float bestDistance = float.MaxValue; + for (int index = window.Start; index <= Math.Min(end, text.Length); index++) { + float width = SystemTtfFont.MeasureVisible(text[window.Start..index], scale, weight).X; + float distance = Math.Abs(width - localX); + if (distance >= bestDistance) continue; + bestDistance = distance; + best = index; + } + return Math.Clamp(best, 0, text.Length); + } + + public readonly record struct TextWindow(string Text, int Start, int CaretOffset); +} diff --git a/Source/TextEditShortcuts.cs b/Source/TextEditShortcuts.cs new file mode 100644 index 0000000..fd8888c --- /dev/null +++ b/Source/TextEditShortcuts.cs @@ -0,0 +1,50 @@ +using Microsoft.Xna.Framework.Input; + +namespace Celeste.Mod.MicroblocksQolUtils; + +/// +/// Native text box shortcuts shared by every custom field: Ctrl+A/C/X/V. Clipboard access goes +/// through Everest's wrapper (SDL), so it works on Windows, Linux and +/// Android. Nothing here touches TextInputEXT's start/stop state, so an IME composition that is +/// in flight keeps running underneath the shortcut. +/// +internal static class TextEditShortcuts { + /// Applies a Ctrl shortcut; returns true when one was consumed. + public static bool Handle(TextEditBuffer buffer) { + if (!MaterialTextInputFocus.ControlDown) return false; + if (MaterialTextInputFocus.Pressed(Keys.A)) { + buffer.SelectAll(); + return true; + } + if (MaterialTextInputFocus.Pressed(Keys.C)) return Copy(buffer); + if (MaterialTextInputFocus.Pressed(Keys.X)) return Cut(buffer); + if (MaterialTextInputFocus.Pressed(Keys.V)) return Paste(buffer); + return false; + } + + private static bool Copy(TextEditBuffer buffer) { + if (!buffer.HasSelection) return false; + try { + TextInput.SetClipboardText(buffer.SelectedText); + return true; + } catch { + return false; + } + } + + private static bool Cut(TextEditBuffer buffer) { + if (!Copy(buffer)) return false; + buffer.DeleteSelection(); + return true; + } + + private static bool Paste(TextEditBuffer buffer) { + string text; + try { + text = TextInput.GetClipboardText() ?? ""; + } catch { + return false; + } + return buffer.InsertText(text); + } +} diff --git a/Source/TextEntryOverlay.cs b/Source/TextEntryOverlay.cs new file mode 100644 index 0000000..7babb47 --- /dev/null +++ b/Source/TextEntryOverlay.cs @@ -0,0 +1,625 @@ +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 TextEditBuffer edit; + private Page page = Page.Letters; + private bool fieldFocused; + 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. + edit = new TextEditBuffer(initial ?? "", this.maxLength); + RebuildRows(); + Tag = Tags.HUD | Tags.PauseUpdate; + Depth = -3_000_000; + } + + public string Buffer => edit.Text; + + 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; + } + UpdateKeyboardCaret(); + UpdateHover(); + if (Input.MenuConfirm.Pressed) { + if (fieldFocused) { + Accept(); + } else if (inActions) { + RunAction(actionIndex); + } else { + PressKey(rows[cursorRow][cursorColumn]); + } + return; + } + UpdateNavigation(); + UpdateLongPress(); + } + + private void UpdateKeyboardCaret() { + if (MaterialTextInputFocus.Pressed(Keys.Left)) edit.MoveLeft(); + else if (MaterialTextInputFocus.Pressed(Keys.Right)) edit.MoveRight(); + else if (MaterialTextInputFocus.Pressed(Keys.Home)) edit.MoveHome(); + else if (MaterialTextInputFocus.Pressed(Keys.End)) edit.MoveEnd(); + else if (MaterialTextInputFocus.Pressed(Keys.Delete)) edit.Delete(); + } + + private void UpdateHover() { + Vector2 mouse = MaterialTouch.Position; + MaterialRect field = FieldRect(new MaterialRect(PanelX, PanelY, PanelWidth, PanelHeight)); + 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 (field.Contains(mouse)) { + fieldFocused = true; + } else if (hoverRow >= 0) { + fieldFocused = false; + inActions = false; + cursorRow = hoverRow; + cursorColumn = hoverColumn; + } else if (hoverAction >= 0) { + fieldFocused = false; + inActions = true; + actionIndex = hoverAction; + } + } + + if (!MaterialTouch.PressedLeftButton) return; + if (field.Contains(mouse)) { + SetCursorFromPointer(mouse, field); + } else if (hoverRow >= 0) { + PressKey(rows[hoverRow][hoverColumn]); + } else if (hoverAction >= 0) { + RunAction(hoverAction); + } + } + + private void SetCursorFromPointer(Vector2 mouse, MaterialRect field) { + float textWidth = field.Width - 100f; + TextEditGeometry.TextWindow window = TextEditGeometry.Window(edit, textWidth, 0.36f, + UiFontWeight.Bold); + edit.SetCursor(TextEditGeometry.IndexFromX(edit, mouse.X - (field.X + 24f), window, + 0.36f, UiFontWeight.Bold)); + } + + private void UpdateNavigation() { + bool left = Input.MenuLeft.Pressed; + bool right = Input.MenuRight.Pressed; + bool up = Input.MenuUp.Pressed; + bool down = Input.MenuDown.Pressed; + if (fieldFocused) { + if (left) edit.MoveLeft(); + else if (right) edit.MoveRight(); + else if (up || down) fieldFocused = false; + return; + } + 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); + } else { + fieldFocused = true; + } + } + 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 (!edit.Insert(character)) { + Audio.Play("event:/ui/main/button_invalid"); + return; + } + error = 0f; + Audio.Play("event:/ui/main/button_select"); + } + + private void Backspace() { + if (!edit.Backspace()) return; + error = 0f; + Audio.Play("event:/ui/main/button_back"); + } + + private void Accept() { + if (edit.Text.Length < minLength) { + error = 1f; + Audio.Play("event:/ui/main/button_invalid"); + return; + } + if (!commit(edit.Text)) { + 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, + fieldFocused || error > 0f ? 2f : 1f, + (error > 0f ? Color.OrangeRed : palette.Primary) + * (fieldFocused || error > 0f ? 1f : 0.7f)); + + float textWidth = field.Width - 100f; + TextEditGeometry.TextWindow window = TextEditGeometry.Window(edit, textWidth, 0.36f, + UiFontWeight.Bold); + int caretOffset = Math.Clamp(window.CaretOffset, 0, window.Text.Length); + string before = window.Text[..caretOffset]; + string after = window.Text[caretOffset..]; + bool empty = edit.Text.Length == 0; + Color color = empty ? palette.OnSurfaceVariant * 0.7f : palette.OnSurface; + MaterialUiKit.Text(empty ? "输入内容…" : before + after, + 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(before, 0.36f, UiFontWeight.Bold).X; + float caretX = Math.Min(field.Right - 100f, field.X + 24f + width); + MaterialUi.Line(new Vector2(caretX, field.Y + 20f), + new Vector2(caretX, field.Bottom - 20f), 2.5f, palette.Primary); + } + + MaterialUiKit.Text($"{edit.Text.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); + + if (fieldFocused) { + MaterialUiKit.Text("←→ 移动光标 · ↓ 回键盘", + new Vector2(field.X + 24f, field.Bottom - 6f), new Vector2(0f, 0f), + MaterialTextRole.Caption, palette.Primary, 1f, scaleOverride: 0.22f); + } + } + + 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; + } +} diff --git a/Source/TextKeyRepeat.cs b/Source/TextKeyRepeat.cs new file mode 100644 index 0000000..a674c8b --- /dev/null +++ b/Source/TextKeyRepeat.cs @@ -0,0 +1,42 @@ +namespace Celeste.Mod.MicroblocksQolUtils; + +/// +/// Turns a held key into the keyboard repeat pattern native text boxes use: one action on the +/// press, then seconds later another every +/// seconds. Feed it once per frame with the raw key state. +/// +internal sealed class TextKeyRepeat { + public const float InitialDelay = 0.40f; + public const float Interval = 0.045f; + + private bool armed; + private float held; + private float threshold = InitialDelay; + + public void Reset() { + armed = false; + held = 0f; + threshold = InitialDelay; + } + + /// Returns true when the key should act this frame (press or a repeat tick). + public bool Update(bool down, float delta) { + if (!down) { + Reset(); + return false; + } + if (!armed) { + armed = true; + held = 0f; + threshold = InitialDelay; + return true; + } + held += Math.Max(0f, delta); + if (held < threshold) return false; + // One action per frame even when the frame was long: the caret should not jump a + // whole paragraph after a hitch. + held = 0f; + threshold = Interval; + return true; + } +} diff --git a/Tests/ModOptions/ModOptions.csproj b/Tests/ModOptions/ModOptions.csproj index 0ae7fef..b771328 100644 --- a/Tests/ModOptions/ModOptions.csproj +++ b/Tests/ModOptions/ModOptions.csproj @@ -8,5 +8,7 @@ + + diff --git a/Tests/ModOptions/Program.cs b/Tests/ModOptions/Program.cs index 0d05a56..5fbe64b 100644 --- a/Tests/ModOptions/Program.cs +++ b/Tests/ModOptions/Program.cs @@ -65,6 +65,84 @@ List Entries(params (string Label, int Value)[] pairs) => Console.WriteLine($"OptionRules tests passed ({assertions} assertions)"); +// --- TextEditBuffer: real spaces, caret, selection, cut/paste semantics --- + +var edit = new TextEditBuffer("ab c", 10); +Check(edit.Text == "ab c", "text should be kept verbatim, spaces included"); +Check(edit.Cursor == 4, "the caret should start at the end"); +Check(!edit.HasSelection, "a fresh buffer should not select anything"); + +edit.MoveHome(); +edit.MoveRight(extend: true); +edit.MoveRight(extend: true); +Check(edit.SelectionStart == 0 && edit.SelectionLength == 2, + "shift+right should extend the selection from the caret"); +Check(edit.SelectedText == "ab", "the selection should expose the selected text"); +edit.MoveLeft(extend: true); +Check(edit.SelectionLength == 1, "shift+left should shrink the selection"); +edit.MoveLeft(); +Check(!edit.HasSelection && edit.Cursor == 0, + "a plain left should collapse the selection to its start"); +edit.MoveRight(); +Check(edit.Cursor == 1, "a plain right on a collapsed caret should move one character"); + +edit = new TextEditBuffer("ac", 5); +edit.MoveHome(); +edit.MoveRight(); +Check(edit.Insert('b') && edit.Text == "abc" && edit.Cursor == 2, + "inserting should happen at the caret, not at the end"); + +edit = new TextEditBuffer("hello", 5); +edit.SelectAll(); +Check(edit.Backspace() && edit.Text == "" && edit.Cursor == 0, + "backspace should remove the selection"); +edit = new TextEditBuffer("hello", 5); +edit.MoveHome(); +edit.MoveRight(); +Check(edit.Delete() && edit.Text == "hllo", "delete should remove the character after the caret"); + +edit = new TextEditBuffer("old value", 20); +edit.SelectAll(); +Check(edit.InsertText("new") && edit.Text == "new" && edit.Cursor == 3, + "pasting should replace the selection"); +edit = new TextEditBuffer("", 20); +Check(edit.InsertText("a\r\n\tb") && edit.Text == "ab", + "line breaks and tabs should be dropped from pasted text"); +Check(new TextEditBuffer(" a ", 10).Text == " a ", + "leading and trailing spaces should survive"); + +edit = new TextEditBuffer("abc", 3); +edit.MoveEnd(); +Check(!edit.InsertText("de"), "an insert at the limit should be rejected"); +edit.SelectAll(); +Check(edit.InsertText("abcdef") && edit.Text == "abc", + "replacing a selection should stay within the maximum length"); + +edit = new TextEditBuffer("hello", 10); +edit.MoveHome(); +edit.MoveEnd(extend: true); +Check(edit.SelectionStart == 0 && edit.SelectionLength == 5, + "shift+end should select from the anchor to the end"); +edit.SetCursor(9); +Check(edit.Cursor == 5, "the caret should clamp to the text length"); + +// --- TextKeyRepeat: press, delay, repeat interval, release --- + +var repeat = new TextKeyRepeat(); +Check(repeat.Update(true, 1f / 60f), "a press should act on the first frame"); +Check(!repeat.Update(true, TextKeyRepeat.InitialDelay * 0.5f), + "a held key should wait for the initial delay"); +Check(repeat.Update(true, TextKeyRepeat.InitialDelay * 0.6f), + "a held key should repeat after the initial delay"); +Check(!repeat.Update(true, TextKeyRepeat.Interval * 0.5f), + "repeats should respect the repeat interval"); +Check(repeat.Update(true, TextKeyRepeat.Interval * 0.6f), + "a held key should keep repeating"); +repeat.Update(false, 0f); +Check(repeat.Update(true, 0f), "releasing and pressing again should act immediately"); + +Console.WriteLine($"Text editing tests passed ({assertions} assertions)"); + enum TestMode { Off, On,