Focus

Day apps need two things from focus: to know when a control gains or loses it, and to move it. Both are declarative (one reactive signal per form stands in for focus-node objects and view references), and both ride the machinery Day already has: the event sink, origin-tagged writes, and per-node Toolkit duties.

1. The API

Focus binds to a signal with .focused(…), exactly like every other two-way control binding. One name covers both shapes (a marker-trait overload, the IntoText pattern):

// One field: a Bool signal. Native focus writes it; writing it moves focus.
let editing = Signal::new(false);
text_field(query).focused(editing)

// A form: one Option<K> signal for the whole group (the recommended shape).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Field { User, Pass }
let focus = Signal::new(Some(Field::User));
column((
    text_field(user)
        .on_submit(move || focus.set(Some(Field::Pass)))
        .focused((focus, Field::User)),
    text_field(pass).focused((focus, Field::Pass)),
    button("Log in").action(move || {
        if user.with(|s| s.is_empty()) { focus.set(Some(Field::User)); return; }

    }),
))
  • Native → signal: when the control gains focus the signal becomes true / Some(K); when it loses focus, false / None, unless another bound control gained it in the same turn, in which case the group signal moves straight to that control’s value (no None in between).
  • Signal → native: writing Some(K) / true requests native focus for the bound control. Writing None / false resigns it; on iOS and Android that dismisses the soft keyboard, matching the platform convention SwiftUI set.
  • Reading is free. focus.get() == Some(Field::User) is a tracked read like any other, with nothing else to wire.
  • Field chaining is a write in on_submit(f), which ships with the same change: the native end-editing / return hooks focus needs are the ones Event::Submitted needs.

.focusable() opts a composed container into focus: the canvas contract (press-to-focus, FocusChanged both ways, the keys through .on_key while focused) behind the Toolkit::set_focusable duty. Implemented on macos-appkit (2026-08, the content-list keyboard work in docs/navigation.md); on every other backend the duty is a no-op today, so the piece renders normally and never joins the key loop, the same silence unfocusable controls have. Reserved with names but not implemented: default_focus(…) on containers, focus_order(n), and focus scopes for dialogs. Tab/Shift-Tab traversal stays native. Day wraps real widgets, so platform traversal is already correct (§13: focus order follows layout order).

2. Semantics: the rules

  1. Writes are requests. A write asks the toolkit on the next main-loop turn; the last write in a turn wins.
  2. The signal converges on reality. Backends report the resulting state through Event::FocusChanged, and that report is what lands in the signal (through the echo guard). A request naming a target the platform will not focus produces no event: platform focus is unchanged, and the next real focus event corrects the signal.
  3. One-turn latency. focus.set(…) then focus.get() in the same turn reads the old value, the same asynchrony every reference framework has, made explicit.
  4. Mount reconciliation. When a piece bound to K::V mounts and the signal already reads Some(K::V), it requests focus. Set the signal, then present the sheet: the field focuses when it appears. (The initial false/None is not applied: resigning focus a control never had would steal it from whoever has it.)
  5. Echo discipline. A programmatic focus move fires native focus events; those must not re-request. The pieces layer keeps a per-binding echo cell of the native state and skips applies that already match it; backends additionally skip resigns when the control doesn’t own focus (so a stale release can’t blur a sibling).
  6. Loss and gain pair up. When focus moves between two controls bound to the same group signal, the event pump dispatches the queued gain before the loss it arrived with, so the signal transitions Some(A)Some(B) without an observable None. A loss with no queued successor (click on empty space, window resigned) writes false / None.

3. How it rides the existing machinery

layerwhat shipped
day-specEvent::FocusChanged(bool) became real. New defaulted duty: fn focus(&mut self, h: &Handle, node: NodeId, focused: bool) {} — the scroll_to/enable_gesture shape.
day-coreTreeOps::focus_node (clones the handle, calls the duty), NodeProbe.focused (mirrored from FocusChanged for dayscript), gain-before-loss pairing in the pump, enqueue_events batch enqueue.
day-piecesDecorate::focused(…) with IntoFocusBinding markers for Signal<bool> and (Signal<Option<K>>, K); the echo cell; on_submit(f) on text_field.
day-mockfocus #N true/false op log + MockWidget.focused — the F1 test contract. The mock duty does not synthesize FocusChanged back; tests emit events explicitly.
day-scriptfocus: { id, focused? } drives the real duty (keyboards and end-editing flows engage) and assert_focused: { id, focused? } reads NodeProbe.focused (retryable — focus lands a turn after the request).

