Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/src/app/docs/native-ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ pub fn draft(model: *const Model) []const u8 {

`on-resize` (on the `split` element; `Ui.valueMsg(.tag)` on `on_resize` in Zig views) names a variant whose payload is the new first-pane fraction (`f32`): after every divider drag, keyboard adjustment, or assistive increment/decrement the runtime delivers the fraction it already applied and clamped — store it in the model and echo it back through the split's `value`, and rebuilds never fight live resizing.

`on-press` and `on-double-press` optionally carry the modifiers held at click time. An arm declaring the four booleans `shift`, `control`, `alt`, and `super` — alongside at most one authored payload field — receives them from the runtime, exactly the way `on-drag` fills geometry around an authored `sourceId`. Markup keeps its ordinary spelling (`on-press="select:{row.id}"` fills `id`; a bare `on-press="clear"` names the modifier-only form), so cmd-click and shift-click become expressible without a new attribute. An arm without all four booleans is an ordinary payload and resolves exactly as before.

`on-drag` makes any element a draggable spatial object and names the closed record `{ sourceId, phase, x, y, viewWidth, viewHeight }`. Markup supplies numeric `sourceId` from a binding such as `on-drag="card_dragged:{card.id}"`; the runtime supplies `phase` (a number able to represent 0, 1, and 2) plus floating-point view-local `x`, `y`, `viewWidth`, and `viewHeight` (`f32`/`f64` in Zig, `number` in TypeScript). Geometry stays floating-point because pointer capture can carry negative coordinates outside the view. Phase 0 means motion, 1 release, and 2 cancellation. The renderer lifts the actual source appearance under the pointer at full opacity and leaves its in-flow space blank. Apps that need precise insertion can keep committed data unchanged during phase 0 while returning a derived view that moves the same `global-key` into the candidate position. Its hidden in-flow rendering is the one card-sized reserved slot: it begins at the source, moves to each candidate, and never duplicates. Keyed draggable neighbors ease between candidate poses; release carries the floating item from its pointer position into that slot and commits the exact order. A plain Escape during the drag dispatches phase 2, consumes that key, and carries the item back to its source slot; pointer cancellation uses the same path. Reduced-motion appearances snap these reflows. `examples/kanban` demonstrates within-column and cross-column reordering with this pattern.

`on-dismiss` (on the dismissible surfaces: `dialog`, `drawer`, `sheet`, `dropdown-menu`; `ElementOptions.on_dismiss` in Zig views) dispatches when Escape or a click outside dismisses the surface, so the model owns the close — clear the open flag in `update`. The engine hides the surface immediately as an optimistic echo; the next rebuild's source tree is truth. Escape works regardless of focus: it dismisses the nearest surface up the focused widget's chain, and when nothing relevant is focused — a menu opened from a plain-text trigger takes no focus — it falls back to the topmost mounted anchored surface. `on-hold` (any element; `ElementOptions.on_hold`) is press-and-hold: a pointer held ~350 ms dispatches the hold Msg and the release presses nothing, a quick click dispatches `on-press` as usual, and a right/ctrl-click with no context menu on its route dispatches the hold Msg immediately (a declared `<context-menu>` always wins the right-click) — the crumb-switcher shape (`on-press` selects, `on-hold` opens an anchored menu). Both legs are live-drivable through automation: `native automate widget-hold <view> <id>` runs the pointer+timer gesture, `widget-context-press <view> <id>` the secondary click.
Expand Down
1 change: 1 addition & 0 deletions skill-data/automation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ native automate screenshot inbox-canvas
native automate screenshot inbox-canvas 2
native automate widget-action canvas 2 press
native automate widget-click canvas 3
native automate widget-click canvas 3 cmd # modified click: cmd, shift, cmd+shift
native automate widget-hold canvas 3
native automate widget-context-press canvas 3
native automate widget-drag canvas 4 0.25 0.82
Expand Down
2 changes: 2 additions & 0 deletions skill-data/native-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,8 @@ For `<if test>`, prefer an explicit boolean predicate method over numeric truthi

Scroll offsets follow the same mirror discipline as text: the Msg carries the offset the runtime ALREADY applied, so store it in the model and echo it back through the scroll's `value` — the echoed source value equals the runtime offset, which the scroll reconcile rule treats as "unchanged", so rebuilds never stomp live scrolling. `on-scroll` is how long content pages or lazy-loads: keep a bounded window in the model and slide it from `offset` (near-end when `offset + viewport_extent` approaches `content_extent`).

A press arm may also ask for the MODIFIERS held at click time: declare the four booleans `shift`/`control`/`alt`/`super` beside at most one authored payload field, and the runtime fills them (markup still spells the binding the same way — `on-press="select:{r.id}"` fills `id`, a bare `on-press="clear"` names the modifier-only form). This is what makes cmd-click and shift-click expressible; an arm without all four booleans is an ordinary payload and behaves exactly as before. `native automate widget-click <view> <id> [cmd|shift|cmd+shift]` drives it.

`on-hover-enter` and `on-hover-leave` (any element; `ElementOptions.on_hover_enter` / `on_hover_leave` in Zig views) are the pointer-hover containment pair — Elm's onMouseEnter/onMouseLeave: enter dispatches once when the pointer enters the element's hit region, leave once when it exits — discrete edges, never per-move, so hover previews, prefetch, and hover cards are ordinary Msgs. Binding either makes the element hover-hittable the way a bound press makes it pressable — but never pressable: clicks fall through, no accessibility action, and NO hover wash (the wash is the visual channel of acting controls; a `quiet_hover` content tile that binds hover stays visually quiet while the model hears it). Nested bound elements track containment independently (entering a bound row inside a bound card never leaves the card); enters fire outermost-first, leaves innermost-first. Every enter is answered by exactly one eventual leave — the leave Msg is captured while the element stands (kept fresh across rebuilds, retained if unbound), so it still arrives when the exit is the element unmounting. Exits resolve exactly like the hover wash: moving off and the pointer leaving the window are direct edges; content scrolling or reflowing out from under a stationary pointer re-hit-tests the last pointer position; a dismissal removing the surface under the pointer delivers that surface's leaves immediately, and whatever it reveals is entered when the model's close rebuild re-hit-tests (pair dismissible surfaces with `on-dismiss`, as always); overlays occlude hover the way they occlude clicks. Mouse/trackpad only — containment advances on hover-phase motion, a pointer floating without contact that touch physically cannot produce, so touch never synthesizes hover and hover-revealed affordances need a second path.

A handler or update error DEGRADES, it does not exit the app: dispatch catches it, records it in a bounded ring (`runtime.dispatchErrors()`, the `error event=... name=...` lines and `dispatch_errors=` count in automation snapshots, and a `dispatch.error` trace record at error level), and the app keeps running. Trace-sink capacity failures likewise never fail dispatch — dropped records are counted (`dropped_trace_records=`), not fatal. Design for it: an arm that can fail should still surface its own status in the model; the error ring is the safety net, not the UX.
Expand Down
36 changes: 34 additions & 2 deletions src/primitives/canvas/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1427,10 +1427,42 @@ pub fn Ui(comptime Msg: type) type {
/// dispatcher the runtime pointer path uses; the two-argument
/// form stays the single-click entry point.
pub fn msgForPointerClick(self: Tree, target_id: ObjectId, phase: canvas.WidgetPointerPhase, click_count: u8) ?Msg {
return self.msgForPointerClickModified(target_id, phase, click_count, .{});
}

/// `msgForPointerClick` carrying the modifiers held at click
/// time. An arm declaring the press-record shape receives them;
/// every other arm resolves exactly as the three-argument form,
/// so this is the dispatcher the runtime pointer path uses.
pub fn msgForPointerClickModified(
self: Tree,
target_id: ObjectId,
phase: canvas.WidgetPointerPhase,
click_count: u8,
modifiers: canvas.WidgetKeyboardModifiers,
) ?Msg {
if (phase == .up and click_count == 2) {
if (self.msgFor(target_id, .double_press)) |msg| return msg;
if (self.msgFor(target_id, .double_press)) |msg| return withPressModifiers(msg, modifiers);
}
return self.msgForPointer(target_id, phase);
const msg = self.msgForPointer(target_id, phase) orelse return null;
return withPressModifiers(msg, modifiers);
}

/// Fill the four modifier booleans on an arm that declared them,
/// leaving every other arm untouched. The authored payload is
/// already in place: markup built it, this only adds what only
/// the runtime knows.
pub fn withPressModifiers(msg: Msg, modifiers: canvas.WidgetKeyboardModifiers) Msg {
return switch (msg) {
inline else => |payload, tag| if (comptime reflect.declaredWidgetPressRecord(@TypeOf(payload))) blk: {
var out = payload;
out.shift = modifiers.shift;
out.control = modifiers.control;
out.alt = modifiers.alt;
out.super = modifiers.super;
break :blk @unionInit(Msg, @tagName(tag), out);
} else msg,
};
}

/// Typed dispatch for keyboard events: engine control intents
Expand Down
17 changes: 17 additions & 0 deletions src/primitives/canvas/ui_markup_compiled.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2347,6 +2347,23 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
}
return @unionInit(MsgT, field.name, {});
}
// A press-modifier arm carries the four booleans the runtime
// fills at dispatch, so the authored binding sets its own field
// and the rest start false.
if (comptime interpreter.declaredWidgetPressRecord(field.type)) {
const payload_name = comptime interpreter.pressPayloadFieldName(field.type);
comptime {
if (payload_name == null and expression.payload.len > 0) fail(node, "message does not take a payload");
if (payload_name != null and expression.payload.len == 0) fail(node, "message requires a payload");
}
var payload: field.type = std.mem.zeroes(field.type);
if (comptime payload_name) |name| {
const press_variant = comptime pathVariant(node, entries, expression.payload, true);
const press_value = bindingValue(node, entries, expression.payload, ui, model, scope, true);
@field(payload, name) = coerce(@FieldType(field.type, name), node, press_variant, ui, press_value);
}
return @unionInit(MsgT, field.name, payload);
}
comptime {
if (expression.payload.len == 0) fail(node, "message requires a payload");
}
Expand Down
46 changes: 43 additions & 3 deletions src/primitives/canvas/ui_markup_contract.zig
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ pub const ValueKind = expr.ValueKind;
/// preview insertion/reordering on change and restore on cancellation.
/// Version 6: live drag geometry requires floating-point fields so captured
/// out-of-view coordinates cannot trap an integer conversion at dispatch.
pub const format_version: u32 = 6;
/// Version 7: `press` classifies the modifier-carrying press record and
/// `MsgTag.press_payload` names the class of its authored field; a
/// format-6 artifact calls the record unsupported and would reject a valid
/// `on-press` binding.
pub const format_version: u32 = 7;

