GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(widget): migrate Paginator (Phase 5r)
Paginator moves onto the narrow traits behind Adapted<Paginator>, keeping
its value-embedded ButtonStrip + Vec<Page> and proxying events to them.
New adapter surface, each a legacy behavior the adapter couldn't express:
- Layout::register_embedded_children(host_id, ctx): legacy tick/layout
re-registered the embedded children into the ctx registry every frame;
load-bearing for the spatial grid (the registered strip blocks backplate
drags over the sidebar). Called from Element::tick and Element::layout.
- Paint::aggregates_child_extra_quads: legacy container extra_quads served
the children's chrome only, with the own bg quad in all_quads alone
(email/layout-interface draw extra_quads over their own backgrounds;
test-interface draws all_quads). all_quads now uses a shared
own_plain_quads() helper so the two views never double-serve.
- Paint::forwarded_highlight(ctx): legacy highlight_quad forwarded to the
strip's (layout-interface draws it directly); excluded from
all_quads/paint_self, which now gate on legacy_focus_highlight.
The pages container surface (add_widget_to_page/set_pages/...) is ported
bug-for-bug but has no callers workspace-wide.
Verified: 171 tests; all four consumer apps build; cce-layout-interface
live A/B byte-identical (AE=0) across idle/hover/click/selected-hover;
cce-test-interface A/B equals its launch-to-launch waveform noise exactly.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MjP3pGQEDLkJbV5WEmYBe
src/widget/container/paginator.rs | 437 ++++++++++++++++++++------------------
src/widget/model.rs | 101 +++++++--
2 files changed, 312 insertions(+), 226 deletions(-)
diff --git a/src/widget/container/paginator.rs b/src/widget/container/paginator.rs
index b8d1d22..4c81856 100644
--- a/src/widget/container/paginator.rs
+++ b/src/widget/container/paginator.rs
@@ -1,11 +1,38 @@
+//! Narrow-trait `Paginator` (Phase 5r) — a vertical sidebar tab strip plus a stack of pages, of
+//! which only the selected one shows. Both halves are EMBEDDED legacy widgets owned by value:
+//! the tabs live in a [`ButtonStrip`], the content in a `Vec<Page>` (Page is an embedded-base
+//! container that dissolves in Phase 6 — it does not go through `Adapted` itself). The adapter's
+//! container concern does the subtree plumbing, filtered to the strip + selected page by
+//! [`Layout::child_visible`]; the model keeps the legacy specifics: strip/pages arrangement in
+//! [`Layout::arrange_children`], event proxying in [`Input::on_event`] (via `EventCtx::ui`), and
+//! the [`PageSelector`] / [`MenuController`] capabilities.
+//!
+//! Two legacy behaviors ride hooks new with this migration:
+//! - [`Layout::register_embedded_children`]: legacy `tick`/`layout` re-registered the strip and
+//! pages into the ctx registry every frame — load-bearing for the spatial grid (the registered
+//! strip is what makes the sidebar block backplate drags; see
+//! `backplate::tests::test_paginator_blocks_backplate_drag`).
+//! - [`Paint::aggregates_child_extra_quads`] + [`Paint::forwarded_highlight`]: legacy
+//! `extra_quads` served the children's chrome only (cce-email and cce-layout-interface render
+//! the tab column through that getter — the paginator's own background quad lives in
+//! `all_quads` alone), and legacy `highlight_quad` forwarded to the strip's (the hovered-tab
+//! tint cce-layout-interface draws directly).
+//!
+//! In practice every app uses only the tab-strip half (`selected_page`/`set_selected_page`/
+//! `sidebar_w`/`menu_click`) and manages page content itself; the `pages` container surface
+//! (`add_widget_to_page` & co.) is ported bug-for-bug but has no callers workspace-wide.
+
use crate::colors;
-use crate::widget::*;
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
use crate::widget::input::ButtonStrip;
-use crate::widget::display::TextLabel;
+use crate::widget::{
+ Adapted, Element, Event, EventCtx, Input, Layout, MenuController, PageSelector, Paint,
+ UiContext, WidgetId,
+};
use super::page::Page;
pub struct Paginator {
- pub base: Widget,
pub sidebar_menu: ButtonStrip,
pub pages: Vec<Page>,
pub selected_page: usize,
@@ -17,11 +44,10 @@ pub struct Paginator {
}
impl Paginator {
- pub fn new(pages: Vec<String>) -> Self {
+ pub fn new(pages: Vec<String>) -> Adapted<Paginator> {
let num_pages = pages.len();
- let temp_paginator = Self {
- base: Widget::new_rect(0.0, 0.0, 0.0, 0.0),
+ let temp_paginator = Paginator {
sidebar_menu: ButtonStrip::new(0.0, 0.0, 0.0, 0.0),
pages: Vec::new(),
selected_page: 0,
@@ -50,8 +76,7 @@ impl Paginator {
pages_containers[0].visible = true;
}
- Self {
- base: Widget::new_rect(0.0, 0.0, 0.0, 0.0),
+ Adapted::new(Paginator {
sidebar_menu,
pages: pages_containers,
selected_page: 0,
@@ -60,9 +85,11 @@ impl Paginator {
page_labels: pages,
on_page_changed_cb: None,
just_clicked: None,
- }
+ })
}
+}
+impl Adapted<Paginator> {
pub fn with_sidebar_mode(self, _enabled: bool) -> Self {
self
}
@@ -148,7 +175,7 @@ impl PageSelector for Paginator {
let font_fam = font_info.0;
let font_size = font_info.1;
let padding = crate::layout::button_padding();
-
+
let mut max_w = 0.0;
for label in &self.page_labels {
let trimmed = label.trim();
@@ -189,58 +216,13 @@ impl PageSelector for Paginator {
}
}
-impl Element for Paginator {
- crate::impl_widget_base!(Paginator);
-
- fn rect(&self) -> (f32, f32, f32, f32) {
- (self.base.x, self.base.y, self.base.w, self.base.h)
- }
- fn blocks_backplate_drag(&self) -> bool { false }
-
- fn wants_tick(&self) -> bool {
+impl Layout for Paginator {
+ fn has_container_children(&self) -> bool {
true
}
- fn layout(&mut self, origin: Point, constraints: LayoutConstraints, ctx: &mut UiContext) {
- let size = self.measure(constraints, ctx);
- self.set_rect(origin.x, origin.y, size.width, size.height);
-
- let parent_id = self.base.id();
-
- let menu_ptr = &mut self.sidebar_menu as *mut ButtonStrip;
- ctx.register_widget(self.sidebar_menu.base.id(), menu_ptr);
- ctx.link_ids(parent_id, self.sidebar_menu.base.id());
-
- for plate in &mut self.pages {
- let plate_ptr = plate as *mut Page;
- ctx.register_widget(plate.base.base.id(), plate_ptr);
- ctx.link_ids(parent_id, plate.base.base.id());
- }
- }
-
- fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
- self.base.x = x;
- self.base.y = y;
- self.base.w = w;
- self.base.h = h;
-
- let sidebar_w = self.sidebar_w();
- self.sidebar_menu.set_rect(x, y, sidebar_w, h);
-
- let page_x = x + sidebar_w;
- let page_w = (w - sidebar_w).max(0.0);
- for (i, plate) in self.pages.iter_mut().enumerate() {
- plate.set_rect(page_x, y, page_w, h);
- plate.visible = (i == self.selected_page) && !self.page_hidden;
- }
- }
-
- fn color(&self) -> [f32; 4] {
- colors::sidebar_bg_color()
- }
-
- fn children(&self, _ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> {
- let mut childs = Vec::new();
+ fn container_children(&self) -> Vec<*mut (dyn Element + 'static)> {
+ let mut childs: Vec<*mut (dyn Element + 'static)> = Vec::new();
childs.push(&self.sidebar_menu as &dyn Element as *const (dyn Element + 'static) as *mut (dyn Element + 'static));
for plate in &self.pages {
childs.push(plate as &dyn Element as *const (dyn Element + 'static) as *mut (dyn Element + 'static));
@@ -248,203 +230,171 @@ impl Element for Paginator {
childs
}
- fn is_child_visible(&self, child_id: WidgetId) -> bool {
- if self.sidebar_menu.base.id() == child_id {
+ /// The strip always shows; a page only while selected and not hidden (legacy
+ /// `is_child_visible`).
+ fn child_visible(&self, child: *mut (dyn Element + 'static)) -> bool {
+ let strip_ptr = &self.sidebar_menu as &dyn Element as *const (dyn Element + 'static);
+ if std::ptr::addr_eq(child, strip_ptr) {
return true;
}
if let Some(plate) = self.pages.get(self.selected_page) {
- if !self.page_hidden && plate.base.base.id() == child_id {
- return true;
+ if !self.page_hidden {
+ let plate_ptr = plate as &dyn Element as *const (dyn Element + 'static);
+ if std::ptr::addr_eq(child, plate_ptr) {
+ return true;
+ }
}
}
false
}
- fn cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
- let mut changed = false;
- if self.sidebar_menu.cursor_moved(px, py, ctx) {
- changed = true;
- }
- for (i, plate) in self.pages.iter_mut().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- if plate.cursor_moved(px, py, ctx) {
- changed = true;
- }
- }
- }
- changed
- }
+ /// The legacy `set_rect` body: strip on the left at its measured width, every page filling
+ /// the remainder, page visibility synced to the selection.
+ fn arrange_children(&mut self, rect: Rect, _host: *mut (dyn Element + 'static)) {
+ let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
+ let sidebar_w = self.sidebar_w();
+ self.sidebar_menu.set_rect(x, y, sidebar_w, h);
- fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
- let mut changed = false;
- if self.sidebar_menu.mouse_input(button, state, px, py, ctx) {
- changed = true;
- if let Some(idx) = self.sidebar_menu.take_click() {
- self.set_selected_page(idx);
- self.just_clicked = Some(idx);
- }
- }
+ let page_x = x + sidebar_w;
+ let page_w = (w - sidebar_w).max(0.0);
+ let selected = self.selected_page;
+ let hidden = self.page_hidden;
for (i, plate) in self.pages.iter_mut().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- if plate.mouse_input(button, state, px, py, ctx) {
- changed = true;
- }
- }
+ plate.set_rect(page_x, y, page_w, h);
+ plate.visible = (i == selected) && !hidden;
}
- changed
}
- fn tick(&mut self, dt: f32, ctx: &mut UiContext) -> bool {
- // Register internal children in the widget registry so hit tests correctly block dragging and reach buttons
+ fn register_embedded_children(&mut self, host_id: WidgetId, ctx: &mut UiContext) {
let menu_ptr = &mut self.sidebar_menu as *mut ButtonStrip;
ctx.register_widget(self.sidebar_menu.base.id(), menu_ptr);
+ ctx.link_ids(host_id, self.sidebar_menu.base.id());
for plate in &mut self.pages {
let plate_ptr = plate as *mut Page;
ctx.register_widget(plate.base.base.id(), plate_ptr);
+ ctx.link_ids(host_id, plate.base.base.id());
}
+ }
+}
- let mut changed = false;
- if self.sidebar_menu.tick(dt, ctx) {
- changed = true;
- }
- for (i, plate) in self.pages.iter_mut().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- if plate.tick(dt, ctx) {
- changed = true;
- }
- }
- }
- changed
+impl Paint for Paginator {
+ fn color(&self) -> [f32; 4] {
+ colors::sidebar_bg_color()
}
- fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
- let mut quads = Vec::new();
+ /// Own geometry is just the sidebar background (the legacy `all_quads` head; the strip's
+ /// and selected page's pixels arrive through the adapter's child aggregation / the paint
+ /// walk's recursion).
+ fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
let c = self.color();
if c[3] > 0.0 {
- quads.push((self.base.x, self.base.y, self.base.w, self.base.h, c));
- }
- quads.extend(self.sidebar_menu.all_quads(ctx));
- for (i, plate) in self.pages.iter().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- quads.extend(plate.all_quads(ctx));
- }
- }
- quads
- }
-
- fn text_labels(&self) -> Vec<TextLabel> {
- let mut labels = Vec::new();
- labels.extend(self.sidebar_menu.text_labels());
- for (i, plate) in self.pages.iter().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- labels.extend(plate.text_labels());
- }
- }
- labels
- }
-
- fn text_labels_with_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
- let mut labels = Vec::new();
- labels.extend(self.sidebar_menu.text_labels_with_bounds(ctx));
- for (i, plate) in self.pages.iter().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- labels.extend(plate.text_labels_with_bounds(ctx));
- }
+ ctx.quad(rect, c);
}
- labels
}
- fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
- let mut labels = Vec::new();
- labels.extend(self.sidebar_menu.text_labels_with_font_and_bounds(ctx));
- for (i, plate) in self.pages.iter().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- labels.extend(plate.text_labels_with_font_and_bounds(ctx));
- }
- }
- labels
- }
-
- fn get_text_items(&self) -> Vec<(&glyphon::Buffer, f32, f32, glyphon::Color)> {
- let mut items = Vec::new();
- items.extend(self.sidebar_menu.get_text_items());
- for (i, plate) in self.pages.iter().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- items.extend(plate.get_text_items());
- }
- }
- items
+ /// Legacy `extra_quads` served the strip's + selected page's chrome only — cce-email and
+ /// cce-layout-interface draw the tab column through this getter, over their own backgrounds.
+ fn aggregates_child_extra_quads(&self) -> bool {
+ true
}
- fn as_page_selector(&self) -> Option<&dyn PageSelector> {
- Some(self)
+ /// Legacy `highlight_quad` forwarded to the strip's (the hovered-tab tint
+ /// cce-layout-interface draws directly).
+ fn forwarded_highlight(&self, ctx: &UiContext) -> Option<Option<(f32, f32, f32, f32, [f32; 4])>> {
+ Some(self.sidebar_menu.highlight_quad(ctx))
}
+}
- fn as_page_selector_mut(&mut self) -> Option<&mut dyn PageSelector> {
- Some(self)
+impl Input for Paginator {
+ fn blocks_backplate_drag(&self) -> bool {
+ false
}
- fn as_menu_controller(&self) -> Option<&dyn MenuController> {
- Some(self)
+ /// Legacy `mouse_input` saw every press — the strip and pages ran their own hit checks.
+ fn gates_presses(&self) -> bool {
+ false
}
- fn as_menu_controller_mut(&mut self) -> Option<&mut dyn MenuController> {
- Some(self)
+ fn wants_tick(&self) -> bool {
+ true
}
- fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
- let mut quads = Vec::new();
- quads.extend(self.sidebar_menu.extra_quads());
- for (i, plate) in self.pages.iter().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- quads.extend(plate.extra_quads());
+ /// Event proxying, the legacy forwarding bodies: strip first (draining its click into the
+ /// page selection), then the selected page while not hidden.
+ fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+ let Some(ui) = ectx.ui.as_deref_mut() else {
+ return false;
+ };
+ let selected = self.selected_page;
+ let hidden = self.page_hidden;
+ match event {
+ Event::PointerMove { x: px, y: py, .. } => {
+ let mut changed = self.sidebar_menu.cursor_moved(*px, *py, ui);
+ for (i, plate) in self.pages.iter_mut().enumerate() {
+ if i == selected && !hidden {
+ if plate.cursor_moved(*px, *py, ui) {
+ changed = true;
+ }
+ }
+ }
+ changed
}
- }
- quads
- }
-
- fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
- let mut changed = false;
- if self.sidebar_menu.mouse_wheel(delta, px, py, ctx) {
- changed = true;
- }
- for (i, plate) in self.pages.iter_mut().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- if plate.mouse_wheel(delta, px, py, ctx) {
+ Event::MouseButton { button, state, x: px, y: py, .. } => {
+ let mut changed = false;
+ if self.sidebar_menu.mouse_input(*button, *state, *px, *py, ui) {
changed = true;
+ if let Some(idx) = self.sidebar_menu.take_click() {
+ self.set_selected_page(idx);
+ self.just_clicked = Some(idx);
+ }
}
+ for (i, plate) in self.pages.iter_mut().enumerate() {
+ if i == selected && !hidden {
+ if plate.mouse_input(*button, *state, *px, *py, ui) {
+ changed = true;
+ }
+ }
+ }
+ changed
}
- }
- changed
- }
-
- fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
- let mut changed = false;
- if self.sidebar_menu.keyboard_input(event, ctx) {
- changed = true;
- }
- for (i, plate) in self.pages.iter_mut().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- if plate.keyboard_input(event, ctx) {
- changed = true;
+ Event::MouseWheel { delta, x: px, y: py, .. } => {
+ let mut changed = self.sidebar_menu.mouse_wheel(delta, *px, *py, ui);
+ for (i, plate) in self.pages.iter_mut().enumerate() {
+ if i == selected && !hidden {
+ if plate.mouse_wheel(delta, *px, *py, ui) {
+ changed = true;
+ }
+ }
}
+ changed
}
- }
- changed
- }
-
- fn prepare_text(&mut self, fs: &mut glyphon::FontSystem) {
- self.sidebar_menu.prepare_text(fs);
- for (i, plate) in self.pages.iter_mut().enumerate() {
- if i == self.selected_page && !self.page_hidden {
- plate.prepare_text(fs);
+ Event::KeyInput(key_event) => {
+ let mut changed = self.sidebar_menu.keyboard_input(key_event, ui);
+ for (i, plate) in self.pages.iter_mut().enumerate() {
+ if i == selected && !hidden {
+ if plate.keyboard_input(key_event, ui) {
+ changed = true;
+ }
+ }
+ }
+ changed
}
+ _ => false,
}
}
- fn highlight_quad(&self, ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> {
- self.sidebar_menu.highlight_quad(ctx)
+ fn menu_controller(&self) -> Option<&dyn MenuController> {
+ Some(self)
+ }
+ fn menu_controller_mut(&mut self) -> Option<&mut dyn MenuController> {
+ Some(self)
+ }
+ fn page_selector(&self) -> Option<&dyn PageSelector> {
+ Some(self)
+ }
+ fn page_selector_mut(&mut self) -> Option<&mut dyn PageSelector> {
+ Some(self)
}
}
@@ -470,8 +420,73 @@ impl MenuController for Paginator {
fn get_menu_items_at(&self, _px: f32, _py: f32) -> Option<(usize, String, Vec<String>, f32, f32, f32, f32)> { None }
}
-impl Drop for Paginator {
- fn drop(&mut self) {
- clear_widget_references(self);
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::context::UiContext;
+ use crate::widget::{ElementState, MouseButton};
+
+ fn paginator() -> Adapted<Paginator> {
+ let mut p = Paginator::new(vec!["One".to_string(), "Two".to_string()]);
+ Element::set_rect(&mut p, 0.0, 0.0, 400.0, 300.0);
+ p
+ }
+
+ #[test]
+ fn sidebar_click_switches_page_and_drains_menu_click() {
+ let mut ctx = UiContext::new();
+ let mut p = paginator();
+ let (id, ptr) = (p.id(), p.as_ptr_mut());
+ ctx.register_widget(id, ptr);
+
+ // Click the second tab (the strip commits selection on release): the selection moves,
+ // the pages' visibility flips, and menu_click reports (1, 0) once.
+ let (bx, by, bw, bh) = p.sidebar_menu.item_rect(1);
+ assert!(bw > 0.0, "strip laid out by arrange_children");
+ p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + bw / 2.0, by + bh / 2.0, &mut ctx);
+ p.mouse_input(MouseButton::Left, ElementState::Released, bx + bw / 2.0, by + bh / 2.0, &mut ctx);
+ assert_eq!(p.selected_page, 1);
+ assert!(p.pages[1].visible && !p.pages[0].visible);
+ {
+ let elem: &mut dyn Element = &mut p;
+ assert_eq!(elem.as_menu_controller_mut().unwrap().menu_click(), Some((1, 0)));
+ assert_eq!(elem.as_menu_controller_mut().unwrap().menu_click(), None, "click drained");
+ }
+
+ // The PageSelector capability rides the wrapper (cce-test-interface's downcast).
+ let elem: &dyn Element = &p;
+ let ps = elem.as_page_selector().unwrap();
+ assert_eq!(ps.selected_page(), 1);
+ assert!(ps.sidebar_w() > 0.0);
+ }
+
+ #[test]
+ fn plain_quads_split_like_legacy_and_registration_heals_on_tick() {
+ let mut ctx = UiContext::new();
+ let mut p = paginator();
+ let (id, ptr) = (p.id(), p.as_ptr_mut());
+ ctx.register_widget(id, ptr);
+
+ // Legacy split: `extra_quads` is the children's chrome only; the sidebar background
+ // quad lives in `all_quads` alone (email/layout-interface draw their own backgrounds
+ // under `extra_quads`).
+ let extra = Element::extra_quads(&p);
+ let strip_extra = p.sidebar_menu.extra_quads();
+ assert_eq!(extra.len(), strip_extra.len(), "children-only plain view (pages emit none)");
+ let bg = colors::sidebar_bg_color();
+ if bg[3] > 0.0 {
+ let bg_quad = (0.0, 0.0, 400.0, 300.0, bg);
+ assert!(!extra.contains(&bg_quad), "no own bg in extra_quads");
+ assert!(Element::all_quads(&p, &ctx).contains(&bg_quad), "own bg in all_quads");
+ }
+
+ // The embedded strip + pages land in the registry on tick (the spatial grid feeds off
+ // it — the registered strip is what blocks backplate drags over the sidebar).
+ Element::tick(&mut p, 0.016, &mut ctx);
+ let strip_id = p.sidebar_menu.base.id();
+ assert!(
+ ctx.tree.iter_registered().any(|(w_id, _)| w_id == strip_id),
+ "strip registered by the tick-path healing"
+ );
}
}
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 8f2d0d4..b0953cf 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -180,6 +180,16 @@ pub trait Layout {
fn tracked_parent(&self) -> Option<Option<*mut (dyn Element + 'static)>> {
None
}
+
+ /// Republish value-embedded legacy children into the ctx registry (Paginator's ButtonStrip
+ /// + Pages). Legacy value-owning containers re-registered their children on EVERY `tick` and
+ /// `layout` because the children's addresses move with the owning struct (host struct moves,
+ /// `Vec` reallocation) — and the registration is load-bearing: the spatial grid is rebuilt
+ /// from registered widgets, and it is the registered ButtonStrip (whose
+ /// `blocks_backplate_drag` is true) that makes the sidebar block backplate drags. The
+ /// adapter calls this from `Element::tick` and `Element::layout`, mirroring the legacy
+ /// cadence. `host_id` is the adapter's id, for `link_ids`. Default: nothing embedded.
+ fn register_embedded_children(&mut self, _host_id: WidgetId, _ctx: &mut UiContext) {}
}
/// The paint concern — a widget's fill color, its own (non-recursive) geometry emission, and
@@ -287,6 +297,29 @@ pub trait Paint {
fn legacy_focus_highlight(&self) -> bool {
false
}
+
+ /// Legacy container `extra_quads` aggregation: when `true`, the adapter's `extra_quads`
+ /// serves the visible children's `extra_quads` — and ONLY those, like the legacy container
+ /// overrides (Paginator returned its strip's + selected page's chrome; its own background
+ /// quad lived in `all_quads` alone). Hosts that render a container through the plain
+ /// `extra_quads` getter (cce-email's and cce-layout-interface's sidebar draw) read exactly
+ /// this view. The widget's own [`paint`](Paint::paint) prims still reach `all_quads` and
+ /// the scene walk. Default: off (a leaf's `extra_quads` is its own prims).
+ fn aggregates_child_extra_quads(&self) -> bool {
+ false
+ }
+
+ /// Forward the legacy `Element::highlight_quad` to somewhere else entirely — Paginator
+ /// served its ButtonStrip's highlight (the hovered-tab tint cce-layout-interface draws by
+ /// calling `highlight_quad` directly). Outer `Some` replaces the adapter's highlight logic
+ /// with the inner value; `None` (default) keeps the standard behavior
+ /// ([`legacy_focus_highlight`](Paint::legacy_focus_highlight)). A forwarded highlight is
+ /// served ONLY through the direct `highlight_quad` getter — the adapter keeps it out of
+ /// `all_quads`/`paint_self`, where the child's own aggregation already carries it (legacy
+ /// containers likewise excluded it from their `all_quads` overrides).
+ fn forwarded_highlight(&self, _ctx: &UiContext) -> Option<Option<(f32, f32, f32, f32, [f32; 4])>> {
+ None
+ }
}
/// What an event handler may reach beyond its own state — the RFC §3.5 `EventCtx`, grown as
@@ -673,6 +706,20 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
pc.finish().items.into_iter().map(|item| item.prim).collect()
}
+ /// This widget's OWN plain-quad prims from [`Paint::paint`] — the shared source for the
+ /// `extra_quads`/`all_quads` reverse bridges (kept separate from `extra_quads` itself,
+ /// which may serve the child aggregation instead —
+ /// [`Paint::aggregates_child_extra_quads`]).
+ fn own_plain_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ self.painted_prims()
+ .into_iter()
+ .filter_map(|prim| match prim {
+ Prim::Quad { rect, color } => Some((rect.x, rect.y, rect.width, rect.height, color)),
+ _ => None,
+ })
+ .collect()
+ }
+
/// The container's children that pass the [`Layout::child_visible`] policy — the set the
/// adapter's subtree plumbing (aggregation, recursion, hit-through) operates on. Empty for
/// non-containers.
@@ -928,6 +975,8 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
// `arrange_children`.
let size = self.measure(constraints, ctx);
self.set_rect(origin.x, origin.y, size.width, size.height);
+ let host_id = self.base.id();
+ Layout::register_embedded_children(&mut self.inner, host_id, ctx);
if Layout::has_container_children(&self.inner) && self.visible {
let rect = self.content_rect();
Layout::layout_children_ctx(&mut self.inner, rect, ctx);
@@ -1043,6 +1092,12 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
/// `Element` default byte-for-byte: primary tint when ctx-focused (or active), secondary
/// when hovered, over the row-substituted, side-label-inset span.
fn highlight_quad(&self, ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> {
+ // A forwarding widget (Paginator → its ButtonStrip) serves the forwarded value here —
+ // and only here; `all_quads`/`paint_self` gate on `legacy_focus_highlight` instead, so
+ // the forwarded quad is never double-drawn.
+ if let Some(forwarded) = Paint::forwarded_highlight(&self.inner, ctx) {
+ return forwarded;
+ }
if !Paint::legacy_focus_highlight(&self.inner) {
return None;
}
@@ -1097,9 +1152,12 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
Paint::paint(&self.inner, self.content_rect(), ctx);
// The legacy default `paint_self` drained `all_quads`, which carries the focus
// highlight — replicate for opt-in widgets, over the background (same draw order).
- if let Some((hx, hy, hw, hh, hc)) = Element::highlight_quad(self, ui) {
- if hc != crate::colors::HIGHLIGHT_SECONDARY {
- ctx.quad(Rect { x: hx, y: hy, width: hw, height: hh }, hc);
+ // Forwarded highlights stay out: the paint walk reaches the owning child itself.
+ if Paint::legacy_focus_highlight(&self.inner) {
+ if let Some((hx, hy, hw, hh, hc)) = Element::highlight_quad(self, ui) {
+ if hc != crate::colors::HIGHLIGHT_SECONDARY {
+ ctx.quad(Rect { x: hx, y: hy, width: hw, height: hh }, hc);
+ }
}
}
// Inline-label widgets emit their own text in `paint`; detached labels come from the
@@ -1187,13 +1245,16 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
if Paint::serves_legacy_plain_quads(&self.inner) {
return Paint::legacy_plain_quads(&self.inner, self.content_rect());
}
- self.painted_prims()
- .into_iter()
- .filter_map(|prim| match prim {
- Prim::Quad { rect, color } => Some((rect.x, rect.y, rect.width, rect.height, color)),
- _ => None,
- })
- .collect()
+ // Legacy container aggregation (Paginator): the plain view is the visible children's
+ // chrome, and only that — the widget's own background stays in `all_quads`.
+ if Paint::aggregates_child_extra_quads(&self.inner) {
+ let mut out = Vec::new();
+ for child in self.visible_children() {
+ out.extend(unsafe { &*child }.extra_quads());
+ }
+ return out;
+ }
+ self.own_plain_quads()
}
/// When the widget serves a legacy plain-quad view, its geometry reaches
@@ -1205,12 +1266,18 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
if Paint::serves_legacy_plain_quads(&self.inner) {
return Vec::new();
}
- let mut quads = self.extra_quads();
+ // Own prims directly (NOT `extra_quads`, which may serve the child aggregation — those
+ // children arrive once, through the recursion below).
+ let mut quads = if self.visible() { self.own_plain_quads() } else { Vec::new() };
// The `Element` default's highlight inclusion (secondary/hover tint excluded), live
- // only for widgets that opt into the legacy overlay.
- if let Some(hq) = Element::highlight_quad(self, ctx) {
- if hq.4 != crate::colors::HIGHLIGHT_SECONDARY {
- quads.push(hq);
+ // only for widgets that opt into the legacy overlay. A forwarded highlight
+ // ([`Paint::forwarded_highlight`]) is deliberately excluded: its owner's aggregation
+ // already carries it, matching the legacy container `all_quads` overrides.
+ if Paint::legacy_focus_highlight(&self.inner) {
+ if let Some(hq) = Element::highlight_quad(self, ctx) {
+ if hq.4 != crate::colors::HIGHLIGHT_SECONDARY {
+ quads.push(hq);
+ }
}
}
if self.visible() {
@@ -1317,6 +1384,10 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
Input::is_dragging(&self.inner)
}
fn tick(&mut self, dt: f32, ctx: &mut UiContext) -> bool {
+ // Legacy value-owning containers healed their children's registry entries every tick
+ // (addresses move with the owning struct); same cadence here.
+ let host_id = self.base.id();
+ Layout::register_embedded_children(&mut self.inner, host_id, ctx);
let rect = self.content_rect();
let mut changed = Input::tick(&mut self.inner, dt, rect);
if self.visible {