Skip to content
Input and Focus

Input and Focus

gift turns mouse, touch and keyboard input into events and delivers each event to one node of the retained tree. A pointer event goes to the node under the pointer. A key event goes to the node that holds the keyboard focus. The built-in controls handle all of this for you: a button lights up on hover, fires on a tap, takes the focus and reacts to space and enter, and you only write the action.

This page explains what happens underneath: how gift finds the node under the pointer, how a press is tracked until the release, where hover, press and focus are kept, how the focus moves, how text, the clipboard and the on-screen keyboard work, and what to watch out for when you write a control of your own.

The input pipeline

Input is resolved once per tick, in the update phase, before gift builds and lays out. The Ebitengine backend polls the mouse, the touches and the keyboard, detects the changes since the last tick and calls the matching methods of gift.App: PointerDown, PointerMove, PointerUp, PointerWheel, KeyDown, KeyUp and TypeRune. gift then dispatches the event to an interactor, the input half of a node.

Because input runs before the build, an event handler can write state directly. The rebuild it causes is part of the same tick. Calling one of the input methods while gift paints panics.

Who receives an event depends on its kind:

EventDelivered toBubbles to ancestors
EventPointerDownthe topmost interactive node under the pointeryes
EventPointerMove, EventPointerUpthe node that took the press, inside or notyes
EventPointerMove with no button downthe node under the mouseyes
EventPointerCancel, EventLongPressthe node that took the pressno
EventPointerEnter, EventPointerLeavethe node the mouse moved onto or offno
EventWheelthe node under the pointeryes
EventKeyDown, EventKeyUp, EventRunethe focused nodeyes
EventFocusGained, EventFocusLostthe node that gained or lost the focusno

“Bubbles” means: when the receiving interactor returns false, gift offers the event to the nearest ancestor that has an interactor, and so on up to the root. That is how a scroll view sees a wheel event that happened over a label inside it. An event that nobody handles is dropped.

Hit testing

A node takes part in hit testing only when its element declares an interactor. Everything else is transparent to input. A plain VStack is not a hit target: a click in its padding or in the gap between two children hits whatever is behind it, not the stack.

gift walks the retained tree front to back and returns the first interactive node whose bounds contain the point:

  • The last child wins. Children are tested in reverse drawing order, so the child drawn on top gets the hit. Overlapping children of a ZStack resolve the same way.
  • A child wins over its parent. A container only gets the hit where none of its children did.
  • Clips apply. A node inside an ancestor with Clip(true) or inside a scroll view cannot be hit outside the visible part of that ancestor. Scroll offsets and transforms are applied exactly as they are for painting.
  • The hit area is the rectangle. Rounded corners are not followed: a click in the corner of a rounded button hits the button.
  • Disabled nodes still block. A disabled control is a hit target, so a click on it does not fall through to a sibling drawn behind it. Its own interactor receives no events; a pointer or key event that hits it bubbles on to its ancestors. It is skipped by the focus order, and the controls of package ui draw their disabled look whatever the hover and press state.
  • Hidden subtrees do not exist for input. An inactive tab of a TabBar and a covered screen of a NavigationStack are not hit tested and not in the focus order.

The scrim of a Modal uses these rules. It is a window-sized interactive node on top of the content and swallows every pointer event, so a tap next to an Alert cannot reach the button behind it.

App.HitTest(p) runs the same walk and returns a gift.NodeRef. It exists for tests and diagnostics.

Pointers, capture and gestures

Mouse and touch go through the same events and the same rules. Each event carries the Pointer id and the Device, gift.PointerMouse or gift.PointerTouch. Positions are in window coordinates, in logical pixels: the space that EventContext.DeviceBounds returns. EventContext.Bounds is the node’s rectangle before the scroll offsets of its ancestors, so compare Event.Pos with DeviceBounds.

Capture

The node that takes a press captures the pointer. Every move, the release and a cancel go to that node until the pointer comes up, even when the pointer has left the node. Two fields of gift.Event tell the node what happened:

  • Inside reports whether the position is inside the node, with the clips of its ancestors taken into account.
  • Dragged reports whether the pointer moved further than gift.DragSlop (8 logical pixels) from where it went down.

A tap is an EventPointerUp with Inside true and Dragged false. This is why pressing a button, sliding off it and releasing next to it does not activate it: the button hears the release, but with Inside false. While the pointer is outside, the button also loses its pressed look, and gets it back when the pointer returns.

