diff --git a/.editorconfig b/.editorconfig index b58cf93a..cd7d387b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,6 +11,14 @@ indent_size = 4 csharp_new_line_before_members_in_object_initializers = false csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion csharp_prefer_braces = true:suggestion +csharp_style_expression_bodied_methods = false:warning +csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_operators = false:warning +csharp_style_expression_bodied_properties = false:warning +csharp_style_expression_bodied_indexers = false:warning +csharp_style_expression_bodied_accessors = false:warning +csharp_style_expression_bodied_local_functions = false:warning +csharp_style_expression_bodied_lambdas = false:warning csharp_style_prefer_utf8_string_literals = true:suggestion csharp_style_var_elsewhere = false:suggestion csharp_style_var_for_built_in_types = false:suggestion diff --git a/.llm/context.md b/.llm/context.md index 4d767797..df597c74 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -81,7 +81,8 @@ Install the SDK selected by root `global.json` before running .NET tooling. CI a 1. `using` directives go INSIDE the namespace block (see any file in `Runtime/`); `#if` blocks INSIDE namespace; `#define` at file top. 2. All code is formatted via [CSharpier](https://csharpier.com/): `dotnet tool run csharpier format` (tools pinned in `.config/dotnet-tools.json`, enforced by pre-commit). Do not hand-format. -3. Explicit types over `var`; explicit access modifiers on every member. +3. Explicit types over `var`; explicit access modifiers on every member. Use braces for all function bodies and property accessors, including small forwarding methods and lambdas. + Expression-bodied members and expression lambdas are forbidden. Switch-expression arms keep their required `=>` syntax. 4. Classes/structs are `sealed`/`readonly` where possible; collections exposed immutably by default - mutable access only when necessary. 5. Zero warnings. No dead code, no commented-out code. A Debug.Log-family call logs the exception itself, never `.Message` (the in-game funnel renders the message by design; enforced by `npm --prefix tooling~ run lint:exception-tostring`). diff --git a/.llm/skills/formatting-and-linting/SKILL.md b/.llm/skills/formatting-and-linting/SKILL.md index 5b466d14..05a17bd1 100644 --- a/.llm/skills/formatting-and-linting/SKILL.md +++ b/.llm/skills/formatting-and-linting/SKILL.md @@ -20,14 +20,19 @@ Restore tools first (`dotnet tool restore`) - the `dotnet-tool-restore` pre-comm ## Formatting 1. Format only the files you touched: `dotnet tool run csharpier format -- ` -2. Whole repo check: `dotnet tool run csharpier format --check .` (or run pre-commit). -3. NEVER hand-adjust CSharpier output (spacing, braces, wrapping). If output looks wrong, the fix is upstream CSharpier config, not manual edits. +2. Whole repo check: `dotnet tool run csharpier check .` (or run pre-commit). +3. For Markdown C# samples, use stdin with `--write-stdout --stdin-path /absolute/Example.cs`. Ignored temporary files can be skipped by the formatter. +4. NEVER hand-adjust CSharpier output (spacing, braces, wrapping). If output looks wrong, the fix is upstream CSharpier config, not manual edits. ## Style contract (from `.editorconfig` + README) - `using` directives inside the namespace block; `#if` blocks inside namespace. - Explicit types over `var`; explicit access modifiers everywhere. -- Braces always (`csharp_prefer_braces`). +- Braces always: methods, constructors, operators, local functions, property/indexer accessors, and lambdas use block bodies. Small forwarding methods follow the same rule. + `csharp_prefer_braces` covers control flow; `csharp_style_expression_bodied_* = false` covers function/member bodies. CSharpier preserves the chosen body form, so formatting + alone cannot enforce this rule. Sweep touched C# files for expression bodies after formatting. Keep the required `=>` syntax in switch-expression arms. +- Preserve callback return semantics when adding braces: `Action` callbacks execute statements; `Func` callbacks return values. Preserve `async`, `ref` returns, and exception flow. + Use syntax-aware review to distinguish methods/properties, expression lambdas, block lambdas, and switch arms; a raw `=>` match does not classify them. - Modifier order: `public private protected internal file new static abstract virtual sealed readonly override extern unsafe volatile async required`. - Files: UTF-8 BOM + CRLF for C# assets (repo default). Do not "fix" line endings on untouched files - that pollutes diffs. Exceptions: `.llm/**` and `tooling~/scripts/**` are UTF-8 no BOM + LF (enforced by `.editorconfig` overrides and the LLM linters). diff --git a/.llm/skills/input-system-integration/SKILL.md b/.llm/skills/input-system-integration/SKILL.md index cd47ebc5..7124fccc 100644 --- a/.llm/skills/input-system-integration/SKILL.md +++ b/.llm/skills/input-system-integration/SKILL.md @@ -16,7 +16,7 @@ metadata: ## PlayerInput binding -Bind InputActions to these messages (UnityEvents/`SendMessage` style on the Terminal): +Default `Send Messages` actions use these message names on the Terminal: | Message | Action | | ------------------------ | ---------------------- | @@ -28,6 +28,23 @@ Bind InputActions to these messages (UnityEvents/`SendMessage` style on the Term | `CompleteCommand` | Auto-complete forward | | `ReverseCompleteCommand` | Auto-complete backward | | `EnterCommand` | Execute current buffer | +| `Navigate` | Move control focus | +| `Submit` | Activate focused UI | +| `Cancel` | Return to input/close | + +`TerminalPlayerInputController` serializes all eleven `*ActionName` fields. Default names retain `Send Messages` routing. Custom names use `PlayerInput` Behavior +`Invoke C Sharp Events`; the controller subscribes to `onActionTriggered` while enabled. Identifiers accept names or `MapName/ActionName` paths from that player's action asset. +Keep each route on a distinct action. Resolve unqualified names in the current map before the asset. Missing, blank, or malformed identifiers do not claim native navigation. + +When changing identifiers, sweep dispatch, typing-control lookup, repeat/cancel state, and native UI Toolkit ownership together. `Send Messages` derives handler names from action +names; replacing lookup literals alone does not route renamed actions. Callback routing must ignore `started` and mirror message phases: `performed`, plus `canceled` for Value +actions. Submit/Cancel also require pressed values. Do not mutate the player's notification behavior. Pair event subscriptions across enable/disable; test re-enabling for +duplicates. The controller fixture runs the same queued-device suite with defaults and serialized custom map paths. Keep both modes passing. + +Both adapters call protected virtual typed action hooks: no arguments for command/history/toggle routes, `Vector2` for Navigate, and pressed `bool` for Submit/Cancel. Subclasses +use those hooks to customize both modes. Public `On*(InputValue)` overrides remain message-specific. Never fabricate InputValue or call its adapters with null from a +CallbackContext route: subclasses may read that argument. Preserve the typing gate before reading navigation's Vector2; a suppressed scalar character binding must not be cast. Test +every subclass hook once under both modes. When using PlayerInput, UNCHECK `Use Hotkeys` on the Terminal - otherwise both paths fire. @@ -45,9 +62,9 @@ mouse, joystick) and counts anything else as typing, so an unrecognized name fai `AnyKeyControl` always types, a `KeyControl` defers to `ProducesTypedText(name)` unless `Keyboard.current.ctrlKey.isPressed`, and any other control types nothing. It fails OPEN (no control -> the message runs), because a console that silently ignores `Close` is worse than a toggle that fires once. -Focus is read through `TerminalUI.AnyConsoleFieldOwnsFocus()`; a closed terminal does not count. `TerminalPlayerInputController` applies the rule to all eight messages through -`ShouldHandleMessage(actionName)`: the action name is the message name minus `On`, it resolves the action from `PlayerInput.currentActionMap` first (an action in another map has no -active control) then the asset, and a name that resolves to nothing leaves the message to run. The string rule's rows live in `Tests/Editor/InputHelpersTests.cs`; the control +Focus is read through `TerminalUI.AnyConsoleFieldOwnsFocus()`; a closed terminal does not count. `TerminalPlayerInputController` applies the rule to non-toggle messages through +`ShouldHandleMessage(actionName)`: each route passes its serialized identifier. It resolves the action from `PlayerInput.currentActionMap` first, then the asset; an action in +another map has no active control. A name that resolves to nothing leaves hand-sent messages to run. The string rule's rows live in `Tests/Editor/InputHelpersTests.cs`; the control rule's rows and the Play Mode rig live in `Tests/Runtime/TerminalPlayerInputControllerTests.cs`, which needs a live device. Add rows in both directions to whichever owns the rule you change. @@ -55,6 +72,15 @@ Three limits to keep in step with the docs: a `ctrl+` chord is recognized only f message still names the key so a character binding is deferred on both halves but a non-typing one runs twice, and a hand-sent message or an unresolvable action name runs as it always has. Only `shift+` and `ctrl+` are parsed in a binding string; `cmd+`/`super+`/`alt+` never resolve to a key. +## Navigation ownership and cancellation + +A configured enabled/bound navigation action owns its native UI Toolkit route inside the terminal; explicit terminal API events still pass. Disabling the action/controller returns +ownership to native navigation. Test real queued stick/button input: direct API calls cannot expose duplicate native Submit dispatch. UI fixtures must load the shipped TSS so the +focus ring uses product geometry. + +Cancel must stop pending repeats AND block held directions until neutral. A typing gate alone misses gamepad input. Test cancel while held, jitter/direction changes, mixed keyboard +and pad bindings, direct API cancel, and action-map handoff. Preserve the neutral latch when a typing control takes over; reset it only on a real release or ownership loss. + ## History navigation semantics - Up/Down navigation past either end yields a blank command (no "sticking"). diff --git a/.llm/skills/llm-attribution/SKILL.md b/.llm/skills/llm-attribution/SKILL.md index 96d72ae9..7dc5611a 100644 --- a/.llm/skills/llm-attribution/SKILL.md +++ b/.llm/skills/llm-attribution/SKILL.md @@ -27,15 +27,21 @@ One line per artifact. Keep it when editing LLM-generated content; remove it onl ## Reading review feedback +Fetch all review surfaces before triage: paginated PR reviews, inline review comments (including replies), issue comments, and GraphQL review threads. Check pagination on nested +comments too. Read human directives and bot leads, including resolved threads; do not infer the feedback set from check conclusions alone. + A review comment names a line at the commit the reviewer saw, not the head. Cursor Bugbot's review lags the branch by a push or two and marks its superseded run `BUGBOT_REVIEW_STALE`, so a comment can describe code that is already fixed. Check the commit each comment names against the branch before acting: ```sh -gh api "repos/wallstop/DxCommandTerminal/pulls//comments" \ - -q '.[] | "\(.user.login) \(.commit_id[0:7]) \(.path):\(.line)"' -gh log --oneline master..HEAD +gh api --paginate "repos/Ambiguous-Interactive/DxTerminal/pulls//comments" \ + -q '.[] | "\(.user.login) \(.original_commit_id[0:7]) \(.path):\(.line)"' +git log --oneline master..HEAD ``` +GitHub can remap `commit_id` to a later head while retaining the original review. Check `original_commit_id`, the review's `commit_id`, and the bot's named commit before calling a +finding current or stale. A resolved thread still needs a verified fix; a new green bot run does not erase human feedback. + `wallstop` is the human reviewer; a comment of theirs is a directive, so read it as one and apply it or answer it with evidence. A bot comment is a lead: verify the mechanism before changing code, and when it is wrong, say so in a reply with the code and the measurement, rather than silently skipping it. Record a disagreement in `progress/` either way - a bot finding that was wrong is a fact worth keeping. diff --git a/CHANGELOG.md b/CHANGELOG.md index b6855283..6afda645 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ Entry style follows [unity-helpers](https://github.com/Ambiguous-Interactive/uni ### Added +- Add serialized PlayerInput action names and custom-map routing through C# events ([#245](https://github.com/Ambiguous-Interactive/DxTerminal/issues/245)). + +- 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..a1c45a45 100644 --- a/README.md +++ b/README.md @@ -241,19 +241,36 @@ void OnEnable() Terminal.Shell.AddCommand( CommandBuilder .Create("heal", "Heals a target") - .Arg("amount", spec => spec.Required().Range(1, 100)) - .Arg("target", spec => spec.Default("self").Choices("self", "ally")) - .Handler((context, arguments) => - { - int amount = arguments.Get(0); - string target = arguments.Get("target"); - // ... - }), + .Arg( + "amount", + spec => + { + return spec.Required().Range(1, 100); + } + ) + .Arg( + "target", + spec => + { + return spec.Default("self").Choices("self", "ally"); + } + ) + .Handler( + (context, arguments) => + { + int amount = arguments.Get(0); + string target = arguments.Get("target"); + // ... + } + ), out handle ); } -void OnDisable() => handle?.Dispose(); +void OnDisable() +{ + handle?.Dispose(); +} ``` Arguments parse through the same `CommandArg` parsers as `[RegisterCommand]` methods (including your registered custom parsers). Bad user input - unknown values, out-of-range @@ -267,19 +284,42 @@ Subcommands route one registered command through its first argument, each with i Terminal.Shell.AddCommand( CommandBuilder .Create("inventory", "Manage the inventory") - .Handler((context, arguments) => { /* runs for a bare `inventory` */ }) + .Handler((context, arguments) => { }) .Subcommand( "add", - add => add - .Arg("item", spec => spec.Required()) - .Arg("count", spec => spec.Default(1).Range(1, 99)) - .Handler((context, arguments) => { /* ... */ }) + add => + { + add.Arg( + "item", + spec => + { + return spec.Required(); + } + ) + .Arg( + "count", + spec => + { + return spec.Default(1).Range(1, 99); + } + ) + .Handler((context, arguments) => { }); + } ) .Subcommand( "remove", - remove => remove - .Arg("item", spec => spec.Required()) - .Handler((context, arguments) => { /* ... */ }) + remove => + { + remove + .Arg( + "item", + spec => + { + return spec.Required(); + } + ) + .Handler((context, arguments) => { }); + } ), out handle ); @@ -463,11 +503,17 @@ CommandBuilder command = CommandBuilder .Create("inspect-object") .Arg( "target", - spec => spec.Required() - .RawParser(adapter.TryParse) - .Choices(adapter.GetChoices, adapter.FormatChoice) + spec => + { + return spec.Required().RawParser(adapter.TryParse).Choices(adapter.GetChoices, adapter.FormatChoice); + } ) - .Handler((context, arguments) => Terminal.Log("{0}", arguments.Get("target").name)); + .Handler( + (context, arguments) => + { + Terminal.Log("{0}", arguments.Get("target").name); + } + ); ``` The same setup works for component types. `FormatChoice` preserves the original name; it does not normalize line endings. Raw parsing does not bypass command tokenization or @@ -707,6 +753,20 @@ 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. + +The `TerminalPlayerInputController` inspector serializes an action name for each route. Existing `Send Messages` setups keep the default names above. To use different names, set +`PlayerInput` Behavior to `Invoke C Sharp Events` and assign those names on the controller. Use `MapName/ActionName` to select a specific map, for example `Console/MoveFocus`. The +controller subscribes automatically. Keep each route on a distinct action. Unqualified names resolve in the current action map first, then the player's asset. + +Subclasses can override the protected action methods in either mode: `HandlePrevious()`, `HandleNext()`, `Close()`, `ToggleSmall()`, `ToggleFull()`, `CompleteCommand()`, +`ReverseCompleteCommand()`, `EnterCommand()`, `Navigate(Vector2)`, `Submit(bool)`, and `Cancel(bool)`. The public `On*(InputValue)` methods remain message-specific adapters. + +Navigation starts at the header. Hold a direction to repeat. Cancel ignores held directions until they return to neutral. 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..3cf509a9 100644 --- a/Runtime/CommandTerminal/Input/TerminalPlayerInputController.cs +++ b/Runtime/CommandTerminal/Input/TerminalPlayerInputController.cs @@ -1,9 +1,11 @@ namespace WallstopStudios.DxCommandTerminal.Input { #if ENABLE_INPUT_SYSTEM + using System; using UI; using UnityEngine; using UnityEngine.InputSystem; + using UnityEngine.UIElements; [DisallowMultipleComponent] public class TerminalPlayerInputController : MonoBehaviour @@ -13,17 +15,48 @@ 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 bool TextInputOwnsFocus + { + get { return TerminalUI.AnyConsoleFieldOwnsFocus(); } + } + + protected virtual float NavigationTime + { + get { return Time.unscaledTime; } + } [Header("System")] public bool enableWarnings = true; public TerminalUI terminal; + [Header("Action Names")] + public string handlePreviousActionName = "HandlePrevious"; + + public string handleNextActionName = "HandleNext"; + + public string closeActionName = "Close"; + + public string toggleSmallActionName = "ToggleSmall"; + + public string toggleFullActionName = "ToggleFull"; + + public string completeCommandActionName = "CompleteCommand"; + + public string reverseCompleteCommandActionName = "ReverseCompleteCommand"; + + public string enterCommandActionName = "EnterCommand"; + + public string navigateActionName = "Navigate"; + + public string submitActionName = "Submit"; + + public string cancelActionName = "Cancel"; + protected bool _enabled; [SerializeField] @@ -31,34 +64,104 @@ public class TerminalPlayerInputController : MonoBehaviour protected PlayerInput _playerInput; + private VisualElement _navigationRoot; + private Vector2 _navigationDirection; + private float? _nextNavigationRepeat; + private long _navigationCancellationVersion; + private bool _navigationNeedsNeutral; + public virtual void OnHandlePrevious(InputValue inputValue) { - if (!ShouldHandleMessage("HandlePrevious")) + HandlePrevious(); + } + + public virtual void OnHandleNext(InputValue inputValue) + { + HandleNext(); + } + + public virtual void OnClose(InputValue inputValue) + { + Close(); + } + + public virtual void OnToggleSmall(InputValue inputValue) + { + ToggleSmall(); + } + + public virtual void OnToggleFull(InputValue inputValue) + { + ToggleFull(); + } + + public virtual void OnCompleteCommand(InputValue input) + { + CompleteCommand(); + } + + public virtual void OnReverseCompleteCommand(InputValue input) + { + ReverseCompleteCommand(); + } + + public virtual void OnEnterCommand(InputValue inputValue) + { + EnterCommand(); + } + + public virtual void OnNavigate(InputValue inputValue) + { + if (inputValue == null) + { + _navigationDirection = Vector2.zero; + _nextNavigationRepeat = null; + return; + } + if (PrepareNavigationInput()) + { + Navigate(inputValue.Get()); + } + } + + public virtual void OnSubmit(InputValue inputValue) + { + Submit(inputValue != null && inputValue.isPressed); + } + + public virtual void OnCancel(InputValue inputValue) + { + Cancel(inputValue != null && inputValue.isPressed); + } + + protected virtual void HandlePrevious() + { + if (!ShouldHandleMessage(handlePreviousActionName)) { return; } terminal.HandlePrevious(); } - public virtual void OnHandleNext(InputValue inputValue) + protected virtual void HandleNext() { - if (!ShouldHandleMessage("HandleNext")) + if (!ShouldHandleMessage(handleNextActionName)) { return; } terminal.HandleNext(); } - public virtual void OnClose(InputValue inputValue) + protected virtual void Close() { - if (!ShouldHandleMessage("Close", allowLogFocus: true)) + if (!ShouldHandleMessage(closeActionName, allowLogFocus: true)) { return; } terminal.Close(); } - public virtual void OnToggleSmall(InputValue inputValue) + protected virtual void ToggleSmall() { /* The toggle key is reserved: it opens and closes the surface @@ -73,7 +176,7 @@ gate does. terminal.ToggleSmall(); } - public virtual void OnToggleFull(InputValue inputValue) + protected virtual void ToggleFull() { if (!IsLive()) { @@ -82,27 +185,27 @@ public virtual void OnToggleFull(InputValue inputValue) terminal.ToggleFull(); } - public virtual void OnCompleteCommand(InputValue input) + protected virtual void CompleteCommand() { - if (!ShouldHandleMessage("CompleteCommand")) + if (!ShouldHandleMessage(completeCommandActionName)) { return; } terminal.CompleteCommand(searchForward: true); } - public virtual void OnReverseCompleteCommand(InputValue input) + protected virtual void ReverseCompleteCommand() { - if (!ShouldHandleMessage("ReverseCompleteCommand")) + if (!ShouldHandleMessage(reverseCompleteCommandActionName)) { return; } terminal.CompleteCommand(searchForward: false); } - public virtual void OnEnterCommand(InputValue inputValue) + protected virtual void EnterCommand() { - if (!ShouldHandleMessage("EnterCommand")) + if (!ShouldHandleMessage(enterCommandActionName)) { return; } @@ -134,11 +237,252 @@ protected virtual void Awake() protected virtual void OnEnable() { _enabled = true; + if (_playerInput != null) + { + _playerInput.onActionTriggered += HandleActionTriggered; + } + PrepareNavigation(); } protected virtual void OnDisable() { _enabled = false; + if (_playerInput != null) + { + _playerInput.onActionTriggered -= HandleActionTriggered; + } + ReleaseNavigation(); + } + + protected virtual void Update() + { + if (IsLive()) + { + ObserveNavigationCancellation(); + } + bool ownsNavigation = OwnsNavigation(navigateActionName); + if (!ownsNavigation) + { + _navigationNeedsNeutral = false; + } + if (!ShouldHandleMessage(navigateActionName, allowLogFocus: true) || !ownsNavigation || 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(); + } + + protected virtual void Navigate(Vector2 value) + { + 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 == Vector2.zero) + { + _navigationNeedsNeutral = false; + _navigationDirection = Vector2.zero; + _nextNavigationRepeat = null; + return; + } + if (_navigationNeedsNeutral || direction == _navigationDirection) + { + return; + } + + _navigationDirection = direction; + terminal.Navigate(direction); + _nextNavigationRepeat = NavigationTime + 0.5f; + } + + protected virtual void Submit(bool pressed) + { + if (pressed && ShouldHandleMessage(submitActionName, allowLogFocus: true)) + { + PrepareNavigation(); + terminal.SubmitNavigation(); + } + } + + protected virtual void Cancel(bool pressed) + { + if (pressed && ShouldHandleMessage(cancelActionName, allowLogFocus: true)) + { + PrepareNavigation(); + terminal.CancelNavigation(); + } + } + + private bool PrepareNavigationInput() + { + if (!IsLive()) + { + _navigationDirection = Vector2.zero; + _nextNavigationRepeat = null; + return false; + } + PrepareNavigation(); + ObserveNavigationCancellation(); + if (!ShouldHandleMessage(navigateActionName, allowLogFocus: true)) + { + _navigationDirection = Vector2.zero; + _nextNavigationRepeat = null; + if (FindAction(navigateActionName)?.phase == InputActionPhase.Canceled) + { + _navigationNeedsNeutral = false; + } + return false; + } + return true; + } + + private void HandleActionTriggered(InputAction.CallbackContext context) + { + if (!IsLive() || !(context.performed || (context.canceled && context.action.type == InputActionType.Value))) + { + return; + } + InputAction action = context.action; + if (action == FindAction(handlePreviousActionName)) + { + HandlePrevious(); + } + else if (action == FindAction(handleNextActionName)) + { + HandleNext(); + } + else if (action == FindAction(closeActionName)) + { + Close(); + } + else if (action == FindAction(toggleSmallActionName)) + { + ToggleSmall(); + } + else if (action == FindAction(toggleFullActionName)) + { + ToggleFull(); + } + else if (action == FindAction(completeCommandActionName)) + { + CompleteCommand(); + } + else if (action == FindAction(reverseCompleteCommandActionName)) + { + ReverseCompleteCommand(); + } + else if (action == FindAction(enterCommandActionName)) + { + EnterCommand(); + } + else if (action == FindAction(navigateActionName)) + { + if (PrepareNavigationInput()) + { + Navigate(context.ReadValue()); + } + } + else if (action == FindAction(submitActionName)) + { + Submit(InputSystem.settings.defaultButtonPressPoint <= context.ReadValue()); + } + else if (action == FindAction(cancelActionName)) + { + Cancel(InputSystem.settings.defaultButtonPressPoint <= context.ReadValue()); + } + } + + 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; + _navigationCancellationVersion = terminal == null ? 0 : terminal.NavigationCancellationVersion; + _navigationNeedsNeutral = false; + } + + private void ObserveNavigationCancellation() + { + long version = terminal.NavigationCancellationVersion; + if (_navigationCancellationVersion == version) + { + return; + } + + _navigationCancellationVersion = version; + _navigationNeedsNeutral |= _navigationDirection != Vector2.zero; + _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, navigateActionName); + } + + private void HandleNativeSubmit(NavigationSubmitEvent evt) + { + SuppressOwnedNavigation(evt, submitActionName); + } + + private void HandleNativeCancel(NavigationCancelEvent evt) + { + SuppressOwnedNavigation(evt, cancelActionName); + } + + // 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); + } } /* @@ -149,13 +493,9 @@ missing terminal drop the message. A key that types text is left consumed, so the field keeps it, and the action runs the next time it fires with no field focused. - The action name is the string each handler passes: PlayerInput - turns the action "ToggleSmall" into the message "OnToggleSmall", - so the name is the only link from a message back to the control - that performed it. A message that PlayerInput does not send a - control with - a hand-sent message, a release that has already - cleared the control, a name that resolves to no action - runs as - it always has, as does every control that is not a typing key. + The serialized action identifier resolves the same action used + by callback routing and native navigation ownership. Unresolved + hand-sent messages retain their existing behavior. The two toggle messages do not pass through here: their handlers answer only , because the stroke that opens a @@ -204,7 +544,13 @@ private bool TryGetDrivingControl(string actionName, out InputControl control) private InputAction FindAction(string actionName) { - if (_playerInput == null) + if (_playerInput == null || string.IsNullOrWhiteSpace(actionName)) + { + return null; + } + + int separator = actionName.IndexOf("/", StringComparison.Ordinal); + if (separator == 0 || separator == actionName.Length - 1) { return null; } 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..54d0f188 100644 --- a/Runtime/CommandTerminal/UI/TerminalUI.cs +++ b/Runtime/CommandTerminal/UI/TerminalUI.cs @@ -59,20 +59,37 @@ ownership transfer to the newest live enabled terminal when the private static TerminalUI _configOwner; // ReSharper disable once MemberCanBePrivate.Global - public bool IsClosed => !IsOpenState(_state) && Mathf.Approximately(_currentWindowHeight, _targetWindowHeight); + public bool IsClosed + { + get { return !IsOpenState(_state) && Mathf.Approximately(_currentWindowHeight, _targetWindowHeight); } + } - public string CurrentTheme => !string.IsNullOrWhiteSpace(_runtimeTheme) ? _runtimeTheme : _persistedTheme; + public string CurrentTheme + { + get { return !string.IsNullOrWhiteSpace(_runtimeTheme) ? _runtimeTheme : _persistedTheme; } + } - public string CurrentFriendlyTheme => ThemeNameHelper.GetFriendlyThemeName(CurrentTheme); + public string CurrentFriendlyTheme + { + get { return ThemeNameHelper.GetFriendlyThemeName(CurrentTheme); } + } - public Font CurrentFont => _runtimeFont != null ? _runtimeFont : _persistedFont; + public Font CurrentFont + { + get { return _runtimeFont != null ? _runtimeFont : _persistedFont; } + } /* Internal for test coverage: a transition assertion needs the state a frame left behind, and IsClosed cannot answer that while the window height is still settling toward its target. */ - internal TerminalState State => _state; + internal TerminalState State + { + get { return _state; } + } + + internal long NavigationCancellationVersion { get; private set; } /* Internal for test coverage of the search's scroll state (see @@ -90,11 +107,20 @@ host that lays out no panel. only flips when a scroll is actually placed, so on a host with no layout it reads the same whether or not anything is wrong. */ - internal bool FindScrollQueued => _pendingFindScroll.HasValue; + internal bool FindScrollQueued + { + get { return _pendingFindScroll.HasValue; } + } - internal bool WantsScrollToEnd => _needsScrollToEnd; + internal bool WantsScrollToEnd + { + get { return _needsScrollToEnd; } + } - private ConsoleKeyBindings KeyBindings => keyBindings ??= new ConsoleKeyBindings(); + private ConsoleKeyBindings KeyBindings + { + get { return keyBindings ??= new ConsoleKeyBindings(); } + } [Header("Window")] [Range(0, 1)] @@ -331,6 +357,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 +699,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 +1722,59 @@ 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; + } + ++NavigationCancellationVersion; + if (InputOwnsFocus) + { + Close(); + } + else + { + FocusInput(allowLogFocus: true); + } + } + public void CompleteCommand(bool searchForward = true) { if (!IsOpenState(_state)) @@ -1903,6 +1986,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 +2040,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,7 +2816,29 @@ definition is written after InitializeFont below. uiRoot.Add(root); root.name = TerminalRootName; root.AddToClassList("terminal-root"); - root.RegisterCallback(static (_, context) => context.ResetWindowIdempotent(), this); + 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); InitializeFont(); @@ -2828,7 +2940,16 @@ The input abstraction is not resolved yet (Awake return; } - if (context._commandIssuedThisFrame || Array.Exists(context._inputHandlers, handler => handler.ShouldHandleInputThisFrame)) + if ( + context._commandIssuedThisFrame + || Array.Exists( + context._inputHandlers, + handler => + { + return handler.ShouldHandleInputThisFrame; + } + ) + ) { if (!string.Equals(context._commandInput.value, context._input.CommandText)) { @@ -3007,17 +3128,35 @@ private void SetupLogToolbar() header, "TerminalSizeButton", "Full", - () => SetState(_state == TerminalState.OpenFull ? TerminalState.OpenSmall : TerminalState.OpenFull) + () => + { + SetState(_state == TerminalState.OpenFull ? TerminalState.OpenSmall : TerminalState.OpenFull); + } ); _sizeButton.AddToClassList("terminal-header-size"); - AddToolbarButton(header, "TerminalCloseButton", "Close", () => SetState(TerminalState.Closed)).AddToClassList("terminal-header-close"); + AddToolbarButton( + header, + "TerminalCloseButton", + "Close", + () => + { + SetState(TerminalState.Closed); + } + ) + .AddToClassList("terminal-header-close"); VisualElement search = new() { name = "LogSearch" }; search.AddToClassList("terminal-search"); _logToolbar.Add(search); _logSearchInput = new TextField("Search") { name = "LogSearchInput", tooltip = "Search log output. Enter selects the next match." }; _logSearchInput.AddToClassList("terminal-search-field"); _logSearchInput.SetValueWithoutNotify(_logFilter.Query ?? string.Empty); - _logSearchInput.RegisterCallback, TerminalUI>(static (evt, terminal) => terminal.OnLogSearchChanged(evt), this); + _logSearchInput.RegisterCallback, TerminalUI>( + static (evt, terminal) => + { + terminal.OnLogSearchChanged(evt); + }, + this + ); _logSearchInput.RegisterCallback( static (evt, terminal) => { @@ -3046,9 +3185,33 @@ private void SetupLogToolbar() _logSearchCount = new Label { name = "LogSearchCount", tooltip = "Matching lines of searchable lines (current match / matching lines)." }; _logSearchCount.AddToClassList("terminal-search-count"); search.Add(_logSearchCount); - _logSearchPrevious = AddToolbarButton(search, "LogSearchPrevious", "Prev", () => StepLogSearch(false)); - _logSearchNext = AddToolbarButton(search, "LogSearchNext", "Next", () => StepLogSearch(true)); - _logSearchClear = AddToolbarButton(search, "LogSearchClear", "Clear", () => ClearLogFilter(false)); + _logSearchPrevious = AddToolbarButton( + search, + "LogSearchPrevious", + "Prev", + () => + { + StepLogSearch(false); + } + ); + _logSearchNext = AddToolbarButton( + search, + "LogSearchNext", + "Next", + () => + { + StepLogSearch(true); + } + ); + _logSearchClear = AddToolbarButton( + search, + "LogSearchClear", + "Clear", + () => + { + ClearLogFilter(false); + } + ); VisualElement types = new() { name = "LogSeverities" }; types.AddToClassList("terminal-severities"); _logToolbar.Add(types); @@ -3324,6 +3487,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 +3723,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; @@ -3817,7 +4061,10 @@ and the bar then showed the previous keystroke's text. if (makeHintsClickable) { Button hintButton = new(); - hintButton.clicked += () => ApplyHint(_autoCompleteContainer.IndexOf(hintButton)); + hintButton.clicked += () => + { + ApplyHint(_autoCompleteContainer.IndexOf(hintButton)); + }; hintButton.text = LogTextSanitizer.Sanitize(hint); hintElement = hintButton; } 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..eeb0a994 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