Focus: Tab, arrows and returning from a menu
A complete C# menu with explicit focus routes, a nested screen, Escape and restoring the selected button.
Run navigation
Add UiSampleStyle.cs, attach FocusMenuDemo to a Control root, build and run with F6. Start is initially selected. Tab/Shift+Tab and up/down cycle through three buttons. Enter on Settings opens the nested screen and selects Back. Escape or Back restores focus to the original button. Close menu hides the example; ShowMenu reveals it and assigns focus again.
FocusMenuDemo.cs
using Reckfell;
public partial class FocusMenuDemo : Control
{
public VBoxContainer Main { get; private set; } = null!;
public VBoxContainer Details { get; private set; } = null!;
public Button Start { get; private set; } = null!;
public Button Options { get; private set; } = null!;
public Button Close { get; private set; } = null!;
public Button Back { get; private set; } = null!;
public int StartCount { get; private set; }
private Control? _returnFocus;
public override void _Ready()
{
UiSampleStyle.Prepare(this);
var panel = new PanelContainer(); AddChild(panel);
panel.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect, margin: 24);
Main = new VBoxContainer(); Details = new VBoxContainer { Visible = false };
panel.AddChild(Main); panel.AddChild(Details);
Start = Button(Main, UiSampleStyle.Text("Начать", "Start"));
Options = Button(Main, UiSampleStyle.Text("Настройки", "Settings"));
Close = Button(Main, UiSampleStyle.Text("Закрыть меню", "Close menu"));
Button[] buttons = [Start, Options, Close];
for (int i = 0; i < buttons.Length; i++)
{
Button current = buttons[i], next = buttons[(i + 1) % buttons.Length], previous = buttons[(i + buttons.Length - 1) % buttons.Length];
current.FocusNext = current.GetPathTo(next); current.FocusPrevious = current.GetPathTo(previous);
current.FocusNeighborBottom = current.GetPathTo(next); current.FocusNeighborTop = current.GetPathTo(previous);
}
Details.AddChild(new Label { Text = UiSampleStyle.Text("Экран настроек", "Settings screen") });
Back = Button(Details, UiSampleStyle.Text("Назад", "Back"));
Start.Pressed += () => StartCount++; Options.Pressed += OpenDetails;
Close.Pressed += Hide; Back.Pressed += CloseDetails;
Callable.From(() => Start.GrabFocus()).CallDeferred();
}
private static Button Button(VBoxContainer parent, string text)
{
var button = new Button { Text = text, FocusMode = FocusModeEnum.All };
parent.AddChild(button); return button;
}
public void OpenDetails()
{
_returnFocus = GetViewport().GuiGetFocusOwner();
Main.Hide(); Details.Show(); Back.GrabFocus();
}
public void CloseDetails()
{
Details.Hide(); Main.Show();
Control target = _returnFocus is Button b && ReckfellObject.IsInstanceValid(b) && b.IsVisibleInTree() && !b.Disabled ? b : Start;
target.GrabFocus();
}
public void ShowMenu() { Show(); if (Details.Visible) Back.GrabFocus(); else Start.GrabFocus(); }
public override void _UnhandledInput(InputEvent @event)
{
if (IsVisibleInTree() && Details.Visible && @event.IsActionPressed("ui_cancel"))
{
CloseDetails(); GetViewport().SetInputAsHandled();
}
else if (IsVisibleInTree() && @event.IsAction("ui_accept")) GetViewport().SetInputAsHandled();
}
}Define the route
| Setting | Purpose |
|---|---|
| FocusMode.None | Exclude from ordinary focus |
| FocusMode.Click | Pointer focus, without ordinary All-style traversal |
| FocusMode.All | Participate in keyboard navigation |
| FocusNext / FocusPrevious | Tab / Shift+Tab route |
| FocusNeighborTop/Bottom/Left/Right | ui_up/down/left/right directions |
Paths are relative to the button itself; GetPathTo supplies that path. Without an explicit path, the engine finds a candidate from hierarchy or geometry. This helps a simple screen but can change after rearranging elements. Initial GrabFocus is deferred until _Ready finishes; merely having buttons does not select one.
A hidden Control loses focus. When opening a screen, assign focus to a visible eligible element; when closing, validate the saved reference and provide a fallback. Disabled prevents button activation but does not automatically mean FocusMode.None. Set None and update neighbors if a disabled button must be skipped. Do not leave focus in a hidden tab.
Inspect effective state rather than only assigned fields:
| Symptom | Check |
|---|---|
| FocusMode.All is set but focus fails | GetFocusModeWithOverride and ancestors’ FocusBehaviorRecursive; the local value can be overridden |
| No focus outline | GetViewport().GuiGetFocusOwner: a missing drawing does not always mean no owner; check the focus style |
| Tab works but arrows do not | FocusNext/Previous and FocusNeighbor are separate routes; verify relative paths |
| Return targets a closed screen | The stored reference must be alive and visible, and the fallback button focusable |
Pause and gameplay input
Set the menu root's ProcessMode.Always if it must work while GetTree().Paused; children can inherit it. Keep gameplay in its appropriate pause mode. This example does not toggle global pause itself.
ui_* actions belong to the interface; Input Map defines keyboard/controller bindings. Do not also use them for jumping/shooting without game-state checks. In this build, keyboard activation of a standard button does not guarantee ui_accept disappears from _UnhandledInput(): the example explicitly consumes it while the menu is active. AcceptEvent in _GuiInput() cannot undo an earlier _Input() or direct Input polling.
Test Tab and directional navigation separately: they are different routes. Physical controllers, device switching and assistive technologies need their own checks. FocusMode.All alone does not certify accessibility.