From 5a2002ca260003be7ee287baf9850cf46bff1028 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:24:21 +0800 Subject: [PATCH 1/7] modoptions: classify option controls by setting type Everest models booleans as Option, integer ranges as Slider and enums as Slider too, so the old option-count heuristic rendered integer ranges as dropdowns and two-value enums as switches. Classify every option into Switch / Dropdown / Range once: bool options are switches, enum-backed or non-numeric Slider/Option values are dropdowns, and consecutive numeric ranges become sliders. Range rows now always use the slider control, dropdown rows open the list, and touch slider targets are only published for real ranges. --- Source/MaterialModOptions.cs | 120 +++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 13 deletions(-) diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs index 6b635c9..94255cf 100644 --- a/Source/MaterialModOptions.cs +++ b/Source/MaterialModOptions.cs @@ -295,11 +295,11 @@ public override void Update() { || MInput.Keyboard.Pressed(Keys.Space)) && menu.Current is { } current && TryGetOption(current, out OptionSnapshot option)) { - if (option.Options.Count == 2) { + if (option.Kind == OptionKind.Switch) { SetOptionIndex(current, option.Index == 0 ? 1 : 0); return; } - if (CanUseDropdown(option)) { + if (option.Kind == OptionKind.Dropdown) { OpenDropdown(current, option); return; } @@ -802,9 +802,9 @@ private void UpdateMouse(ModOptionsLayout layout) { } MaterialRect rect = placement.Value.Rect; if (TryGetOption(item, out OptionSnapshot option)) { - if (option.Options.Count == 2) { + if (option.Kind == OptionKind.Switch) { SetOptionIndex(item, option.Index == 0 ? 1 : 0); - } else if (CanUseDropdown(option)) { + } else if (option.Kind == OptionKind.Dropdown) { OpenDropdown(item, option); } else if (SliderControlArea(rect).Contains(mouse)) { SetOptionFromMouse(item, option, SliderControlRect(rect), mouse.X); @@ -1121,7 +1121,10 @@ 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 (placement.Item is TextMenu.Slider) { + 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 + // in Everest, but it is rendered as a dropdown. targets.Add(new MaterialInteractionTarget(ItemKey(placement.Item) + ".touch-slider", SliderControlRect(PrimaryRect(placement)), TouchKind: "slider")); } @@ -1284,16 +1287,16 @@ private bool RenderStandardItem(TextMenu.Item item, MaterialRect rect, MaterialP bool enabled = !item.Disabled; Color labelColor = enabled ? palette.OnSurface : palette.OnSurfaceVariant * 0.5f; if (TryGetOption(item, out OptionSnapshot option)) { - MaterialRect control = option.Options.Count == 2 - ? SwitchControlArea(rect) - : CanUseDropdown(option) - ? DropdownControlRect(rect) - : SliderControlArea(rect); + MaterialRect control = option.Kind switch { + OptionKind.Switch => SwitchControlArea(rect), + OptionKind.Dropdown => DropdownControlRect(rect), + _ => SliderControlArea(rect) + }; RenderItemLabel(option.Label, rect, control.X - rect.X - 62f, labelColor, alpha, hasDescription); - if (option.Options.Count == 2) { + if (option.Kind == OptionKind.Switch) { RenderSwitch(option, rect, palette, alpha, enabled); - } else if (CanUseDropdown(option)) { + } else if (option.Kind == OptionKind.Dropdown) { RenderOptionControl(item, option, rect, palette, alpha, enabled); } else { RenderOptionSlider(option, rect, palette, alpha, enabled); @@ -1675,8 +1678,10 @@ private static bool TryGetOption(TextMenu.Item item, out OptionSnapshot snapshot labels.Add(label?.ToString() ?? ""); entries.Add(entry); } + Delegate? change = changeField?.GetValue(item) as Delegate; snapshot = new OptionSnapshot( labelField.GetValue(item)?.ToString() ?? "", + ClassifyOption(item, labels, entries, change), Math.Clamp((int)(indexField.GetValue(item) ?? 0), 0, Math.Max(0, labels.Count - 1)), labels, entries, @@ -1687,6 +1692,88 @@ private static bool TryGetOption(TextMenu.Item item, out OptionSnapshot snapshot return true; } + /// + /// TextMenu.Option only knows its value type via a generic argument, and Everest uses + /// Slider for both integer ranges and enums. Classify by the backing property when the + /// change delegate exposes one, and fall back to the value/label shape otherwise. + /// + private static OptionKind ClassifyOption(TextMenu.Item item, List labels, + List entries, Delegate? change) { + if (item is TextMenu.Option) return OptionKind.Switch; + bool isSlider = item is TextMenu.Slider; + bool isIntOption = item is TextMenu.Option; + if (!isSlider && !isIntOption) return OptionKind.Dropdown; + + bool? enumBacked = ChangeTargetsEnum(change); + if (enumBacked is true) return OptionKind.Dropdown; + bool consecutive = HasConsecutiveInts(entries); + bool numericLabels = labels.Count > 0 && labels.All(LooksNumeric); + if (isSlider) { + if (enumBacked is false) return OptionKind.Range; + return consecutive && numericLabels ? OptionKind.Range : OptionKind.Dropdown; + } + // A hand-built Option is only a range when its values really are a sequence. + return consecutive && numericLabels ? OptionKind.Range : OptionKind.Dropdown; + } + + private static bool? ChangeTargetsEnum(Delegate? change) { + object? target = change?.Target; + if (target is null) return null; + bool sawEnum = false; + bool sawNumeric = false; + foreach (FieldInfo field in target.GetType().GetFields( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { + object? value; + try { + value = field.GetValue(target); + } catch { + continue; + } + if (value is PropertyInfo property) { + if (property.PropertyType.IsEnum) sawEnum = true; + else if (IsNumericType(property.PropertyType)) sawNumeric = true; + } else if (value is Array array && array.GetType().GetElementType()?.IsEnum == true) { + sawEnum = true; + } else if (field.FieldType.IsEnum) { + sawEnum = true; + } + } + // A compiler display class can capture several properties from the enclosing scope; + // only trust the answer when the captured property types are unambiguous. + if (sawEnum && !sawNumeric) return true; + if (sawNumeric && !sawEnum) return false; + return null; + } + + private static bool IsNumericType(Type type) => + type == typeof(int) || type == typeof(float) || type == typeof(double) + || type == typeof(long) || type == typeof(short) || type == typeof(byte); + + private static bool HasConsecutiveInts(List entries) { + int? previous = null; + foreach (object entry in entries) { + if (EntryValue(entry) is not int value) return false; + if (previous.HasValue && value != previous.Value + 1) return false; + previous = value; + } + return previous.HasValue; + } + + private static bool LooksNumeric(string value) { + string text = value.Trim(); + int index = text.StartsWith('-') || text.StartsWith('+') ? 1 : 0; + int digits = 0; + while (index < text.Length && char.IsDigit(text[index])) { + index++; + digits++; + } + return digits > 0; + } + + private static object? EntryValue(object entry) => + entry.GetType().GetProperty("Item2")?.GetValue(entry) + ?? entry.GetType().GetField("Item2")?.GetValue(entry); + private static bool TryGetIntSlider(TextMenu.Item item, out IntSliderSnapshot snapshot) { if (item is not TextMenuExt.IntSlider slider) { snapshot = default; @@ -1764,7 +1851,7 @@ private static bool TryGetCompositeContents(TextMenu.Item item, out float titleH } private static bool CanUseDropdown(OptionSnapshot option) => - option.Options.Count >= 3; + option.Kind == OptionKind.Dropdown; private static void SetOptionIndex(TextMenu.Item item, int targetIndex) { if (!TryGetOption(item, out OptionSnapshot option) || option.Options.Count == 0) return; @@ -2075,8 +2162,15 @@ private sealed class ModTab(string id, string title, string version, List Options, List Entries, From 2ca4c46260076ebe37e7f0eb634c038ee06a8518 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:43:34 +0800 Subject: [PATCH 2/7] modoptions: follow mouse slider drags and align option controls - start a slider drag on press and keep updating from the pointer while the button is held (mouse and touch); save settings once on release instead of every step - give range sliders and dropdowns the same 400px control box on the right edge, with the track inset inside it - adapt keyboard steps to the range (about 50 presses end to end) and accelerate x2/x4/x8 after holding a direction for 0.4s/0.8s/1.2s --- Source/MaterialModOptions.cs | 174 +++++++++++++++++++++++++++++++---- 1 file changed, 155 insertions(+), 19 deletions(-) diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs index 94255cf..4636b19 100644 --- a/Source/MaterialModOptions.cs +++ b/Source/MaterialModOptions.cs @@ -84,6 +84,10 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage, IMaterialAcr private int savedItemOrdinal = -1; private TextMenu.Item? dropdownItem; private TextMenu.Item? compositePopupItem; + private TextMenu.Item? draggedSlider; + private bool draggedSliderChanged; + private int keyboardSliderDirection; + private float keyboardSliderHold; private int dropdownHighlight; private int dropdownFirstVisible; private string tabSearchText = ""; @@ -305,6 +309,8 @@ public override void Update() { } } + if (HandleRangeKeyboard()) return; + UpdateMouse(layout); int previousSelection = menu.Selection; menu.Update(); @@ -754,9 +760,82 @@ private List VisibleRows() { private static bool IsWarningColor(Color color) => color == Color.OrangeRed || color == Color.Goldenrod || color == Color.Red || color == Color.Yellow; + /// + /// Range settings use an adaptive keyboard step (about 50 presses for the whole range); + /// holding the direction accelerates after 0.4s (x2 / x4 / x8) and resets on release. + /// Mouse/touch dragging keeps its pixel-linear mapping. + /// + private bool HandleRangeKeyboard() { + if (menu?.Current is not { } item) { + ResetKeyboardSlider(); + return false; + } + bool left = Input.MenuLeft.Check; + bool right = Input.MenuRight.Check; + int direction = left == right ? 0 : left ? -1 : 1; + if (direction == 0) { + ResetKeyboardSlider(); + return false; + } + + bool isRange = TryGetOption(item, out OptionSnapshot option) + && option.Kind == OptionKind.Range; + bool isIntSlider = !isRange && TryGetIntSlider(item, out _); + if (!isRange && !isIntSlider) { + ResetKeyboardSlider(); + return false; + } + + if (direction != keyboardSliderDirection) { + keyboardSliderDirection = direction; + keyboardSliderHold = 0f; + } + keyboardSliderHold += Engine.RawDeltaTime; + bool pressed = left ? Input.MenuLeft.Pressed : Input.MenuRight.Pressed; + if (!pressed) return true; // consume held input; do not let TextMenu move one step + + int multiplier = keyboardSliderHold >= 1.2f ? 8 + : keyboardSliderHold >= 0.8f ? 4 + : keyboardSliderHold >= 0.4f ? 2 + : 1; + if (isRange) { + int span = Math.Max(1, option.Options.Count - 1); + int step = Math.Max(1, (int)MathF.Round(span / 50f)); + SetOptionIndex(item, option.Index + direction * step * multiplier); + } else if (TryGetIntSlider(item, out IntSliderSnapshot slider)) { + int span = Math.Max(1, slider.Maximum - slider.Minimum); + int step = Math.Max(1, (int)MathF.Round(span / 50f)); + SetIntSliderValue(item, slider, slider.Value + direction * step * multiplier); + } + return true; + } + + private void ResetKeyboardSlider() { + keyboardSliderDirection = 0; + keyboardSliderHold = 0f; + } + + private static void SetIntSliderValue(TextMenu.Item item, IntSliderSnapshot slider, int value) { + if (item is not TextMenuExt.IntSlider intSlider) return; + value = Math.Clamp(value, slider.Minimum, slider.Maximum); + if (value == intSlider.Index) return; + int previous = intSlider.Index; + intSlider.PreviousIndex = previous; + intSlider.Index = value; + intSlider.OnValueChange?.Invoke(value); + intSlider.ValueWiggler?.Start(); + Audio.Play(value > previous + ? "event:/ui/main/button_toggle_on" + : "event:/ui/main/button_toggle_off"); + } + private void UpdateMouse(ModOptionsLayout layout) { if (menu is null) return; Vector2 mouse = MaterialTouch.Position; + if (draggedSlider is not null) { + UpdateSliderDrag(layout, mouse); + return; + } bool scrolled = false; if (MaterialTouch.WheelDelta != 0) { float direction = -Math.Sign(MaterialTouch.WheelDelta); @@ -807,11 +886,22 @@ private void UpdateMouse(ModOptionsLayout layout) { } else if (option.Kind == OptionKind.Dropdown) { OpenDropdown(item, option); } else if (SliderControlArea(rect).Contains(mouse)) { + draggedSlider = item; + draggedSliderChanged = false; + int before = option.Index; SetOptionFromMouse(item, option, SliderControlRect(rect), mouse.X); + if (TryGetOption(item, out OptionSnapshot changed) && changed.Index != before) + draggedSliderChanged = true; } } else if (TryGetIntSlider(item, out IntSliderSnapshot slider)) { - if (SliderControlArea(rect).Contains(mouse)) + if (SliderControlArea(rect).Contains(mouse)) { + draggedSlider = item; + draggedSliderChanged = false; + int before = slider.Value; SetIntSliderFromMouse(item, slider, SliderControlRect(rect), mouse.X); + if (TryGetIntSlider(item, out IntSliderSnapshot changed) && changed.Value != before) + draggedSliderChanged = true; + } } else if (IsCompositeMenu(item)) { OpenCompositePopup(item); } else { @@ -820,6 +910,42 @@ private void UpdateMouse(ModOptionsLayout layout) { motion.Pulse(ItemKey(item), mouse); } + private void UpdateSliderDrag(ModOptionsLayout layout, Vector2 mouse) { + if (!MaterialTouch.CheckLeftButton) { + draggedSlider = null; + if (draggedSliderChanged) { + draggedSliderChanged = false; + Add(new Coroutine(Everest.SaveSettings())); + } + return; + } + if (draggedSlider is not { } dragged) return; + if (!TryGetRowPlacement(layout, dragged, out RowPlacement placement)) return; + if (TryGetOption(dragged, out OptionSnapshot option) + && option.Kind == OptionKind.Range) { + int before = option.Index; + SetOptionFromMouse(dragged, option, SliderControlRect(placement.Rect), mouse.X); + if (TryGetOption(dragged, out OptionSnapshot changed) && changed.Index != before) + draggedSliderChanged = true; + } else if (TryGetIntSlider(dragged, out IntSliderSnapshot slider)) { + int before = slider.Value; + SetIntSliderFromMouse(dragged, slider, SliderControlRect(placement.Rect), mouse.X); + if (TryGetIntSlider(dragged, out IntSliderSnapshot changed) && changed.Value != before) + draggedSliderChanged = true; + } + } + + private bool TryGetRowPlacement(ModOptionsLayout layout, TextMenu.Item item, + out RowPlacement placement) { + foreach (RowPlacement candidate in RowPlacements(layout)) { + if (candidate.Item != item) continue; + placement = candidate; + return true; + } + placement = default; + return false; + } + private static void ActivateItem(TextMenu.Item item) { item.ConfirmPressed(); item.OnPressed?.Invoke(); @@ -1392,26 +1518,41 @@ private void RenderOptionControl(TextMenu.Item item, OptionSnapshot option, Mate private static void RenderOptionSlider(OptionSnapshot option, MaterialRect rect, MaterialPalette palette, float alpha, bool enabled) { + MaterialRect control = OptionControlRect(rect); + RenderSliderBackground(control, palette, alpha); MaterialRect track = SliderControlRect(rect); float amount = option.Options.Count <= 1 ? 0f : option.Index / (float)(option.Options.Count - 1); RenderSliderTrack(track, amount, palette, alpha, enabled); string value = option.Options.Count == 0 ? "" : option.Options[Math.Clamp(option.Index, 0, option.Options.Count - 1)]; - MaterialUiKit.Text(MaterialTextUtil.Ellipsize(value, 390f, 0.27f, UiFontWeight.Bold), - new Vector2(track.Center.X, track.Y - 12f), new Vector2(0.5f, 1f), - MaterialTextRole.Label, palette.OnSurfaceVariant, alpha, scaleOverride: 0.27f); + MaterialUiKit.Text(MaterialTextUtil.Ellipsize(value, control.Width - 30f, 0.27f, UiFontWeight.Bold), + new Vector2(track.Center.X, track.Y - 5f), new Vector2(0.5f, 1f), + MaterialTextRole.Label, enabled ? palette.OnSurface : palette.OnSurfaceVariant * 0.5f, + alpha, scaleOverride: 0.27f); + } + + private static void RenderSliderBackground(MaterialRect control, MaterialPalette palette, + float alpha) { + MaterialUi.RoundedRect(control.X, control.Y, control.Width, control.Height, 16f, + palette.Surface * (0.76f * alpha)); + MaterialUi.RoundedOutline(control.X, control.Y, control.Width, control.Height, 16f, 1f, + palette.Outline * (0.52f * alpha)); } private static void RenderIntSlider(IntSliderSnapshot slider, MaterialRect rect, MaterialPalette palette, float alpha, bool enabled) { + MaterialRect control = OptionControlRect(rect); + RenderSliderBackground(control, palette, alpha); MaterialRect track = SliderControlRect(rect); float amount = slider.Maximum == slider.Minimum ? 0f : (slider.Value - slider.Minimum) / (float)(slider.Maximum - slider.Minimum); RenderSliderTrack(track, amount, palette, alpha, enabled); - MaterialUiKit.Text(slider.Value.ToString(), new Vector2(track.Center.X, track.Y - 12f), - new Vector2(0.5f, 1f), MaterialTextRole.Label, palette.OnSurfaceVariant, + MaterialUiKit.Text(slider.Value.ToString(), + new Vector2(track.Center.X, track.Y - 5f), + new Vector2(0.5f, 1f), MaterialTextRole.Label, + enabled ? palette.OnSurface : palette.OnSurfaceVariant * 0.5f, alpha, scaleOverride: 0.27f); } @@ -1929,26 +2070,21 @@ private static int DropdownVisibleCount(int optionCount) => 32f ); - private static MaterialRect DropdownControlRect(MaterialRect rect) => new( + private static MaterialRect OptionControlRect(MaterialRect rect) => new( rect.Right - 424f, rect.Center.Y - 24f, 400f, 48f ); - private static MaterialRect SliderControlArea(MaterialRect rect) => new( - rect.Right - 504f, - rect.Y, - 480f, - rect.Height - ); + private static MaterialRect DropdownControlRect(MaterialRect rect) => OptionControlRect(rect); - private static MaterialRect SliderControlRect(MaterialRect rect) => new( - rect.Right - 474f, - rect.Center.Y + 9f, - 430f, - 8f - ); + private static MaterialRect SliderControlArea(MaterialRect rect) => OptionControlRect(rect); + + private static MaterialRect SliderControlRect(MaterialRect rect) { + MaterialRect control = OptionControlRect(rect); + return new MaterialRect(control.X + 10f, control.Center.Y + 8f, control.Width - 20f, 8f); + } private static MaterialRect ActionControlRect(MaterialRect rect) => new( rect.Right - 304f, From 6f6746de65aac069568b045777505952d77ecae0 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:59:30 +0800 Subject: [PATCH 3/7] modoptions: one-step key taps, smooth slider acceleration and save debounce A single direction tap now always moves one minimum step. Holding starts a smooth ramp after 0.45s up to range/12 steps per second (8..160), with A/D as well as the arrow keys/gamepad. Slider changes mark settings dirty and save 0.5s after the last change (paused while dragging or holding), so the save icon appears once instead of once per release; leaving waits for any pending save to finish. --- Source/MaterialModOptions.cs | 108 +++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 35 deletions(-) diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs index 4636b19..2a4d601 100644 --- a/Source/MaterialModOptions.cs +++ b/Source/MaterialModOptions.cs @@ -85,9 +85,12 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage, IMaterialAcr private TextMenu.Item? dropdownItem; private TextMenu.Item? compositePopupItem; private TextMenu.Item? draggedSlider; - private bool draggedSliderChanged; private int keyboardSliderDirection; private float keyboardSliderHold; + private float keyboardSliderAccumulator; + private float settingsSaveDelay = -1f; + private bool settingsSaveRunning; + private bool settingsDirty; private int dropdownHighlight; private int dropdownFirstVisible; private string tabSearchText = ""; @@ -165,7 +168,7 @@ public override IEnumerator Leave(Oui next) { if (menu is not null) menu.Focused = false; SaveTabState(); for (float timer = 0f; timer < 0.16f; timer += Engine.RawDeltaTime) yield return null; - yield return Everest.SaveSettings(); + yield return SaveSettingsAfterPending(); DetachMenu(); Visible = false; } @@ -203,6 +206,7 @@ public override void Update() { compositePopupScroll.Update(MaxCompositePopupScroll(layout)); ease = Calc.Approach(ease, display ? 1f : 0f, Engine.RawDeltaTime * 7.5f); inputDelay -= Engine.RawDeltaTime; + UpdateSettingsSave(); UpdateInteractions(layout); if (!display) { @@ -761,17 +765,18 @@ private static bool IsWarningColor(Color color) => color == Color.OrangeRed || color == Color.Goldenrod || color == Color.Red || color == Color.Yellow; /// - /// Range settings use an adaptive keyboard step (about 50 presses for the whole range); - /// holding the direction accelerates after 0.4s (x2 / x4 / x8) and resets on release. - /// Mouse/touch dragging keeps its pixel-linear mapping. + /// A single tap always moves one minimum step. Holding starts a smooth ramp after + /// 0.45s: the rate grows from 0 to about range/12 steps per second (8..160), so the + /// same key is fine for nudging and fast for long ranges. A/D work as well as the + /// arrow keys. Mouse/touch dragging keeps its pixel-linear mapping. /// private bool HandleRangeKeyboard() { if (menu?.Current is not { } item) { ResetKeyboardSlider(); return false; } - bool left = Input.MenuLeft.Check; - bool right = Input.MenuRight.Check; + bool left = Input.MenuLeft.Check || MInput.Keyboard.Check(Keys.A); + bool right = Input.MenuRight.Check || MInput.Keyboard.Check(Keys.D); int direction = left == right ? 0 : left ? -1 : 1; if (direction == 0) { ResetKeyboardSlider(); @@ -780,39 +785,78 @@ private bool HandleRangeKeyboard() { bool isRange = TryGetOption(item, out OptionSnapshot option) && option.Kind == OptionKind.Range; - bool isIntSlider = !isRange && TryGetIntSlider(item, out _); + IntSliderSnapshot slider = default; + bool isIntSlider = !isRange && TryGetIntSlider(item, out slider); if (!isRange && !isIntSlider) { ResetKeyboardSlider(); return false; } if (direction != keyboardSliderDirection) { + // The first tap is always exactly one minimum step; repeated events are ignored + // below because the hold timer owns the acceleration. keyboardSliderDirection = direction; keyboardSliderHold = 0f; + keyboardSliderAccumulator = 0f; + if (isRange) SetOptionIndex(item, option.Index + direction); + else SetIntSliderValue(item, slider, slider.Value + direction); + MarkSettingsDirty(); + return true; } + keyboardSliderHold += Engine.RawDeltaTime; - bool pressed = left ? Input.MenuLeft.Pressed : Input.MenuRight.Pressed; - if (!pressed) return true; // consume held input; do not let TextMenu move one step - - int multiplier = keyboardSliderHold >= 1.2f ? 8 - : keyboardSliderHold >= 0.8f ? 4 - : keyboardSliderHold >= 0.4f ? 2 - : 1; - if (isRange) { - int span = Math.Max(1, option.Options.Count - 1); - int step = Math.Max(1, (int)MathF.Round(span / 50f)); - SetOptionIndex(item, option.Index + direction * step * multiplier); - } else if (TryGetIntSlider(item, out IntSliderSnapshot slider)) { - int span = Math.Max(1, slider.Maximum - slider.Minimum); - int step = Math.Max(1, (int)MathF.Round(span / 50f)); - SetIntSliderValue(item, slider, slider.Value + direction * step * multiplier); - } + const float delay = 0.45f; + const float ramp = 1.0f; + if (keyboardSliderHold <= delay) return true; + + int span = isRange ? Math.Max(1, option.Options.Count - 1) + : Math.Max(1, slider.Maximum - slider.Minimum); + float maxRate = Math.Clamp(span / 12f, 8f, 160f); + float progress = Math.Clamp((keyboardSliderHold - delay) / ramp, 0f, 1f); + progress = progress * progress * (3f - 2f * progress); + keyboardSliderAccumulator += maxRate * progress * Engine.RawDeltaTime; + int steps = (int)keyboardSliderAccumulator; + if (steps <= 0) return true; + keyboardSliderAccumulator -= steps; + + if (isRange) SetOptionIndex(item, option.Index + direction * steps); + else SetIntSliderValue(item, slider, slider.Value + direction * steps); + MarkSettingsDirty(); return true; } private void ResetKeyboardSlider() { keyboardSliderDirection = 0; keyboardSliderHold = 0f; + keyboardSliderAccumulator = 0f; + } + + private void MarkSettingsDirty() { + settingsDirty = true; + settingsSaveDelay = 0.5f; + } + + private void UpdateSettingsSave() { + if (settingsSaveDelay < 0f || settingsSaveRunning || !display) return; + // Do not write while a slider is still being dragged or a direction key is held. + if (draggedSlider is not null || keyboardSliderDirection != 0) return; + settingsSaveDelay -= Engine.RawDeltaTime; + if (settingsSaveDelay > 0f || !settingsDirty) return; + settingsDirty = false; + settingsSaveDelay = -1f; + Add(new Coroutine(SaveSettingsDebounced())); + } + + private IEnumerator SaveSettingsDebounced() { + settingsSaveRunning = true; + yield return Everest.SaveSettings(); + settingsSaveRunning = false; + } + + private IEnumerator SaveSettingsAfterPending() { + settingsSaveDelay = -1f; + while (settingsSaveRunning) yield return null; + yield return Everest.SaveSettings(); } private static void SetIntSliderValue(TextMenu.Item item, IntSliderSnapshot slider, int value) { @@ -887,20 +931,18 @@ private void UpdateMouse(ModOptionsLayout layout) { OpenDropdown(item, option); } else if (SliderControlArea(rect).Contains(mouse)) { draggedSlider = item; - draggedSliderChanged = false; int before = option.Index; SetOptionFromMouse(item, option, SliderControlRect(rect), mouse.X); if (TryGetOption(item, out OptionSnapshot changed) && changed.Index != before) - draggedSliderChanged = true; + MarkSettingsDirty(); } } else if (TryGetIntSlider(item, out IntSliderSnapshot slider)) { if (SliderControlArea(rect).Contains(mouse)) { draggedSlider = item; - draggedSliderChanged = false; int before = slider.Value; SetIntSliderFromMouse(item, slider, SliderControlRect(rect), mouse.X); if (TryGetIntSlider(item, out IntSliderSnapshot changed) && changed.Value != before) - draggedSliderChanged = true; + MarkSettingsDirty(); } } else if (IsCompositeMenu(item)) { OpenCompositePopup(item); @@ -913,10 +955,6 @@ private void UpdateMouse(ModOptionsLayout layout) { private void UpdateSliderDrag(ModOptionsLayout layout, Vector2 mouse) { if (!MaterialTouch.CheckLeftButton) { draggedSlider = null; - if (draggedSliderChanged) { - draggedSliderChanged = false; - Add(new Coroutine(Everest.SaveSettings())); - } return; } if (draggedSlider is not { } dragged) return; @@ -926,12 +964,12 @@ private void UpdateSliderDrag(ModOptionsLayout layout, Vector2 mouse) { int before = option.Index; SetOptionFromMouse(dragged, option, SliderControlRect(placement.Rect), mouse.X); if (TryGetOption(dragged, out OptionSnapshot changed) && changed.Index != before) - draggedSliderChanged = true; + MarkSettingsDirty(); } else if (TryGetIntSlider(dragged, out IntSliderSnapshot slider)) { int before = slider.Value; SetIntSliderFromMouse(dragged, slider, SliderControlRect(placement.Rect), mouse.X); if (TryGetIntSlider(dragged, out IntSliderSnapshot changed) && changed.Value != before) - draggedSliderChanged = true; + MarkSettingsDirty(); } } @@ -2142,7 +2180,7 @@ private void BeginClose(CloseDestination destination) { } private IEnumerator SaveAndFinishPause() { - yield return Everest.SaveSettings(); + yield return SaveSettingsAfterPending(); Level? level = pauseLevel; if (level is null) yield break; level.AllowHudHide = oldAllowHudHide; From be32b787bb8031d2c0a5a1549223deffeda926be Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:00:34 +0800 Subject: [PATCH 4/7] modoptions: reset the debounced save flag on failure --- Source/MaterialModOptions.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs index 2a4d601..8a1e24e 100644 --- a/Source/MaterialModOptions.cs +++ b/Source/MaterialModOptions.cs @@ -849,8 +849,11 @@ private void UpdateSettingsSave() { private IEnumerator SaveSettingsDebounced() { settingsSaveRunning = true; - yield return Everest.SaveSettings(); - settingsSaveRunning = false; + try { + yield return Everest.SaveSettings(); + } finally { + settingsSaveRunning = false; + } } private IEnumerator SaveSettingsAfterPending() { From 25eacaade145751edbd52b5667acf58b2eafec0e Mon Sep 17 00:00:00 2001 From: Dilant Date: Fri, 23 Oct 2026 22:52:06 +0800 Subject: [PATCH 5/7] modoptions: cache option metadata and refine slider input --- Source/MaterialModOptions.cs | 380 ++++++++++++++++++++--------------- 1 file changed, 218 insertions(+), 162 deletions(-) diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs index 8a1e24e..86d09b9 100644 --- a/Source/MaterialModOptions.cs +++ b/Source/MaterialModOptions.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; using Celeste.Mod.UI; @@ -30,6 +31,8 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage, IMaterialAcr private const int DropdownMaxVisibleItems = 8; private const int SearchTextLimit = 80; private const float CompositePopupHorizontalPadding = 48f; + // Rate-limit the tick audio when adjusting slider. + private const float SliderAudioInterval = 0.08f; private static readonly string[] TabIconPool = [ "bolt", "layers", "map", "route", "sports_esports", "timer", "visibility", @@ -52,6 +55,11 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage, IMaterialAcr private static bool hookFailed; private static readonly FieldInfo? textMenuWidthField = typeof(TextMenu).GetField("width", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly ConditionalWeakTable optionMetadata = new(); + private static readonly FieldInfo? intSliderMinimumField = typeof(TextMenuExt.IntSlider).GetField("min", + BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo? intSliderMaximumField = typeof(TextMenuExt.IntSlider).GetField("max", + BindingFlags.Instance | BindingFlags.NonPublic); private readonly List tabs = []; private readonly List sourceTabs = []; @@ -88,9 +96,7 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage, IMaterialAcr private int keyboardSliderDirection; private float keyboardSliderHold; private float keyboardSliderAccumulator; - private float settingsSaveDelay = -1f; - private bool settingsSaveRunning; - private bool settingsDirty; + private float sliderAudioCooldown; private int dropdownHighlight; private int dropdownFirstVisible; private string tabSearchText = ""; @@ -168,7 +174,7 @@ public override IEnumerator Leave(Oui next) { if (menu is not null) menu.Focused = false; SaveTabState(); for (float timer = 0f; timer < 0.16f; timer += Engine.RawDeltaTime) yield return null; - yield return SaveSettingsAfterPending(); + yield return Everest.SaveSettings(); DetachMenu(); Visible = false; } @@ -206,10 +212,11 @@ public override void Update() { compositePopupScroll.Update(MaxCompositePopupScroll(layout)); ease = Calc.Approach(ease, display ? 1f : 0f, Engine.RawDeltaTime * 7.5f); inputDelay -= Engine.RawDeltaTime; - UpdateSettingsSave(); + sliderAudioCooldown = Math.Max(0f, sliderAudioCooldown - Engine.RawDeltaTime); UpdateInteractions(layout); if (!display) { + CancelSliderDrag(); if (pauseLevel is not null && ease <= 0.001f && !saveStarted) { saveStarted = true; Add(new Coroutine(SaveAndFinishPause())); @@ -217,15 +224,20 @@ public override void Update() { return; } - if (menu is null) return; + if (menu is null) { + CancelSliderDrag(); + return; + } if (configOverlayOpen) { // The key/button config screen owns input while it is open. + CancelSliderDrag(); menu.Focused = false; return; } bool acceptsInput = pauseLevel is not null || Selected && Focused; if (acceptsInput && inputDelay <= 0f && dropdownItem is null && compositePopupItem is null && UpdateSearchInput(layout)) { + CancelSliderDrag(); menu.Focused = false; menu.Update(); CaptureNaturalVisibility(); @@ -234,12 +246,14 @@ public override void Update() { } menu.Focused = searchTarget == SearchTarget.None; if (!menu.Focused) { + CancelSliderDrag(); menu.Update(); CaptureNaturalVisibility(); ApplyTabVisibility(invokeSelectionCallbacks: false); return; } if (!acceptsInput || inputDelay > 0f) { + CancelSliderDrag(); menu.Focused = false; menu.Update(); menu.Focused = true; @@ -249,10 +263,12 @@ public override void Update() { } if (dropdownItem is not null) { + CancelSliderDrag(); UpdateDropdown(layout); return; } if (compositePopupItem is not null) { + CancelSliderDrag(); menu.Focused = false; UpdateCompositePopup(layout); return; @@ -313,11 +329,12 @@ public override void Update() { } } - if (HandleRangeKeyboard()) return; - + bool rangeHandled = HandleRangeKeyboard(); UpdateMouse(layout); int previousSelection = menu.Selection; + if (rangeHandled) menu.Focused = false; menu.Update(); + if (rangeHandled) menu.Focused = true; if (menu.Current is { } expanded && IsCompositeMenu(expanded) && IsExpandedComposite(expanded)) { AdoptCompositePopup(expanded); return; @@ -765,10 +782,9 @@ private static bool IsWarningColor(Color color) => color == Color.OrangeRed || color == Color.Goldenrod || color == Color.Red || color == Color.Yellow; /// - /// A single tap always moves one minimum step. Holding starts a smooth ramp after - /// 0.45s: the rate grows from 0 to about range/12 steps per second (8..160), so the - /// same key is fine for nudging and fast for long ranges. A/D work as well as the - /// arrow keys. Mouse/touch dragging keeps its pixel-linear mapping. + /// A single tap always moves one minimum step. + /// Holding starts a smooth ramp. + /// Mouse / touch dragging keeps its pixel-linear mapping. /// private bool HandleRangeKeyboard() { if (menu?.Current is not { } item) { @@ -792,36 +808,52 @@ private bool HandleRangeKeyboard() { return false; } + bool atLimit = isRange + ? direction < 0 ? option.Index <= 0 : option.Index >= option.Options.Count - 1 + : direction < 0 ? slider.Value <= slider.Minimum : slider.Value >= slider.Maximum; + if (atLimit) { + ResetKeyboardSlider(); + return false; + } + if (direction != keyboardSliderDirection) { // The first tap is always exactly one minimum step; repeated events are ignored // below because the hold timer owns the acceleration. keyboardSliderDirection = direction; keyboardSliderHold = 0f; keyboardSliderAccumulator = 0f; - if (isRange) SetOptionIndex(item, option.Index + direction); - else SetIntSliderValue(item, slider, slider.Value + direction); - MarkSettingsDirty(); + bool tapped = isRange + ? SetOptionIndex(item, option.Index + direction) + : SetIntSliderValue(item, slider, slider.Value + direction); + if (tapped) sliderAudioCooldown = SliderAudioInterval; return true; } + float previousHold = keyboardSliderHold; keyboardSliderHold += Engine.RawDeltaTime; - const float delay = 0.45f; - const float ramp = 1.0f; + const float delay = 0.2f; if (keyboardSliderHold <= delay) return true; int span = isRange ? Math.Max(1, option.Options.Count - 1) : Math.Max(1, slider.Maximum - slider.Minimum); - float maxRate = Math.Clamp(span / 12f, 8f, 160f); + float spanScale = Math.Clamp(MathF.Log10(span) / 4f, 0f, 1f); + float ramp = 0.45f + 1.4f * spanScale; + float maxRate = Math.Clamp(span / 2.5f, 15f, 800f); + float startFraction = Math.Clamp((1f - spanScale) * 0.35f, 0f, 0.35f); float progress = Math.Clamp((keyboardSliderHold - delay) / ramp, 0f, 1f); - progress = progress * progress * (3f - 2f * progress); - keyboardSliderAccumulator += maxRate * progress * Engine.RawDeltaTime; + progress *= progress; + float rate = maxRate * (startFraction + (1f - startFraction) * progress); + keyboardSliderAccumulator += rate * Engine.RawDeltaTime; + if (previousHold <= delay) keyboardSliderAccumulator += 1f; int steps = (int)keyboardSliderAccumulator; if (steps <= 0) return true; keyboardSliderAccumulator -= steps; - if (isRange) SetOptionIndex(item, option.Index + direction * steps); - else SetIntSliderValue(item, slider, slider.Value + direction * steps); - MarkSettingsDirty(); + bool playSound = sliderAudioCooldown <= 0f; + bool changed = isRange + ? SetOptionIndex(item, option.Index + direction * steps, playSound) + : SetIntSliderValue(item, slider, slider.Value + direction * steps, playSound); + if (changed && playSound) sliderAudioCooldown = SliderAudioInterval; return true; } @@ -831,49 +863,22 @@ private void ResetKeyboardSlider() { keyboardSliderAccumulator = 0f; } - private void MarkSettingsDirty() { - settingsDirty = true; - settingsSaveDelay = 0.5f; - } - - private void UpdateSettingsSave() { - if (settingsSaveDelay < 0f || settingsSaveRunning || !display) return; - // Do not write while a slider is still being dragged or a direction key is held. - if (draggedSlider is not null || keyboardSliderDirection != 0) return; - settingsSaveDelay -= Engine.RawDeltaTime; - if (settingsSaveDelay > 0f || !settingsDirty) return; - settingsDirty = false; - settingsSaveDelay = -1f; - Add(new Coroutine(SaveSettingsDebounced())); - } - - private IEnumerator SaveSettingsDebounced() { - settingsSaveRunning = true; - try { - yield return Everest.SaveSettings(); - } finally { - settingsSaveRunning = false; - } - } - - private IEnumerator SaveSettingsAfterPending() { - settingsSaveDelay = -1f; - while (settingsSaveRunning) yield return null; - yield return Everest.SaveSettings(); - } - - private static void SetIntSliderValue(TextMenu.Item item, IntSliderSnapshot slider, int value) { - if (item is not TextMenuExt.IntSlider intSlider) return; + private static bool SetIntSliderValue(TextMenu.Item item, IntSliderSnapshot slider, int value, + bool playSound = true) { + if (item is not TextMenuExt.IntSlider intSlider) return false; value = Math.Clamp(value, slider.Minimum, slider.Maximum); - if (value == intSlider.Index) return; + if (value == intSlider.Index) return false; int previous = intSlider.Index; intSlider.PreviousIndex = previous; intSlider.Index = value; intSlider.OnValueChange?.Invoke(value); intSlider.ValueWiggler?.Start(); - Audio.Play(value > previous - ? "event:/ui/main/button_toggle_on" - : "event:/ui/main/button_toggle_off"); + if (playSound) { + Audio.Play(value > previous + ? "event:/ui/main/button_toggle_on" + : "event:/ui/main/button_toggle_off"); + } + return true; } private void UpdateMouse(ModOptionsLayout layout) { @@ -932,20 +937,17 @@ private void UpdateMouse(ModOptionsLayout layout) { SetOptionIndex(item, option.Index == 0 ? 1 : 0); } else if (option.Kind == OptionKind.Dropdown) { OpenDropdown(item, option); - } else if (SliderControlArea(rect).Contains(mouse)) { + } else if (OptionControlRect(rect).Contains(mouse)) { draggedSlider = item; - int before = option.Index; - SetOptionFromMouse(item, option, SliderControlRect(rect), mouse.X); - if (TryGetOption(item, out OptionSnapshot changed) && changed.Index != before) - MarkSettingsDirty(); + // A discrete press always ticks; only the drag that follows is throttled. + if (SetOptionFromMouse(item, option, SliderControlRect(rect), mouse.X)) + sliderAudioCooldown = SliderAudioInterval; } } else if (TryGetIntSlider(item, out IntSliderSnapshot slider)) { - if (SliderControlArea(rect).Contains(mouse)) { + if (OptionControlRect(rect).Contains(mouse)) { draggedSlider = item; - int before = slider.Value; - SetIntSliderFromMouse(item, slider, SliderControlRect(rect), mouse.X); - if (TryGetIntSlider(item, out IntSliderSnapshot changed) && changed.Value != before) - MarkSettingsDirty(); + if (SetIntSliderFromMouse(item, slider, SliderControlRect(rect), mouse.X)) + sliderAudioCooldown = SliderAudioInterval; } } else if (IsCompositeMenu(item)) { OpenCompositePopup(item); @@ -961,27 +963,35 @@ private void UpdateSliderDrag(ModOptionsLayout layout, Vector2 mouse) { return; } if (draggedSlider is not { } dragged) return; - if (!TryGetRowPlacement(layout, dragged, out RowPlacement placement)) return; + if (!TryGetRowPlacement(layout, dragged, out RowPlacement placement)) { + draggedSlider = null; + return; + } if (TryGetOption(dragged, out OptionSnapshot option) && option.Kind == OptionKind.Range) { - int before = option.Index; - SetOptionFromMouse(dragged, option, SliderControlRect(placement.Rect), mouse.X); - if (TryGetOption(dragged, out OptionSnapshot changed) && changed.Index != before) - MarkSettingsDirty(); + bool playSound = sliderAudioCooldown <= 0f; + if (SetOptionFromMouse(dragged, option, SliderControlRect(placement.Rect), mouse.X, + playSound) && playSound) sliderAudioCooldown = SliderAudioInterval; } else if (TryGetIntSlider(dragged, out IntSliderSnapshot slider)) { - int before = slider.Value; - SetIntSliderFromMouse(dragged, slider, SliderControlRect(placement.Rect), mouse.X); - if (TryGetIntSlider(dragged, out IntSliderSnapshot changed) && changed.Value != before) - MarkSettingsDirty(); + bool playSound = sliderAudioCooldown <= 0f; + if (SetIntSliderFromMouse(dragged, slider, SliderControlRect(placement.Rect), mouse.X, + playSound) && playSound) sliderAudioCooldown = SliderAudioInterval; } } + private void CancelSliderDrag() => draggedSlider = null; + private bool TryGetRowPlacement(ModOptionsLayout layout, TextMenu.Item item, out RowPlacement placement) { - foreach (RowPlacement candidate in RowPlacements(layout)) { - if (candidate.Item != item) continue; - placement = candidate; - return true; + float y = layout.Rows.Y - rowScroll.Offset; + foreach (RowEntry row in VisibleRows()) { + float height = RowHeight(row, layout.Rows.Width); + if (row.Item == item) { + placement = new RowPlacement(row.Item, row.Description, + new MaterialRect(layout.Rows.X, y, layout.Rows.Width, height)); + return true; + } + y += height + RowGap; } placement = default; return false; @@ -995,7 +1005,7 @@ private static void ActivateItem(TextMenu.Item item) { private void UpdateDropdown(ModOptionsLayout layout) { TextMenu.Item? item = dropdownItem; if (item is null || !item.Visible || !TryGetOption(item, out OptionSnapshot option) - || !CanUseDropdown(option)) { + || option.Kind != OptionKind.Dropdown) { CloseDropdown(playSound: false); return; } @@ -1456,8 +1466,7 @@ private bool RenderStandardItem(TextMenu.Item item, MaterialRect rect, MaterialP if (TryGetOption(item, out OptionSnapshot option)) { MaterialRect control = option.Kind switch { OptionKind.Switch => SwitchControlArea(rect), - OptionKind.Dropdown => DropdownControlRect(rect), - _ => SliderControlArea(rect) + _ => OptionControlRect(rect) }; RenderItemLabel(option.Label, rect, control.X - rect.X - 62f, labelColor, alpha, hasDescription); @@ -1471,7 +1480,7 @@ private bool RenderStandardItem(TextMenu.Item item, MaterialRect rect, MaterialP return true; } if (TryGetIntSlider(item, out IntSliderSnapshot slider)) { - MaterialRect control = SliderControlArea(rect); + MaterialRect control = OptionControlRect(rect); RenderItemLabel(slider.Label, rect, control.X - rect.X - 62f, labelColor, alpha, hasDescription); RenderIntSlider(slider, rect, palette, alpha, enabled); @@ -1539,7 +1548,7 @@ private static void RenderSwitch(OptionSnapshot option, MaterialRect rect, Mater private void RenderOptionControl(TextMenu.Item item, OptionSnapshot option, MaterialRect rect, MaterialPalette palette, float alpha, bool enabled) { - MaterialRect control = DropdownControlRect(rect); + MaterialRect control = OptionControlRect(rect); bool open = dropdownItem == item; MaterialUi.RoundedRect(control.X, control.Y, control.Width, control.Height, 16f, palette.Surface * (0.76f * alpha)); @@ -1831,6 +1840,31 @@ private int TabIndexAt(Vector2 point, ModOptionsLayout layout) { } private static bool TryGetOption(TextMenu.Item item, out OptionSnapshot snapshot) { + OptionMetadata metadata = optionMetadata.GetValue(item, BuildOptionMetadata); + if (!metadata.Valid + || metadata.ValuesField?.GetValue(item) is not IEnumerable values) { + snapshot = default; + return false; + } + Delegate? change = metadata.ChangeField?.GetValue(item) as Delegate; + if (!ReferenceEquals(change, metadata.Change) || !ValuesMatch(metadata, values)) + RefreshOptionMetadata(metadata, item, values, change); + snapshot = new OptionSnapshot( + metadata.LabelField!.GetValue(item)?.ToString() ?? "", + metadata.Kind, + Math.Clamp((int)(metadata.IndexField!.GetValue(item) ?? 0), 0, + Math.Max(0, metadata.Labels.Count - 1)), + metadata.Labels, + metadata.Entries, + metadata.IndexField!, + metadata.PreviousIndexField, + metadata.ChangeField + ); + return true; + } + + private static OptionMetadata BuildOptionMetadata(TextMenu.Item item) { + OptionMetadata metadata = new(); FieldInfo? labelField = null; FieldInfo? indexField = null; FieldInfo? previousIndexField = null; @@ -1847,31 +1881,47 @@ private static bool TryGetOption(TextMenu.Item item, out OptionSnapshot snapshot } if (labelField is null || indexField is null || valuesField is null || indexField.FieldType != typeof(int) - || valuesField.GetValue(item) is not IEnumerable values) { - snapshot = default!; - return false; - } - List labels = []; - List entries = []; + || valuesField.GetValue(item) is not IEnumerable values) return metadata; + metadata.LabelField = labelField; + metadata.IndexField = indexField; + metadata.PreviousIndexField = previousIndexField; + metadata.ValuesField = valuesField; + metadata.ChangeField = changeField; + metadata.Valid = true; + RefreshOptionMetadata(metadata, item, values, changeField?.GetValue(item) as Delegate); + return metadata; + } + + private static void RefreshOptionMetadata(OptionMetadata metadata, TextMenu.Item item, + IEnumerable values, Delegate? change) { + metadata.Labels.Clear(); + metadata.Entries.Clear(); foreach (object? entry in values) { if (entry is null) continue; object? label = entry.GetType().GetProperty("Item1")?.GetValue(entry) ?? entry.GetType().GetField("Item1")?.GetValue(entry); - labels.Add(label?.ToString() ?? ""); - entries.Add(entry); + metadata.Labels.Add(label?.ToString() ?? ""); + metadata.Entries.Add(entry); } - Delegate? change = changeField?.GetValue(item) as Delegate; - snapshot = new OptionSnapshot( - labelField.GetValue(item)?.ToString() ?? "", - ClassifyOption(item, labels, entries, change), - Math.Clamp((int)(indexField.GetValue(item) ?? 0), 0, Math.Max(0, labels.Count - 1)), - labels, - entries, - indexField, - previousIndexField, - changeField - ); - return true; + metadata.Change = change; + metadata.Kind = ClassifyOption(item, metadata.Labels, metadata.Entries, change); + } + + private static bool ValuesMatch(OptionMetadata metadata, IEnumerable values) { + if (values is IList list && list.Count == metadata.Entries.Count) { + for (int index = 0; index < list.Count; index++) { + if (!ReferenceEquals(list[index], metadata.Entries[index])) return false; + } + return true; + } + int position = 0; + foreach (object? entry in values) { + if (entry is null) continue; + if (position >= metadata.Entries.Count + || !ReferenceEquals(metadata.Entries[position], entry)) return false; + position++; + } + return position == metadata.Entries.Count; } /// @@ -1883,19 +1933,14 @@ private static OptionKind ClassifyOption(TextMenu.Item item, List labels List entries, Delegate? change) { if (item is TextMenu.Option) return OptionKind.Switch; bool isSlider = item is TextMenu.Slider; - bool isIntOption = item is TextMenu.Option; - if (!isSlider && !isIntOption) return OptionKind.Dropdown; + if (!isSlider && item is not TextMenu.Option) return OptionKind.Dropdown; bool? enumBacked = ChangeTargetsEnum(change); if (enumBacked is true) return OptionKind.Dropdown; - bool consecutive = HasConsecutiveInts(entries); - bool numericLabels = labels.Count > 0 && labels.All(LooksNumeric); - if (isSlider) { - if (enumBacked is false) return OptionKind.Range; - return consecutive && numericLabels ? OptionKind.Range : OptionKind.Dropdown; - } - // A hand-built Option is only a range when its values really are a sequence. - return consecutive && numericLabels ? OptionKind.Range : OptionKind.Dropdown; + if (isSlider && enumBacked is false) return OptionKind.Range; + return HasConsecutiveInts(entries) && labels.Count > 0 && labels.All(LooksNumeric) + ? OptionKind.Range + : OptionKind.Dropdown; } private static bool? ChangeTargetsEnum(Delegate? change) { @@ -1943,13 +1988,28 @@ private static bool HasConsecutiveInts(List entries) { private static bool LooksNumeric(string value) { string text = value.Trim(); + if (double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, out double parsed) && double.IsFinite(parsed)) + return true; int index = text.StartsWith('-') || text.StartsWith('+') ? 1 : 0; - int digits = 0; + bool digits = false; while (index < text.Length && char.IsDigit(text[index])) { index++; - digits++; + digits = true; } - return digits > 0; + if (!digits) return false; + if (index < text.Length && text[index] == '.') { + index++; + int fractionStart = index; + while (index < text.Length && char.IsDigit(text[index])) index++; + if (index == fractionStart) return false; + } + // Try to parse a short unit suffix ["1x", "80%", "10 fps"], + // but reject notes ["1 (recommended)"] and compound labels ["1280×720"]. + // This is based on heuristics and not guaranteed to be accurate. + string suffix = text[index..].Trim(); + return suffix.Length == 0 || suffix is "%" or "×" + || suffix.Length <= 6 && suffix.All(char.IsLetter); } private static object? EntryValue(object entry) => @@ -1961,11 +2021,8 @@ private static bool TryGetIntSlider(TextMenu.Item item, out IntSliderSnapshot sn snapshot = default; return false; } - Type type = typeof(TextMenuExt.IntSlider); - FieldInfo? minimumField = type.GetField("min", BindingFlags.Instance | BindingFlags.NonPublic); - FieldInfo? maximumField = type.GetField("max", BindingFlags.Instance | BindingFlags.NonPublic); - int minimum = (int)(minimumField?.GetValue(slider) ?? slider.Index); - int maximum = (int)(maximumField?.GetValue(slider) ?? slider.Index); + int minimum = (int)(intSliderMinimumField?.GetValue(slider) ?? slider.Index); + int maximum = (int)(intSliderMaximumField?.GetValue(slider) ?? slider.Index); snapshot = new IntSliderSnapshot(slider.Label, slider.Index, minimum, maximum); return true; } @@ -2032,53 +2089,43 @@ private static bool TryGetCompositeContents(TextMenu.Item item, out float titleH return false; } - private static bool CanUseDropdown(OptionSnapshot option) => - option.Kind == OptionKind.Dropdown; - - private static void SetOptionIndex(TextMenu.Item item, int targetIndex) { - if (!TryGetOption(item, out OptionSnapshot option) || option.Options.Count == 0) return; + private static bool SetOptionIndex(TextMenu.Item item, int targetIndex, bool playSound = true) { + if (!TryGetOption(item, out OptionSnapshot option) || option.Options.Count == 0) return false; targetIndex = Math.Clamp(targetIndex, 0, option.Options.Count - 1); - if (targetIndex == option.Index) return; + if (targetIndex == option.Index) return false; option.PreviousIndexField?.SetValue(item, option.Index); option.IndexField.SetValue(item, targetIndex); - object entry = option.Entries[targetIndex]; - object? value = entry.GetType().GetProperty("Item2")?.GetValue(entry) - ?? entry.GetType().GetField("Item2")?.GetValue(entry); + object? value = EntryValue(option.Entries[targetIndex]); if (option.ChangeField?.GetValue(item) is Delegate changed) changed.DynamicInvoke(value); item.ValueWiggler?.Start(); - Audio.Play(targetIndex > option.Index - ? "event:/ui/main/button_toggle_on" - : "event:/ui/main/button_toggle_off"); + if (playSound) { + Audio.Play(targetIndex > option.Index + ? "event:/ui/main/button_toggle_on" + : "event:/ui/main/button_toggle_off"); + } + return true; } - private static void SetOptionFromMouse(TextMenu.Item item, OptionSnapshot option, - MaterialRect track, float mouseX) { - if (option.Options.Count == 0) return; + private static bool SetOptionFromMouse(TextMenu.Item item, OptionSnapshot option, + MaterialRect track, float mouseX, bool playSound = true) { + if (option.Options.Count == 0) return false; float amount = Math.Clamp((mouseX - track.X) / Math.Max(1f, track.Width), 0f, 1f); - SetOptionIndex(item, (int)MathF.Round(amount * (option.Options.Count - 1))); + return SetOptionIndex(item, (int)MathF.Round(amount * (option.Options.Count - 1)), + playSound); } - private static void SetIntSliderFromMouse(TextMenu.Item item, IntSliderSnapshot slider, - MaterialRect track, float mouseX) { - if (item is not TextMenuExt.IntSlider intSlider) return; + private static bool SetIntSliderFromMouse(TextMenu.Item item, IntSliderSnapshot slider, + MaterialRect track, float mouseX, bool playSound = true) { float amount = Math.Clamp((mouseX - track.X) / Math.Max(1f, track.Width), 0f, 1f); int value = (int)MathF.Round(MathHelper.Lerp(slider.Minimum, slider.Maximum, amount)); - value = Math.Clamp(value, slider.Minimum, slider.Maximum); - if (value == intSlider.Index) return; - intSlider.PreviousIndex = intSlider.Index; - intSlider.Index = value; - intSlider.OnValueChange?.Invoke(value); - intSlider.ValueWiggler?.Start(); - Audio.Play(value > slider.Value - ? "event:/ui/main/button_toggle_on" - : "event:/ui/main/button_toggle_off"); + return SetIntSliderValue(item, slider, value, playSound); } private MaterialRect DropdownRect(ModOptionsLayout layout, TextMenu.Item item, int optionCount) { RowPlacement? placement = RowPlacements(layout).FirstOrDefault(row => row.Item == item); MaterialRect control = placement is { Item: not null } row - ? DropdownControlRect(row.Rect) - : DropdownControlRect(new MaterialRect(layout.Rows.X, layout.Rows.Y, layout.Rows.Width, 78f)); + ? OptionControlRect(row.Rect) + : OptionControlRect(new MaterialRect(layout.Rows.X, layout.Rows.Y, layout.Rows.Width, 78f)); float height = DropdownVisibleCount(optionCount) * DropdownItemHeight + 12f; float y = control.Bottom + 6f; if (y + height > layout.Content.Bottom - 12f) y = control.Y - height - 6f; @@ -2118,10 +2165,6 @@ private static int DropdownVisibleCount(int optionCount) => 48f ); - private static MaterialRect DropdownControlRect(MaterialRect rect) => OptionControlRect(rect); - - private static MaterialRect SliderControlArea(MaterialRect rect) => OptionControlRect(rect); - private static MaterialRect SliderControlRect(MaterialRect rect) { MaterialRect control = OptionControlRect(rect); return new MaterialRect(control.X + 10f, control.Center.Y + 8f, control.Width - 20f, 8f); @@ -2183,7 +2226,7 @@ private void BeginClose(CloseDestination destination) { } private IEnumerator SaveAndFinishPause() { - yield return SaveSettingsAfterPending(); + yield return Everest.SaveSettings(); Level? level = pauseLevel; if (level is null) yield break; level.AllowHudHide = oldAllowHudHide; @@ -2345,7 +2388,7 @@ private enum OptionKind { Range } - private sealed record OptionSnapshot( + private readonly record struct OptionSnapshot( string Label, OptionKind Kind, int Index, @@ -2356,6 +2399,19 @@ private sealed record OptionSnapshot( FieldInfo? ChangeField ); + private sealed class OptionMetadata { + public bool Valid; + public FieldInfo? LabelField; + public FieldInfo? IndexField; + public FieldInfo? PreviousIndexField; + public FieldInfo? ValuesField; + public FieldInfo? ChangeField; + public Delegate? Change; + public OptionKind Kind; + public readonly List Labels = []; + public readonly List Entries = []; + } + private readonly record struct IntSliderSnapshot( string Label, int Value, From fbf599703dde82e0fa566c5d30e481701d11d103 Mon Sep 17 00:00:00 2001 From: Dilant Date: Sat, 24 Oct 2026 09:29:57 +0800 Subject: [PATCH 6/7] modoptions: add option tests and run them in the build --- Source/MaterialModOptions.cs | 109 ++---------------- Source/OptionRules.cs | 94 +++++++++++++++ .../ModOptions.Integration.csproj | 16 +++ Tests/ModOptions.Integration/Program.cs | 91 +++++++++++++++ Tests/ModOptions/ModOptions.csproj | 12 ++ Tests/ModOptions/Program.cs | 86 ++++++++++++++ scripts/build-qol-mod.mjs | 14 +++ 7 files changed, 321 insertions(+), 101 deletions(-) create mode 100644 Source/OptionRules.cs create mode 100644 Tests/ModOptions.Integration/ModOptions.Integration.csproj create mode 100644 Tests/ModOptions.Integration/Program.cs create mode 100644 Tests/ModOptions/ModOptions.csproj create mode 100644 Tests/ModOptions/Program.cs diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs index 86d09b9..a86c66d 100644 --- a/Source/MaterialModOptions.cs +++ b/Source/MaterialModOptions.cs @@ -1,5 +1,4 @@ using System.Collections; -using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; using Celeste.Mod.UI; @@ -1904,7 +1903,13 @@ private static void RefreshOptionMetadata(OptionMetadata metadata, TextMenu.Item metadata.Entries.Add(entry); } metadata.Change = change; - metadata.Kind = ClassifyOption(item, metadata.Labels, metadata.Entries, change); + metadata.Kind = OptionRules.Classify( + item is TextMenu.Option, + item is TextMenu.Slider, + item is TextMenu.Option, + OptionRules.ChangeTargetsEnum(change), + metadata.Labels, + metadata.Entries); } private static bool ValuesMatch(OptionMetadata metadata, IEnumerable values) { @@ -1924,98 +1929,6 @@ private static bool ValuesMatch(OptionMetadata metadata, IEnumerable values) { return position == metadata.Entries.Count; } - /// - /// TextMenu.Option only knows its value type via a generic argument, and Everest uses - /// Slider for both integer ranges and enums. Classify by the backing property when the - /// change delegate exposes one, and fall back to the value/label shape otherwise. - /// - private static OptionKind ClassifyOption(TextMenu.Item item, List labels, - List entries, Delegate? change) { - if (item is TextMenu.Option) return OptionKind.Switch; - bool isSlider = item is TextMenu.Slider; - if (!isSlider && item is not TextMenu.Option) return OptionKind.Dropdown; - - bool? enumBacked = ChangeTargetsEnum(change); - if (enumBacked is true) return OptionKind.Dropdown; - if (isSlider && enumBacked is false) return OptionKind.Range; - return HasConsecutiveInts(entries) && labels.Count > 0 && labels.All(LooksNumeric) - ? OptionKind.Range - : OptionKind.Dropdown; - } - - private static bool? ChangeTargetsEnum(Delegate? change) { - object? target = change?.Target; - if (target is null) return null; - bool sawEnum = false; - bool sawNumeric = false; - foreach (FieldInfo field in target.GetType().GetFields( - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { - object? value; - try { - value = field.GetValue(target); - } catch { - continue; - } - if (value is PropertyInfo property) { - if (property.PropertyType.IsEnum) sawEnum = true; - else if (IsNumericType(property.PropertyType)) sawNumeric = true; - } else if (value is Array array && array.GetType().GetElementType()?.IsEnum == true) { - sawEnum = true; - } else if (field.FieldType.IsEnum) { - sawEnum = true; - } - } - // A compiler display class can capture several properties from the enclosing scope; - // only trust the answer when the captured property types are unambiguous. - if (sawEnum && !sawNumeric) return true; - if (sawNumeric && !sawEnum) return false; - return null; - } - - private static bool IsNumericType(Type type) => - type == typeof(int) || type == typeof(float) || type == typeof(double) - || type == typeof(long) || type == typeof(short) || type == typeof(byte); - - private static bool HasConsecutiveInts(List entries) { - int? previous = null; - foreach (object entry in entries) { - if (EntryValue(entry) is not int value) return false; - if (previous.HasValue && value != previous.Value + 1) return false; - previous = value; - } - return previous.HasValue; - } - - private static bool LooksNumeric(string value) { - string text = value.Trim(); - if (double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, - CultureInfo.InvariantCulture, out double parsed) && double.IsFinite(parsed)) - return true; - int index = text.StartsWith('-') || text.StartsWith('+') ? 1 : 0; - bool digits = false; - while (index < text.Length && char.IsDigit(text[index])) { - index++; - digits = true; - } - if (!digits) return false; - if (index < text.Length && text[index] == '.') { - index++; - int fractionStart = index; - while (index < text.Length && char.IsDigit(text[index])) index++; - if (index == fractionStart) return false; - } - // Try to parse a short unit suffix ["1x", "80%", "10 fps"], - // but reject notes ["1 (recommended)"] and compound labels ["1280×720"]. - // This is based on heuristics and not guaranteed to be accurate. - string suffix = text[index..].Trim(); - return suffix.Length == 0 || suffix is "%" or "×" - || suffix.Length <= 6 && suffix.All(char.IsLetter); - } - - private static object? EntryValue(object entry) => - entry.GetType().GetProperty("Item2")?.GetValue(entry) - ?? entry.GetType().GetField("Item2")?.GetValue(entry); - private static bool TryGetIntSlider(TextMenu.Item item, out IntSliderSnapshot snapshot) { if (item is not TextMenuExt.IntSlider slider) { snapshot = default; @@ -2095,7 +2008,7 @@ private static bool SetOptionIndex(TextMenu.Item item, int targetIndex, bool pla if (targetIndex == option.Index) return false; option.PreviousIndexField?.SetValue(item, option.Index); option.IndexField.SetValue(item, targetIndex); - object? value = EntryValue(option.Entries[targetIndex]); + object? value = OptionRules.EntryValue(option.Entries[targetIndex]); if (option.ChangeField?.GetValue(item) is Delegate changed) changed.DynamicInvoke(value); item.ValueWiggler?.Start(); if (playSound) { @@ -2382,12 +2295,6 @@ private sealed class ModTab(string id, string title, string version, List labels, IReadOnlyList entries) { + if (isBool) return OptionKind.Switch; + if (!isSlider && !isIntOption) return OptionKind.Dropdown; + if (enumBacked is true) return OptionKind.Dropdown; + if (isSlider && enumBacked is false) return OptionKind.Range; + return HasConsecutiveInts(entries) && labels.Count > 0 && labels.All(LooksNumeric) + ? OptionKind.Range + : OptionKind.Dropdown; + } + + internal static bool? ChangeTargetsEnum(Delegate? change) { + object? target = change?.Target; + if (target is null) return null; + bool sawEnum = false; + bool sawNumeric = false; + foreach (FieldInfo field in target.GetType().GetFields( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { + object? value; + try { + value = field.GetValue(target); + } catch { + continue; + } + if (value is PropertyInfo property) { + if (property.PropertyType.IsEnum) sawEnum = true; + else if (IsNumericType(property.PropertyType)) sawNumeric = true; + } else if (value is Array array && array.GetType().GetElementType()?.IsEnum == true) { + sawEnum = true; + } else if (field.FieldType.IsEnum) { + sawEnum = true; + } + } + if (sawEnum && !sawNumeric) return true; + if (sawNumeric && !sawEnum) return false; + return null; + } + + internal static bool IsNumericType(Type type) => + type == typeof(int) || type == typeof(float) || type == typeof(double) + || type == typeof(long) || type == typeof(short) || type == typeof(byte); + + internal static bool HasConsecutiveInts(IReadOnlyList entries) { + int? previous = null; + foreach (object entry in entries) { + if (EntryValue(entry) is not int value) return false; + if (previous.HasValue && value != previous.Value + 1) return false; + previous = value; + } + return previous.HasValue; + } + + internal static bool LooksNumeric(string value) { + string text = value.Trim(); + if (double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, out double parsed) && double.IsFinite(parsed)) + return true; + int index = text.StartsWith('-') || text.StartsWith('+') ? 1 : 0; + bool digits = false; + while (index < text.Length && char.IsDigit(text[index])) { + index++; + digits = true; + } + if (!digits) return false; + if (index < text.Length && text[index] == '.') { + index++; + int fractionStart = index; + while (index < text.Length && char.IsDigit(text[index])) index++; + if (index == fractionStart) return false; + } + // Try to parse a short unit suffix ["1x", "80%", "10 fps"], + // but reject notes ["1 (recommended)"] and compound labels ["1280×720"]. + // This is based on heuristics and not guaranteed to be accurate. + string suffix = text[index..].Trim(); + return suffix.Length == 0 || suffix is "%" or "×" + || suffix.Length <= 6 && suffix.All(char.IsLetter); + } + + internal static object? EntryValue(object entry) => + entry.GetType().GetProperty("Item2")?.GetValue(entry) + ?? entry.GetType().GetField("Item2")?.GetValue(entry); +} diff --git a/Tests/ModOptions.Integration/ModOptions.Integration.csproj b/Tests/ModOptions.Integration/ModOptions.Integration.csproj new file mode 100644 index 0000000..4c5e2b0 --- /dev/null +++ b/Tests/ModOptions.Integration/ModOptions.Integration.csproj @@ -0,0 +1,16 @@ + + + net8.0 + Exe + enable + enable + latest + $(CELESTE_ROOT) + + + + $(CelesteRoot)/Celeste.dll + $(CelesteRoot)/FNA.dll + + + diff --git a/Tests/ModOptions.Integration/Program.cs b/Tests/ModOptions.Integration/Program.cs new file mode 100644 index 0000000..8fac9e1 --- /dev/null +++ b/Tests/ModOptions.Integration/Program.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using Celeste; +using Celeste.Mod.MicroblocksQolUtils; + +int assertions = 0; +void Check(bool condition, string message) { + assertions++; + if (!condition) throw new Exception(message); +} + +const BindingFlags PrivateStatic = BindingFlags.Static | BindingFlags.NonPublic; +Type optionsType = typeof(MaterialModOptions); +var tryGetOption = optionsType.GetMethod("TryGetOption", PrivateStatic)!; + +(string Kind, string[] Labels) Snapshot(TextMenu.Item item) { + object?[] arguments = [item, null]; + Check((bool)tryGetOption.Invoke(null, arguments)!, $"no option snapshot: {item.GetType().Name}"); + object snapshot = arguments[1]!; + Type snapshotType = snapshot.GetType(); + string kind = snapshotType.GetProperty("Kind")!.GetValue(snapshot)!.ToString()!; + var options = (List)snapshotType.GetProperty("Options")!.GetValue(snapshot)!; + return (kind, options.ToArray()); +} + +Check(Snapshot(new TextMenu.Option("toggle").Add("Off", false, true).Add("On", true)).Kind + == "Switch", "bool option should be a switch"); +Check(Snapshot(new TextMenu.Option("mode").Add("Off", "off", true).Add("On", "on")).Kind + == "Dropdown", "string option should be a dropdown"); +Check(Snapshot(new TextMenu.Option("int-range") + .Add("0", 0, true).Add("1", 1).Add("2", 2)).Kind + == "Range", "consecutive numeric int option should be a range"); +Check(Snapshot(new TextMenu.Option("int-sparse") + .Add("0", 0, true).Add("5", 5).Add("10", 10)).Kind + == "Dropdown", "sparse int option should be a dropdown"); +Check(Snapshot(new TextMenu.Option("int-named") + .Add("Low", 0, true).Add("Mid", 1).Add("High", 2)).Kind + == "Dropdown", "named int option should be a dropdown"); +Check(Snapshot(new TextMenu.Slider("slider", value => value.ToString(), 0, 2)).Kind + == "Range", "numeric slider should be a range"); +Check(Snapshot(new TextMenu.Slider("named-slider", + value => new[] { "Low", "Mid", "High" }[value], 0, 2)).Kind + == "Dropdown", "named slider should be a dropdown"); + +// The cached classification must follow a change handler that is attached +// after the item has already been shown (Everest exposes Change() on the item). +var enumProperty = typeof(TestModeHolder).GetProperty(nameof(TestModeHolder.Value))!; +var intProperty = typeof(TestIntHolder).GetProperty(nameof(TestIntHolder.Value))!; + +var lateEnum = new TextMenu.Slider("late-enum", value => value.ToString(), 0, 2); +Check(Snapshot(lateEnum).Kind == "Range", "numeric slider should start as a range"); +lateEnum.OnValueChange = new Action(new PropertyHolder { Property = enumProperty }.Apply); +Check(Snapshot(lateEnum).Kind == "Dropdown", "cached kind did not follow a late enum change handler"); + +var lateInt = new TextMenu.Slider("late-int", value => value.ToString(), 0, 2); +lateInt.OnValueChange = new Action(new PropertyHolder { Property = intProperty }.Apply); +Check(Snapshot(lateInt).Kind == "Range", "int-backed slider should stay a range"); + +// Values can also be mutated in place; labels and kind must follow on the next read. +var mutable = new TextMenu.Slider("mutable", value => value.ToString(), 0, 2); +Check(Snapshot(mutable).Kind == "Range", "mutable slider should start as a range"); +mutable.Values[0] = Tuple.Create("Low", 0); +Check(Snapshot(mutable).Labels[0] == "Low", "cached labels did not follow a replaced value"); +Check(Snapshot(mutable).Kind == "Dropdown", "cached kind did not follow a replaced value"); + +mutable.Values.Clear(); +mutable.Values.Add(Tuple.Create("Off", 0)); +mutable.Values.Add(Tuple.Create("On", 1)); +(string Kind, string[] Labels) refreshed = Snapshot(mutable); +Check(refreshed.Kind == "Dropdown", "cached kind did not follow a resized value list"); +Check(string.Join(',', refreshed.Labels) == "Off,On", "cached labels did not follow a resized value list"); + +Console.WriteLine($"ModOptions integration tests passed ({assertions} assertions)"); + +enum TestMode { + Off, + On, + Auto +} + +static class TestModeHolder { + public static TestMode Value { get; set; } +} + +static class TestIntHolder { + public static int Value { get; set; } +} + +sealed class PropertyHolder { + public PropertyInfo? Property; + public void Apply(int value) { } +} diff --git a/Tests/ModOptions/ModOptions.csproj b/Tests/ModOptions/ModOptions.csproj new file mode 100644 index 0000000..0ae7fef --- /dev/null +++ b/Tests/ModOptions/ModOptions.csproj @@ -0,0 +1,12 @@ + + + net8.0 + Exe + enable + enable + latest + + + + + diff --git a/Tests/ModOptions/Program.cs b/Tests/ModOptions/Program.cs new file mode 100644 index 0000000..0d05a56 --- /dev/null +++ b/Tests/ModOptions/Program.cs @@ -0,0 +1,86 @@ +using System.Reflection; +using Celeste.Mod.MicroblocksQolUtils; + +int assertions = 0; +void Check(bool condition, string message) { + assertions++; + if (!condition) throw new Exception(message); +} + +List Entries(params (string Label, int Value)[] pairs) => + pairs.Select(pair => (object)Tuple.Create(pair.Label, pair.Value)).ToList(); + +foreach (string value in new[] { + "0", "42", "-5", "+7", "1.5", "1,000", "1e3", "1x", "2×", "80%", "10 fps", "1.5 ms" + }) { + Check(OptionRules.LooksNumeric(value), $"expected a numeric label: \"{value}\""); +} +foreach (string value in new[] { + "", " ", "-", "+", "1.2.3", "1 (recommended)", "1280×720", "low", "NaN", "Infinity" + }) { + Check(!OptionRules.LooksNumeric(value), $"expected a non-numeric label: \"{value}\""); +} + +Check(OptionRules.EntryValue(Tuple.Create("label", 7)) is 7, "EntryValue should read Item2"); + +Check(OptionRules.Classify(true, false, false, null, [], []) == OptionKind.Switch, + "bool option should be a switch"); +Check(OptionRules.Classify(false, false, false, null, ["Off", "On"], []) == OptionKind.Dropdown, + "non-int option should be a dropdown"); +Check(OptionRules.Classify(false, false, true, null, ["0", "1", "2"], + Entries(("0", 0), ("1", 1), ("2", 2))) == OptionKind.Range, + "consecutive numeric int option should be a range"); +Check(OptionRules.Classify(false, false, true, null, ["0", "5", "10"], + Entries(("0", 0), ("5", 5), ("10", 10))) == OptionKind.Dropdown, + "sparse int option should be a dropdown"); +Check(OptionRules.Classify(false, false, true, null, ["Low", "Mid", "High"], + Entries(("Low", 0), ("Mid", 1), ("High", 2))) == OptionKind.Dropdown, + "named int option should be a dropdown"); +Check(OptionRules.Classify(false, true, true, null, ["0", "1", "2"], + Entries(("0", 0), ("1", 1), ("2", 2))) == OptionKind.Range, + "numeric slider with an unknown backing should be a range"); +Check(OptionRules.Classify(false, true, true, false, ["Low", "Mid"], + Entries(("Low", 0), ("Mid", 1))) == OptionKind.Range, + "int-backed slider should be a range"); +Check(OptionRules.Classify(false, true, true, true, ["0", "1", "2"], + Entries(("0", 0), ("1", 1), ("2", 2))) == OptionKind.Dropdown, + "enum-backed slider should be a dropdown"); +Check(OptionRules.Classify(false, false, true, true, ["Low", "Mid"], + Entries(("Low", 0), ("Mid", 1))) == OptionKind.Dropdown, + "enum-backed int option should be a dropdown"); + +var enumProperty = typeof(TestModeHolder).GetProperty(nameof(TestModeHolder.Value))!; +var intProperty = typeof(TestIntHolder).GetProperty(nameof(TestIntHolder.Value))!; +var enumChange = new Action(new PropertyHolder { EnumProperty = enumProperty }.Apply); +var intChange = new Action(new PropertyHolder { IntProperty = intProperty }.Apply); +var mixedChange = new Action(new PropertyHolder { + EnumProperty = enumProperty, + IntProperty = intProperty +}.Apply); + +Check(OptionRules.ChangeTargetsEnum(null) is null, "a missing handler should be unknown"); +Check(OptionRules.ChangeTargetsEnum(enumChange) is true, "enum property should be detected"); +Check(OptionRules.ChangeTargetsEnum(intChange) is false, "int property should be detected"); +Check(OptionRules.ChangeTargetsEnum(mixedChange) is null, "mixed captures should be ambiguous"); + +Console.WriteLine($"OptionRules tests passed ({assertions} assertions)"); + +enum TestMode { + Off, + On, + Auto +} + +static class TestModeHolder { + public static TestMode Value { get; set; } +} + +static class TestIntHolder { + public static int Value { get; set; } +} + +sealed class PropertyHolder { + public PropertyInfo? EnumProperty; + public PropertyInfo? IntProperty; + public void Apply(int value) { } +} diff --git a/scripts/build-qol-mod.mjs b/scripts/build-qol-mod.mjs index 22653e3..ff746e4 100644 --- a/scripts/build-qol-mod.mjs +++ b/scripts/build-qol-mod.mjs @@ -14,6 +14,7 @@ const argumentValue = (name) => { }; const target = argumentValue("--target"); const disableFfmpeg = process.argv.includes("--no-ffmpeg"); +const disableTests = process.argv.includes("--no-tests"); if (disableFfmpeg && process.argv.includes("--install")) { throw new Error("Cannot install a --no-ffmpeg build: it cannot record videos. Remove --no-ffmpeg to build and install the complete mod."); } @@ -108,6 +109,19 @@ if (!existsSync(nativeOutput)) { throw new Error(`The native library was not produced at ${nativeOutput}`); } run("dotnet", ["build", resolve(root, "Source/MicroblocksQolUtils.csproj"), "-c", "Release"]); +if (!disableTests) { + // ModOptions.Integration exercises real TextMenu items, so it needs runnable game + // assemblies from CELESTE_ROOT; ModOptions only links Source/OptionRules.cs and + // always runs. + const testEnvironment = { ...process.env, CELESTE_ROOT: celesteRoot }; + for (const project of [ + "Tests/ModOptions/ModOptions.csproj", + "Tests/ModOptions.Integration/ModOptions.Integration.csproj", + ]) { + console.log(`Testing ${project}`); + run("dotnet", ["run", "--project", resolve(root, project), "-c", "Release"], testEnvironment); + } +} rmSync(output, { recursive: true, force: true }); mkdirSync(resolve(output, "Code"), { recursive: true }); cpSync(dll, resolve(output, "Code/MicroblocksQolUtils.dll")); From 7ab759add1d56ff4c30bac75ecc89e95477cc64d Mon Sep 17 00:00:00 2001 From: Dilant Date: Sat, 24 Oct 2026 12:24:16 +0800 Subject: [PATCH 7/7] build: make the game-backed option tests opt-in --- scripts/build-qol-mod.mjs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/scripts/build-qol-mod.mjs b/scripts/build-qol-mod.mjs index ff746e4..1d74f3f 100644 --- a/scripts/build-qol-mod.mjs +++ b/scripts/build-qol-mod.mjs @@ -15,6 +15,7 @@ const argumentValue = (name) => { const target = argumentValue("--target"); const disableFfmpeg = process.argv.includes("--no-ffmpeg"); const disableTests = process.argv.includes("--no-tests"); +const integrationTests = process.argv.includes("--integration-tests"); if (disableFfmpeg && process.argv.includes("--install")) { throw new Error("Cannot install a --no-ffmpeg build: it cannot record videos. Remove --no-ffmpeg to build and install the complete mod."); } @@ -110,14 +111,10 @@ if (!existsSync(nativeOutput)) { } run("dotnet", ["build", resolve(root, "Source/MicroblocksQolUtils.csproj"), "-c", "Release"]); if (!disableTests) { - // ModOptions.Integration exercises real TextMenu items, so it needs runnable game - // assemblies from CELESTE_ROOT; ModOptions only links Source/OptionRules.cs and - // always runs. const testEnvironment = { ...process.env, CELESTE_ROOT: celesteRoot }; - for (const project of [ - "Tests/ModOptions/ModOptions.csproj", - "Tests/ModOptions.Integration/ModOptions.Integration.csproj", - ]) { + const projects = ["Tests/ModOptions/ModOptions.csproj"]; + if (integrationTests) projects.push("Tests/ModOptions.Integration/ModOptions.Integration.csproj"); + for (const project of projects) { console.log(`Testing ${project}`); run("dotnet", ["run", "--project", resolve(root, project), "-c", "Release"], testEnvironment); }