Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/ui/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
22 changes: 22 additions & 0 deletions docs/ui/migration.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
1 change: 1 addition & 0 deletions internal/uimigrate/uiapi.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions ui/api_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions ui/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions ui/element.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion ui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion ui/layoutcache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
77 changes: 77 additions & 0 deletions ui/navigation_keys.go
Original file line number Diff line number Diff line change
@@ -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
}
113 changes: 113 additions & 0 deletions ui/navigation_keys_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
4 changes: 4 additions & 0 deletions ui/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Loading