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_sourceis hashed into the current scope, soResponse::idis stable across frames and distinct insideUi::push_idloops. Two regions with one id in a pass are reported as anIdCollisiondiagnostic. - Geometry.
rectcomes fromUi::allocate_space(or your own layout). Allocate first, then interact: inside a Grid cell, Table row orUi::visualgroup the region moves with the content, exactly like aButton. - 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::adddoes. Idle widgets never ask for frames.
Sense
Combine with |. A sense only chooses which input the response reports.
| Sense | Reports |
|---|---|
Sense::NONE | Nothing; a passive region |
Sense::HOVER | hovered; presses still reach what is underneath |
Sense::CLICK | clicked, double_clicked, secondary_clicked, pressed; Enter and Space click while focused |
Sense::DRAG | pressed, drag_started, dragged, drag_delta, drag_stopped; capture continues outside the rectangle |
Sense::FOCUS | has_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::showlike 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 hascode(navigation),layout(the printed Latin letter),physical,logical,repeatand 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 handleclicked()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:
| Method | Sets |
|---|---|
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, hidden | Flags; 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_shortcut | Extra 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::FOCUSis focusable in the tree; aFocusrequest moves keyboard focus to it. - A
Clickrequest on a region withSense::CLICKarrives asResponse::clicked(), exactly like a pointer click. Nothing has to be declared for it. - Every other request is returned by
Ui::accessibleas anAccessAction, once, and only for kinds the node declared withaction(..):Increment,Decrement,SetValue(String),SetNumericValue(f64),Expand,Collapseand 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
- Use stable
id_sourcevalues; scope loops withUi::push_id. - Allocate before interacting; give the widget a nonzero rectangle.
- Read events from the returned
Responseand keys fromUi::keys; never fromInputStatetransitions. - Build related controls in a
FocusGroupinstead of making each a Tab stop. - Treat
response.enabledas the disabled state; do not test your own flag. - Return the response so wrappers (
.tooltip(..),.context_menu(..)) can attach. - Describe the widget with
Ui::accessible: a role, a name, and the requests it accepts. - Check the result with the debug overlay: press F3 in the custom widget example.