UI: Tab and Shift+Tab move focus through the view hierarchy

Unlike the directional moves, this doesn't look at where anything ended up on
screen - it walks the hierarchy in the order views were added, flattening nested
groups in place. That's what makes it predictable in the layouts where "what's
to the right of this" has no good answer.

A view is a stop if it's focusable and enabled, the same test the directional
moves apply, so the two agree on what's reachable. Hidden subtrees are skipped
whole, which is what keeps a TabHolder's inactive tabs - V_GONE rather than
removed - out of the order without any special casing. Containers are gated on
visibility only, not enabled, matching Key/Touch/Axis: disabling a container
doesn't stop its children being interactive anywhere else either.

Ctrl+Tab stays with ChoiceStrip, which uses it to switch tabs.

focusMoves now holds FocusMove rather than raw keycodes, so the direction is
decided in one place while the modifiers are still around, and a held key
repeats in the direction it was originally pressed with - the synthesized repeat
has no modifiers of its own. That also retires the keycode switch in
UpdateViewHierarchy and IsScrollKey, which had no other callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfY7iFJEjmRXf1XGrTs4MF
This commit is contained in:
Henrik Rydgård
2026-08-27 20:52:20 +02:00
co-authored by Claude Opus 5
parent 8cb5ce7585
commit ea1ad8ffed
5 changed files with 177 additions and 27 deletions
+68
View File
@@ -99,6 +99,9 @@
#include "Core/Util/BlockAllocator.h"
#include "Core/Debugger/Breakpoints.h"
#include "Core/Debugger/SymbolMap.h"
#include "Common/UI/Root.h"
#include "Common/UI/View.h"
#include "Common/UI/ViewGroup.h"
#include "Core/Debugger/MemBlockInfo.h"
#include "Core/FileSystems/ISOFileSystem.h"
#include "Core/MemMap.h"
@@ -2855,6 +2858,70 @@ bool TestVFS();
bool TestZipSlip();
bool TestLzrc();
bool TestDemangle();
// Tab/Shift+Tab focus navigation walks the view hierarchy in declaration order rather than by
// geometry, so what it does is entirely determined by CollectTabOrder - which is worth pinning
// down, since the interesting cases (nesting, hidden tabs, disabled items) are all structural.
bool TestUITabOrder() {
using namespace UI;
LinearLayout root(ORIENT_VERTICAL);
// A label is not a tab stop, but the item after it is.
root.Add(new TextView("label"));
Choice *a = root.Add(new Choice("a"));
// Nested groups are flattened in place, in order.
LinearLayout *inner = root.Add(new LinearLayout(ORIENT_HORIZONTAL));
Choice *b = inner->Add(new Choice("b"));
Choice *disabled = inner->Add(new Choice("disabled"));
disabled->SetEnabled(false);
// A hidden subtree is skipped whole - this is how the inactive tabs of a TabHolder,
// which are V_GONE rather than removed, stay out of the way.
LinearLayout *hidden = root.Add(new LinearLayout(ORIENT_VERTICAL));
hidden->SetVisibility(V_GONE);
hidden->Add(new Choice("hidden"));
root.Add(new Spacer());
Choice *c = root.Add(new Choice("c"));
Choice *invisible = root.Add(new Choice("invisible"));
invisible->SetVisibility(V_INVISIBLE);
std::vector<View *> order;
root.CollectTabOrder(&order);
EXPECT_EQ_INT((int)order.size(), 3);
EXPECT_TRUE(order[0] == a);
EXPECT_TRUE(order[1] == b);
EXPECT_TRUE(order[2] == c);
// Tab walks forwards and wraps at the end, Shift+Tab does the reverse.
EXPECT_TRUE(FindTabOrderNeighbor(&root, a, FocusMove::NEXT) == b);
EXPECT_TRUE(FindTabOrderNeighbor(&root, b, FocusMove::NEXT) == c);
EXPECT_TRUE(FindTabOrderNeighbor(&root, c, FocusMove::NEXT) == a);
EXPECT_TRUE(FindTabOrderNeighbor(&root, c, FocusMove::PREV) == b);
EXPECT_TRUE(FindTabOrderNeighbor(&root, b, FocusMove::PREV) == a);
EXPECT_TRUE(FindTabOrderNeighbor(&root, a, FocusMove::PREV) == c);
// A view that has gone away (or was never a stop) doesn't stall navigation - it starts
// from whichever end we're heading towards.
EXPECT_TRUE(FindTabOrderNeighbor(&root, disabled, FocusMove::NEXT) == a);
EXPECT_TRUE(FindTabOrderNeighbor(&root, disabled, FocusMove::PREV) == c);
EXPECT_TRUE(FindTabOrderNeighbor(&root, nullptr, FocusMove::NEXT) == a);
// With a single stop, both directions land back on it, and with none there's nothing to do.
LinearLayout one(ORIENT_VERTICAL);
Choice *only = one.Add(new Choice("only"));
EXPECT_TRUE(FindTabOrderNeighbor(&one, only, FocusMove::NEXT) == only);
EXPECT_TRUE(FindTabOrderNeighbor(&one, only, FocusMove::PREV) == only);
LinearLayout empty(ORIENT_VERTICAL);
empty.Add(new TextView("just a label"));
EXPECT_TRUE(FindTabOrderNeighbor(&empty, nullptr, FocusMove::NEXT) == nullptr);
return true;
}
bool TestTextureReplacer();
TestItem availableTests[] = {
@@ -2924,6 +2991,7 @@ TestItem availableTests[] = {
TEST_ITEM(Lzrc),
TEST_ITEM(Demangle),
TEST_ITEM(TextureReplacer),
TEST_ITEM(UITabOrder),
};
int main(int argc, const char *argv[]) {