Gestures gift recognizes

  • Long press. When a pointer stays down for gift.LongPressDelay (500 ms) without moving further than DragSlop, the capturing node receives one EventLongPress.
  • Wheel. EventWheel carries the scroll amount in Delta. It goes to the node under the pointer and bubbles until a scroll container that can still move in that direction consumes it.
  • Drag to scroll. A scroll container watches the moves that bubble up from its children. When a drag that started on a button inside it passes the slop, the container takes the pointer with EventContext.StealPointer. The button receives EventPointerCancel, loses its pressed look and never sees the release, so it cannot fire.

EventPointerCancel means the gesture ended without a release. A node must treat it as a reset, never as an activation.

Limits

  • Only the primary mouse button drives the pointer model. The right and middle buttons produce no events.
  • gift tracks one touch at a time. A second finger that comes down while the first is still down is dropped and counted in Diagnostics.DiscardedTouches. There is no pinch or multi-finger gesture.

Hover, press and focus: gift.Interaction

Hover, press and focus are presentation state that gift keeps in the retained node, not in your view. The dispatcher updates a gift.Interaction and marks the node for repaint; the painter reads it back.

FieldSet while
Hovera mouse is over the node and no other node has captured the pointer
Presseda pointer this node captured is down and inside it
Focusedthe node holds the keyboard focus
FocusVisiblethe node holds the focus and the focus came from the keyboard
Disabledthe node is disabled

Two consequences matter for application code:

  • Hover and press rebuild nothing. Moving the mouse across a toolbar runs no view function and measures nothing. You never store hover or press in your own state. You describe the looks instead, for example with ButtonView.HoverStyle, PressedStyle and DisabledStyle; see Button.
  • The state survives rebuilds. A rebuild replaces the view, not the node, so a button that is pressed while some state elsewhere changes stays pressed.

While a pointer is down, hover is frozen. A drag that passes over other buttons does not light them up, whether or not the press captured anything.

Touch input

Touch follows the same model with one difference: a touch never produces hover. A finger exists only while it is down, so it sends no EventPointerEnter or EventPointerLeave and never sets Interaction.Hover. A tap on a touchscreen therefore leaves no hover highlight behind on the button that was tapped.

Do not decide behavior on Event.Device. Ebitengine reports touches only on mobile platforms. On Linux under X11, which is what a Raspberry Pi kiosk runs, a touchscreen arrives through the display server as a mouse: every event is PointerMouse, and because the cursor stays where the finger lifted, the last tapped control keeps its hover look until the pointer goes somewhere else. A control that only worked for PointerTouch would never work on that hardware. Write controls that behave correctly for both, and use explicit switches such as ui.SetOnScreenKeyboard for behavior that depends on the deployment.

Keyboard focus

Key events go to the node that holds the keyboard focus. A node can hold it when its element declares an interactor and Focusable, and it is not disabled. In package ui these controls are focusable: Button (and everything built on it, such as tappable rows and the items of a TabBar), Toggle, Slider, SegmentedControl, TextField, VirtualList and ImageGallery. A disabled control is not focusable.

How the focus moves

  • Press on a focusable control. The control takes the focus from its own handler. Every focusable control in package ui does this on EventPointerDown.
  • Press on anything else. A press on empty space, or on a hit target that cannot be focused, such as a scroll view, clears the focus. A click on the background therefore removes a stale caret and puts the on-screen keyboard away. The on-screen keyboard itself is the one exception; a press on one of its keys keeps the focus on the field it types into.
  • Tab and shift-tab. When no node handles a tab key press, gift moves the focus to the next focusable node, or to the previous one with shift held.
  • From code. App.MoveFocus(forward) does what tab does. Inside an interactor, EventContext.RequestFocus and ClearFocus take or drop the focus.

Focus order

The tab order is document order: the order in which nodes appear in the tree you built, parents before their children, earlier siblings before later ones. It is not the geometry on screen. If you want the focus to move left to right and then down, build the views in that order.

The order wraps around at both ends. Disabled controls and hidden subtrees are skipped. Tab does not repeat when held, so the user can count presses.

A focused control may handle tab itself by returning true for it; gift then does not move the focus. The text field deliberately does not do that, so tab always leaves a field.

Focus traps

A Modal confines the focus order to its presented view. While an alert or a sheet is up, tab cycles through its controls and never reaches the screen behind it. When a trap appears, gift also takes the focus away from anything outside it. The innermost and the most recently presented trap wins, so an alert over a sheet traps inside the alert.

The mechanism is gift.Element.FocusTrap. Hiding works the same way: a node that is hidden while it holds the focus loses it and receives EventFocusLost after the build.

