GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(context): inserted widgets tick; Handle::none; Group::widget_w_h
Phase 3 of the owning registry (every app on handles) needed three things
from the toolkit:
- UiContext::insert registers a widget that wants_tick as a tick
receiver, as register_widget always did. Without it an inserted tree
list never applied its search (it filters in its tick); the data
editor's A/B caught it.
- Handle::none() (and Handle's Default): a handle naming no widget, for
values built where there is no context -- a page state a worker
fetches and merges field by field into the app's copy.
- Group::widget_w_h: the fixed-width form cell for a handle.
RFC phase 3 marked done; CLAUDE.md updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 23 +++++++++++++++++++----
docs/rfc-owning-registry.md | 15 +++++++++++++++
src/context.rs | 6 ++++++
src/layout/form.rs | 16 ++++++++++++++++
src/widget/handle.rs | 43 +++++++++++++++++++++++++++++++++++++++++++
5 files changed, 99 insertions(+), 4 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 453047a..bfb0fff 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1750,7 +1750,9 @@ registers raw pointers to them. Every entry keeps a watch on a liveness token be
and every accessor (`get_ptr`, `children_ptrs`, `iter_registered`, …) resolves a pointer only
while that token exists.
-**App widgets live in `Owned` boxes** (`widget::Owned<W>`, since 2026-10-07). An `Owned` keeps
+**App widgets lived in `Owned` boxes** (`widget::Owned<W>`, 2026-10-07; since 2026-10-08 they
+are `Handle`s into the context, below, and `Owned` remains for the toolkit's embedded children
+until phase 5). An `Owned` keeps
the widget in a heap allocation of its own and carries a token for that ALLOCATION. Moving the
`Owned` (a `Vec` reallocating, a struct returned by value) does not move the widget, and the token
dies only when the box is freed. `Owned` is itself a `WidgetHost`, forwarding every trait method, and
@@ -1820,9 +1822,22 @@ app access that overlaps a context call. `ctx.remove(h)` gives it back by value;
the context drops the rest; `clear_hierarchy` keeps them. And every call the context makes
into a widget that hands it the context goes through `lend`, which takes the widget out of
reach for the call: a widget reaching itself through the context mid-event gets `None`,
-for owned and pointer entries alike. The demo app is on handles; the other apps move one at
-a time, then `Owned` and the pointer API go (the RFC's phases). New code uses handles:
-`render_widget_h`, `Form::widget_h`, `register_popover_id`, `paint_root_into(ctx, &ctx[h], pc)`.
+for owned and pointer entries alike. **Every app is on handles** (phase 3, 2026-10-08); what
+still registers by pointer is the toolkit's own embedded children (phase 4), and then `Owned`
+and the pointer API go (phase 5). New code uses handles: `render_widget_h`,
+`Form::widget_h` / `widget_w_h`, `register_popover_id`, `focus_id` / `unfocus_id` /
+`set_focused_id`, `link_ids`, `paint_root_into(ctx, &ctx[h], pc)`. Three things the apps'
+move taught:
+
+- **An inserted widget that `wants_tick` is a tick receiver**, as one registered by pointer
+ always was. Until the fix `insert` skipped it, and an inserted tree list never applied
+ its search (it does so in its tick) — the data editor's A/B caught it.
+- **A widget made per frame is inserted, placed and removed** (a status dot in a timer
+ row, a usage bar), and a row list rebuilt on data removes the outgoing handles
+ (`ctx.remove`) and inserts the new ones; nothing re-registers each frame any more.
+- **A value built where there is no context** — a page state a worker fetches, merged
+ field by field into the app's copy — holds `Handle::none()` (also `Handle`'s
+ `Default`), which names no widget and is never read.
`a_dropped_widget_is_never_handed_out`, `a_clone_has_a_liveness_of_its_own`,
`an_owned_widget_survives_its_vec_reallocating`,
diff --git a/docs/rfc-owning-registry.md b/docs/rfc-owning-registry.md
index 51d2354..3caf58f 100644
--- a/docs/rfc-owning-registry.md
+++ b/docs/rfc-owning-registry.md
@@ -64,6 +64,21 @@ itself, reaching back through the context — can reach it a second time.
typing, the theme dropdown and the Options dialog (open, pick, OK; and Escape) is
identical to the pixel to the pointer-registry build at every step.
3. **Every app**, one crate at a time, by widget count: the smallest first.
+ **DONE (2026-10-08).** cce-text-editor, cce-list, cce-notes, cce-weather,
+ cce-authenticator, cce-color-editor, cce-cloud, cce-designer, cce-graph,
+ cce-display-manager, cce-fonts, cce-files, cce-secrets, cce-mail, cce-data-editor,
+ cce-relief, cce-gallery and cce-system-interface hold `Handle`s; each was driven in a
+ shadow session against its pointer-registry build and matched to the pixel, but for live
+ data and one explained case (cce-fonts' preview: the runner now shapes it before the
+ app does, with the font system the glyph pass draws with). Toolkit additions on the
+ way: `focus_id` / `unfocus_id`, `Group::widget_w_h`, `Handle::none` (and `Default`) for
+ values built off-thread, and `insert` registering a tick receiver as `register_widget`
+ did — missing, an inserted tree list never filtered. The move also retired a real bug:
+ cce-files' prompt focused a stack-local `TextBox` by pointer and then moved it into its
+ box, and opening a second prompt corrupted the heap ("double free or corruption"); it
+ now inserts first and focuses by id. Left on the pointer path: the toolkit's embedded
+ children (the designer dialog's dropdown and colour selectors, a ramp's preset
+ dropdown, a tree list's fields), which are phase 4, and widgets tests build on the stack.
4. **The toolkit's embedded children** on handles.
5. **Delete the pointer path**: `Owned`, `register_host` / `register_widget` / `set_focused_ptr`
and the other `unsafe fn`s, `Liveness`, `stable_target`. The tree holds owned slots only.
diff --git a/src/context.rs b/src/context.rs
index 962480b..4e917d1 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -158,8 +158,14 @@ impl UiContext {
/// The widget lives in the context from here on; reach it with [`get`](Self::get) /
/// [`get_mut`](Self::get_mut) (or `ctx[h]`), give it back with [`remove`](Self::remove).
pub fn insert<W: WidgetHost + 'static>(&mut self, widget: W) -> Handle<W> {
+ // A widget that animates is ticked by the context (`tick`), as one registered by
+ // pointer is (`register_widget`): a tree list applies its search there.
+ let wants_tick = crate::widget::WidgetHostExt::wants_tick(&widget);
let id = self.tree.insert_owned(widget);
self.invalidate_coverage_cache();
+ if wants_tick {
+ self.register_tick_receiver(id);
+ }
Handle::from_id(id)
}
diff --git a/src/layout/form.rs b/src/layout/form.rs
index e59187c..496d8fb 100644
--- a/src/layout/form.rs
+++ b/src/layout/form.rs
@@ -160,6 +160,22 @@ impl<'f, 'w, P: RenderTarget + 'w> Group<'f, 'w, P> {
self
}
+ /// [`widget_w`](Self::widget_w) for a widget the context owns, as
+ /// [`widget_h`](Self::widget_h) is [`widget`](Self::widget)'s.
+ pub fn widget_w_h<T: WidgetHost + 'static>(&mut self, ctx: &UiContext, h: crate::widget::Handle<T>, width: f32, fallback: f32) -> &mut Self {
+ let h_px = ctx.get(h).map_or(fallback, |w| w.preferred_height().unwrap_or(fallback) + w.label_strip());
+ let span = self.row_span();
+ let draw: Draw<'w, P> = Box::new(move |pc, r, ctx| {
+ ctx.lend_h(h, |w, ctx| {
+ let (x, width) = span.unwrap_or((r.x, r.width));
+ w.set_row_rect(x, width);
+ render_widget(pc, w, r.x, r.y, r.width, r.height, ctx);
+ });
+ });
+ self.form.add(self.node, Style::default(), Size::new(width, h_px), Some(draw));
+ self
+ }
+
/// A piece the page paints itself, `w` × `h` (in a column the width is the column's;
/// in a row `w` is the cell's width, and it grows into the row's slack when `grow`).
pub fn draw(&mut self, w: f32, h: f32, grow: bool, draw: impl FnOnce(&mut P, Rect, &mut UiContext) + 'w) -> &mut Self {
diff --git a/src/widget/handle.rs b/src/widget/handle.rs
index 47b9343..e17d17e 100644
--- a/src/widget/handle.rs
+++ b/src/widget/handle.rs
@@ -23,12 +23,29 @@ impl<W: ?Sized> Handle<W> {
Handle { id, _w: PhantomData }
}
+ /// A handle that names no widget: it resolves to nothing (`get`, `lend_h`), and
+ /// indexing the context with it panics. It stands in a handle field of a value built
+ /// where there is no context — a page state a worker thread fetches, whose widgets the
+ /// app's own copy keeps — and that field is never read. Ids start at 1, so 0 is no
+ /// widget's.
+ pub const fn none() -> Self {
+ Handle { id: WidgetId(0), _w: PhantomData }
+ }
+
/// The widget's id: what focus, links, popovers and dispatch roots are keyed by.
pub fn id(&self) -> WidgetId {
self.id
}
}
+/// [`Handle::none`], so a state that holds handles can derive `Default` for the copy a
+/// worker builds.
+impl<W: ?Sized> Default for Handle<W> {
+ fn default() -> Self {
+ Self::none()
+ }
+}
+
impl<W: ?Sized> Clone for Handle<W> {
fn clone(&self) -> Self {
*self
@@ -139,6 +156,32 @@ mod tests {
assert!(ctx.get(h).is_some() && ctx.lend(h.id(), |_, _| ()).is_some(), "back afterwards");
}
+ /// A handle that names no widget resolves to nothing, and never to a widget the
+ /// context owns.
+ #[test]
+ fn a_none_handle_names_nothing() {
+ let mut ctx = UiContext::new();
+ let h = ctx.insert(Slider::new());
+ let none: super::Handle<crate::widget::Adapted<Slider>> = Default::default();
+ assert_ne!(none, h);
+ assert!(ctx.get(none).is_none() && ctx.lend_h(none, |_, _| ()).is_none());
+ assert!(ctx.get(h).is_some());
+ }
+
+ /// A widget that animates is ticked by the context once it is inserted, as one
+ /// registered by pointer was, and leaves the tick list when it is removed. Without it an
+ /// inserted tree list never applied its search (it does so in its tick).
+ #[test]
+ fn an_inserted_widget_that_ticks_is_ticked() {
+ let mut ctx = UiContext::new();
+ let h = ctx.insert(crate::widget::TextBox::new(String::new()));
+ assert!(ctx.tick_receivers.contains(&h.id()), "a text box ticks");
+ let still = ctx.insert(Slider::new());
+ assert!(!ctx.tick_receivers.contains(&still.id()), "a slider does not");
+ ctx.remove(h);
+ assert!(!ctx.tick_receivers.contains(&h.id()), "removed, it leaves the tick list");
+ }
+
/// An app that rebuilds its links every frame (`clear_hierarchy`) does not hand back the
/// widgets the context owns by doing so.
#[test]