From 561e7c02052596b3ee4912794087bb512c8adf05 Mon Sep 17 00:00:00 2001 From: wallstop Date: Mon, 19 Oct 2026 03:08:46 +0000 Subject: [PATCH 1/6] Add gamepad navigation and current search match markers --- CHANGELOG.md | 4 + README.md | 7 + .../Input/TerminalPlayerInputController.cs | 148 ++++++++++++- Runtime/CommandTerminal/UI/KeyEvents.cs | 2 +- Runtime/CommandTerminal/UI/TerminalUI.cs | 146 +++++++++++++ Styles/BaseStyles.uss | 15 ++ .../Capture/TerminalSurfaceCaptureTests.cs | 7 + .../TerminalPlayerInputControllerTests.cs | 202 +++++++++++++++++- Tests/Runtime/TerminalUILogFilterTests.cs | 95 ++++++++ 9 files changed, 622 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6855283..60b7e21f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Entry style follows [unity-helpers](https://github.com/Ambiguous-Interactive/uni ### Added +- Add gamepad control navigation, submit, cancel, and visible header focus ([#241](https://github.com/Ambiguous-Interactive/DxTerminal/issues/241), + [#245](https://github.com/Ambiguous-Interactive/DxTerminal/issues/245)). +- Add a current search-match row marker that preserves log text selection ([#242](https://github.com/Ambiguous-Interactive/DxTerminal/issues/242)). + - Add repeat collapse with entry-based search counts ([#242](https://github.com/wallstop/DxCommandTerminal/issues/242)). - Add Top, Bottom, and Auto terminal anchoring with safe-area placement ([#241](https://github.com/wallstop/DxCommandTerminal/issues/241), [#245](https://github.com/wallstop/DxCommandTerminal/issues/245)). diff --git a/README.md b/README.md index 0ca52c08..bb2c9df3 100644 --- a/README.md +++ b/README.md @@ -707,6 +707,13 @@ You can also use `PlayerInput` or similar to bind InputActions to all available - `CompleteCommand`: Uses auto-complete to attempt to complete the current command using what is typed in the command buffer, cycling forwards through results. - `ReverseCompleteCommand`: Uses auto-complete to attempt to complete the current command using what is typed in the command buffer, but cycling backwards through results. - `EnterCommand`: Takes the current buffer and attempts to execute it as a command + parameters. +- `Navigate`: Moves focus through terminal controls. Bind a Vector2 Value action to a stick or D-pad. +- `Submit`: Activates the focused control or runs the command line. Bind a Button action. +- `Cancel`: Returns to command input, then closes on the next press. Bind a Button action. + +Navigation starts at the header. Hold a direction to repeat. Focused controls show the theme's selected colors. Enabled, bound navigation actions own their corresponding UI Toolkit +events inside this terminal. Disable those actions to restore Unity's default navigation. Other UI documents keep their input. Search marks the current matching row. Prev and Next +move this marker without changing text selection. ## Note diff --git a/Runtime/CommandTerminal/Input/TerminalPlayerInputController.cs b/Runtime/CommandTerminal/Input/TerminalPlayerInputController.cs index f658a0ef..a6e9358d 100644 --- a/Runtime/CommandTerminal/Input/TerminalPlayerInputController.cs +++ b/Runtime/CommandTerminal/Input/TerminalPlayerInputController.cs @@ -4,6 +4,7 @@ namespace WallstopStudios.DxCommandTerminal.Input using UI; using UnityEngine; using UnityEngine.InputSystem; + using UnityEngine.UIElements; [DisallowMultipleComponent] public class TerminalPlayerInputController : MonoBehaviour @@ -13,12 +14,14 @@ public class TerminalPlayerInputController : MonoBehaviour /// panel focus. The live read is the shipped behavior; a game /// that wants a non-toggle character binding to keep running /// while the field has focus overrides this to false, - /// which restores the pre-rule behavior for the six messages the - /// rule covers. The two toggle messages do not consult this + /// which restores the pre-rule behavior for non-toggle messages. + /// The two toggle messages do not consult this /// property - their key is reserved (#218). /// protected virtual bool TextInputOwnsFocus => TerminalUI.AnyConsoleFieldOwnsFocus(); + protected virtual float NavigationTime => Time.unscaledTime; + [Header("System")] public bool enableWarnings = true; @@ -31,6 +34,10 @@ public class TerminalPlayerInputController : MonoBehaviour protected PlayerInput _playerInput; + private VisualElement _navigationRoot; + private Vector2 _navigationDirection; + private float? _nextNavigationRepeat; + public virtual void OnHandlePrevious(InputValue inputValue) { if (!ShouldHandleMessage("HandlePrevious")) @@ -109,6 +116,64 @@ public virtual void OnEnterCommand(InputValue inputValue) terminal.EnterCommand(); } + public virtual void OnNavigate(InputValue inputValue) + { + if (!ShouldHandleMessage("Navigate", allowLogFocus: true) || inputValue == null) + { + _navigationDirection = Vector2.zero; + _nextNavigationRepeat = null; + return; + } + + PrepareNavigation(); + Vector2 value = inputValue.Get(); + if (!float.IsFinite(value.x) || !float.IsFinite(value.y)) + { + _navigationDirection = Vector2.zero; + _nextNavigationRepeat = null; + return; + } + + float horizontal = Mathf.Abs(value.x); + float vertical = Mathf.Abs(value.y); + Vector2 direction = + Mathf.Max(horizontal, vertical) < 0.5f ? Vector2.zero + : vertical < horizontal ? new Vector2(Mathf.Sign(value.x), 0f) + : new Vector2(0f, Mathf.Sign(value.y)); + if (direction == _navigationDirection) + { + return; + } + + _navigationDirection = direction; + if (direction == Vector2.zero) + { + _nextNavigationRepeat = null; + return; + } + + terminal.Navigate(direction); + _nextNavigationRepeat = NavigationTime + 0.5f; + } + + public virtual void OnSubmit(InputValue inputValue) + { + if (ShouldHandleMessage("Submit", allowLogFocus: true) && inputValue != null && inputValue.isPressed) + { + PrepareNavigation(); + terminal.SubmitNavigation(); + } + } + + public virtual void OnCancel(InputValue inputValue) + { + if (ShouldHandleMessage("Cancel", allowLogFocus: true) && inputValue != null && inputValue.isPressed) + { + PrepareNavigation(); + terminal.CancelNavigation(); + } + } + protected virtual void Awake() { _playerInput = _serializedPlayerInput; @@ -134,11 +199,90 @@ protected virtual void Awake() protected virtual void OnEnable() { _enabled = true; + PrepareNavigation(); } protected virtual void OnDisable() { _enabled = false; + ReleaseNavigation(); + } + + protected virtual void Update() + { + if (!ShouldHandleMessage("Navigate", allowLogFocus: true) || !OwnsNavigation("Navigate") || terminal.State is not (TerminalState.OpenSmall or TerminalState.OpenFull)) + { + _navigationDirection = Vector2.zero; + _nextNavigationRepeat = null; + return; + } + + if (_nextNavigationRepeat is float nextRepeat && nextRepeat <= NavigationTime) + { + terminal.Navigate(_navigationDirection); + _nextNavigationRepeat = NavigationTime + 0.1f; + } + } + + protected virtual void LateUpdate() + { + PrepareNavigation(); + } + + private void PrepareNavigation() + { + VisualElement documentRoot = terminal == null || terminal._uiDocument == null ? null : terminal._uiDocument.rootVisualElement; + if (documentRoot != null && _navigationRoot != null && _navigationRoot.parent == documentRoot) + { + return; + } + + ReleaseNavigation(); + _navigationRoot = documentRoot?.Q("TerminalRoot"); + _navigationRoot?.RegisterCallback(HandleNativeMove, TrickleDown.TrickleDown); + _navigationRoot?.RegisterCallback(HandleNativeSubmit, TrickleDown.TrickleDown); + _navigationRoot?.RegisterCallback(HandleNativeCancel, TrickleDown.TrickleDown); + } + + private void ReleaseNavigation() + { + _navigationRoot?.UnregisterCallback(HandleNativeMove, TrickleDown.TrickleDown); + _navigationRoot?.UnregisterCallback(HandleNativeSubmit, TrickleDown.TrickleDown); + _navigationRoot?.UnregisterCallback(HandleNativeCancel, TrickleDown.TrickleDown); + _navigationRoot = null; + _navigationDirection = Vector2.zero; + _nextNavigationRepeat = null; + } + + private bool OwnsNavigation(string actionName) + { + InputAction action = FindAction(actionName); + return action != null && action.enabled && 0 < action.bindings.Count; + } + + private void HandleNativeMove(NavigationMoveEvent evt) + { + SuppressOwnedNavigation(evt, "Navigate"); + } + + private void HandleNativeSubmit(NavigationSubmitEvent evt) + { + SuppressOwnedNavigation(evt, "Submit"); + } + + private void HandleNativeCancel(NavigationCancelEvent evt) + { + SuppressOwnedNavigation(evt, "Cancel"); + } + + // The configured controller owns this route, including native default bindings. + private void SuppressOwnedNavigation(EventBase evt, string actionName) + { + if (IsLive() && !terminal.IsExplicitNavigationEvent(evt) && OwnsNavigation(actionName)) + { + evt.StopImmediatePropagation(); + KeyEvents.Consume(_navigationRoot, evt); + } } /* diff --git a/Runtime/CommandTerminal/UI/KeyEvents.cs b/Runtime/CommandTerminal/UI/KeyEvents.cs index 0a1046b7..3a646e11 100644 --- a/Runtime/CommandTerminal/UI/KeyEvents.cs +++ b/Runtime/CommandTerminal/UI/KeyEvents.cs @@ -28,7 +28,7 @@ The element supplies the panel. A null panel is tolerated rather dispatched element, so there is always one, and a null-check here keeps a teardown race from throwing inside a key handler. */ - public static void Consume(VisualElement element, KeyDownEvent evt) + public static void Consume(VisualElement element, EventBase evt) { evt.StopPropagation(); diff --git a/Runtime/CommandTerminal/UI/TerminalUI.cs b/Runtime/CommandTerminal/UI/TerminalUI.cs index bd2ced37..70150a60 100644 --- a/Runtime/CommandTerminal/UI/TerminalUI.cs +++ b/Runtime/CommandTerminal/UI/TerminalUI.cs @@ -331,6 +331,9 @@ that jumped to none. private Button _logSearchNext; private Button _logSearchClear; private Button _sizeButton; + private readonly Dictionary _queuedNavigationEvents = new(); + private EventBase _explicitNavigationEvent; + private VisualElement _highlightedLogRow; private Button _collapseButton; private bool _collapseLogs; private bool _lastSeenCollapse; @@ -670,6 +673,7 @@ private void Start() private void LateUpdate() { + ReleaseNavigationEvents(); /* The idle check runs before the animation step: the frame where HandleHeightAnimation snaps to the closed target is the frame @@ -1692,6 +1696,58 @@ correctly and then ignored them. } } + /// Moves focus through the open terminal's controls. + public void Navigate(Vector2 direction) + { + if (!TryGetNavigationTarget(out VisualElement target) || !float.IsFinite(direction.x) || !float.IsFinite(direction.y) || direction == Vector2.zero) + { + return; + } + if (InputOwnsFocus || target == _terminalContainer) + { + _sizeButton.Focus(); + return; + } + DispatchNavigationEvent(target, NavigationMoveEvent.GetPooled(direction)); + } + + /// Activates the focused control or runs the command line. + public void SubmitNavigation() + { + if (!TryGetNavigationTarget(out VisualElement target)) + { + return; + } + if (InputOwnsFocus) + { + EnterCommand(); + return; + } + if (SearchOwnsFocus) + { + StepLogSearch(true); + return; + } + DispatchNavigationEvent(target, NavigationSubmitEvent.GetPooled()); + } + + /// Returns to command input, then closes on the next cancel. + public void CancelNavigation() + { + if (!TryGetNavigationTarget(out _)) + { + return; + } + if (InputOwnsFocus) + { + Close(); + } + else + { + FocusInput(allowLogFocus: true); + } + } + public void CompleteCommand(bool searchForward = true) { if (!IsOpenState(_state)) @@ -1903,6 +1959,11 @@ internal void RetryInputFocus() } } + internal bool IsExplicitNavigationEvent(EventBase evt) + { + return ReferenceEquals(_explicitNavigationEvent, evt); + } + internal void HandleLogCopyResult(TextField field, KeyDownEvent evt, bool verified) { if (field == null || evt == null) @@ -1952,6 +2013,8 @@ internal void TeardownUI() _logSearchNext = null; _logSearchClear = null; _sizeButton = null; + ReleaseNavigationEvents(); + _highlightedLogRow = null; _collapseButton = null; Array.Clear(_severityButtons, 0, _severityButtons.Length); Array.Clear(_lastSeverityCounts, 0, _lastSeverityCounts.Length); @@ -2726,6 +2789,8 @@ definition is written after InitializeFont below. uiRoot.Add(root); root.name = TerminalRootName; root.AddToClassList("terminal-root"); + root.RegisterCallback(static (evt, terminal) => terminal.ObserveNavigationEvent(evt), this, TrickleDown.TrickleDown); + root.RegisterCallback(static (evt, terminal) => terminal.ObserveNavigationEvent(evt), this, TrickleDown.TrickleDown); root.RegisterCallback(static (_, context) => context.ResetWindowIdempotent(), this); InitializeTheme(root); @@ -3324,6 +3389,76 @@ private void ReleaseLogFocus() } } + private void DispatchNavigationEvent(VisualElement target, EventBase evt) + { + // Retain the pooled reference until queued dispatch has crossed our root. + _queuedNavigationEvents.Add(evt, true); + try + { + evt.target = target; + target.SendEvent(evt); + } + finally + { + if (_queuedNavigationEvents.ContainsKey(evt)) + { + _queuedNavigationEvents[evt] = false; + } + else + { + evt.Dispose(); + } + } + } + + private void ObserveNavigationEvent(EventBase evt) + { + if (_queuedNavigationEvents.TryGetValue(evt, out bool sending)) + { + _queuedNavigationEvents.Remove(evt); + _explicitNavigationEvent = evt; + if (!sending) + { + evt.Dispose(); + } + } + else + { + _explicitNavigationEvent = null; + } + } + + private void ReleaseNavigationEvents() + { + // Ancestor callbacks can stop an event before our root observes it. + foreach (KeyValuePair pending in _queuedNavigationEvents) + { + if (!pending.Value) + { + pending.Key.Dispose(); + } + } + _queuedNavigationEvents.Clear(); + _explicitNavigationEvent = null; + } + + private bool TryGetNavigationTarget(out VisualElement target) + { + if (!isActiveAndEnabled || !IsOpenState(_state) || IsPaletteSurfaceOpen() || _terminalContainer?.panel == null) + { + target = null; + return false; + } + VisualElement focused = _terminalContainer.panel.focusController.focusedElement as VisualElement; + if (focused != null && !_terminalContainer.Contains(focused)) + { + target = null; + return false; + } + target = focused ?? _terminalContainer; + return true; + } + private bool CanFocusInput(bool allowLogFocus = false) { if (!IsOpenState(_state) || IsPaletteSurfaceOpen() || _textInput == null || _textInput.focusController == null) @@ -3490,6 +3625,17 @@ evt.currentTarget is not TextField field } } + int? currentMatch = _logFilter.HasQuery ? _logFilter.CurrentMatch : null; + int sourceIndex = currentMatch.GetValueOrDefault() - 1; + int activeIndex = 0 <= sourceIndex && sourceIndex < _logWindowCache.SourceCount ? _logWindowCache.GetRenderedIndex(sourceIndex) : -1; + VisualElement highlighted = 0 <= activeIndex && activeIndex < logCount ? content[activeIndex] : null; + if (!ReferenceEquals(_highlightedLogRow, highlighted)) + { + _highlightedLogRow?.RemoveFromClassList("terminal-output-label--current-match"); + highlighted?.AddToClassList("terminal-output-label--current-match"); + _highlightedLogRow = highlighted; + } + _needsScrollToEnd |= ObserveLogTail(newLogs); ApplyPendingFindScroll(content, logCount); _jumpLatestButton.style.display = _logTail.Detached ? DisplayStyle.Flex : DisplayStyle.None; diff --git a/Styles/BaseStyles.uss b/Styles/BaseStyles.uss index ab3917b8..8af3777d 100644 --- a/Styles/BaseStyles.uss +++ b/Styles/BaseStyles.uss @@ -250,6 +250,12 @@ white-space: normal; margin: 0; padding: 0; + border-left-width: 3px; + border-left-color: transparent; +} + +.terminal-output-label--current-match { + border-left-color: var(--caret-color); } .terminal-output-label > .unity-base-field__label { @@ -600,3 +606,12 @@ margin-top: 6px; font-size: 10px; color: var(--text-shell, rgba(158, 158, 158, 1)); +} + +.terminal-header .terminal-button:focus, +.terminal-search .terminal-button:focus, +.terminal-severities .terminal-button:focus, +.input-container .terminal-button:focus { + background-color: var(--button-selected-bg); + color: var(--button-selected-text); +} diff --git a/Tests/Runtime/Capture/TerminalSurfaceCaptureTests.cs b/Tests/Runtime/Capture/TerminalSurfaceCaptureTests.cs index 88ac3212..b1dca58e 100644 --- a/Tests/Runtime/Capture/TerminalSurfaceCaptureTests.cs +++ b/Tests/Runtime/Capture/TerminalSurfaceCaptureTests.cs @@ -490,10 +490,17 @@ public IEnumerator CapturesNarrowScreenTerminalSmall() Assert.That(logContent.childCount, Is.EqualTo(2), "The repeat group and wrapped message remain visible"); TextField repeat = (TextField)logContent[0]; Assert.That(repeat.label, Does.EndWith(" x2")); + Assert.That(repeat.ClassListContains("terminal-output-label--current-match"), Is.True); + Assert.That(repeat.resolvedStyle.borderLeftWidth, Is.EqualTo(3)); + Assert.That(repeat.resolvedStyle.borderLeftColor.a, Is.GreaterThan(0)); Vector2 metadataSize = repeat.labelElement.MeasureTextSize(repeat.label, 0f, VisualElement.MeasureMode.Undefined, 0f, VisualElement.MeasureMode.Undefined); Assert.That(repeat.labelElement.layout.width + 0.5f, Is.GreaterThanOrEqualTo(metadataSize.x), "The repeat badge fits"); Assert.That(root.Q("TerminalRoot").ClassListContains("terminal-root--bottom"), Is.True); Assert.That(root.worldBound.yMax, Is.EqualTo(root.panel.visualTree.worldBound.yMax).Within(0.5f), "Auto pins the narrow sheet to the viewport bottom"); + Button sizeControl = root.Q