zaxis 0.1.0

Custom widgets

Write a widget outside the crate with Ui::interact, Sense, owned keys and focus groups.

A widget is anything that implements Widget. Composing built-in controls needs nothing more. A widget with its own geometry and input uses one extra call, Ui::interact, which asks for a Response for a rectangle you allocated.

For explicit positioning, ui.at(source, rect, build) builds an ordinary vertical subtree clipped to a local rectangle and leaves the parent cursor unchanged. Inside PanZoom, it places Button/TextEdit/Slider and application widgets in content coordinates, including negative positions. Paint and interact accept the same local rectangles. After the complete pass, Context::input_transform(id) for delivered input, and Context::visual_transform(id) maps a widget's layout coordinates to displayed logical coordinates; use its inverse for application-specific pointer math. Response::drag_delta remains in displayed logical pixels. PanZoom's output also converts content to screen and back, including deferred placement and ancestor scale.

use zaxis::{vec2, Border, Color, Response, Sense, Shape, Ui, Widget};

struct Pad<'a> { on: &'a mut bool }

impl Widget for Pad<'_> {
    fn ui(self, ui: &mut Ui<'_>) -> Response {
        let rect = ui.allocate_space(vec2(120.0, 40.0));
        let mut response = ui.interact(rect, "pad", Sense::CLICK | Sense::FOCUS);
        if response.clicked() {
            *self.on = !*self.on;
            response.mark_changed();
        }
        let fill = if *self.on { Color::rgb(60, 140, 90) } else { Color::gray(52) };
        ui.paint(Shape::rect(rect, fill).corner_radius(6.0)
            .border(Border::new(if response.focus_visible { 2.0 } else { 1.0 }, Color::gray(110))));
        response
    }
}

// ui.add(Pad { on: &mut flag }.tooltip("Toggle the pad"));

Ui::interact(rect, id_source, sense) -> Response

  • Id. id_source is hashed into the current scope, so Response::id is stable across frames and distinct inside Ui::push_id loops. Two regions with one id in a pass are reported as an IdCollision diagnostic.
  • Geometry. rect comes from Ui::allocate_space (or your own layout). Allocate first, then interact: inside a Grid cell, Table row or Ui::visual group the region moves with the content, exactly like a Button.
  • One path. The region is registered through the same hit path as the built-in controls. It is clipped by windows, scroll areas and split panels, hidden when scrolled out of view, disabled by Ui::add_enabled_ui (a disabled region blocks the controls underneath like a disabled button), ordered by window and popup layers, captured by the pointer while pressed, reached by Tab, and activated by Enter or Space. There is no second hit test and nothing to register by hand.
  • Repaint. Any event on the response requests the follow-up pass, as Ui::add does. Idle widgets never ask for frames.

Sense

Combine with |. A sense only chooses which input the response reports.

SenseReports
Sense::NONENothing; a passive region
Sense::HOVERhovered; presses still reach what is underneath
Sense::CLICKclicked, double_clicked, secondary_clicked, pressed; Enter and Space click while focused
Sense::DRAGpressed, drag_started, dragged, drag_delta, drag_stopped; capture continues outside the rectangle
Sense::FOCUShas_focus, focus_visible, gained_focus, lost_focus; Tab and presses focus it

A focused widget gets its keys from Ui::keys, below. Response::mark_changed() reports that the widget changed its value, so Ui::add and callers see changed() like they do for a Slider.

Keys: Ui::keys(&response, KeyInterest) -> Vec<KeyEvent>

A focused widget owns the keys it declares. Declare them in every pass, then apply the events that arrive, in order, to your model:

use zaxis::winit::keyboard::KeyCode;
use zaxis::{KeyInterest, Mods};

ui.claim_keys(&response, KeyInterest::arrows().repeats());
ui.claim_keys(&response, KeyInterest::arrows().with_mods(Mods::SHIFT).repeats());
for key in ui.take_keys(&response) {
    let step = if key.modifiers.shift_key() { 0.01 } else { 0.05 };
    match key.code {
        KeyCode::ArrowUp | KeyCode::ArrowRight => *value += step,
        _ => *value -= step,
    }
}

ui.keys(&response, interest) is claim_keys and take_keys in one. The model stays yours: the library keeps the declaration, the events and the focus, never a closure or a value.

  • A claim is ownership. The dispatcher consumes each matching press on arrival, before the next pass, so Actions, containers and the host do not see it, even if the widget ignores the event. A key you do not declare takes the usual path. A claim beats a shortcut on the same key while the widget has focus; a chord in progress, a listening KeyBox, an open popup and a modal come first.
  • Overlays. A widget written outside the crate opens a popup with Popup::show like a built-in one. Built inside another popup it is that popup's child: no registration, and Escape, outside presses and focus restoration follow the nested popup rules. Keys go to the leaf popup only.
  • Declare in every pass. Not declaring gives the keys back. A widget that is disabled, hidden, clipped away or removed claims nothing, and events nobody takes lapse after one pass. The region needs Sense::FOCUS; a text field, slider or other control with keys of its own keeps them and ignores claims (reported as a usage diagnostic).
  • Events. Presses arrive in order and are never merged; repeats only with .repeats(), releases only with .releases(). Every event has code (navigation), layout (the printed Latin letter), physical, logical, repeat and the modifiers at that moment. A held key's repeats and release go to the widget that took the press even after focus moved.
  • Enter and Space click a focused region. Claim KeyInterest::activation() to take them as events instead; do not both handle clicked() and the key.
  • Text. Typing, IME and editing stay with TextEdit; do not claim printable keys to type, and a claimed printable key types nothing.