4. Per-toolkit implementation

“Observe” is gain/loss detection, “drive” is the request/resign duty.

backendobservedrive
AppKittext fields: DayTextField overrides becomeFirstResponder (gain); controlTextDidEndEditing: on the DayTarget delegate (loss — except NSTextMovementReturn, which is Submitted: AppKit keeps the field first responder on return). Non-text controls drive but don’t observe in v1.makeFirstResponder(view) / (None). The resign guard unwraps the shared field editor back to its delegate field before deciding ownership.
UIKitEditingDidBegin / EditingDidEnd targets on the text field; EditingDidEndOnExit is Submitted (registering it is also what makes Return dismiss the keyboard).becomeFirstResponder() / resignFirstResponder() — focus is the keyboard on iOS. Buttons are not first-responder focusable.
GTK 4EventControllerFocus (enter/leave) on entry, button, switch, and scale — it tracks focus-within, which covers GtkEntry’s inner GtkText. Entry::activate is Submitted.grab_focus(), retried once at map if the widget isn’t mapped yet (rule 4); resign via root.set_focus(None), only while the widget holds focus-within.
Qt 6day_qt_enable_focus — a FocusIn/FocusOut event filter (the DayGestureFilter pattern) on line edit, button, checkbox, and slider; popup-reason focus-outs are ignored (menus are transient). returnPressed is Submitted.setFocus(Qt::OtherFocusReason) / clearFocus() (only while focused). Qt delivers focus events only in the active window, so the duty activates it first — via the OS when allowed, else app-locally (QApplication::setActiveWindow, kept by Qt for exactly this driving/embedding case).
AndroidView.OnFocusChangeListener on the inner TextInputEditText → event kind 16; OnEditorActionListener (IME action or hardware enter key-down) → Submitted (kind 17).DayBridge.focusView: requestFocus() + InputMethodManager.showSoftInput on gain; on resign, hide the IME and clearFocus() — which lands on DayActivity’s focusable-in-touch-mode root instead of snapping to the first focusable field.
XAMLGotFocus/LostFocus per control (system XAML has no global focus event) on button, toggle, slider, and text box; KeyDown Enter in a TextBox is Submitted.Control.Focus(FocusState::Programmatic) (draws no focus visual); resign parks focus on an invisible 1×1 ContentControl sink (IsTabStop flipped around the call, so it never sits in the tab order).
ArkUINODE_ON_FOCUS / NODE_ON_BLUR registered on button, text input, toggle, and slider; NODE_TEXT_INPUT_ON_SUBMIT is Submitted.OH_ArkUI_FocusRequest(node) (typed non-focusable errors ignored — rule 2); resign via OH_ArkUI_FocusClear(OH_ArkUI_GetContextByNode(node)), guarded by NODE_FOCUS_STATUS.
mocklogged op + MockWidget.focusedlogged op

Which controls are focusable. Text fields are focusable everywhere. On desktop, buttons, toggles, and sliders are too, with the platform’s keyboard-access rules (macOS buttons join the key loop only with Full Keyboard Access on, and AppKit v1 doesn’t observe them; Qt button focus policy is style-dependent). On touch mobile, non-text controls generally are not focusable, and the bindings stay quiet there.

A canvas is focusable on every toolkit, and observes both ways. It is the one built-in piece with no native control underneath, so nothing would otherwise make it the focused element, and without that, keys could never reach what an app draws. Decorate::on_key is built on this (docs/menus.md): keys follow focus, so a canvas that has it hears the arrows and a text field that takes it gets them back.

backendfocusabletakes focus on press
AppKitacceptsFirstRespondermouseDown:, before forwarding to the gesture recognizers
UIKitcanBecomeFirstRespondertouchesBegan:, likewise
GTK 4set_focusablea GestureClick that calls grab_focus
Qt 6Qt::StrongFocusthe focus policy itself (click or tab)
web-doma tabindexa pointerdown listener
XAMLControl::IsTabStop on a ContentControl host wrapped around the Canvas — in system XAML IsTabStop and Focus are Control members (they moved up to UIElement only in WinUI 3), and a Panel is not a ControlPointerPressed on the host, which the press reaches by bubbling out of the Canvas; the Panel also takes a transparent Background, because an unpainted one is not hit-testable
ArkUIthe NODE_FOCUSABLE attributeArkUI’s own focus handling
AndroidsetFocusableInTouchMode, without which requestFocus refuses it during touch inputdispatchTouchEvent, before the gesture listener, and only for a canvas the app hung .on_key on: in touch mode a focus move can dismiss a raised soft keyboard

