diff --git a/CHANGELOG.md b/CHANGELOG.md index 4357e995..ec17e7c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project uses version tags that match the mod version in `everest.yaml`, whi ### Added +- Add **Menu Mouse** to the Interface tab. Hold `Left Alt` (or the bind in its popup) in a Celeste menu to point and click: hover selects, left click confirms or steps the value under the cursor, right click goes back, and the wheel scrolls. Works in the pause menu, options and mod options, the main menu, file select, chapter select, the chapter panel, and the journal. On by default. - Add Automatic Error Reports for selected Akron startup, overlay, settings and StartPos failures. Reporting is on by default when a Sentry destination is configured, with an opt-out in the Interface tab. Existing saved Off choices are preserved. Reports send only bounded Akron stack details and the mod version. Local logging and Send diagnostics keep their existing behavior. ### Changed @@ -16,6 +17,10 @@ This project uses version tags that match the mod version in `everest.yaml`, whi - Turning **Enabled** off removes Akron's overlay, HUD, hotkeys, interop, error reports, and debug console commands on the next launch. Only its mod options entry stays, so it can be turned back on there. - Add a **Hold** bind row to the Fast Lookout popup, set with its **Bind** button. The hold starts unbound, and Everest's keyboard and controller config used to be the only place to set it. Keyboard keys in this hold now act as one chord, like Akron's other binds: a hold saved as two separate keys needs both held, so bind it again if you meant either key. Controller and mouse buttons still work on their own. +### Fixed + +- Ignore Click Teleport clicks while the game is paused, including the click that unpauses it, so a click on the pause menu never moves Madeline or toggles Cursor Zoom. Cursor Tools, Cursor Zoom, Entity Inspector and Free Camera mouse control also stand down while Menu Mouse is driving a menu. + ## Akron Beta 84 ### Fixed diff --git a/Source/Commands/akron-qa-commands.cs b/Source/Commands/akron-qa-commands.cs index 8023decf..2ea52752 100644 --- a/Source/Commands/akron-qa-commands.cs +++ b/Source/Commands/akron-qa-commands.cs @@ -2096,7 +2096,7 @@ public static void QaCursorToolsState(string clickAction = "") { AkronModule.Settings.CursorToolsClickAction = AkronCursorToolsClickAction.ClickTeleport; } - bool simulatedHold = AkronModule.ShouldUseCursorToolsHold(AkronModule.Settings.CursorTools, true, false); + bool simulatedHold = AkronModule.ShouldUseCursorToolsHold(AkronModule.Settings.CursorTools, true, false, false); AkronCursorToolsClickAction normalizedClickAction = AkronModuleSettings.NormalizeCursorToolsClickAction(AkronModule.Settings.CursorToolsClickAction); Log("qa-cursor-tools-enabled: " + AkronModule.Settings.CursorTools.ToString().ToLowerInvariant()); Log("qa-cursor-tools-simulated-hold: " + simulatedHold.ToString().ToLowerInvariant()); diff --git a/Source/Commands/akron-status-commands.cs b/Source/Commands/akron-status-commands.cs index ed1bce57..75c91249 100644 --- a/Source/Commands/akron-status-commands.cs +++ b/Source/Commands/akron-status-commands.cs @@ -78,6 +78,8 @@ public static void Status() { Log("safe-mode: " + AkronModule.Settings.SafeMode.ToString().ToLowerInvariant()); Log("consume-menu-input: " + AkronModule.Settings.ConsumeGameplayInputInMenu.ToString().ToLowerInvariant()); Log("pause-gameplay-in-menu: " + AkronModule.Settings.PauseGameplayInMenu.ToString().ToLowerInvariant()); + Log("menu-mouse: " + AkronModule.Settings.MenuMouse.ToString().ToLowerInvariant()); + Log("menu-mouse-hold: " + AkronModuleSettings.DescribeBinding(AkronModule.Settings.MenuMouseHold)); Log("overlay-opacity: " + AkronModule.Settings.OverlayOpacity.ToString(CultureInfo.InvariantCulture)); Log("overlay-theme: " + AkronOverlayThemes.CurrentDisplayName()); Log("allow-pause-buffering: " + AkronModule.Settings.AllowPauseBuffering.ToString().ToLowerInvariant()); diff --git a/Source/Module/AkronModule.cs b/Source/Module/AkronModule.cs index f2907bbc..8990b5cf 100644 --- a/Source/Module/AkronModule.cs +++ b/Source/Module/AkronModule.cs @@ -239,6 +239,7 @@ public override void Load() { Everest.Events.Level.OnUnpause += LevelOnUnpause; Everest.Events.Level.OnExit += LevelOnExit; AkronEntityInspector.LoadInspectorPin(); + AkronMenuMouse.Load(); MethodInfo dashCoroutineMethod = ResolvePlayerDashCoroutineMethod(); if (dashCoroutineMethod != null) { dashCoroutineHook = new ILHook(dashCoroutineMethod, PlayerDashCoroutineIlHook); @@ -399,6 +400,7 @@ public override void Unload() { Everest.Events.Level.OnUnpause -= LevelOnUnpause; Everest.Events.Level.OnExit -= LevelOnExit; AkronEntityInspector.UnloadInspectorPin(); + AkronMenuMouse.Unload(); dashCoroutineHook?.Dispose(); dashCoroutineHook = null; lookoutRoutineHook?.Dispose(); @@ -1523,7 +1525,14 @@ private static void MiniTextboxOnRender(On.Celeste.MiniTextbox.orig_Render orig, private static void TextMenuOnUpdate(On.Celeste.TextMenu.orig_Update orig, TextMenu self) { ReplacePauseMenuButtonActionIfNeeded(self?.Current as TextMenu.Button); + // Menu Mouse scrolls with the wheel, so the native auto-scroll must not move a + // menu the pointer is driving (see AkronMenuMouse.HoldsScroll). + bool holdScroll = AkronMenuMouse.HoldsScroll(self); + float heldY = holdScroll ? self.Y : 0f; orig(self); + if (holdScroll) { + self.Y = heldY; + } KeepNativeTextMenuInsideViewport(self); } diff --git a/Source/Module/AkronModuleSettings.cs b/Source/Module/AkronModuleSettings.cs index dac0c3c8..c93e3519 100644 --- a/Source/Module/AkronModuleSettings.cs +++ b/Source/Module/AkronModuleSettings.cs @@ -137,6 +137,9 @@ public void ApplySubmissionMode(bool enabled) { public int IndicatorOffsetY { get; set; } public bool ConsumeGameplayInputInMenu { get; set; } = true; public bool PauseGameplayInMenu { get; set; } + // Menu Mouse lets the cursor drive Celeste's own menus while MenuMouseHold is held. + // On by default: it does nothing until the hold bind is pressed in a menu. + public bool MenuMouse { get; set; } = true; public int OverlayOpacity { get; set; } = 96; public AkronOverlayThemePreset OverlayThemePreset { get; set; } = AkronOverlayThemePreset.Default; public int OverlayScale { get; set; } = 100; @@ -321,6 +324,9 @@ public void ApplySubmissionMode(bool enabled) { [DefaultButtonBinding(0, Keys.LeftAlt)] public ButtonBinding CursorToolsHold { get; set; } + [DefaultButtonBinding(0, Keys.LeftAlt)] + public ButtonBinding MenuMouseHold { get; set; } + public bool ReducedVisualNoise { get; set; } public bool NoParticles { get; set; } public bool NoTrails { get; set; } diff --git a/Source/Module/akron-module-overlay-input.cs b/Source/Module/akron-module-overlay-input.cs index fd2fb58c..f7f175fd 100644 --- a/Source/Module/akron-module-overlay-input.cs +++ b/Source/Module/akron-module-overlay-input.cs @@ -441,7 +441,8 @@ private static void UpdateOverlayCursorState() { ShouldShowEntityInspectorCursor() || ShouldShowClickTeleportCursor() || ShouldShowCursorZoomCursor() || - ShouldShowFreeCameraMouseCursor(); + ShouldShowFreeCameraMouseCursor() || + AkronMenuMouse.ShowsCursor; if (shouldShowCursor) { ShowManagedCursorForTransientUi(); return; @@ -471,6 +472,7 @@ private static void CaptureCursorVisibilityIfNeeded() { private static bool ShouldShowClickTeleportCursor() { return Engine.Scene is Level && Overlay?.Visible != true && + !AkronMenuMouse.ShowsCursor && IsClickTeleportCursorActive() && AkronPolicy.CanUse(AkronFeatureKind.ClickTeleport).Allowed; } @@ -481,17 +483,30 @@ internal static bool ShouldShowEntityInspectorCursor() { Settings.EntityInspector || Settings.CursorTools, IsEntityInspectorCursorHoldActive() || IsCursorToolsInspectorPinActive(), Overlay?.Visible == true, - AkronPolicy.CanUse(AkronFeatureKind.EntityInspector).Allowed); + AkronPolicy.CanUse(AkronFeatureKind.EntityInspector).Allowed, + AkronMenuMouse.ShowsCursor); } internal static bool IsEntityInspectorCursorHoldActive() { return IsButtonBindingHeld(AkronModuleSettings.ResolveEntityInspectorCursorHoldBinding(Settings)); } - internal static bool ShouldShowEntityInspectorCursor(bool entityInspector, bool cursorHoldBindingHeld, bool overlayVisible, bool policyAllowed) { + // The Akron overlay and its placement modes own the mouse while they are up, so + // Menu Mouse only runs when neither is active. + internal static bool IsMenuMouseHoldActive() { + return Settings.MenuMouse && + Overlay?.Visible != true && + Overlay?.IsTransientMouseUiActive != true && + IsButtonBindingHeld(Settings.MenuMouseHold); + } + + // Like the overlay, Menu Mouse driving a menu owns the shared Left Alt hold, so the + // inspector neither previews nor pins entities behind that menu. + internal static bool ShouldShowEntityInspectorCursor(bool entityInspector, bool cursorHoldBindingHeld, bool overlayVisible, bool policyAllowed, bool menuMouseOwnsCursor) { return entityInspector && cursorHoldBindingHeld && !overlayVisible && + !menuMouseOwnsCursor && policyAllowed; } diff --git a/Source/Module/akron-module-settings-label-rows.cs b/Source/Module/akron-module-settings-label-rows.cs index 85c5dd3e..8b0ecfbf 100644 --- a/Source/Module/akron-module-settings-label-rows.cs +++ b/Source/Module/akron-module-settings-label-rows.cs @@ -170,6 +170,7 @@ private static void EnsureCurrentButtonBindingDefaults(AkronModuleSettings setti settings.ClickTeleportCursor = EnsureButtonBinding(settings.ClickTeleportCursor, CreateLeftAltHoldBinding); settings.CursorZoomHold = EnsureButtonBinding(settings.CursorZoomHold, CreateLeftAltHoldBinding); settings.CursorToolsHold = EnsureButtonBinding(settings.CursorToolsHold, CreateLeftAltHoldBinding); + settings.MenuMouseHold = EnsureButtonBinding(settings.MenuMouseHold, CreateLeftAltHoldBinding); } private static ButtonBinding EnsureButtonBinding(ButtonBinding binding, Func defaultFactory) { diff --git a/Source/Module/akron-module-visual-runtime.cs b/Source/Module/akron-module-visual-runtime.cs index 9bdc9f58..44bd652c 100644 --- a/Source/Module/akron-module-visual-runtime.cs +++ b/Source/Module/akron-module-visual-runtime.cs @@ -9,15 +9,19 @@ namespace Celeste.Mod.Akron; public partial class AkronModule { - internal static bool ShouldUseCursorToolsHold(bool cursorTools, bool cursorToolsHoldBindingHeld, bool overlayVisible) { - return cursorTools && cursorToolsHoldBindingHeld && !overlayVisible; + // Cursor Tools and Menu Mouse share the Left Alt default. While Menu Mouse drives a + // menu it owns the cursor, so the hold must not also pan, zoom or freeze the level + // behind it (freezing would stop the pause menu from updating at all). + internal static bool ShouldUseCursorToolsHold(bool cursorTools, bool cursorToolsHoldBindingHeld, bool overlayVisible, bool menuMouseOwnsCursor) { + return cursorTools && cursorToolsHoldBindingHeld && !overlayVisible && !menuMouseOwnsCursor; } internal static bool IsCursorToolsHoldActive() { return ShouldUseCursorToolsHold( Settings.CursorTools, Settings.CursorToolsHold?.Check ?? false, - IsOverlayVisible); + IsOverlayVisible, + AkronMenuMouse.ShowsCursor); } internal static bool IsClickTeleportEffectiveEnabled() { @@ -52,7 +56,8 @@ internal static bool IsFreeCameraMouseControlEffectiveEnabled() { return IsFreeCameraMouseControlEffectiveEnabled( Settings.FreeCameraMouseControl, IsCursorToolsHoldActive(), - Settings.CursorToolsFreeCamera); + Settings.CursorToolsFreeCamera, + AkronMenuMouse.ShowsCursor); } internal static bool IsCursorToolEffectiveEnabled(bool savedToolEnabled, bool cursorToolsHoldActive, bool cursorToolsOptionEnabled) { @@ -64,10 +69,13 @@ internal static bool IsClickTeleportCursorActive(bool clickTeleportEnabled, bool cursorToolsHoldActive && cursorToolsClickTeleportEnabled; } - internal static bool IsFreeCameraMouseControlEffectiveEnabled(bool freeCameraMouseControlEnabled, bool cursorToolsHoldActive, bool cursorToolsFreeCameraEnabled) { - return freeCameraMouseControlEnabled || - cursorToolsHoldActive && - cursorToolsFreeCameraEnabled; + // Free Camera pans every level update, paused or not, so pointing at a menu row + // with Menu Mouse would otherwise drag the camera behind the menu. + internal static bool IsFreeCameraMouseControlEffectiveEnabled(bool freeCameraMouseControlEnabled, bool cursorToolsHoldActive, bool cursorToolsFreeCameraEnabled, bool menuMouseOwnsCursor) { + return !menuMouseOwnsCursor && + (freeCameraMouseControlEnabled || + cursorToolsHoldActive && + cursorToolsFreeCameraEnabled); } internal static bool IsCursorToolsFreezeGameplayEffectiveEnabled() { @@ -101,21 +109,17 @@ private static void ApplyStartPosMousePlacement(Level level, Player player) { private static void CaptureClickTeleportTargetBeforeCameraMovement(Level level, Player player) { pendingClickTeleportTarget = null; - if (!IsClickTeleportEffectiveEnabled() || - Settings.StartPosMousePlacement || - player == null || - player.Dead || - IsOverlayVisible || - !ShouldShowClickTeleportCursor()) { - clickTeleportLastLeftDown = false; - return; - } - MouseState mouse = Mouse.GetState(); - bool leftDown = mouse.LeftButton == ButtonState.Pressed; - bool pressed = leftDown && !clickTeleportLastLeftDown; - clickTeleportLastLeftDown = leftDown; - if (!pressed) { + // A click while paused belongs to the pause menu (Menu Mouse shares the Left Alt + // default), never to the room behind it. + bool gated = !IsClickTeleportEffectiveEnabled() || + Settings.StartPosMousePlacement || + level.Paused || + player == null || + player.Dead || + IsOverlayVisible || + !ShouldShowClickTeleportCursor(); + if (!TrackFreshPress(mouse.LeftButton == ButtonState.Pressed, gated, ref clickTeleportLastLeftDown)) { return; } @@ -125,6 +129,15 @@ private static void CaptureClickTeleportTargetBeforeCameraMovement(Level level, pendingClickTeleportTarget = target; } + // A fresh press is one that starts while the feature is live. The input is tracked + // while gated too: the Alt+click on Resume is still held on the first unpaused frame, + // and must not count as a new press (teleporting Madeline or toggling Cursor Zoom). + internal static bool TrackFreshPress(bool down, bool gated, ref bool lastDown) { + bool pressed = !gated && down && !lastDown; + lastDown = down; + return pressed; + } + private static void ApplyClickTeleport(Level level, Player player) { if (!IsClickTeleportEffectiveEnabled() || Settings.StartPosMousePlacement || @@ -231,9 +244,12 @@ internal static void MoveHairForTeleport(Player player, Vector2 delta) { private static void UpdateCursorZoom(Level level) { bool cursorToolsHoldActive = IsCursorToolsHoldActive(); bool cursorZoomEnabled = IsCursorZoomEffectiveEnabled(); - if (!cursorZoomEnabled || IsOverlayVisible || !AkronPolicy.CanUse(AkronFeatureKind.CursorZoom).Allowed) { + bool bindDown = Settings.CursorZoomHold?.Check ?? false; + // Menu Mouse reads the same wheel; like the overlay, it pauses zoom input. + bool gated = !cursorZoomEnabled || IsOverlayVisible || AkronMenuMouse.ShowsCursor || !AkronPolicy.CanUse(AkronFeatureKind.CursorZoom).Allowed; + bool bindPressed = TrackFreshPress(Settings.CursorZoom && bindDown, gated, ref cursorZoomLastBindDown); + if (gated) { cursorZoomHadScrollSample = false; - cursorZoomLastBindDown = false; if (!cursorZoomEnabled) { cursorZoomToggleActive = false; DeactivateCursorZoom(level); @@ -241,10 +257,6 @@ private static void UpdateCursorZoom(Level level) { return; } - bool bindDown = Settings.CursorZoomHold?.Check ?? false; - bool bindPressed = Settings.CursorZoom && bindDown && !cursorZoomLastBindDown; - cursorZoomLastBindDown = Settings.CursorZoom ? bindDown : false; - AkronCursorZoomActivationMode activationMode = AkronModuleSettings.NormalizeCursorZoomActivationMode(Settings.CursorZoomActivationMode); bool active = cursorToolsHoldActive || bindDown; if (activationMode == AkronCursorZoomActivationMode.Toggle) { diff --git a/Source/Overlay/akron-overlay-bindable-actions.cs b/Source/Overlay/akron-overlay-bindable-actions.cs index 2519ba79..0c6914d2 100644 --- a/Source/Overlay/akron-overlay-bindable-actions.cs +++ b/Source/Overlay/akron-overlay-bindable-actions.cs @@ -296,6 +296,7 @@ private static string DescribeBindingForAction(string label) { "Click Teleport" => "Hold " + AkronModuleSettings.DescribeBinding(settings.ClickTeleportCursor) + " and click", "Cursor Zoom" => "Hold " + AkronModuleSettings.DescribeBinding(settings.CursorZoomHold) + " and scroll", "Cursor Tools" => "Hold " + AkronModuleSettings.DescribeBinding(settings.CursorToolsHold), + "Menu Mouse" => "Hold " + AkronModuleSettings.DescribeBinding(settings.MenuMouseHold) + " in menus", "Reload Room" => AkronModuleSettings.DescribeBinding(settings.ReloadRoom), "Reload Chapter" => AkronModuleSettings.DescribeBinding(settings.ReloadChapter), "Open Debug Map" => AkronModuleSettings.DescribeBinding(settings.OpenDebugMap), @@ -768,6 +769,7 @@ private static ButtonBinding ResolveDefaultButtonBinding(AkronModuleSettings set "Player/Click Teleport" => settings.ClickTeleportCursor, "Creator/Cursor Zoom" => settings.CursorZoomHold, "Creator/Cursor Tools" => settings.CursorToolsHold, + "Interface/Menu Mouse" => settings.MenuMouseHold, "Creator/Entity Inspector" => settings.ToggleEntityInspector, "Level/Show Hitboxes" => settings.ToggleHitboxes, "Level/Freeze Gameplay" => settings.FreezeGameplay, diff --git a/Source/Overlay/akron-overlay-copy.cs b/Source/Overlay/akron-overlay-copy.cs index cf91f543..63a8b14d 100644 --- a/Source/Overlay/akron-overlay-copy.cs +++ b/Source/Overlay/akron-overlay-copy.cs @@ -154,6 +154,7 @@ public sealed partial class AkronOverlay { ["Hide Akron HUD"] = new[] { "hud", "hide", "akron", "labels" }, ["Trail Visibility"] = new[] { "trail", "dash trail", "ghost trail", "classic wave trail", "always trail", "no trail", "hide trail", "cut rate" }, ["Block Gameplay Input"] = new[] { "input", "consume", "madeline", "overlay", "menu" }, + ["Menu Mouse"] = new[] { "mouse", "cursor", "click", "scroll", "menu", "pause menu", "chapter select", "file select", "alt" }, ["Stamina Widget"] = new[] { "stamina", "widget", "text", "hud" }, ["Speed Widget"] = new[] { "speed", "widget", "text", "hud" }, ["Dash Widget"] = new[] { "dashes", "widget", "text", "hud" }, @@ -408,6 +409,7 @@ public sealed partial class AkronOverlay { ["Hide Akron HUD"] = "Hide every Akron label and indicator while keeping their settings.", ["Trail Visibility"] = "Choose Vanilla, Hidden, or Always for Madeline's trail. The popup sets the cut rate for Always.", ["Block Gameplay Input"] = "Keep gameplay input from reaching Madeline while the overlay is open. Off, she keeps moving behind the menu.", + ["Menu Mouse"] = "Hold the cursor hotkey in a Celeste menu to point and click: hover selects, left click confirms, right click goes back, the wheel scrolls.", ["Stamina Widget"] = "Show Madeline's current stamina as a text line in the label stack.", ["Speed Widget"] = "Show Madeline's current speed as a text line in the label stack.", ["Dash Widget"] = "Show Madeline's current dash count as a text line in the label stack.", diff --git a/Source/Overlay/akron-overlay-entries.cs b/Source/Overlay/akron-overlay-entries.cs index 5708de66..b952aeaf 100644 --- a/Source/Overlay/akron-overlay-entries.cs +++ b/Source/Overlay/akron-overlay-entries.cs @@ -257,6 +257,7 @@ private static List BuildEntriesForTab(string tab, Level level) { UploadPackRow(level), Toggle("Pause While Open", () => AkronModule.Settings.PauseGameplayInMenu, value => AkronModule.Settings.PauseGameplayInMenu = value), Toggle("Block Gameplay Input", () => AkronModule.Settings.ConsumeGameplayInputInMenu, value => AkronModule.Settings.ConsumeGameplayInputInMenu = value, "input", "madeline", "overlay", "consume"), + Toggle("Menu Mouse", () => AkronModule.Settings.MenuMouse, value => AkronModule.Settings.MenuMouse = value, "mouse", "cursor", "click", "menu", "pause menu", "chapter select", "file select", "alt"), Toggle("Streamer Mode", () => AkronModule.Settings.StreamerMode, value => AkronModule.Settings.StreamerMode = value), LoggingToggle(), ErrorReportingToggle(), diff --git a/Source/Overlay/akron-overlay-options-popup.cs b/Source/Overlay/akron-overlay-options-popup.cs index 8e8f7e62..7be17823 100644 --- a/Source/Overlay/akron-overlay-options-popup.cs +++ b/Source/Overlay/akron-overlay-options-popup.cs @@ -200,6 +200,7 @@ private static bool HasOptionsPopup(string label) { string.Equals(label, "Free Camera", StringComparison.OrdinalIgnoreCase) || string.Equals(label, "Click Teleport", StringComparison.OrdinalIgnoreCase) || string.Equals(label, "Cursor Tools", StringComparison.OrdinalIgnoreCase) || + string.Equals(label, "Menu Mouse", StringComparison.OrdinalIgnoreCase) || string.Equals(label, "Camera Offset", StringComparison.OrdinalIgnoreCase) || string.Equals(label, "Cursor Zoom", StringComparison.OrdinalIgnoreCase) || string.Equals(label, "Golden Start", StringComparison.OrdinalIgnoreCase) || @@ -433,6 +434,12 @@ private static string DescribeOptionsPopupValue(string label) { : "Off"; } + if (string.Equals(label, "Menu Mouse", StringComparison.OrdinalIgnoreCase)) { + return AkronModule.Settings.MenuMouse + ? AkronModuleSettings.DescribeBinding(AkronModule.Settings.MenuMouseHold) + : "Off"; + } + if (string.Equals(label, "Cursor Zoom", StringComparison.OrdinalIgnoreCase)) { return AkronModule.Settings.CursorZoom ? AkronModule.Settings.CursorZoomPercent + "%" diff --git a/Source/Overlay/akron-overlay-popup-controls.cs b/Source/Overlay/akron-overlay-popup-controls.cs index bb861b0d..d1b29993 100644 --- a/Source/Overlay/akron-overlay-popup-controls.cs +++ b/Source/Overlay/akron-overlay-popup-controls.cs @@ -94,6 +94,8 @@ private void DrawImGuiOptionsPopupContent(ActionEntry entry, string popupId) { DrawClickTeleportPopupControls(popupId); } else if (string.Equals(entry.Label, "Cursor Tools", StringComparison.OrdinalIgnoreCase)) { DrawCursorToolsPopupControls(popupId); + } else if (string.Equals(entry.Label, "Menu Mouse", StringComparison.OrdinalIgnoreCase)) { + DrawMenuMousePopupControls(popupId); } else if (string.Equals(entry.Label, "Camera Offset", StringComparison.OrdinalIgnoreCase)) { DrawCameraOffsetPopupControls(popupId); } else if (string.Equals(entry.Label, "Cursor Zoom", StringComparison.OrdinalIgnoreCase)) { diff --git a/Source/Overlay/akron-overlay-visual-popups.cs b/Source/Overlay/akron-overlay-visual-popups.cs index e91501fc..2df1c8e1 100644 --- a/Source/Overlay/akron-overlay-visual-popups.cs +++ b/Source/Overlay/akron-overlay-visual-popups.cs @@ -135,6 +135,18 @@ private void DrawClickTeleportPopupControls(string popupId) { "Hold this while Click Teleport is enabled, then left-click in the gameplay viewport to teleport."); } + private void DrawMenuMousePopupControls(string popupId) { + DrawCursorHoldBindingRow( + "Interface/Menu Mouse", + "Menu Mouse / Cursor hold", + "menu-mouse", + AkronModule.Settings.MenuMouseHold, + value => AkronModule.Settings.MenuMouseHold = value, + AkronModuleSettings.CreateLeftAltHoldBinding(), + popupId, + "Hold this in a Celeste menu to point and click. Hover selects, left click confirms, right click goes back, the wheel scrolls."); + } + private void DrawCursorToolsPopupControls(string popupId) { DrawCursorHoldBindingRow( "Creator/Cursor Tools", diff --git a/Source/Runtime/akron-menu-mouse.cs b/Source/Runtime/akron-menu-mouse.cs new file mode 100644 index 00000000..1670ccde --- /dev/null +++ b/Source/Runtime/akron-menu-mouse.cs @@ -0,0 +1,677 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Monocle; + +namespace Celeste.Mod.Akron; + +// Menu Mouse: while the Menu Mouse hold bind is held (Left Alt by default), the cursor +// drives Celeste's own menus. Hovering selects, left click activates, right click goes +// back and the wheel scrolls. +// +// The pointer only chooses *what* is selected. Every action is delivered by marking the +// matching menu VirtualButton (MenuConfirm, MenuCancel, MenuLeft, ...) as pressed for one +// frame, so the menu runs its own confirm/back/step code with its own sounds, guards and +// mod hooks. Akron never re-implements what Confirm does on a file slot or a chapter tab. +// +// Hit-testing runs right after MInput.Update, before the scene updates, against the layout +// each menu drew on the previous frame. Supported surfaces: every focused TextMenu (pause +// menu, options, mod options, Akron prompts), the main menu, file select, chapter select, +// the chapter panel and the journal. +internal static class AkronMenuMouse { + private const float FileSlotHalfHeight = 150f; + private const float FileSlotButtonSpacing = 15f; + private const float ChapterIconIdleSize = 100f; + private const float ChapterIconHoverGrowth = 44f; + + private static readonly List syntheticPresses = new List(); + private static readonly Dictionary adjustableItemTypes = new Dictionary(); + // Reused every frame the hold is active, so hit-testing does not allocate. + private static readonly List rowBuffer = new List(); + + private static bool wasActive; + private static Vector2 lastPointer; + // The TextMenu the pointer drove this frame; see HoldsScroll. + private static TextMenu pointerMenu; + + // Chapter select and the chapter panel only move one step per Left/Right press, and + // chapter select also has a short input delay. A click on a far icon or tab is walked + // there one native step at a time. + private static Oui stepOwner; + private static int stepTarget; + private static int stepInjectedFrom = -1; + + // True while Menu Mouse owns the cursor this frame. Read by the module's cursor + // visibility logic. + internal static bool ShowsCursor { get; private set; } + + internal enum ItemZone { + Body, + Decrease, + Increase + } + + // One TextMenu item, reduced to what hit-testing needs. + internal readonly struct MenuRow { + public readonly bool Visible; + public readonly float Height; + public readonly bool Hoverable; + + public MenuRow(bool visible, float height, bool hoverable) { + Visible = visible; + Height = height; + Hoverable = hoverable; + } + } + + private readonly struct PointerInput { + public readonly Vector2 Position; + public readonly bool Moved; + public readonly bool Click; + public readonly bool Back; + // -1 scrolls toward the top of a list, 1 toward the bottom, 0 no scroll. + public readonly int Scroll; + + public PointerInput(Vector2 position, bool moved, bool click, bool back, int scroll) { + Position = position; + Moved = moved; + Click = click; + Back = back; + Scroll = scroll; + } + + public bool Hovers => Moved || Click; + } + + public static void Load() { + On.Monocle.MInput.Update += MInputOnUpdate; + } + + public static void Unload() { + On.Monocle.MInput.Update -= MInputOnUpdate; + ReleaseSyntheticPresses(); + Deactivate(); + } + + private static void MInputOnUpdate(On.Monocle.MInput.orig_Update orig) { + ReleaseSyntheticPresses(); + orig(); + Update(Engine.Scene); + } + + private static void Update(Scene scene) { + if (scene == null || !AkronModule.IsMenuMouseHoldActive()) { + Deactivate(); + return; + } + + Vector2 pointer = MInput.Mouse.Position; + // Hover only follows real pointer movement (or the first held frame), so a + // stationary cursor never fights the wheel or the keyboard for the selection. + PointerInput input = new PointerInput( + pointer, + !wasActive || pointer != lastPointer, + MInput.Mouse.PressedLeftButton, + MInput.Mouse.PressedRightButton, + -Math.Sign(MInput.Mouse.WheelDelta)); + lastPointer = pointer; + + ShowsCursor = UpdateSurface(scene, input); + wasActive = ShowsCursor; + if (!ShowsCursor) { + ClearStep(); + } + } + + // Returns true when the scene shows a menu Menu Mouse can drive. + private static bool UpdateSurface(Scene scene, in PointerInput input) { + pointerMenu = null; + TextMenu menu = FindTopFocusedMenu(scene, out bool anyVisibleMenu); + if (menu != null) { + ClearStep(); + UpdateTextMenu(menu, input); + return true; + } + + if (anyVisibleMenu) { + // A visible but unfocused TextMenu means an Everest submenu (or similar child) + // holds focus and reads input itself. Only back is safe to forward to it. + ClearStep(); + PressIf(input.Back, Input.MenuCancel); + return true; + } + + if (scene is not Overworld overworld || overworld.Current is not { Focused: true } oui) { + return false; + } + + if (stepOwner != null && stepOwner != oui) { + ClearStep(); + } + + switch (oui) { + case OuiMainMenu mainMenu: + UpdateMainMenu(mainMenu, input); + return true; + case OuiFileSelect fileSelect: + UpdateFileSelect(fileSelect, input); + return true; + case OuiChapterSelect chapterSelect: + UpdateChapterSelect(chapterSelect, input); + return true; + case OuiChapterPanel chapterPanel: + UpdateChapterPanel(chapterPanel, input); + return true; + case OuiJournal journal: + UpdateJournal(journal, input); + return true; + default: + return false; + } + } + + private static TextMenu FindTopFocusedMenu(Scene scene, out bool anyVisibleMenu) { + anyVisibleMenu = false; + TextMenu top = null; + foreach (Entity entity in scene.Entities) { + if (entity is not TextMenu { Visible: true } menu) { + continue; + } + + anyVisibleMenu = true; + // The last focused menu in the entity list is the one drawn on top. + if (menu.Focused) { + top = menu; + } + } + + return top; + } + + private static void UpdateTextMenu(TextMenu menu, in PointerInput input) { + pointerMenu = menu; + if (input.Back) { + Press(Input.MenuCancel); + return; + } + + float lineHeight = ActiveFont.LineHeight; + bool scrolled = input.Scroll != 0 && ScrollTextMenu(menu, input.Scroll, lineHeight); + if (!TryHitTextMenuItem(menu, input.Position, lineHeight, out int index, out ItemZone zone)) { + return; + } + + // After a wheel scroll the row under a still cursor has changed, so it is + // selected as if the cursor had moved onto it. + if (input.Hovers || scrolled) { + SelectTextMenuItem(menu, index); + } + + if (input.Click) { + Press(zone switch { + ItemZone.Decrease => Input.MenuLeft, + ItemZone.Increase => Input.MenuRight, + _ => Input.MenuConfirm + }); + } + } + + // While the pointer drives a menu, the menu must not slide under it: TextMenu's + // auto-scroll centers the selection, so every hover would move the list and the + // next row would end up under a still cursor. The module's TextMenu.Update hook + // asks this and puts Y back after the native update. AutoScroll itself is left + // alone because Everest submenus save and restore their container's flag. When + // the hold is released, auto-scroll resumes and eases to the selection. + internal static bool HoldsScroll(TextMenu menu) { + return ShowsCursor && ReferenceEquals(menu, pointerMenu); + } + + // The wheel scrolls the list itself, two lines per notch. + private static bool ScrollTextMenu(TextMenu menu, int direction, float lineHeight) { + if (!TryScrollMenuY(menu.Y, direction * lineHeight * 2f, menu.Height, menu.Justify.Y, Engine.Height, out float y)) { + return false; + } + + menu.Y = y; + return true; + } + + // Moves a menu's Y by `distance` (positive scrolls toward the bottom of the list) + // within the bounds TextMenu.ScrollTargetY clamps auto-scroll to. Menus that fit + // TextMenu.ScrollableMinSize (screen height minus 300) do not scroll. Returns false + // when Y would not change, including at either end. + internal static bool TryScrollMenuY(float currentY, float distance, float menuHeight, float justifyY, float screenHeight, out float y) { + y = currentY; + if (menuHeight <= screenHeight - 300f) { + return false; + } + + float lowest = screenHeight - 150f - menuHeight * justifyY; + float highest = 150f + menuHeight * justifyY; + // Calc.Clamp's order: never throws when the bounds cross, unlike Math.Clamp. + y = Math.Min(Math.Max(currentY - distance, lowest), highest); + return y != currentY; + } + + // Reads the menu's layout and resolves the row under the pointer. The geometry + // itself lives in FindRowAt and ResolveValueZone, which take plain values. + private static bool TryHitTextMenuItem(TextMenu menu, Vector2 pointer, float lineHeight, out int index, out ItemZone zone) { + index = -1; + zone = ItemZone.Body; + float width = menu.Width; + float left = menu.X - menu.Justify.X * width; + if (pointer.X < left || pointer.X > left + width) { + return false; + } + + List items = menu.Items; + rowBuffer.Clear(); + foreach (TextMenu.Item item in items) { + rowBuffer.Add(new MenuRow(item.Visible, item.Visible ? item.Height() : 0f, item.Hoverable)); + } + + index = FindRowAt(rowBuffer, menu.Y - menu.Justify.Y * menu.Height, menu.ItemSpacing, pointer.Y, out float rowTop); + if (index < 0) { + return false; + } + + TextMenu.Item hit = items[index]; + zone = ResolveValueZone(pointer.X, pointer.Y, rowTop, lineHeight, left + width, hit.RightWidth(), IsAdjustable(hit)); + return true; + } + + // Mirrors TextMenu.renderItems: rows stack down from `top`, separated by `spacing`, + // and hidden rows take no space. Returns the index of the row under pointerY, or -1 + // over a gap, a header or a disabled row. + internal static int FindRowAt(List rows, float top, float spacing, float pointerY, out float rowTop) { + rowTop = top; + for (int i = 0; i < rows.Count; i++) { + MenuRow row = rows[i]; + if (!row.Visible) { + continue; + } + + if (pointerY >= rowTop && pointerY < rowTop + row.Height) { + return row.Hoverable ? i : -1; + } + + rowTop += row.Height + spacing; + } + + return -1; + } + + // Choice rows draw their value right-aligned in a column valueWidth wide, with "<" + // at its left edge and ">" at its right edge. Clicking the left half of that column + // steps the value down, the right half steps it up. Only the row's first line counts, + // so an expanded option submenu's children still confirm. + internal static ItemZone ResolveValueZone(float pointerX, float pointerY, float rowTop, float lineHeight, float menuRight, float valueWidth, bool adjustable) { + if (!adjustable || valueWidth <= 0f || pointerY >= rowTop + lineHeight) { + return ItemZone.Body; + } + + float valueLeft = menuRight - valueWidth; + if (pointerX < valueLeft) { + return ItemZone.Body; + } + + return pointerX < valueLeft + valueWidth * 0.5f ? ItemZone.Decrease : ItemZone.Increase; + } + + // Sliders and choice rows override LeftPressed to step their value. Buttons and key + // binding rows do not, so a click anywhere on them confirms. + internal static bool IsAdjustable(TextMenu.Item item) { + Type type = item.GetType(); + if (!adjustableItemTypes.TryGetValue(type, out bool adjustable)) { + adjustable = type.GetMethod(nameof(TextMenu.Item.LeftPressed), Type.EmptyTypes)?.DeclaringType != typeof(TextMenu.Item); + adjustableItemTypes[type] = adjustable; + } + + return adjustable; + } + + // Same enter/leave callbacks as TextMenu.MoveSelection, without its sound: hover + // sweeps would otherwise play a rollover for every row crossed. + private static void SelectTextMenuItem(TextMenu menu, int index) { + if (menu.Selection == index) { + return; + } + + menu.Current?.OnLeave?.Invoke(); + menu.Selection = index; + menu.Current?.OnEnter?.Invoke(); + } + + private static void UpdateMainMenu(OuiMainMenu mainMenu, in PointerInput input) { + if (input.Back) { + Press(Input.MenuCancel); + return; + } + + PressIf(input.Scroll < 0, Input.MenuUp); + PressIf(input.Scroll > 0, Input.MenuDown); + + MenuButton hit = null; + foreach (MenuButton button in mainMenu.Buttons) { + if (button.Scene == mainMenu.Scene && button.Visible && HitsMainMenuButton(button, input.Position)) { + hit = button; + break; + } + } + + if (hit == null) { + return; + } + + if (input.Hovers && !hit.Selected) { + hit.Selected = true; + // Selecting a MenuButton makes it ignore input for one update, so the key + // press that moved the selection cannot also confirm. A click that lands + // the cursor and presses in the same frame means "open this", so it has to + // reach the button it just selected. + if (input.Click) { + hit.canAcceptInput = true; + } + } + + PressIf(input.Click, Input.MenuConfirm); + } + + // Bounds follow each button's Render. Buttons added by other mods with their own + // layouts are not hit-tested; the keyboard still reaches them. + private static bool HitsMainMenuButton(MenuButton button, Vector2 pointer) { + Vector2 position = button.Position; + switch (button) { + case MainMenuClimb climb: { + float labelWidth = ActiveFont.Measure(climb.label).X * 1.5f * climb.labelScale; + float halfWidth = Math.Max(climb.icon.Width, labelWidth) * 0.5f; + return Contains(position.X - halfWidth, position.Y, halfWidth * 2f, climb.ButtonHeight, pointer); + } + case MainMenuSmallButton small: { + // Icon at x+0 (eased 32px right when selected), label 84px after it. + float width = 32f + 84f + ActiveFont.Measure(small.label).X * small.labelScale; + float centerY = position.Y + ActiveFont.LineHeight * 0.5f; + return Contains(position.X, centerY - small.ButtonHeight * 0.5f, width, small.ButtonHeight, pointer); + } + default: + return false; + } + } + + private static void UpdateFileSelect(OuiFileSelect fileSelect, in PointerInput input) { + if (input.Back) { + Press(Input.MenuCancel); + return; + } + + if (!fileSelect.SlotSelected) { + UpdateFileSlots(fileSelect, input); + return; + } + + OuiFileSelectSlot slot = fileSelect.Slots[fileSelect.SlotIndex]; + if (slot == null || slot.StartingGame) { + return; + } + + PressIf(input.Scroll < 0, Input.MenuUp); + PressIf(input.Scroll > 0, Input.MenuDown); + if (slot.deleting) { + UpdateFileDeletePrompt(slot, input); + } else { + UpdateFileSlotButtons(slot, input); + } + } + + private static void UpdateFileSlots(OuiFileSelect fileSelect, in PointerInput input) { + OuiFileSelectSlot[] slots = fileSelect.Slots; + int next = fileSelect.SlotIndex + input.Scroll; + // Everest wraps Up on the first slot and Down on the last; the wheel stops instead. + if (input.Scroll != 0 && next >= 0 && next < slots.Length) { + Press(input.Scroll < 0 ? Input.MenuUp : Input.MenuDown); + } + + int hit = -1; + for (int i = 0; i < slots.Length; i++) { + OuiFileSelectSlot slot = slots[i]; + if (slot == null || !slot.Visible) { + continue; + } + + float halfWidth = slot.Card.Width * 0.5f; + if (Contains(slot.X - halfWidth, slot.Y - FileSlotHalfHeight, halfWidth * 2f, FileSlotHalfHeight * 2f, input.Position)) { + hit = i; + break; + } + } + + if (hit < 0) { + return; + } + + if (input.Hovers && fileSelect.SlotIndex != hit) { + fileSelect.SlotIndex = hit; + // OuiFileSelect.Update only rescrolls when its own input changed the index. + foreach (OuiFileSelectSlot slot in slots) { + slot?.MoveTo(slot.IdlePosition.X, slot.IdlePosition.Y); + } + } + + PressIf(input.Click, Input.MenuConfirm); + } + + // Mirrors OuiFileSelectSlot.orig_Render: buttons stack centered under the card, + // starting 150px above it and easing down 350px as the slot opens. + private static void UpdateFileSlotButtons(OuiFileSelectSlot slot, in PointerInput input) { + List buttons = slot.buttons; + float y = slot.Y - 150f + 350f * slot.selectedEase; + for (int i = 0; i < buttons.Count; i++) { + OuiFileSelectSlot.Button button = buttons[i]; + float height = ActiveFont.LineHeight * button.Scale; + float halfWidth = ActiveFont.Measure(button.Label).X * button.Scale * 0.5f; + if (Contains(slot.X - halfWidth, y, halfWidth * 2f, height, input.Position)) { + if (input.Hovers) { + slot.buttonIndex = i; + } + + PressIf(input.Click, Input.MenuConfirm); + return; + } + + y += height + FileSlotButtonSpacing; + } + } + + // Mirrors the delete confirmation in OuiFileSelectSlot.orig_Render: "yes" then "no", + // centered on screen and sliding up 64px as the prompt eases in. + private static void UpdateFileDeletePrompt(OuiFileSelectSlot slot, in PointerInput input) { + const float scale = 0.8f; + float lineHeight = ActiveFont.LineHeight; + float top = 540f + 16f + 64f * (1f - Ease.CubeOut(slot.deletingEase)); + string[] labels = { Dialog.Clean("file_delete_yes"), Dialog.Clean("file_delete_no") }; + for (int i = 0; i < labels.Length; i++) { + float halfWidth = ActiveFont.Measure(labels[i]).X * scale * 0.5f; + if (Contains(960f - halfWidth, top + lineHeight * i, halfWidth * 2f, lineHeight * scale, input.Position)) { + if (input.Hovers) { + slot.deleteIndex = i; + } + + PressIf(input.Click, Input.MenuConfirm); + return; + } + } + } + + // Chapter icons move the mountain camera, so selection happens on click rather than + // on hover: click an icon to walk to it, click the selected icon to open it. + private static void UpdateChapterSelect(OuiChapterSelect chapterSelect, in PointerInput input) { + if (input.Back) { + ClearStep(); + Press(Input.MenuCancel); + return; + } + + int area = chapterSelect.area; + // OuiChapterSelect ignores Left/Right/Confirm until its input delay runs out. + bool ready = chapterSelect.inputDelay <= Engine.DeltaTime; + if (ContinueStep(chapterSelect, area, ready)) { + return; + } + + if (ready) { + PressIf(input.Scroll < 0, Input.MenuLeft); + PressIf(input.Scroll > 0, Input.MenuRight); + } + + if (!input.Click) { + return; + } + + List icons = chapterSelect.icons; + for (int i = 0; i < icons.Count; i++) { + OuiChapterSelectIcon icon = icons[i]; + if (icon == null || icon.IsHidden || icon.HideIcon || !icon.Visible) { + continue; + } + + // OuiChapterSelectIcon.Render scales every icon to 100px, growing to 144px + // while it is the selected one. + float halfSize = (ChapterIconIdleSize + ChapterIconHoverGrowth * Ease.CubeInOut(icon.sizeEase)) * 0.5f; + if (!Contains(icon.X - halfSize, icon.Y - halfSize, halfSize * 2f, halfSize * 2f, input.Position)) { + continue; + } + + if (i == area) { + PressIf(ready, Input.MenuConfirm); + } else { + StartStep(chapterSelect, i, area, ready); + } + + return; + } + } + + // The A/B/C side tabs and checkpoint tabs: click a tab to walk to it, click the + // selected tab to confirm it. + private static void UpdateChapterPanel(OuiChapterPanel panel, in PointerInput input) { + if (input.Back) { + ClearStep(); + Press(Input.MenuCancel); + return; + } + + int selected = panel.option; + if (ContinueStep(panel, selected, ready: true)) { + return; + } + + PressIf(input.Scroll < 0, Input.MenuLeft); + PressIf(input.Scroll > 0, Input.MenuRight); + if (!input.Click) { + return; + } + + Vector2 center = panel.OptionsRenderPosition; + List options = panel.options; + for (int i = 0; i < options.Count; i++) { + OuiChapterPanel.Option option = options[i]; + // Option.Render centers the tab background 10px below the render position. + Vector2 tabCenter = option.GetRenderPosition(center) + new Vector2(0f, 10f); + float halfWidth = option.Bg.Width * option.Scale * 0.5f; + float halfHeight = option.Bg.Height * option.Scale * 0.5f; + if (!Contains(tabCenter.X - halfWidth, tabCenter.Y - halfHeight, halfWidth * 2f, halfHeight * 2f, input.Position)) { + continue; + } + + if (i == selected) { + Press(Input.MenuConfirm); + } else { + StartStep(panel, i, selected, ready: true); + } + + return; + } + } + + // The journal is a full-screen book: click the left or right half, or scroll, to + // turn pages. + private static void UpdateJournal(OuiJournal journal, in PointerInput input) { + if (input.Back) { + Press(Input.MenuCancel); + return; + } + + bool previous = input.Scroll < 0 || input.Click && input.Position.X < 960f; + bool next = input.Scroll > 0 || input.Click && input.Position.X >= 960f; + PressIf(previous, Input.MenuLeft); + PressIf(next && !previous, Input.MenuRight); + } + + internal static void StartStep(Oui owner, int target, int current, bool ready) { + stepOwner = owner; + stepTarget = target; + stepInjectedFrom = -1; + ContinueStep(owner, current, ready); + } + + // Presses Left or Right once per accepted step until the owner's selection reaches + // the target. Returns true while a walk is in progress so other pointer input waits. + // A press that did not move the selection (a locked chapter, a hidden icon) ends the + // walk where it stopped. + internal static bool ContinueStep(Oui owner, int current, bool ready) { + if (stepOwner != owner) { + return false; + } + + if (current == stepTarget || current == stepInjectedFrom) { + ClearStep(); + return false; + } + + if (ready) { + Press(stepTarget > current ? Input.MenuRight : Input.MenuLeft); + stepInjectedFrom = current; + } + + return true; + } + + internal static void ClearStep() { + stepOwner = null; + stepInjectedFrom = -1; + } + + private static void Deactivate() { + ShowsCursor = false; + wasActive = false; + pointerMenu = null; + ClearStep(); + } + + private static void PressIf(bool condition, VirtualButton button) { + if (condition) { + Press(button); + } + } + + // VirtualButton.Pressed reports true while its buffer counter is positive. The next + // VirtualButton.Update resets the counter unless the real binding is held, and + // ReleaseSyntheticPresses resets it just before that update in case it is, so a + // synthetic press lasts exactly one frame either way. + internal static void Press(VirtualButton button) { + button.bufferCounter = Math.Max(button.bufferCounter, 1f); + syntheticPresses.Add(button); + } + + internal static void ReleaseSyntheticPresses() { + foreach (VirtualButton button in syntheticPresses) { + button.bufferCounter = 0f; + } + + syntheticPresses.Clear(); + } + + private static bool Contains(float x, float y, float width, float height, Vector2 point) { + return point.X >= x && point.X <= x + width && point.Y >= y && point.Y <= y + height; + } +} diff --git a/Source/Setups/akron-setup-settings.cs b/Source/Setups/akron-setup-settings.cs index 900d02f2..ef26fb51 100644 --- a/Source/Setups/akron-setup-settings.cs +++ b/Source/Setups/akron-setup-settings.cs @@ -30,6 +30,7 @@ private AkronSetupState CaptureCurrentState() { LagPauserIgnoreSpeedrunToolLoadStates = LagPauserIgnoreSpeedrunToolLoadStates, LowDistractionOverlay = IsLowDistractionActive(), PauseGameplayInMenu = PauseGameplayInMenu, + MenuMouse = MenuMouse, OverlayOpacity = OverlayOpacity, OverlayThemePreset = OverlayThemePreset, OverlayScale = OverlayScale, diff --git a/Source/Setups/akron-setup-state-apply.cs b/Source/Setups/akron-setup-state-apply.cs index 96b9317d..df820445 100644 --- a/Source/Setups/akron-setup-state-apply.cs +++ b/Source/Setups/akron-setup-state-apply.cs @@ -28,6 +28,7 @@ private void ApplySetupState(AkronSetupState state) { LagPauserRepeatCooldownMs = ClampLagPauserWindowMs(resolved.LagPauserRepeatCooldownMs); LagPauserIgnoreSpeedrunToolLoadStates = resolved.LagPauserIgnoreSpeedrunToolLoadStates; PauseGameplayInMenu = resolved.PauseGameplayInMenu; + MenuMouse = resolved.MenuMouse; OverlayOpacity = ClampOverlayOpacity(resolved.OverlayOpacity); OverlayThemePreset = resolved.OverlayThemePreset; OverlayScale = ClampOverlayScale(resolved.OverlayScale); diff --git a/Source/Setups/akron-setup-state.cs b/Source/Setups/akron-setup-state.cs index 3a932f9f..b48c22a6 100644 --- a/Source/Setups/akron-setup-state.cs +++ b/Source/Setups/akron-setup-state.cs @@ -21,6 +21,7 @@ public sealed class AkronSetupState { public bool LagPauserIgnoreSpeedrunToolLoadStates { get; set; } public bool LowDistractionOverlay { get; set; } public bool PauseGameplayInMenu { get; set; } + public bool MenuMouse { get; set; } = true; public int OverlayOpacity { get; set; } = 96; public AkronOverlayThemePreset OverlayThemePreset { get; set; } = AkronOverlayThemePreset.Default; public int OverlayScale { get; set; } = 100; diff --git a/docs/feature-guide/interface.mdx b/docs/feature-guide/interface.mdx index 09ac5400..24fdcba5 100644 --- a/docs/feature-guide/interface.mdx +++ b/docs/feature-guide/interface.mdx @@ -16,6 +16,7 @@ The Interface tab controls the Akron overlay itself and the public `.akr` pack w | Upload Pack | Action | Submit the active map's StartPos, Auto Kill, or Auto Deafen setup through the moderated community catalog flow. | None | | Pause While Open | Toggle | When enabled, opening the Akron menu stops gameplay updates until the menu closes. | None | | Block Gameplay Input | Toggle | Keep gameplay input from reaching Madeline while the overlay is open. On by default. | None | +| Menu Mouse | Toggle | Hold the Menu Mouse key (`Left Alt` by default) to use the mouse in Celeste menus: the pause menu, options and mod options, the main menu, file select, chapter select, the chapter panel, and the journal. Hover selects a row, left click confirms it or steps the value under the cursor, right click goes back, and the wheel scrolls long lists. The list stays still while you hold the key and recenters on the selection when you let go. In chapter select and the chapter panel, click once to select and again to open. On Linux desktops that move windows with Alt+drag, such as Cinnamon, the desktop takes Alt+clicks before the game does; bind another key in the popup or change the desktop's window-move key. On by default. | None | | Streamer Mode | Toggle | Replace full local filesystem paths with filenames. | None | | Logging | Toggle | Write local Akron diagnostic logs to `Saves/AkronLogs`. | Goldberry/Hardlist clear | | Automatic Error Reports | Toggle | Send selected Akron error reports through Sentry. On by default when configured; turn off here. Unavailable without a configured destination. See [error reporting](/reference/error-reporting) for the exact data sent. | None | @@ -49,6 +50,7 @@ Rows with a triangle expose the controls listed below. | Theme | Button in levels | Allow the floating activation button in active levels. | None | | Theme | Button in menus | Allow the floating activation button outside active levels. | None | | Theme | Search autofocus | Focus Akron search automatically when the overlay opens. | None | +| Menu Mouse | Cursor | Set the hold input that turns on Menu Mouse in menus. | None | | Logging | Normal | Record normal events only. Quietest level. | Goldberry/Hardlist clear | | Logging | Diagnostic | Record normal events plus one 60-second summary line counting feature uses and policy checks. The default. | Goldberry/Hardlist clear | | Logging | Verbose | Record one line per feature use instead of the 60-second summary. Louder than Diagnostic. | Goldberry/Hardlist clear | diff --git a/docs/getting-started/hotkeys.mdx b/docs/getting-started/hotkeys.mdx index 2c639cd8..e8a6477e 100644 --- a/docs/getting-started/hotkeys.mdx +++ b/docs/getting-started/hotkeys.mdx @@ -46,8 +46,9 @@ Bind Akron actions from the overlay: right-click an action row, and review curre | Click Teleport cursor hold | `Left Alt` | - | | Cursor Zoom hold | `Left Alt` | - | | Cursor Tools hold | `Left Alt` | - | +| Menu Mouse hold | `Left Alt` | - | -Actions marked `-` have no default binding on that input type. Open Menu starts on `Tab`. Click Teleport, Cursor Zoom, Cursor Tools, and Entity Inspector cursor holds default to `Left Alt` where their popup exposes a cursor bind. Other shortcut actions start unbound so you can assign them intentionally. +Actions marked `-` have no default binding on that input type. Open Menu starts on `Tab`. Click Teleport, Cursor Zoom, Cursor Tools, Menu Mouse, and Entity Inspector cursor holds default to `Left Alt` where their popup exposes a cursor bind. Other shortcut actions start unbound so you can assign them intentionally. **Save State**, **Load State**, **Previous Slot**, and **Next Slot** drive Speedrun Tool's savestate slot from 1-9; the Keybinds tab shows the first two as **Speedrun Tool Capture State** and **Speedrun Tool Restore State**. **Set StartPos**, **Load StartPos**, **Clear StartPos**, and **Load StartPos Slot 1-9** control Akron's StartPos. diff --git a/tests/feature-registry-tests.cs b/tests/feature-registry-tests.cs index 78eacb37..fd8a4464 100644 --- a/tests/feature-registry-tests.cs +++ b/tests/feature-registry-tests.cs @@ -464,6 +464,7 @@ public void ActiveCheatContributorsIgnoreEnabledGreenQualityOfLifeOptions() [InlineData("Interface", "Upload Pack", null)] [InlineData("Interface", "Streamer Mode", null)] [InlineData("Interface", "Block Gameplay Input", null)] + [InlineData("Interface", "Menu Mouse", null)] [InlineData("Interface", "Logging", AkronFeatureKind.Logging)] [InlineData("Internal Recorder", "Start Recording", AkronFeatureKind.InternalRecorder)] [InlineData("Internal Recorder", "Build Clear Video", AkronFeatureKind.InternalRecorder)] diff --git a/tests/menu-mouse-tests.cs b/tests/menu-mouse-tests.cs new file mode 100644 index 00000000..f054f452 --- /dev/null +++ b/tests/menu-mouse-tests.cs @@ -0,0 +1,210 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.CompilerServices; +using Celeste; +using Monocle; +using Xunit; + +namespace Celeste.Mod.Akron.Tests; + +// Menu Mouse maps the cursor onto Celeste's own menus and drives them through +// one-frame presses of their VirtualButtons. CI's Celeste assembly is reference-only, +// so these tests drive the plain-value geometry and read VirtualButton's buffer +// counter directly instead of calling Celeste members. +[Collection(AkronSharedStateCollection.Name)] +public sealed class MenuMouseTests { + private const float LineHeight = 60f; + + private static readonly FieldInfo BufferCounterField = + typeof(VirtualButton).GetField("bufferCounter", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private class PlainRow : TextMenu.Item { + } + + // Overrides LeftPressed like TextMenu.Option and Everest's sliders do. + private sealed class ChoiceRow : PlainRow { + public override void LeftPressed() { + } + } + + [Fact] + public void RowsStackLikeTheMenuDrawsThem() { + // Header 100 (not hoverable), A 60, hidden, B 60, spacing 4, top at 426: + // header [426, 526), A [530, 590), B [594, 654); the hidden row takes no space. + List rows = new List { + new AkronMenuMouse.MenuRow(visible: true, height: 100f, hoverable: false), + new AkronMenuMouse.MenuRow(visible: true, height: 60f, hoverable: true), + new AkronMenuMouse.MenuRow(visible: false, height: 0f, hoverable: true), + new AkronMenuMouse.MenuRow(visible: true, height: 60f, hoverable: true) + }; + + Assert.Equal(-1, Row(rows, 470f)); + Assert.Equal(1, Row(rows, 530f)); + Assert.Equal(-1, Row(rows, 592f)); + Assert.Equal(3, Row(rows, 594f)); + Assert.Equal(3, Row(rows, 653f)); + Assert.Equal(-1, Row(rows, 654f)); + Assert.Equal(-1, Row(rows, 400f)); + + Assert.Equal(3, AkronMenuMouse.FindRowAt(rows, 426f, 4f, 600f, out float rowTop)); + Assert.Equal(594f, rowTop); + } + + [Fact] + public void DisabledRowsAreNotHit() { + List rows = new List { + new AkronMenuMouse.MenuRow(visible: true, height: 60f, hoverable: false) + }; + + Assert.Equal(-1, AkronMenuMouse.FindRowAt(rows, 510f, 4f, 540f, out _)); + } + + [Theory] + // A row at y 510 in a menu whose right edge is 1260, with a 200px value column. + // Zones: 0 = body (confirm), 1 = decrease, 2 = increase. + [InlineData(800f, 540f, 0)] + [InlineData(1100f, 540f, 1)] + [InlineData(1159f, 540f, 1)] + [InlineData(1160f, 540f, 2)] + [InlineData(1200f, 540f, 2)] + // Below the first line: an expanded option submenu's children confirm. + [InlineData(1200f, 570f, 0)] + public void ValueColumnHalvesStepAChoiceDownAndUp(float x, float y, int expected) { + Assert.Equal((AkronMenuMouse.ItemZone) expected, AkronMenuMouse.ResolveValueZone(x, y, 510f, LineHeight, 1260f, 200f, adjustable: true)); + } + + [Fact] + public void RowsThatCannotStepConfirmAnywhere() { + // Key binding rows draw their keys in the right column but have no value to step. + Assert.Equal(AkronMenuMouse.ItemZone.Body, AkronMenuMouse.ResolveValueZone(1200f, 540f, 510f, LineHeight, 1260f, 200f, adjustable: false)); + Assert.Equal(AkronMenuMouse.ItemZone.Body, AkronMenuMouse.ResolveValueZone(1200f, 540f, 510f, LineHeight, 1260f, 0f, adjustable: true)); + } + + [Fact] + public void OnlyRowsThatOverrideLeftPressedStep() { + Assert.True(AkronMenuMouse.IsAdjustable((ChoiceRow) RuntimeHelpers.GetUninitializedObject(typeof(ChoiceRow)))); + Assert.False(AkronMenuMouse.IsAdjustable((PlainRow) RuntimeHelpers.GetUninitializedObject(typeof(PlainRow)))); + } + + [Theory] + // A menu that fits the screen never scrolls. + [InlineData(540f, 150f, 700f, 0.5f, false, 540f)] + // A 2000px menu centered on screen moves between 930 - 1000 and 150 + 1000. + [InlineData(540f, 150f, 2000f, 0.5f, true, 390f)] + [InlineData(540f, -150f, 2000f, 0.5f, true, 690f)] + [InlineData(540f, 5000f, 2000f, 0.5f, true, -70f)] + [InlineData(540f, -5000f, 2000f, 0.5f, true, 1150f)] + [InlineData(-70f, 150f, 2000f, 0.5f, false, -70f)] + [InlineData(1150f, -150f, 2000f, 0.5f, false, 1150f)] + // Top-justified, the bounds cross (930 > 150); like Calc.Clamp, the upper one wins. + [InlineData(540f, 150f, 2000f, 0f, true, 150f)] + public void WheelScrollStaysWithinAutoScrollBounds(float currentY, float distance, float menuHeight, float justifyY, bool expectedMoved, float expectedY) { + bool moved = AkronMenuMouse.TryScrollMenuY(currentY, distance, menuHeight, justifyY, 1080f, out float y); + + Assert.Equal(expectedMoved, moved); + Assert.Equal(expectedY, y); + } + + [Fact] + public void SyntheticPressLastsUntilReleased() { + VirtualButton button = Button(); + try { + AkronMenuMouse.Press(button); + // VirtualButton.Pressed is true while the buffer counter is positive, for + // every reader in the frame. + Assert.True(IsPressed(button)); + + AkronMenuMouse.ReleaseSyntheticPresses(); + Assert.False(IsPressed(button)); + } finally { + AkronMenuMouse.ReleaseSyntheticPresses(); + } + } + + [Fact] + public void StepWalkerPressesOncePerAcceptedStepUntilTheTarget() { + WithMenuArrows((left, right) => { + Oui owner = Owner(); + + // Chapter 1 -> 3: the first step goes out at once. + AkronMenuMouse.StartStep(owner, target: 3, current: 1, ready: true); + Assert.True(IsPressed(right)); + Assert.False(IsPressed(left)); + AkronMenuMouse.ReleaseSyntheticPresses(); + + // Chapter select's input delay is running: wait without pressing. + Assert.True(AkronMenuMouse.ContinueStep(owner, current: 2, ready: false)); + Assert.False(IsPressed(right)); + + Assert.True(AkronMenuMouse.ContinueStep(owner, current: 2, ready: true)); + Assert.True(IsPressed(right)); + AkronMenuMouse.ReleaseSyntheticPresses(); + + // Arrived: the walk ends and nothing more is pressed. + Assert.False(AkronMenuMouse.ContinueStep(owner, current: 3, ready: true)); + Assert.False(IsPressed(right)); + Assert.False(AkronMenuMouse.ContinueStep(owner, current: 3, ready: true)); + }); + } + + [Fact] + public void StepWalkerStopsWhenAStepIsRefused() { + WithMenuArrows((left, right) => { + Oui owner = Owner(); + + AkronMenuMouse.StartStep(owner, target: 0, current: 2, ready: true); + Assert.True(IsPressed(left)); + AkronMenuMouse.ReleaseSyntheticPresses(); + + // The selection did not move (a locked or hidden chapter): stop, do not retry. + Assert.False(AkronMenuMouse.ContinueStep(owner, current: 2, ready: true)); + Assert.False(IsPressed(left)); + Assert.False(AkronMenuMouse.ContinueStep(owner, current: 1, ready: true)); + Assert.False(IsPressed(left)); + }); + } + + [Fact] + public void StepWalkerIgnoresOtherScreens() { + WithMenuArrows((left, right) => { + AkronMenuMouse.StartStep(Owner(), target: 3, current: 1, ready: true); + AkronMenuMouse.ReleaseSyntheticPresses(); + + Assert.False(AkronMenuMouse.ContinueStep(Owner(), current: 2, ready: true)); + Assert.False(IsPressed(right)); + }); + } + + private static int Row(List rows, float y) { + return AkronMenuMouse.FindRowAt(rows, 426f, 4f, y, out _); + } + + private static Oui Owner() { + return (Oui) RuntimeHelpers.GetUninitializedObject(typeof(OuiJournal)); + } + + private static VirtualButton Button() { + return (VirtualButton) RuntimeHelpers.GetUninitializedObject(typeof(VirtualButton)); + } + + private static bool IsPressed(VirtualButton button) { + return (float) BufferCounterField.GetValue(button)! > 0f; + } + + private static void WithMenuArrows(System.Action test) { + VirtualButton previousLeft = Input.MenuLeft; + VirtualButton previousRight = Input.MenuRight; + VirtualButton left = Button(); + VirtualButton right = Button(); + Input.MenuLeft = left; + Input.MenuRight = right; + try { + test(left, right); + } finally { + AkronMenuMouse.ReleaseSyntheticPresses(); + AkronMenuMouse.ClearStep(); + Input.MenuLeft = previousLeft; + Input.MenuRight = previousRight; + } + } +} diff --git a/tests/module-settings-tests.cs b/tests/module-settings-tests.cs index aa08c444..f3d3fb51 100644 --- a/tests/module-settings-tests.cs +++ b/tests/module-settings-tests.cs @@ -828,7 +828,8 @@ public void DefaultButtonBindingsStartEmptyExceptMenuAndLeftAltHolds() ["EntityInspectorCursorHold"] = ((XnaButtons)0, Keys.LeftAlt), ["ClickTeleportCursor"] = ((XnaButtons)0, Keys.LeftAlt), ["CursorZoomHold"] = ((XnaButtons)0, Keys.LeftAlt), - ["CursorToolsHold"] = ((XnaButtons)0, Keys.LeftAlt) + ["CursorToolsHold"] = ((XnaButtons)0, Keys.LeftAlt), + ["MenuMouseHold"] = ((XnaButtons)0, Keys.LeftAlt) }; foreach (PropertyInfo property in typeof(AkronModuleSettings).GetProperties()) @@ -1363,24 +1364,28 @@ public void ScreenshotDefaultsKeepGameFrameCaptureSimple() } [Theory] - [InlineData(false, true, false, false)] - [InlineData(true, false, false, false)] - [InlineData(true, true, true, false)] - [InlineData(true, true, false, true)] - public void CursorToolsHoldRequiresSettingBindingAndHiddenOverlay(bool enabled, bool bindingHeld, bool overlayVisible, bool expected) + [InlineData(false, true, false, false, false)] + [InlineData(true, false, false, false, false)] + [InlineData(true, true, true, false, false)] + [InlineData(true, true, false, false, true)] + // Menu Mouse driving a menu owns the shared Left Alt hold. + [InlineData(true, true, false, true, false)] + public void CursorToolsHoldRequiresSettingBindingHiddenOverlayAndNoMenuMouse(bool enabled, bool bindingHeld, bool overlayVisible, bool menuMouseOwnsCursor, bool expected) { - Assert.Equal(expected, AkronModule.ShouldUseCursorToolsHold(enabled, bindingHeld, overlayVisible)); + Assert.Equal(expected, AkronModule.ShouldUseCursorToolsHold(enabled, bindingHeld, overlayVisible, menuMouseOwnsCursor)); } [Theory] - [InlineData(false, true, false, true, false)] - [InlineData(true, false, false, true, false)] - [InlineData(true, true, true, true, false)] - [InlineData(true, true, false, false, false)] - [InlineData(true, true, false, true, true)] - public void EntityInspectorCursorRequiresInspectorHoldHiddenOverlayAndPolicy(bool entityInspector, bool bindingHeld, bool overlayVisible, bool policyAllowed, bool expected) + [InlineData(false, true, false, true, false, false)] + [InlineData(true, false, false, true, false, false)] + [InlineData(true, true, true, true, false, false)] + [InlineData(true, true, false, false, false, false)] + [InlineData(true, true, false, true, false, true)] + // Menu Mouse driving a menu owns the shared Left Alt hold. + [InlineData(true, true, false, true, true, false)] + public void EntityInspectorCursorRequiresInspectorHoldHiddenOverlayPolicyAndNoMenuMouse(bool entityInspector, bool bindingHeld, bool overlayVisible, bool policyAllowed, bool menuMouseOwnsCursor, bool expected) { - Assert.Equal(expected, AkronModule.ShouldShowEntityInspectorCursor(entityInspector, bindingHeld, overlayVisible, policyAllowed)); + Assert.Equal(expected, AkronModule.ShouldShowEntityInspectorCursor(entityInspector, bindingHeld, overlayVisible, policyAllowed, menuMouseOwnsCursor)); } [Theory] @@ -1404,13 +1409,30 @@ public void ClickTeleportCursorUsesNormalBindOrCursorTools(bool clickTeleportEna Assert.Equal(expected, AkronModule.IsClickTeleportCursorActive(clickTeleportEnabled, clickTeleportHoldActive, cursorToolsHeld, cursorToolsClickTeleport)); } + [Fact] + public void InputHeldThroughAGatedFrameIsNotAFreshPress() + { + bool lastDown = false; + + // Paused: the Alt+click lands on the pause menu, not the room. + Assert.False(AkronModule.TrackFreshPress(down: true, gated: true, ref lastDown)); + // Unpaused by that click, still held: no teleport, no Cursor Zoom toggle. + Assert.False(AkronModule.TrackFreshPress(down: true, gated: false, ref lastDown)); + Assert.False(AkronModule.TrackFreshPress(down: false, gated: false, ref lastDown)); + // A new press while live counts once. + Assert.True(AkronModule.TrackFreshPress(down: true, gated: false, ref lastDown)); + Assert.False(AkronModule.TrackFreshPress(down: true, gated: false, ref lastDown)); + } + [Theory] - [InlineData(false, true, true, true)] - [InlineData(false, true, false, false)] - [InlineData(true, false, false, true)] - public void CursorToolsFreeCameraUsesMouseControlByDefault(bool freeCameraMouseControl, bool cursorToolsHeld, bool cursorToolsFreeCamera, bool expected) + [InlineData(false, true, true, false, true)] + [InlineData(false, true, false, false, false)] + [InlineData(true, false, false, false, true)] + // Menu Mouse driving a menu owns the pointer, even for the saved mouse-control setting. + [InlineData(true, false, false, true, false)] + public void CursorToolsFreeCameraUsesMouseControlByDefault(bool freeCameraMouseControl, bool cursorToolsHeld, bool cursorToolsFreeCamera, bool menuMouseOwnsCursor, bool expected) { - Assert.Equal(expected, AkronModule.IsFreeCameraMouseControlEffectiveEnabled(freeCameraMouseControl, cursorToolsHeld, cursorToolsFreeCamera)); + Assert.Equal(expected, AkronModule.IsFreeCameraMouseControlEffectiveEnabled(freeCameraMouseControl, cursorToolsHeld, cursorToolsFreeCamera, menuMouseOwnsCursor)); } [Theory]