/// Where the app's build step writes the artifact, relative to the app
/// directory (a build product lives under zig-out, not in durable state).
Expand Down Expand Up @@ -146,12 +150,17 @@ pub const Iterable = struct {
/// cannot be built from markup at all. `legacy_scroll_state` is the
/// RETIRED one-axis scroll record, recognized only so `on-scroll` can
/// teach the two-axis migration by field name.
pub const PayloadClass = enum { none, string, integer, float, boolean, enum_tag, text_input, scroll_state, legacy_scroll_state, terminal_state, drag_drop, unsupported };
pub const PayloadClass = enum { none, string, integer, float, boolean, enum_tag, text_input, scroll_state, legacy_scroll_state, terminal_state, drag_drop, press, unsupported };

pub const MsgTag = struct {
name: []const u8,
payload: PayloadClass = .none,
payload_type: []const u8 = "",
/// For `.press` arms only: the class of the ONE authored field the
/// markup binding fills, or `.none` for the payload-less form. The
/// four modifier booleans are the runtime's to fill, so they never
/// appear here.
press_payload: PayloadClass = .none,
};

pub const Contract = struct {
Expand Down Expand Up @@ -327,6 +336,7 @@ fn describeMsgs(comptime Msg: type, comptime specials: Specials) []const MsgTag
.name = field.name,
.payload = payloadClassOf(field.type, specials),
.payload_type = if (field.type == void) "" else @typeName(field.type),
.press_payload = pressPayloadClassOf(field.type, specials),
}};
}
return tags;
Expand Down Expand Up @@ -356,6 +366,7 @@ fn payloadClassOf(comptime T: type, comptime specials: Specials) PayloadClass {
// resolution as both engines' terminalConstructor.
if (reflect.declaredTerminalStateRecord(T)) return .terminal_state;
if (reflect.declaredWidgetDragDropRecord(T)) return .drag_drop;
if (reflect.declaredWidgetPressRecord(T)) return .press;
return switch (@typeInfo(T)) {
.int => .integer,
.float => .float,
Expand All @@ -366,6 +377,16 @@ fn payloadClassOf(comptime T: type, comptime specials: Specials) PayloadClass {
};
}

/// The class of a press arm's authored field, so `on-press="tag:{path}"`
/// is kind-checked exactly like an ordinary payload. `.none` for any arm
/// that is not a press record, and for the payload-less press form.
fn pressPayloadClassOf(comptime T: type, comptime specials: Specials) PayloadClass {
if (T == void) return .none;
if (!reflect.declaredWidgetPressRecord(T)) return .none;
const name = reflect.pressPayloadFieldName(T) orelse return .none;
return payloadClassOf(@FieldType(T, name), specials);
}

fn optOutNames(comptime T: type) []const []const u8 {
comptime {
if (!@hasDecl(T, opt_out_decl)) return &.{};
Expand Down Expand Up @@ -934,6 +955,25 @@ const Checker = struct {
}
}
const found = tag orelse return self.failNamed(node, unknown_tag_message, expression.tag, .{ .msgs = self.contract });
// A press arm's four modifier booleans are the runtime's to fill;
// markup binds only the authored field, so the payload rules apply
// to THAT field's class rather than to the record.
if (found.payload == .press) {
if (found.press_payload == .none) {
if (expression.payload.len > 0) return self.failAttr(node, attribute, no_payload_message);
return;
}
if (expression.payload.len == 0) return self.failAttr(node, attribute, payload_required_message);
const press_resolved = try self.resolveBinding(node, expression.payload, true);
switch (found.press_payload) {
.integer => try self.requirePayloadKind(node, attribute, press_resolved, &.{.integer}, found),
.float => try self.requirePayloadKind(node, attribute, press_resolved, &.{ .float, .integer }, found),
.string, .enum_tag => try self.requirePayloadKind(node, attribute, press_resolved, &.{.string}, found),
.boolean => {},
else => return self.failPayloadType(node, attribute, press_resolved, found),
}
return;
}
if (found.payload == .none) {
if (expression.payload.len > 0) return self.failAttr(node, attribute, no_payload_message);
return;
Expand All @@ -948,7 +988,7 @@ const Checker = struct {
.boolean => {},
// These payloads cannot be constructed from a markup binding
// (input/scroll payloads bind through their own events).
.text_input, .scroll_state, .legacy_scroll_state, .terminal_state, .drag_drop, .unsupported => return self.failPayloadType(node, attribute, resolved, found),
.text_input, .scroll_state, .legacy_scroll_state, .terminal_state, .drag_drop, .press, .unsupported => return self.failPayloadType(node, attribute, resolved, found),
.none => unreachable,
}
}
Expand Down
45 changes: 45 additions & 0 deletions src/primitives/canvas/ui_markup_reflect.zig
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,51 @@ fn isDragPhaseNumber(comptime T: type) bool {
};
}

/// The keyboard modifiers a press-carrying Msg arm declares. Named once
/// so the predicate, the injector, and the contract all agree on the
/// vocabulary.
pub const press_modifier_field_names = [_][]const u8{ "shift", "control", "alt", "super" };

/// A markup `on-press` / `on-double-press` Msg payload that also wants the
/// modifiers held at click time. The authored binding fills its own field
/// (`on-press="select:{row.id}"`); the runtime fills the four booleans, the
/// way `on-drag` fills phase and geometry around an authored `sourceId`.
///
/// Shape: the four modifier booleans, plus AT MOST one other field — the
/// authored payload, whatever the app named it. Zero other fields is the
/// payload-less form (`on-press="clear"` on an arm that only wants to know
/// which modifiers were down). Requiring the four names keeps this
/// unambiguous against ordinary record payloads, and keeps a plain
/// `{ id: number }` arm on exactly the path it takes today.
pub fn declaredWidgetPressRecord(comptime T: type) bool {
const info = switch (@typeInfo(T)) {
.@"struct" => |s| s,
else => return false,
};
if (info.fields.len != press_modifier_field_names.len and
info.fields.len != press_modifier_field_names.len + 1) return false;
inline for (press_modifier_field_names) |name| {
if (!@hasField(T, name)) return false;
if (@FieldType(T, name) != bool) return false;
}
return true;
}

/// The authored payload field of a press record, or null for the
/// payload-less form. The press predicate has already established that at
/// most one field falls outside the modifier vocabulary.
pub fn pressPayloadFieldName(comptime T: type) ?[]const u8 {
const info = @typeInfo(T).@"struct";
inline for (info.fields) |field| {
comptime var is_modifier = false;
inline for (press_modifier_field_names) |name| {
if (comptime std.mem.eql(u8, field.name, name)) is_modifier = true;
}
if (!is_modifier) return field.name;
}
return null;
}

/// A mirror of the RETIRED one-axis scroll state — `{offset, velocity,
/// viewport_extent, content_extent}` in either spelling. Recognized only
/// to fail with a teaching that names the new per-axis fields, so an app
Expand Down
Loading