The focus ring

The focus decides where key events go. The focus ring shows the keyboard user where they are, and it would be noise for someone who does not use a keyboard. gift therefore records where the focus came from:

  • A focus installed while a key event is dispatched, or by App.MoveFocus, is visible. FocusVisible is true.
  • A focus installed while a pointer event is dispatched is not visible. Focused is true, FocusVisible false.

Every focus ring in package ui reads FocusVisible. Tab onto a button and it shows a ring; tap it and it does not, although it holds the focus and space would activate it. A text field always shows its caret while focused, because the caret reads Focused. This is the rule CSS calls :focus-visible.

The default ring is a 2 pixel border in ColorAccent. Buttons and text fields let you change it with FocusRing; a zero width border removes it:

ui.Button(ui.Text("Save"), save).
	FocusRing(ui.Border{Width: 3, Color: ui.ColorAccent})

Toggles, sliders and segmented controls draw the default ring.

Keys and characters

The keyboard reaches gift on two separate channels, and a control that edits text listens to both.

  • Keys (EventKeyDown, EventKeyUp) name a physical key by its position on a US keyboard. They carry commands: navigation, activation, deletion, shortcuts.
  • Characters (EventRune) carry the text the platform’s keyboard layout produced. On a German keyboard the key in the position of the US semicolon produces ö, and that only arrives as a rune.

Never insert text from a gift.Key. Read Event.Rune from EventRune instead. Umlauts, accents, AltGr and dead keys arrive as resolved runes. There is no input method for composed scripts such as Chinese or Japanese.

The keys gift knows

gift.Key is deliberately small: tab, space, enter, escape, the four arrows, home, end, page up, page down, backspace and delete, plus KeyA, KeyC, KeyV, KeyX and KeyZ as operands of the editing shortcuts. The Ebitengine backend reports only these keys as key events. Other keys produce no key event; if they type a character, it arrives as a rune.

Event.Mods holds the modifiers (ModShift, ModControl, ModAlt, ModMeta) and is also set on pointer events. Test it with Has:

if e.Key == gift.KeyC && e.Mods.Has(gift.ShortcutModifier) {
	// copy
}

gift.ShortcutModifier is command on macOS and control elsewhere. gift.WordModifier, which turns a caret move into a word move, is option on macOS and control elsewhere. gift dispatches no shortcuts itself; these two variables only make all views agree on which modifier means what. If the attached keyboard does not match the operating system, for example a Mac keyboard on a Raspberry Pi, assign the variable before the first frame:

gift.ShortcutModifier = gift.ModMeta

Key repeat

gift repeats a held key itself, because Ebitengine has no key repeat. After gift.KeyRepeatDelay (400 ms) it sends a synthetic EventKeyDown every gift.KeyRepeatInterval (30 per second), with Event.Repeat set. Only the arrows, page up, page down, backspace and delete repeat. Space, enter, escape, tab, home and end do not, so holding enter on a button fires it once. The repeat stops when the focus moves. Held character keys repeat at the rate of the operating system, as runes.

Escape and keys without a focus

On a touch panel usually nothing is focused, because a tap on the background clears the focus and no keyboard moves it. A key from a keyboard plugged into it would then reach nobody. A node can declare gift.Element.KeyFallback to receive key events while nothing holds the focus; the last one in document order wins. NavigationStack uses it so that escape pops the top screen, and Sheet uses it so that escape calls its OnDismiss, whether or not anything is focused.

When the on-screen keyboard is showing, escape first puts the keyboard away and is not delivered to anybody. A second press reaches the screen.

The clipboard

A text field supports select all, copy, cut and paste with the shortcut modifier and A, C, X and V. It goes through a process-wide ui.Clipboard, an interface with two methods, Text and SetText.

By default the clipboard is a ui.MemoryClipboard. It works completely inside your process but exchanges text with no other program. That is enough for a kiosk and for tests. To use the clipboard of the operating system, import the clipboard package for its side effect:

import _ "github.com/worldiety/gift/clipboard"

or install it with your logger, so that its failures are logged:

ui.SetClipboard(clipboard.New(logger))

The package supports macOS (NSPasteboard) and Linux under X11, without cgo. On other platforms, including Windows and Wayland without XWayland, a read reports no text and a write is dropped. It transfers at most clipboard.MaxBytes (256 KiB); longer text is cut off at a rune boundary without telling the caller. Failures and truncations are logged only to the logger you pass to clipboard.New; the side effect import logs nothing. Under X11 the copied text disappears when your process exits. See the package documentation for the details.

