Skip to content

Input bindings

Every input-consuming feature in Tick UI reads its actions through a per-feature bindings config. Confirming a focused button, copying selected text, opening the debug console, and toggling the debugger overlay are all named actions (Confirm, Copy, Toggle) with a configurable input behind them. This page explains the framework.

For the procedure that exercises the framework, refer to Configure input bindings.

Per-feature, polymorphic, in a container

Three ideas combine.

Per feature. Each input-consuming feature owns its own bindings class. Navigation has NavigationBindings. The textbox has TextboxBindings. The console has ConsoleBindings. The debugger has DebuggerBindings. Adding a future feature means adding a new bindings class without touching the others.

Polymorphic per backend. Each bindings class is abstract. Two concrete subclasses read input natively: a legacy subclass (LegacyXxxBindings) that uses UnityEngine.Input and an Input System subclass (InputSystemXxxBindings) that uses UnityEngine.InputSystem. The two backends do not share a key type and need no translation map. Compile-time selection through ENABLE_INPUT_SYSTEM decides which subclass XxxBindings.Default returns for the active backend.

In a container. InputBindings is a struct with one field per feature:

public struct InputBindings
{
    public NavigationBindings Navigation;
    public TextboxBindings    Textbox;
    public ConsoleBindings    Console;
    public DebuggerBindings   Debugger;
}

The container sits on Configuration.Bindings and is surfaced as UI.Bindings. Widgets resolve actions through ui.Bindings.Feature.Action(). A null field is filled with the per-feature default at ingest, so partial customisation works without listing every untouched feature.

What each feature exposes

The action queries each feature owns:

Feature Actions
NavigationBindings NavigateUp, NavigateDown, NavigateLeft, NavigateRight, Confirm, Cancel
TextboxBindings SelectAll, Copy, Paste, Cut, Submit, DeleteBack, DeleteForward, CaretLeft, CaretRight, CaretHome, CaretEnd
ConsoleBindings Toggle, Submit, HistoryPrev, HistoryNext, AutocompleteNext, AutocompletePrev
DebuggerBindings Toggle, LockClickThroughHeld, InspectElementHeld

Every action returns bool. Pressed-this-frame is the default. The two held-state actions are DebuggerBindings.LockClickThroughHeld and DebuggerBindings.InspectElementHeld, named with the Held suffix for clarity. InspectElementHeld is virtual rather than abstract and returns false by default, so an existing custom DebuggerBindings subclass keeps compiling; opt in by overriding it.

How a binding describes a key

Two helper structs unify per-backend key descriptions:

  • LegacyKeyBinding: a UnityEngine.KeyCode and a ModifierMask. Any KeyCode is bindable, including JoystickButton0 through JoystickButton19 for controller buttons. An implicit conversion from KeyCode keeps no-modifier bindings terse.
  • InputSystemKeyBinding: a UnityEngine.InputSystem.Key and a ModifierMask, with an implicit conversion from Key. These keyboard-shortcut features (textbox, console, debugger) bind keyboard keys only; the gamepad-aware InputSystemControl type (Key? or GamepadButton?, with implicit conversions) is used by InputSystemNavigationBindings.

Each binding helper exposes static AnyPressed(List<...>), AnyHeld(List<...>), and AnyHeldKey(List<...>) helpers. Each concrete bindings subclass stores a List per action and forwards the action method to one of those helpers.

NavigationBindings is the exception. It pre-dates the helper structs and keeps a plain List<KeyCode> (legacy) or List<InputSystemControl> (Input System) for each direction, plus a separate ConfirmKeys / CancelKeys (legacy) or ConfirmControls / CancelControls (Input System). Navigation does not support modifier shortcuts because none of its actions need them.

Modifier matching

ModifierMask is a [Flags] enum: None, Ctrl, Shift, Alt. A binding's modifier mask matches the held modifier state exactly. Ctrl+Shift+C does not trigger a binding registered as Ctrl+C. This is what lets Tab and Shift+Tab bind separately to AutocompleteNext and AutocompletePrev in the console.

To accept multiple modifier combinations for the same action, register the action with multiple binding entries (one for each combination).

The per-frame update

InputBindings.Update runs once per frame from inside BeginFrame, after the raw input pump. Each feature's Update chains from there. Most features have nothing to do here. The legacy navigation backend uses its Update to edge-detect the D-pad axes, because legacy Input has no built-in D-pad button (refer to Configure input bindings for the project setup it requires).

What lives outside bindings

IInput is the raw-input surface that providers expose to the UI. Bindings own all named actions. IInput keeps only what bindings cannot replace:

  • Mouse position, delta, scroll delta.
  • Mouse buttons: down, up, held, and left-double-click.
  • Modifier-held queries: GetCtrlHeld, GetShiftHeld, GetAltHeld. The textbox uses these to gate typed-character insertion (do not insert c while Ctrl+C is being pressed). The debugger uses them for non-bindable modal state, such as scrub speed inside the layout preview.
  • PeekTextInput / ConsumeTextInput: the raw typed-character stream the textbox inserts into its buffer. PeekTextInput returns a ReadOnlySpan<char> view (no per-frame string allocation); the textbox calls ConsumeTextInput once it has read the characters so they are not delivered again.

A custom IInput implementation supplies these uninterpreted inputs. Bindings consume input through the providers' static APIs (Keyboard.current, Gamepad.current, Input.GetKey), not through the IInput surface.

Configuration entry points

Two entry points cover setup and runtime:

  • Configuration.Bindings: pass an InputBindings value to the UI constructor through the Configuration. Null fields are filled with the per-feature Default before the first frame runs.
  • UI.SetBindings(InputBindings): replace bindings at runtime. Useful for an in-game keybinding screen that mutates bindings live.

For the procedure that exercises both entry points, refer to Configure input bindings.

Pitfalls

A shortcut fires for Ctrl+Shift+C but not for Ctrl+C alone. : Modifier match is exact. Add a separate binding entry to the same action for each modifier combination you want to accept.

The legacy D-pad does not move focus. : The default axis names (DPadHorizontal, DPadVertical) are not present in the project's Input Manager. Add them under Project Settings > Input Manager mapped to the controller D-pad, rename them on LegacyNavigationBindings.DpadHorizontalAxis / DpadVerticalAxis, or set UseDpad to false.

Customising one feature appears to reset the others. : Each LegacyXxxBindings constructor starts with empty Lists for every action. Customisation built up field-by-field on a fresh instance loses the defaults for every field you do not touch. Start from the per-feature Default, mutate the fields you care about, then assign the result to InputBindings.

The debugger's lock-click-through never fires. : LockClickThroughHeld defaults to "either Ctrl key held" and uses AnyHeldKey, which ignores the modifier-mask comparison. The binding key is itself a modifier; a custom rebinding should keep the same helper, not switch to AnyHeld.

Additional resources