diff --git a/docs/ui/input.md b/docs/ui/input.md index 945524296..1bc79a749 100644 --- a/docs/ui/input.md +++ b/docs/ui/input.md @@ -21,6 +21,29 @@ if card.DoubleClicked() { } ``` +## Arrows left by child controls + +`element.HandleNavigationKeys(keys, fn)` opts an element into handling arrows +left by its focused child, before default scrolling. `keys` accepts only +`ui.KeyLeft`, `ui.KeyRight`, `ui.KeyUp` and `ui.KeyDown`; other keys panic. +Only unmodified or Shift-modified arrows are routed. Existing shortcuts and +native child handling retain priority. Enter, Space and all other keys are +unchanged. + +The nearest registered scope owns navigation. If it does not list an arrow, +or its callback returns false, that arrow retains its default behavior; an +outer scope is not tried. Editing and read-only text controls, compound +fields and native overlay boundaries between the focus and the owner block +routing. A scope inside an overlay handles only its own content, not input +across the overlay boundary. + +The callback receives `ui.InputEvent`, runs synchronously on the UI thread +between builds, and returns true to claim the arrow. A frame follows even +when it returns false. Do not retain Elements, build UI or enter a nested +event loop in it. An omitted registration, empty key list or nil callback +removes the handler on the next commit. This API does not make an element +focusable or register a shortcut, capture callback or global listener. + ## The pointer `Hovered`, `Pressed`, `Clicked`, `DoubleClicked`, `RightClicked`, `Dragged` diff --git a/docs/ui/migration.md b/docs/ui/migration.md index e37d711d5..f68eb5a0d 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -1,5 +1,27 @@ # Migrating to checked elements +## Opt-in child-first arrow navigation + +`Element.HandleNavigationKeys(keys, fn)` adds an optional nearest-owner route +after child/native handling and existing shortcuts, before default scrolling. +Existing applications need no migration. Do not replace `OnShortcut` globally: +use this only when a container owns arrows left by non-editing descendants. + +```go +ui.Row(c).HandleNavigationKeys([]ui.Key{ui.KeyLeft, ui.KeyRight}, func(ev ui.InputEvent) bool { + movePillFocus(ev.Key, ev.Mods&ui.Shift != 0) + return true +}).Children(buildPills) +``` + +Only the four arrows, with no modifiers or Shift, are eligible; unsupported +declarations panic. Enter and Space remain with their original controls. A +false result keeps default handling but does not try an outer scope. Text +controls, compound fields and intervening native overlay boundaries block +routing. Omission, an empty list or a nil callback unregisters on commit. +The synchronous UI-thread callback must not build UI or retain Elements; a +frame follows either result. See [input](input.md#arrows-left-by-child-controls). + ## Programmatic context-menu opening `Element.OpenContextMenuAt(x, y)` requests the element's existing `ContextMenu` diff --git a/internal/uimigrate/uiapi.go b/internal/uimigrate/uiapi.go index 539812155..3924f7db3 100644 --- a/internal/uimigrate/uiapi.go +++ b/internal/uimigrate/uiapi.go @@ -241,6 +241,7 @@ var elementChains = map[string]bool{ "Grow": true, "HandleCapturedPointer": true, "HandleInput": true, + "HandleNavigationKeys": true, "HandleTextInput": true, "HasPopup": true, "Height": true, diff --git a/ui/api_gen.go b/ui/api_gen.go index 368ecd489..b9a04e139 100644 --- a/ui/api_gen.go +++ b/ui/api_gen.go @@ -5367,6 +5367,31 @@ func (_handle Element) HandleInput(fn func(ev InputEvent) bool) Element { return wrapElement(_node.HandleInput(fn)) } +// HandleNavigationKeys handles arrows left by the focused control in the +// element's subtree, before they scroll. keys may contain only KeyLeft, +// KeyRight, KeyUp and KeyDown; another key panics. With no keys or a nil fn, +// the element stops handling navigation. Omission in a build does the same. +// +// The nearest registered element owns navigation: an arrow it did not list, +// or that fn leaves, never goes to an outer navigation handler. Existing +// shortcuts and native control handling take priority. Text fields, editors +// (including read-only editors), compound fields and overlay boundaries +// between the focus and the owner prevent routing. Only no modifiers or Shift +// are admitted; typing, composition and other keys are unchanged. +// +// fn runs on the UI thread as the key arrives, between builds; true takes it, +// false leaves its default handling. A frame follows either result. Do not +// build UI, retain Elements or run a nested event loop in fn. +func (_handle Element) HandleNavigationKeys(keys []Key, fn func(arg0 InputEvent) bool) Element { + _node := _handle.node() + if _node == nil { + return Element{} + } + _ctx := _node.c + _ = _ctx + return wrapElement(_node.HandleNavigationKeys(keys, fn)) +} + // HandleTextInput connects an element to the system's text-input services. // Pair it with HandleInput for keys, pointer selection and Edit-menu commands, // and Draw for rendering. It adds focus and the text cursor without creating diff --git a/ui/context.go b/ui/context.go index 8146dbd80..741589c09 100644 --- a/ui/context.go +++ b/ui/context.go @@ -903,13 +903,13 @@ type state struct { // input, caret and takesText are those of the last frame's element // (HandleInput, TextCaret). - input func(InputEvent) bool - observe func(InputEvent) // ObserveInput - textClient TextInputClient - textAdapter *textInputAdapter - caret Rect - takesText bool - noIME bool + input func(InputEvent) bool + observe func(InputEvent) // ObserveInput + navigation *navigationKeys + textClient TextInputClient + textAdapter *textInputAdapter + caret Rect + takesText, noIME, navigationBarrier bool // copyData is what a Selectable container's selection copies as // (CopyData). copyData func(parts []SelectedText) transfer.Data diff --git a/ui/element.go b/ui/element.go index 9f8dad05e..4c2961a3e 100644 --- a/ui/element.go +++ b/ui/element.go @@ -453,6 +453,7 @@ type node struct { // the text it takes is (TextCaret). inputFn func(InputEvent) bool observeFn func(InputEvent) // the input in it (ObserveInput) + navigation *navigationKeys textClient TextInputClient caret Rect takesText bool diff --git a/ui/input.go b/ui/input.go index 9dad8d433..d3edf24c7 100644 --- a/ui/input.go +++ b/ui/input.go @@ -1041,6 +1041,7 @@ func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, code KeyCod return true } input := InputEvent{Kind: InputKeyDown, Key: key, PhysicalKey: physical, Code: code, Mods: mods, Repeat: repeat, Text: text} + focused := rt.focused if rt.captureInput(input, rt.focused) { return true } @@ -1134,9 +1135,19 @@ func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, code KeyCod return false } } + claimed := rt.claimed(k) + if rt.routeNavigation != nil && !claimed { + routeFocus := rt.focused + if rt.routeNavigation(input, focused) { + return true + } + if rt.focused != routeFocus { + claimed = rt.claimed(k) + } + } // Space types a space where the focus takes text, rather than // paging, as in a web page's field: the text comes after the key. - if !rt.claimed(k) && !(typesText(k) && rt.focusTakesText()) && rt.scrollKey(mods, key) { + if !claimed && !(typesText(k) && rt.focusTakesText()) && rt.scrollKey(mods, key) { return false } rt.keys = append(rt.keys, k) diff --git a/ui/layoutcache_test.go b/ui/layoutcache_test.go index c5aa43592..abc106b4b 100644 --- a/ui/layoutcache_test.go +++ b/ui/layoutcache_test.go @@ -455,7 +455,7 @@ func TestLayoutInputs(t *testing.T) { other := "epoch key valueInput id depth track transform translatePct world content contentSet " + "pose poseAnim poseSet poseM poseFade reposed ink noCull backface bg fill grad stripes borderC borderStyle radius " + "shadows material effect effecting cache grads blur opacity opacitySet undimmed cursor paintFn hitFn objectFn styleFn paintAfterFn " + - "inputFn observeFn textClient caret takesText noIME copyData webView maximizeButton fit gray objectPos dismissed rotate label widget role " + + "inputFn observeFn navigation textClient caret takesText noIME copyData webView maximizeButton fit gray objectPos dismissed rotate label widget role " + "checked expanded busy current autoComplete orient tip highlighted focusHighlights keepOpen accValue accRange hasRange accFill accStep barInset scrollBase hideScrollbars keepScrollbars barsFrom snap snapAlign " + "listRow rowIndex rowsOf focusGroup groupSelects segment activeDescendant nameFrom nameJoin labelledBy focusTo " + "keyShortcuts accGrid accSet " + diff --git a/ui/navigation_keys.go b/ui/navigation_keys.go new file mode 100644 index 000000000..3363f1431 --- /dev/null +++ b/ui/navigation_keys.go @@ -0,0 +1,77 @@ +package ui + +type navigationKeys struct { + keys uint8 + fn func(InputEvent) bool +} + +func navigationKeyMask(key Key) uint8 { + switch key { + case KeyLeft: + return 1 + case KeyRight: + return 2 + case KeyUp: + return 4 + case KeyDown: + return 8 + } + return 0 +} + +// HandleNavigationKeys handles arrows left by the focused control in the +// element's subtree, before they scroll. keys may contain only KeyLeft, +// KeyRight, KeyUp and KeyDown; another key panics. With no keys or a nil fn, +// the element stops handling navigation. Omission in a build does the same. +// +// The nearest registered element owns navigation: an arrow it did not list, +// or that fn leaves, never goes to an outer navigation handler. Existing +// shortcuts and native control handling take priority. Text fields, editors +// (including read-only editors), compound fields and overlay boundaries +// between the focus and the owner prevent routing. Only no modifiers or Shift +// are admitted; typing, composition and other keys are unchanged. +// +// fn runs on the UI thread as the key arrives, between builds; true takes it, +// false leaves its default handling. A frame follows either result. Do not +// build UI, retain Elements or run a nested event loop in fn. +func (e *node) HandleNavigationKeys(keys []Key, fn func(InputEvent) bool) *node { + var mask uint8 + for _, key := range keys { + bit := navigationKeyMask(key) + if bit == 0 { + panic("ui: HandleNavigationKeys requires arrow keys") + } + mask |= bit + } + e.navigation = nil + if mask != 0 && fn != nil { + e.navigation = &navigationKeys{keys: mask, fn: fn} + e.c.rt.routeNavigation = e.c.rt.navigationKey + } + return e +} + +func (rt *engine) navigationKey(ev InputEvent, focused uint64) bool { + bit := navigationKeyMask(ev.Key) + if bit == 0 || ev.Mods&^Shift != 0 || focused == 0 || + rt.focused != focused || !rt.windowFocus() { + return false + } + for s := rt.states[focused]; s != nil; s = rt.states[s.parent] { + if s.navigationBarrier || s.flags&(flagDisabled|flagInert|flagEditable) != 0 || + s.ignoreInput || s.editor != nil || s.textClient != nil || s.takesText || + s.role == RoleTextField || s.role == RoleComboBox { + return false + } + if scope := s.navigation; scope != nil { + if scope.keys&bit == 0 { + return false + } + ev.Time = rt.now() + handled := scope.fn(ev) + rt.requestFrame() + return handled + } + } + return false +} diff --git a/ui/navigation_keys_test.go b/ui/navigation_keys_test.go new file mode 100644 index 000000000..9cc373832 --- /dev/null +++ b/ui/navigation_keys_test.go @@ -0,0 +1,113 @@ +package ui + +import "testing" + +func TestNavigationKeysRespectFocusedChildAndNearestOwner(t *testing.T) { + for _, tc := range []struct { + name, child string + key Key + mods Modifiers + declared []Key + decline bool + wantScope int + wantChild int + }{ + {name: "left", key: KeyLeft, wantScope: 1}, + {name: "right", key: KeyRight, wantScope: 1}, + {name: "up", key: KeyUp, wantScope: 1}, + {name: "down", key: KeyDown, wantScope: 1}, + {name: "shift-left", key: KeyLeft, mods: Shift, wantScope: 1}, + {name: "shift-right", key: KeyRight, mods: Shift, wantScope: 1}, + {name: "shift-up", key: KeyUp, mods: Shift, wantScope: 1}, + {name: "shift-down", key: KeyDown, mods: Shift, wantScope: 1}, + {name: "ctrl", key: KeyRight, mods: Ctrl}, + {name: "alt", key: KeyRight, mods: Alt}, + {name: "super", key: KeyRight, mods: Super}, + {name: "unlisted", key: KeyLeft, declared: []Key{KeyRight}}, + {name: "declined", key: KeyRight, decline: true, wantScope: 1}, + {name: "child-handled", child: "handler", key: KeyRight, wantChild: 1}, + {name: "child-shortcut", child: "shortcut", key: KeyRight, wantChild: 1}, + {name: "field", child: "field", key: KeyRight}, + {name: "readonly", child: "readonly", key: KeyRight}, + {name: "password", child: "password", key: KeyRight}, + {name: "combobox", child: "combobox", key: KeyRight}, + {name: "enter", key: KeyEnter, wantChild: 1}, + {name: "space", key: KeySpace, wantChild: 1}, + {name: "inside-overlay", child: "inside-overlay", key: KeyRight, wantScope: 1}, + {name: "outside-overlay", child: "outside-overlay", key: KeyRight}, + } { + t.Run(tc.name, func(t *testing.T) { + scopeCalls, outerCalls, childCalls := 0, 0, 0 + text, selected := "text", "Alpha" + keys := tc.declared + if keys == nil { + keys = []Key{KeyLeft, KeyRight, KeyUp, KeyDown} + } + var received InputEvent + child := func(c *Context) { + switch tc.child { + case "field", "readonly", "password": + input := TextInput(c, &text).Focus() + if tc.child == "readonly" { + input.ReadOnly(true) + } + if tc.child == "password" { + input.Password() + } + case "combobox": + Combobox(c, &selected, []string{"Alpha", "Beta"}).Focus() + default: + button := Button(c, "Pill").Focus().OnClick(func() { childCalls++ }) + if tc.child == "handler" { + button.HandleInput(func(ev InputEvent) bool { + if ev.Kind == InputKeyDown && ev.Key == tc.key { + childCalls++ + return true + } + return false + }) + } + if tc.child == "shortcut" { + button.OnShortcut(tc.mods, tc.key, func() { childCalls++ }) + } + } + } + tt := NewTester(func(c *Context) { + build := func() { + Column(c).HandleNavigationKeys([]Key{KeyLeft, KeyRight, KeyUp, KeyDown}, func(InputEvent) bool { + outerCalls++ + return true + }).Children(func() { + Column(c).HandleNavigationKeys(keys, func(ev InputEvent) bool { + scopeCalls++ + received = ev + return !tc.decline + }).Children(func() { + if tc.child == "outside-overlay" { + Overlay(c, func() { child(c) }) + } else { + child(c) + } + }) + }) + } + if tc.child == "inside-overlay" { + Overlay(c, func() { Box(c).Modal().Children(build) }) + } else { + build() + } + }, 300, 200) + tt.Key(tc.mods, tc.key) + if scopeCalls != tc.wantScope || outerCalls != 0 || childCalls != tc.wantChild { + t.Fatalf("scope %d, outer %d, child %d; want %d, 0, %d", scopeCalls, outerCalls, childCalls, tc.wantScope, tc.wantChild) + } + if scopeCalls > 0 && (received.Kind != InputKeyDown || received.Key != tc.key || received.Mods != tc.mods) { + t.Fatalf("routed event lost key/modifiers: %+v", received) + } + tt.Frame() + if scopeCalls != tc.wantScope { + t.Fatal("a later frame redelivered the arrow") + } + }) + } +} diff --git a/ui/runtime.go b/ui/runtime.go index 8ab45d01f..157f8a663 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -317,6 +317,7 @@ type engine struct { pointerX, pointerY float32 inputCapture func(InputEvent, InputTarget) bool + routeNavigation func(InputEvent, uint64) bool captureClicks *inputCaptureState releaseCapture func(button int) captureSerial uint64 @@ -1457,6 +1458,9 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { s.webView = e.webView s.role, s.focusHighlights = e.role, e.focusHighlights s.input, s.caret, s.takesText, s.noIME = e.inputFn, e.caret, e.takesText, e.noIME + s.navigation = e.navigation + s.navigationBarrier = hidden || e.hidden || e.focusTo != nil || e.webView != nil || + e.popover != nil || e.id == overlayID || e.flags&flagModal != 0 if rt.pressed == s && s.capturedPointer && s.flags&flagDisabled != 0 { rt.pointerCancel(rt.pressButton) }