Your own code reaches the installed clipboard with ui.CurrentClipboard(), which never returns nil. Call it from an event handler on the UI goroutine:

ui.Button(ui.Text("Copy code"), func() {
	ui.CurrentClipboard().SetText(code)
})

Call SetClipboard before the first frame. SetClipboard(nil) restores the in-process default. In a test, install a fresh MemoryClipboard and restore the default afterwards:

cb := &ui.MemoryClipboard{}
ui.SetClipboard(cb)
t.Cleanup(func() { ui.SetClipboard(nil) })

The on-screen keyboard

A kiosk without a hardware keyboard needs a keyboard on screen. gift draws one itself, because Ebitengine cannot open the platform’s keyboard and a Raspberry Pi kiosk has none to open. It is off by default. Two steps turn it on:

func main() {
	ui.SetOnScreenKeyboard(nil, true)
	app := gift.New(gift.Options{Root: screen})
	// ...
}

func screen(ctx *gift.Context) gift.View {
	form := ui.VStack(
		ui.TextField(ui.Editor(ctx, "name", "")).Placeholder("Name"),
	)
	return ui.Window(form, ui.OnScreenKeyboard()).Align(geom.Bottom)
}

With the switch on, a text field asks for the keyboard when it gains the focus and withdraws the request when it loses it. The keyboard view shows itself while a request is open and takes no space otherwise. The keys inject their input through the same path as a physical keyboard, so a control cannot tell the difference.

  • Placement. Put ui.OnScreenKeyboard() once, as a direct child of the ui.Window, and align the window to the bottom. While the keyboard shows, the other children are laid out in the space above it, so a scroll view gets shorter and a submit button at the bottom stays visible. Overlay.AvoidKeyboard(false) turns that off. A Modal belongs inside the window, next to the keyboard, not around it.
  • Revealing the field. A focused text field scrolls itself into view with EventContext.ScrollIntoView, and gift repeats that once the keyboard has appeared.
  • Dismissing. Escape, the keyboard’s own dismiss key, a tap on the background and App.DismissSoftKeyboard all put the keyboard away by clearing the focus.
  • Gate on the deployment. The switch is a statement about the machine, not about the pointer: as explained under Touch input, a kiosk touchscreen arrives as a mouse.

The Keyboard page shows the keyboard and its styling modifiers.

Writing your own interactive view

When you build a control yourself, its element declares an interactor and, if it should take part in the tab order, Focusable: true. Focusable without an interactor is ignored. The interactor is any value with a HandleEvent method:

type tapTarget struct{ onTap func() }

func (t tapTarget) HandleEvent(ctx *gift.EventContext, e gift.Event) bool {
	switch e.Kind {
	case gift.EventPointerDown:
		ctx.RequestFocus() // a press focuses the node, without a ring
		return true        // claim the press
	case gift.EventPointerUp:
		if e.Inside && !e.Dragged {
			t.onTap()
		}
		return true
	case gift.EventPointerCancel:
		// the gesture ended without a release: reset, never activate
	case gift.EventKeyDown:
		if e.Key == gift.KeySpace || e.Key == gift.KeyEnter {
			t.onTap()
			return true
		}
	}
	return false
}

The rules that keep a control well behaved:

  • Return what you handled. true stops bubbling. Return false for everything else, in particular for tab, so that the focus can move, and for moves you do not need, so that an enclosing scroll view can recognize a drag.
  • Check Inside and Dragged on the release. A control that only checks Inside fires after a drag.
  • Treat cancel as a reset. It can arrive in the middle of your own dispatch, when a scroll container steals the pointer.
  • Mind Event.Repeat. Moving a cursor on a repeated arrow key is the point of repeat. Activating something on a repeat is not.
  • Do not allocate. HandleEvent runs inside gift’s zero allocation contract for input.
  • Read the interaction, do not copy it. A painter reads hover, press and focus through PaintContext.Interaction(); gift already marked the node for repaint when they changed.

The EventContext is valid only during the call. It gives the node its bounds, its interaction, the focus, scrolling and the on-screen keyboard; see the table below. Custom views explains the rest of a view of your own: the layouter, the painter and ControlState.

Testing and diagnostics

The gifttest harness drives the same entry points as the backend. Tab, ShiftTab, Key, TypeText, ClickAt, TapAt and the Tap, Press, LongPress, Swipe and Wheel methods of a found node produce real events, and AssertFocus, AssertNoFocus, AssertHovered and AssertPressed check the result. TapAt and TouchDownAt send PointerTouch events, so a test can assert that a tap leaves no hover.