See Input for the priority order, limits and overflow.

Focus groups: several controls, one Tab stop

Controls built inside FocusGroup::show share one Tab stop and move focus with the arrow keys; a custom widget joins by being built there, with nothing else to register:

use zaxis::{AccessRole, FocusGroup, Widget};

FocusGroup::new("panel").label("Panel").role(AccessRole::Toolbar).show(ui, |ui| {
    ui.horizontal(|ui| {
        ui.add(zaxis::Button::new("Reset"));
        ui.add(Swatch(&mut color));      // your Widget with Sense::FOCUS
        ui.add(zaxis::TextEdit::new(&mut name).id_source("name"));
    });
});

Tab and Shift+Tab enter and leave; Left/Right (.vertical(): Up/Down) move between controls, Home and End jump to the ends, .wrap(true) closes the ring. Disabled, hidden, clipped and removed controls are skipped, a click focuses any member and makes it the stop, and the group remembers it. A text field and a slider are members that keep their own keys; Tab leaves them. A group inside a group is one member of the outer one, and FocusGroup::slot marks a part that navigates itself. The model and the layout stay yours: a toolbar is an ordinary function of your application that builds a group, which is how a library toolbar would be written too. See Input for the full rules and examples/custom_widget.rs for a knob, a swatch, a text field and a slider in one panel.

Events are one-shot

clicked, double_clicked, secondary_clicked, drag_started, drag_stopped, gained_focus and lost_focus are true for exactly one pass per user input. dragged is true while a drag is in progress and on the pass that ends it; drag_delta is the pointer movement since the previous pass, starting at the press position, so the deltas of one drag add up to its displacement. See Input and responses for the full list and the rules that tie them together.

Drawing and caching

Ui::paint(shape) takes Shape::rect, Circle, Line and the other shapes. A shape whose description did not change since the previous pass reuses its tessellated geometry, so a widget that paints from its current value costs nothing while idle and tessellates again only when the value or a state changes. Keep per-frame data out of the shape (no animation clocks, no counters) and the cache does the rest. Text inside a custom widget is a Text widget or a ui.label.

For a shader of your own on the shape of a widget (animated gradient, procedural noise, a glow, a frosted pane) use a material instead of painting many shapes: it costs one cached mesh and one uniform block per draw. Its hit region is still the rectangle you give Ui::interact.

Accessibility

A custom widget that describes nothing is not in the accessibility tree. Describe it with Ui::accessible after Ui::interact; the node takes the id and rectangle of the response:

use zaxis::{AccessRole, Sense};

let mut response = ui.interact(rect, "pad", Sense::CLICK | Sense::FOCUS);
ui.accessible(&response, |node| {
    node.role(AccessRole::Switch).label("Pad").toggled(*self.on);
});
if response.clicked() {
    *self.on = !*self.on;
    response.mark_changed();
}

The closure runs only while assistive technology is connected, so building a name costs nothing otherwise. AccessNode sets what the node says:

MethodSets
role(AccessRole)What the widget is; the default is Group
label(text), description(text)The name and the detail spoken after it
value(text), numeric(value, min, max), step(size)A text value, or a range value and the size of one step
toggled(..), selected(bool), expanded(bool)State of a two- or three-state control, an item, a disclosure
disabled, read_only, required, invalid, busy, hiddenFlags; a region inside a disabled Ui is published as disabled already
orientation, level, position_in_set(index, size)Axis, heading or tree level, zero-based position in a set
placeholder, url, keyboard_shortcutExtra text properties
live(AccessLive)Changes of the text are announced without moving focus
labelled_by(id), described_by(id)Name or describe the node with another widget's text
action(AccessActionKind)Accept a request of that kind

Requests from assistive technology arrive one pass later:

  • A region with Sense::FOCUS is focusable in the tree; a Focus request moves keyboard focus to it.
  • A Click request on a region with Sense::CLICK arrives as Response::clicked(), exactly like a pointer click. Nothing has to be declared for it.
  • Every other request is returned by Ui::accessible as an AccessAction, once, and only for kinds the node declared with action(..): Increment, Decrement, SetValue(String), SetNumericValue(f64), Expand, Collapse and the rest. A numeric value may be out of range or not finite; clamp it.

Ui::accessible_group(id_source, role, label, |ui| ..) groups the widgets built in the closure under one named node without changing layout. A caller can also rename, describe, re-role or hide any widget with .accessible_label(..), .accessible_description(..), .accessible_role(..) and .accessibility_hidden(). See Accessibility for a slider that handles Increment and Decrement.

Checklist

  1. Use stable id_source values; scope loops with Ui::push_id.
  2. Allocate before interacting; give the widget a nonzero rectangle.
  3. Read events from the returned Response and keys from Ui::keys; never from InputState transitions.
  4. Build related controls in a FocusGroup instead of making each a Tab stop.
  5. Treat response.enabled as the disabled state; do not test your own flag.
  6. Return the response so wrappers (.tooltip(..), .context_menu(..)) can attach.
  7. Describe the widget with Ui::accessible: a role, a name, and the requests it accepts.
  8. Check the result with the debug overlay: press F3 in the custom widget example.
Edit on GitHub

On this page