diff --git a/Source/MaterialModOptions.cs b/Source/MaterialModOptions.cs index 6b635c9..a86c66d 100644 --- a/Source/MaterialModOptions.cs +++ b/Source/MaterialModOptions.cs @@ -30,6 +30,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 +54,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 = []; @@ -84,6 +91,11 @@ public sealed class MaterialModOptions : Oui, IMaterialAcrylicPage, IMaterialAcr private int savedItemOrdinal = -1; private TextMenu.Item? dropdownItem; private TextMenu.Item? compositePopupItem; + private TextMenu.Item? draggedSlider; + private int keyboardSliderDirection; + private float keyboardSliderHold; + private float keyboardSliderAccumulator; + private float sliderAudioCooldown; private int dropdownHighlight; private int dropdownFirstVisible; private string tabSearchText = ""; @@ -199,9 +211,11 @@ public override void Update() { compositePopupScroll.Update(MaxCompositePopupScroll(layout)); ease = Calc.Approach(ease, display ? 1f : 0f, Engine.RawDeltaTime * 7.5f); inputDelay -= Engine.RawDeltaTime; + 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())); @@ -209,15 +223,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(); @@ -226,12 +245,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; @@ -241,10 +262,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; @@ -295,19 +318,22 @@ 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; } } + 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; @@ -754,9 +780,113 @@ private List VisibleRows() { 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. + /// 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 || 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(); + return false; + } + + bool isRange = TryGetOption(item, out OptionSnapshot option) + && option.Kind == OptionKind.Range; + IntSliderSnapshot slider = default; + bool isIntSlider = !isRange && TryGetIntSlider(item, out slider); + if (!isRange && !isIntSlider) { + ResetKeyboardSlider(); + 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; + 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.2f; + if (keyboardSliderHold <= delay) return true; + + int span = isRange ? Math.Max(1, option.Options.Count - 1) + : Math.Max(1, slider.Maximum - slider.Minimum); + 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; + 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; + + 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; + } + + private void ResetKeyboardSlider() { + keyboardSliderDirection = 0; + keyboardSliderHold = 0f; + keyboardSliderAccumulator = 0f; + } + + 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 false; + int previous = intSlider.Index; + intSlider.PreviousIndex = previous; + intSlider.Index = value; + intSlider.OnValueChange?.Invoke(value); + intSlider.ValueWiggler?.Start(); + 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) { 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); @@ -802,16 +932,22 @@ 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); + } else if (OptionControlRect(rect).Contains(mouse)) { + draggedSlider = item; + // 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)) - SetIntSliderFromMouse(item, slider, SliderControlRect(rect), mouse.X); + if (OptionControlRect(rect).Contains(mouse)) { + draggedSlider = item; + if (SetIntSliderFromMouse(item, slider, SliderControlRect(rect), mouse.X)) + sliderAudioCooldown = SliderAudioInterval; + } } else if (IsCompositeMenu(item)) { OpenCompositePopup(item); } else { @@ -820,6 +956,46 @@ private void UpdateMouse(ModOptionsLayout layout) { motion.Pulse(ItemKey(item), mouse); } + private void UpdateSliderDrag(ModOptionsLayout layout, Vector2 mouse) { + if (!MaterialTouch.CheckLeftButton) { + draggedSlider = null; + return; + } + if (draggedSlider is not { } dragged) return; + if (!TryGetRowPlacement(layout, dragged, out RowPlacement placement)) { + draggedSlider = null; + return; + } + if (TryGetOption(dragged, out OptionSnapshot option) + && option.Kind == OptionKind.Range) { + bool playSound = sliderAudioCooldown <= 0f; + if (SetOptionFromMouse(dragged, option, SliderControlRect(placement.Rect), mouse.X, + playSound) && playSound) sliderAudioCooldown = SliderAudioInterval; + } else if (TryGetIntSlider(dragged, out IntSliderSnapshot slider)) { + 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) { + 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; + } + private static void ActivateItem(TextMenu.Item item) { item.ConfirmPressed(); item.OnPressed?.Invoke(); @@ -828,7 +1004,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; } @@ -1121,7 +1297,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 +1463,15 @@ 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), + _ => OptionControlRect(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); @@ -1301,7 +1479,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); @@ -1369,7 +1547,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)); @@ -1389,26 +1567,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); } @@ -1646,6 +1839,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; @@ -1662,29 +1880,53 @@ 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); + } + metadata.Change = 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) { + 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; } - snapshot = new OptionSnapshot( - labelField.GetValue(item)?.ToString() ?? "", - Math.Clamp((int)(indexField.GetValue(item) ?? 0), 0, Math.Max(0, labels.Count - 1)), - labels, - entries, - indexField, - previousIndexField, - changeField - ); - 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; } private static bool TryGetIntSlider(TextMenu.Item item, out IntSliderSnapshot snapshot) { @@ -1692,11 +1934,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; } @@ -1763,53 +2002,43 @@ private static bool TryGetCompositeContents(TextMenu.Item item, out float titleH return false; } - private static bool CanUseDropdown(OptionSnapshot option) => - option.Options.Count >= 3; - - 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 = OptionRules.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; @@ -1842,26 +2071,17 @@ 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 SliderControlRect(MaterialRect rect) => new( - rect.Right - 474f, - rect.Center.Y + 9f, - 430f, - 8f - ); + 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, @@ -2075,8 +2295,9 @@ private sealed class ModTab(string id, string title, string version, List Options, List Entries, @@ -2085,6 +2306,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, diff --git a/Source/OptionRules.cs b/Source/OptionRules.cs new file mode 100644 index 0000000..d9e9491 --- /dev/null +++ b/Source/OptionRules.cs @@ -0,0 +1,94 @@ +using System.Globalization; +using System.Reflection; + +namespace Celeste.Mod.MicroblocksQolUtils; + +internal enum OptionKind { + Switch, + Dropdown, + Range +} + +internal static class OptionRules { + internal static OptionKind Classify(bool isBool, bool isSlider, bool isIntOption, + bool? enumBacked, IReadOnlyList 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..1d74f3f 100644 --- a/scripts/build-qol-mod.mjs +++ b/scripts/build-qol-mod.mjs @@ -14,6 +14,8 @@ 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."); } @@ -108,6 +110,15 @@ 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) { + const testEnvironment = { ...process.env, CELESTE_ROOT: celesteRoot }; + 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); + } +} rmSync(output, { recursive: true, force: true }); mkdirSync(resolve(output, "Code"), { recursive: true }); cpSync(dll, resolve(output, "Code/MicroblocksQolUtils.dll"));