Outside tests, App.Focus, App.FocusVisible, App.HitTest and App.NodeInteraction answer the same questions. Diagnostics counts HitTests, InputEvents, DiscardedTouches, RunesTyped and KeyRepeats.

API

gift.EventContext

Methods

MethodDescription
Animate(d time.Duration)Animate keeps the receiving node marked for repaint for the next d, on the clock App.BeginInput is given.
Bounds() geom.RectBounds returns the absolute rectangle of the receiving node, as computed by the last layout pass.
ClearFocus()ClearFocus removes the keyboard focus from whatever holds it.
ControlState() ControlStateControlState returns the control state of the node receiving the event.
DeviceBounds() geom.RectDeviceBounds returns the rectangle of the receiving node mapped through the transforms of its ancestors, which is the space Event.Pos lives in.
DismissSoftKeyboard() boolDismissSoftKeyboard puts the on-screen keyboard away from inside an event handler, by blurring whatever asked for it, and reports whether there was one up.
Interaction() InteractionInteraction returns the hover, press, focus and disabled state gift maintains for the receiving node.
KeyDown(k Key, mods Mods)KeyDown injects a key press, as App.KeyDown does for the backend.
KeyUp(k Key, mods Mods)KeyUp injects a key release; see EventContext.KeyDown.
Now() time.DurationNow returns the timestamp of the event being dispatched, on the clock App.BeginInput is given: the clock a control’s animation is started on.
Repaint()Repaint marks the receiving node as needing to be drawn again, without rebuilding or re measuring anything.
RequestFocus()RequestFocus moves the keyboard focus to the receiving node, if it declared itself focusable.
RequestLayout()RequestLayout marks the receiving node as needing another layout pass in the next update, without rebuilding anything.
RequestSoftKeyboard(want bool)RequestSoftKeyboard declares whether the receiving node wants an on-screen keyboard, and asks for a rebuild when the answer changed.
ScrollBy(d float64) boolScrollBy moves the scroll container receiving the event by d document units and reports whether the offset changed; see EventContext.ScrollTo.
ScrollIndicator() (ScrollIndicatorState, bool)ScrollIndicator returns the indicator state of the scroll container receiving the event, and false when this node is not one.
ScrollInfo() (ScrollInfo, bool)ScrollInfo returns the state of the scroll container receiving the event, and false when this node is not one.
ScrollIntoView() boolScrollIntoView adjusts every scroll container above the receiving node so that the node is visible, and reports whether anything moved.
ScrollTo(off float64) boolScrollTo moves the scroll container receiving the event to the document offset off, clamped, and reports whether the offset changed.
SetControlState(v ControlState)SetControlState writes the control state of the node receiving the event and marks it for repaint when it changed.
SetScrollIndicator(v ScrollIndicatorState) boolSetScrollIndicator writes the indicator state of the scroll container receiving the event and reports whether it changed.
StealPointer() boolStealPointer moves the capture of the pointer whose event is being dispatched to the receiving node, and reports whether the node holds it afterwards.
TypeRune(r rune)TypeRune injects one character as though the platform’s keyboard had produced it.

gift.EventContext on pkg.go.dev ↗

gift.Interaction

type Interaction struct {
	Hover        bool
	Pressed      bool
	Focused      bool
	FocusVisible bool
	Disabled     bool
}

gift.Interaction on pkg.go.dev ↗

Clipboard and on-screen keyboard

FunctionDescription
func OnScreenKeyboardEnabled() boolOnScreenKeyboardEnabled reports whether the kiosk switch is on.
func OnScreenKeyboardHeight() float32OnScreenKeyboardHeight is how tall OnScreenKeyboard makes itself, in logical pixels, with its default padding and no KeyboardView.Frame.
func SetClipboard(c Clipboard)SetClipboard installs the clipboard implementation for the whole process.
func SetOnScreenKeyboard(app *gift.App, on bool)SetOnScreenKeyboard turns gift’s own on-screen keyboard on or off for the whole process and asks app to rebuild, which is what makes the change visible.

Constructors

ConstructorDescription
func CurrentClipboard() ClipboardCurrentClipboard returns the installed clipboard, or the in-process default.

ui.Clipboard on pkg.go.dev ↗

Methods

MethodDescription
Clear()Clear empties the clipboard, so that MemoryClipboard.Text reports false again.
SetText(s string)SetText implements Clipboard.
Text() (string, bool)Text implements Clipboard.

ui.MemoryClipboard on pkg.go.dev ↗

Related