Device Input (Sensors, Gamepad, Geolocation)

Introduction

Motion sensors (accelerometer / gyroscope / magnetometer), gamepads, and geolocation are event-driven: you attach a callback to a window-level event filter and the framework invokes it when a new sample arrives. You do not poll on a Timer. Inside the callback you read the current value through an accessor on CallbackInfo. This is the same „react to an event, then read the detail“ shape as a key press or a mouse move.

This avoids the busy-loop a poll would cause and keeps the device's sample rate in charge of how often your code runs.

Motion sensors

Attach a callback to WindowEventFilter::SensorChanged; it fires when any sensor reading advances. Read the value with CallbackInfo::get_sensor_reading:

let dom = Dom::create_body().with_callback(
    EventFilter::Window(WindowEventFilter::SensorChanged),
    state.clone(),
    on_sensor,
);

extern "C" fn on_sensor(mut data: RefAny, info: CallbackInfo) -> Update {
    if let OptionSensorReading::Some(r) = info.get_sensor_reading(SensorKind::Accelerometer) {
        if let Some(mut s) = data.downcast_mut::<MyState>() {
            s.tilt_x = r.x;  // m/s^2 on each axis; r.magnitude() for total
        }
    }
    Update::RefreshDom
}

There is also a node-level HoverEventFilter::SensorChanged mirror, for the „redraw this node when a reading changes“ pattern. Identical consecutive readings do not re-fire (an idle sensor streaming the same value is silent).

Gamepad

Attach to WindowEventFilter::GamepadInput; read state with CallbackInfo::get_primary_gamepad (the first connected pad - the common single-controller case) or get_gamepad_state(id):

extern "C" fn on_gamepad(mut data: RefAny, info: CallbackInfo) -> Update {
    if let OptionGamepadState::Some(pad) = info.get_primary_gamepad() {
        let jump = pad.is_pressed(GamepadButton::South);   // A / Cross
        let move_x = pad.left_stick_x;                     // [-1, 1]
        // ... drive your game/menu state
    }
    Update::RefreshDom
}

GamepadState carries connected, a buttons bitset (read via is_pressed), two sticks, and the triggers (left_z / right_z). A disconnect keeps the slot with connected = false so you can observe it.

Geolocation

Geolocation follows the WebAuthn-style permission model: mounting a Dom::create_geolocation_probe(...) node is the permission request. Once a backend delivers a fix you read it with CallbackInfo::get_location_fix:

// Mounting the probe asks the OS for location.
container.with_child(Dom::create_geolocation_probe(GeolocationProbeConfig {
    high_accuracy: true,
    background: false,
    max_accuracy_m: 0.0,
    min_interval_ms: 0,
}));

// Read the latest fix from any callback:
if let OptionLocationFix::Some(fix) = info.get_location_fix() {
    let (lat, lon) = (fix.latitude_deg, fix.longitude_deg);
}

LocationFix carries latitude/longitude, accuracy, altitude (+ accuracy), heading, speed, and a timestamp. See the AzulMaps example for a live readout.

Pen / stylus

Pen input (Apple Pencil, S-Pen, Wacom) is state-based, like sensors: react to ordinary pointer events (MouseDown / MouseMove / MouseUp) and read the pen detail with CallbackInfo::get_pen_state (or get_pen_pressure / get_pen_tilt). PenState carries position, pressure, tilt, the contact + eraser flags, the barrel button, and the extended axes (tangential pressure, barrel roll). The platform backend populates it through the gesture manager; examples/azul-paint uses exactly this to draw pressure-modulated, barrel-roll-oriented strokes.

extern "C" fn on_pointer_move(mut data: RefAny, info: CallbackInfo) -> Update {
    if let Some(pen) = info.get_pen_state().into_option() {
        let width = 2.0 + pen.pressure * 10.0; // light touch -> thin line
        // ... draw with `width`, oriented by pen.barrel_roll, erase if is_eraser
    }
    Update::RefreshDom
}

(The PenDown/Move/Up event filters exist but are not the path apps use - pointer-event + get_pen_state is.)

Safe-area insets (notches)

On displays with a notch, rounded corners, or system bars (a MacBook notch, a phone, a tablet) part of the window is obscured. Read the insets - the margins to keep interactive content clear of - with CallbackInfo::get_safe_area_insets:

extern "C" fn on_event(data: RefAny, info: CallbackInfo) -> Update {
    let safe = info.get_safe_area_insets();          // css SafeAreaInsets
    if let Some(top) = safe.top.into_option() {
        // ... pad the top bar by `top` so it clears the notch
    }
    Update::DoNothing
}

SafeAreaInsets carries top / right / bottom / left, each an OptionPixelValue (None where there is no inset). It's the same azul_css::SafeAreaInsets the styling system holds, so the values are shared with layout. The platform shell populates them - macOS reads the notch via NSView.safeAreaInsets; iOS (UIView.safeAreaInsets) and Android (WindowInsets) are being wired. Zero on displays with no inset.

Configurability

  • Sensors: the manager keeps one reading per SensorKind; request rate / which sensors are platform-backend concerns (CoreMotion / Android SensorManager).
  • Gamepad: deadzone + rumble are platform-backend features; the cross- platform surface gives you raw stick/trigger values and the button bitset.
  • Geolocation: GeolocationProbeConfig configures accuracy, background delivery, max accuracy, and minimum interval at mount time.

Testing without hardware

Every path above is exercised synthetically (no device) by layout/tests/synthetic_events.rs: it injects readings/states/fixes through the same push_sensor_reading / push_gamepad_state / push_location_fix channels the real platform backends use, then asserts the manager folds them and the EventProvider yields SensorChanged / GamepadInput. See e2e-testing. The pen path is exercised via GestureAndDragManager::update_pen_state -> get_pen_state.

See also

Back to guide index