Each reports FocusChanged both ways (becomeFirstResponder/resignFirstResponder, focus/blur, GotFocus/LostFocus, NODE_ON_FOCUS/NODE_ON_BLUR, onFocusChanged), so .focused(signal) binds two-way and assert_focused can see it.

A view that is not in a window cannot hold the keyboard, and Day does not work around that. On iOS a full-screen modal takes the presenting view out of the window, so a canvas behind a compact inspector sheet (docs/inspector.md) refuses focus until the sheet closes; the request lapses and the signal snaps back, which is rule 2.

5. Testing it

  • Unit (mock): two-way Bool binding, group moves without a None blip, mount-time Some(K) requests focus, on_submit fires (day-pieces/tests/mock_e2e.rs).
  • dayscript: the showcase walkthrough’s Focus block runs focus / assert_focused against the real duty on every scripted platform (macOS AppKit/GTK/Qt, iOS, Android: 208/208), with assertions kept to text fields, the one control focusable everywhere.
  • Showcase: the Focus page (Day-Showcase/src/pages/focus.rs) demonstrates the group signal steering a form (with Return chaining), the plain Bool binding, and non-text-control focus with a live readout.

6. Prior art: what Day adopted and rejected

  • SwiftUI (@FocusState, .focused(_:equals:)). Adopted: the Bool + Optional-of-Hashable binding shape, nil clears focus and dismisses the keyboard, moved focus writes back on loss. Rejected: the unconstructible binding (state can’t live outside the view); Day signals have no such restriction.
  • Flutter (FocusNode/FocusScope). Rejected as an API (imperative node lifecycle inside a declarative tree); adopted as semantics: “focus changes apply after the build phase” is rule 3, and the scope-restore behavior informs the reserved focus-scope design.
  • floem (the nearest cousin): its request_focus(when) showed that signal-driven focus writes work; its event-only reads are the asymmetry rule 2 closes.
  • iced / egui / Slint / GPUI: converge on last-write-wins, next-frame application, and focus-nowhere being representable, all reflected in the rules.

7. Resolved design questions

  1. Group-signal loss semantics — solved in the pump: a FocusChanged(false) scans the queue for a paired FocusChanged(true) from another node and dispatches the gain first, so group signals never blip through None (rule 6).
  2. Resign target on Android/XAML — both resign to a focusable root: Android to the activity’s focusable-in-touch-mode content root, XAML to a hidden focus-sink control.
  3. focused_eq naming — one .focused() name via two marker-trait impls; the tuple form (signal, key) replaces a second method.
  4. Headless CI focus — programmatic grab_focus/setFocus is window-local on every backend (Qt after the app-local activation above), so scripted runs don’t depend on the window manager granting real OS focus.

8. Keyboard avoidance (the keyboard never covers the focused field)

Each mobile backend consumes the soft keyboard natively and resizes the Day root through the WindowResized event (the same relayout path a rotation takes), so the whole UI shrinks to the visible area and the focused field is scrolled back into view:

  • Android: the root’s wrapper folds WindowInsetsCompat.Type.ime() into the bottom margin (alongside the system-bar insets), so a raised keyboard shrinks the root exactly like a taller navigation bar would. The resize flows to Day (DayFixed.onSizeChangedWindowResized), Day relayouts, and the platform ScrollView then applies its stock resized-with-focus behavior, scrolling the focused descendant back into view.
  • iOS: the app delegate observes UIKeyboardWillChangeFrame, clamps the root view’s bottom to the keyboard’s top, emits WindowResized, and after Day’s relayout reveals the focused UITextField via scrollRectToVisible on its nearest enclosing UIScrollView. Show, hide, and height changes (emoji pane, hardware-keyboard toggles) all ride the one notification.
  • HarmonyOS: the window’s UIContext is set to KeyboardAvoidMode.RESIZE, so ArkUI shrinks the page instead of translating it; the host page’s onAreaChange forwards the new area through the resized() NAPI export, and Day relayouts into it.

Desktop backends have no soft keyboard, so nothing engages. Avoidance needs no per-app opt-in; every Day app gets it. The related programmatic primitive is TreeOps::scroll_reveal (docs/scroll.md), which scrolls any element’s nearest scroll ancestor into view.