git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit1d0e035d2e23dba330eeb907b4aabb6485dc0449
parent0090790763
authorLucas Galante <[email protected]>
date2026-07-08 10:04
feat(widget): migrate Button, the widest-radius widget (Phase 5f)

Press/release contract forced an adapter refinement: presses stay
hit-gated, releases flow ungated — a press-tracking widget must see the
release wherever the cursor ended up to commit or cancel, the legacy
mouse_input contract (pinned by a router-level test including
out-of-rect cancel and callback fire). Adds Layout::layout_ignore and
Input::set_selected forwards.

The model ports the per-kind color matrix, SVG icon quads, per-kind
label justification/fonts, and the Phase 2b intrinsic_size; by-value
builders are mirrored on Adapted<Button>. In-crate consumers fixed
(multi_control/keybinds_control/ramp/treelist/parameters_bg fields;
json_layout and the demo call take_click on the box instead of
concrete downcasts).

Verified: full workspace builds; 154 tests + cce-cloud's json_layout
hover-simulation test pass; test-interface pixel-diffs cursor-only vs
the 5e baseline; live hover A/B against the stashed legacy build shows
the identical fill pixel (inert hover there is pre-existing app
behavior, not a regression).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LkfJazPs9bRchkcozmxXCX

 docs/rfc-core-rebuild.md              |  17 ++
 src/main.rs                           |  10 +-
 src/widget/container/parameters_bg.rs |   2 +-
 src/widget/container/treelist.rs      |   6 +-
 src/widget/input/button.rs            | 511 +++++++++++++++++++---------------
 src/widget/input/keybinds_control.rs  |   4 +-
 src/widget/input/multi_control.rs     |   4 +-
 src/widget/input/ramp.rs              |  10 +-
 src/widget/json_layout.rs             |   8 +-
 src/widget/model.rs                   |  28 +-
 10 files changed, 350 insertions(+), 250 deletions(-)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index b2c66bf..3092439 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -424,6 +424,23 @@ Constraint respected: **each crate still builds standalone** — the new core is
     diffed 0 against the pre-migration baseline, and a `wlrctl`-injected click on the live
     compositor flipped the Toggle's bordered half on-screen — the full input path through the
     adapter exercised for real. 152 tests pass.
+  - **5f — `Button` (widest-radius widget: 19 app files + 8 in-crate). DONE.** The press/release
+    contract forced an adapter refinement: **presses stay hit-gated, releases now flow ungated**
+    — a press-tracking widget must see the release wherever the cursor ended up to commit
+    (in-rect → `take_click` + `on_click_cb`) or cancel, exactly the legacy `mouse_input`
+    contract (pinned by a router-level test incl. out-of-rect cancel). Also added:
+    `Layout::layout_ignore` and `Input::set_selected` forwards. The model ports the per-kind
+    color matrix (Primary/Reset/ListRow/CopyIcon × pressed/hovered/selected + bg overrides),
+    SVG icon quads, per-kind label justification/fonts, and the Phase 2b `intrinsic_size`; the
+    9 by-value builders are mirrored on `Adapted<Button>` (`with_label` comes from the generic
+    + `sync_label`). In-crate consumers fixed (multi_control, keybinds_control, ramp, treelist,
+    parameters_bg fields; json_layout + demo now call `take_click` on the box instead of
+    concrete downcasts); ~11 app repos updated (field types + raw-cast→`as_ptr_mut` cleanups).
+    **Verification:** full workspace (minus compositor, which doesn't use widgets) builds;
+    154 cce-ui tests + cce-cloud's json_layout hover-simulation test pass; test-interface
+    pixel-diffs cursor-only vs the 5e baseline; a live hover A/B against the stashed legacy
+    build showed the identical fill pixel (the inert hover on that page is pre-existing app
+    behavior, not a regression).
   - **Still to do:** migrate remaining widgets
     off `impl Element` onto the narrow traits (per-widget, Phase 6 flavour); replace the 8 live
     `as_*_controller` downcast pairs (called by `cce-designer`, `cce-test-interface`, and ~10
diff --git a/src/main.rs b/src/main.rs
index 6017919..ae62044 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1337,11 +1337,11 @@ impl PointerHandler for AppState {
                                         if w.page_idx != active_page {
                                             continue;
                                         }
-                                        if let Some(btn_w) = w.widget.as_any_mut().downcast_mut::<cce_ui::widget::Button>() {
-                                            if btn_w.take_click() {
-                                                clicked_btn_id = Some(w.id.clone());
-                                                break;
-                                            }
+                                        // take_click is an Element method; Phase 5 Buttons
+                                        // are Adapted, so ask the box directly.
+                                        if w.widget_type == "button" && w.widget.take_click() {
+                                            clicked_btn_id = Some(w.id.clone());
+                                            break;
                                         }
                                     }
                                 }
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index ea8b810..76ffaa5 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -13,7 +13,7 @@ pub struct ParametersBg {
     pub sliders: Vec<Option<Slider>>,
     pub float3s: Vec<Option<Float3>>,
     pub spinboxes: Vec<Option<Spinbox>>,
-    pub buttons: Vec<Option<Button>>,
+    pub buttons: Vec<Option<crate::widget::Adapted<Button>>>,
     pub choices: Vec<Option<Dropdown>>,
     pub texts: Vec<Option<TextBox>>,
     pub checkboxes: Vec<Option<crate::widget::Adapted<Checkbox>>>,
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index ad090d4..ce18734 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -161,7 +161,7 @@ pub struct TreeList {
     pub base: Widget,
     pub scroll_box: ScrollBox,
     pub search_box: TextBox,
-    pub add_key_btn: Button,
+    pub add_key_btn: crate::widget::Adapted<Button>,
     pub add_key_popover_open: bool,
     pub add_key_popover_box: TextBox,
     pub new_key_path_request: Option<String>,
@@ -1165,7 +1165,7 @@ impl Element for TreeList {
                 ctx.link_ids(self_id, sb_id);
                 (*sb_ptr).set_parent(Some(self_ptr), ctx);
 
-                let btn_ptr = &mut (*self_ptr).add_key_btn as *mut Button as *mut (dyn Element + 'static);
+                let btn_ptr = (*self_ptr).add_key_btn.as_ptr_mut();
                 let btn_id = (*self_ptr).add_key_btn.base().unwrap().id();
                 ctx.register_widget(btn_id, btn_ptr);
                 ctx.link_ids(self_id, btn_id);
@@ -1185,7 +1185,7 @@ impl Element for TreeList {
         let self_ptr = self as *const Self as *mut Self;
         unsafe {
             list.push(&mut (*self_ptr).search_box as *mut TextBox as *mut (dyn Element + 'static));
-            list.push(&mut (*self_ptr).add_key_btn as *mut Button as *mut (dyn Element + 'static));
+            list.push((*self_ptr).add_key_btn.as_ptr_mut());
             if (*self_ptr).add_key_popover_open {
                 list.push(&mut (*self_ptr).add_key_popover_box as *mut TextBox as *mut (dyn Element + 'static));
             }
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index b7d962c..83c4670 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -1,5 +1,16 @@
+//! Narrow-trait `Button` (Phase 5f). Press/release semantics match the legacy `mouse_input`
+//! exactly: press (hit-gated by the adapter) arms it; release *anywhere* commits (in-rect,
+//! firing `on_click_cb` + `take_click`) or cancels — which is why the adapter forwards releases
+//! ungated. Hover is tracked from `MouseEnter`/`MouseLeave`; press+hover drive the per-kind
+//! color matrix that becomes `Animated<f32>` lerping in RFC §3.6.
+
 use crate::colors;
-use crate::widget::*;
+use crate::scene::layout::{Rect, Size};
+use crate::scene::paint::PaintCtx;
+use crate::widget::{
+    Adapted, Control, Element, ElementState, Event, Input, Justification, Layout, MouseButton,
+    Paint, Svg,
+};
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum ButtonKind {
@@ -11,7 +22,6 @@ pub enum ButtonKind {
 
 #[derive(Clone)]
 pub struct Button {
-    base: Widget,
     pressed: bool,
     just_clicked: bool,
     kind: ButtonKind,
@@ -22,28 +32,30 @@ pub struct Button {
     pub label_color: Option<[f32; 4]>,
     pub justify: Justification,
     pub svg: Option<Svg>,
+    label: Option<String>,
+    hovered: bool,
 }
 
 impl std::fmt::Debug for Button {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("Button")
-            .field("base", &self.base)
             .field("pressed", &self.pressed)
             .field("just_clicked", &self.just_clicked)
             .field("kind", &self.kind)
             .field("selected", &self.selected)
+            .field("label", &self.label)
+            .field("hovered", &self.hovered)
             .field("on_click_cb", &self.on_click_cb.as_ref().map(|_| "<callback>"))
             .finish()
     }
 }
 
 impl Button {
-    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
-        Self {
-            base: Widget::new_rect(x, y, w, h),
+    fn model(kind: ButtonKind) -> Button {
+        Button {
             pressed: false,
             just_clicked: false,
-            kind: ButtonKind::Primary,
+            kind,
             selected: false,
             on_click_cb: None,
             bg: None,
@@ -51,62 +63,65 @@ impl Button {
             label_color: None,
             justify: Justification::Center,
             svg: None,
+            label: None,
+            hovered: false,
         }
     }
 
-    pub fn new_reset(x: f32, y: f32, w: f32, h: f32) -> Self {
-        Self {
-            base: Widget::new_rect(x, y, w, h),
-            pressed: false,
-            just_clicked: false,
-            kind: ButtonKind::Reset,
-            selected: false,
-            on_click_cb: None,
-            bg: None,
-            hover_bg: None,
-            label_color: None,
-            justify: Justification::Center,
-            svg: None,
-        }
+    fn adapted(kind: ButtonKind, x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
+        let mut b = Adapted::new(Button::model(kind));
+        Element::set_rect(&mut b, x, y, w, h);
+        b
     }
 
-    pub fn new_list_row(x: f32, y: f32, w: f32, h: f32) -> Self {
-        Self {
-            base: Widget::new_rect(x, y, w, h),
-            pressed: false,
-            just_clicked: false,
-            kind: ButtonKind::ListRow,
-            selected: false,
-            on_click_cb: None,
-            bg: None,
-            hover_bg: None,
-            label_color: None,
-            justify: Justification::Center,
-            svg: None,
-        }
+    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
+        Button::adapted(ButtonKind::Primary, x, y, w, h)
     }
 
-    pub fn new_copy_icon(x: f32, y: f32, w: f32, h: f32) -> Self {
-        Self {
-            base: Widget::new_rect(x, y, w, h),
-            pressed: false,
-            just_clicked: false,
-            kind: ButtonKind::CopyIcon,
-            selected: false,
-            on_click_cb: None,
-            bg: None,
-            hover_bg: None,
-            label_color: None,
-            justify: Justification::Center,
-            svg: None,
-        }
+    pub fn new_reset(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
+        Button::adapted(ButtonKind::Reset, x, y, w, h)
     }
 
-    pub fn with_label(mut self, label: &str) -> Self {
-        self.base.label = Some(label.to_string());
-        self
+    pub fn new_list_row(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
+        Button::adapted(ButtonKind::ListRow, x, y, w, h)
     }
 
+    pub fn new_copy_icon(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
+        Button::adapted(ButtonKind::CopyIcon, x, y, w, h)
+    }
+
+    /// Hover state, also settable by immediate-mode hosts that hit-test themselves.
+    pub fn hovered(&self) -> bool {
+        self.hovered
+    }
+
+    pub fn set_hovered(&mut self, hovered: bool) {
+        self.hovered = hovered;
+    }
+
+    fn font(&self) -> (String, f32) {
+        let font_str = if self.kind == ButtonKind::ListRow {
+            crate::layout::list_font()
+        } else {
+            crate::layout::button_font()
+        };
+        let (family, size) = crate::layout::parse_font_string(&font_str);
+        (family, size.unwrap_or(12.0))
+    }
+
+    fn label_width(&self, label: &str) -> f32 {
+        if label == "📋" {
+            12.0
+        } else {
+            let (family, size) = self.font();
+            crate::widget::display::measure_text_width(label, &family, size)
+        }
+    }
+}
+
+/// The by-value builder chain, mirrored on the wrapped type (`with_label` comes from the generic
+/// `Adapted::with_label`, which syncs the model's copy via `Paint::sync_label`).
+impl Adapted<Button> {
     pub fn with_svg(mut self, svg: Svg) -> Self {
         self.svg = Some(svg);
         self
@@ -148,117 +163,153 @@ impl Button {
     }
 }
 
-impl Element for Button {
-    crate::impl_widget_base!(Button);
-    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])>{ None }
+impl Layout for Button {
+    fn inline_label(&self) -> bool {
+        true
+    }
+
+    /// The app positions buttons itself in legacy page layout (legacy `layout_ignore`).
+    fn layout_ignore(&self) -> bool {
+        true
+    }
 
+    /// Content size for the scene layout engine (ported from Phase 2b): the label's measured
+    /// width plus an 8px inset each side, at the configured button height; an icon button is a
+    /// square at that height.
+    fn intrinsic_size(&self) -> Option<Size> {
+        let height = crate::layout::button_height();
+        if self.svg.is_some() {
+            return Some(Size::new(height, height));
+        }
+        let label = self.label.as_deref().unwrap_or("");
+        Some(Size::new(self.label_width(label) + 16.0, height))
+    }
+}
+
+impl Paint for Button {
     fn color(&self) -> [f32; 4] {
-        if self.pressed || self.base.hovered {
+        if self.pressed || self.hovered {
             if let Some(hbg) = self.hover_bg {
                 return hbg;
             }
-        } else {
-            if let Some(bg) = self.bg {
-                return bg;
-            }
+        } else if let Some(bg) = self.bg {
+            return bg;
         }
         match self.kind {
             ButtonKind::Primary => {
-                if self.pressed { colors::button_press_color() }
-                else if self.base.hovered { colors::button_hover_color() }
-                else { colors::button_background_color() }
+                if self.pressed {
+                    colors::button_press_color()
+                } else if self.hovered {
+                    colors::button_hover_color()
+                } else {
+                    colors::button_background_color()
+                }
             }
             ButtonKind::Reset => {
-                if self.pressed { colors::RESET_BTN_PRESS }
-                else if self.base.hovered { colors::RESET_BTN_HOVER }
-                else { colors::RESET_BTN_IDLE }
+                if self.pressed {
+                    colors::RESET_BTN_PRESS
+                } else if self.hovered {
+                    colors::RESET_BTN_HOVER
+                } else {
+                    colors::RESET_BTN_IDLE
+                }
             }
             ButtonKind::ListRow => {
                 if self.selected {
                     if self.pressed { [0.30, 0.52, 0.78, 0.6] }
-                    else if self.base.hovered { [0.30, 0.52, 0.78, 0.5] }
+                    else if self.hovered { [0.30, 0.52, 0.78, 0.5] }
                     else { [0.20, 0.40, 0.65, 0.4] }
                 } else {
                     if self.pressed { [0.20, 0.20, 0.25, 0.25] }
-                    else if self.base.hovered { [0.20, 0.20, 0.25, 0.15] }
+                    else if self.hovered { [0.20, 0.20, 0.25, 0.15] }
                     else { [0.0, 0.0, 0.0, 0.0] }
                 }
             }
             ButtonKind::CopyIcon => {
                 if self.selected {
                     if self.pressed { [0.30, 0.52, 0.78, 0.5] }
-                    else if self.base.hovered { [0.30, 0.52, 0.78, 0.5] }
+                    else if self.hovered { [0.30, 0.52, 0.78, 0.5] }
                     else { [0.20, 0.40, 0.65, 0.2] }
                 } else {
                     if self.pressed { [0.20, 0.20, 0.25, 0.25] }
-                    else if self.base.hovered { [0.20, 0.20, 0.25, 0.25] }
+                    else if self.hovered { [0.20, 0.20, 0.25, 0.25] }
                     else { [0.0, 0.0, 0.0, 0.0] }
                 }
             }
         }
     }
 
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        if button != MouseButton::Left { return false; }
-        match state {
-            ElementState::Pressed => {
-                if self.hit_test(px, py, ctx) {
-                    self.pressed = true;
-                    return true;
-                }
-            }
-            ElementState::Released => {
-                if self.pressed && self.hit_test(px, py, ctx) {
-                    self.just_clicked = true;
-                    if let Some(ref cb) = self.on_click_cb {
-                        cb();
-                    }
-                }
-                let was = self.pressed;
-                self.pressed = false;
-                return was;
-            }
+    fn corner_style(&self) -> Option<(f32, (bool, bool, bool, bool))> {
+        let r = crate::layout::button_corner_radius();
+        if r > 0.0 {
+            Some((r, (true, true, true, true)))
+        } else {
+            None
         }
-        false
     }
 
-    fn take_click(&mut self) -> bool {
-        if self.just_clicked { self.just_clicked = false; true } else { false }
+    fn widget_font(&self) -> Option<String> {
+        if self.kind == ButtonKind::ListRow {
+            Some(crate::layout::list_font())
+        } else {
+            Some(crate::layout::button_font())
+        }
     }
 
-    fn text_labels(&self) -> Vec<TextLabel> {
-        if self.svg.is_some() {
-            return Vec::new();
-        }
-        let mut labels = Vec::new();
-        if let Some(ref label) = self.base.label {
-            let mut font_size = 12.0;
-            let mut font_family = "sans-serif".to_string();
-            if let Some(font_str) = self.widget_font() {
-                let (parsed_fam, parsed_size) = crate::layout::parse_font_string(&font_str);
-                font_family = parsed_fam;
-                if let Some(ps) = parsed_size {
-                    font_size = ps;
-                }
+    fn sync_label(&mut self, label: &str) {
+        self.label = Some(label.to_string());
+    }
+
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
+        let radius = crate::layout::button_corner_radius();
+        let color = self.color();
+
+        // Background (+ optional configured border), split by radius exactly as the legacy
+        // `all_rounded_quads` (rounded) / `extra_quads` (square) overrides emitted it.
+        if radius > 0.0 {
+            if let Some(bc) = colors::button_border_color() {
+                ctx.rounded_rect(rect, radius, (true, true, true, true), bc);
+                ctx.rounded_rect(
+                    Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: h - 2.0 },
+                    (radius - 1.0).max(0.0),
+                    (true, true, true, true),
+                    color,
+                );
+            } else if color[3].abs() > 0.001 {
+                ctx.rounded_rect(rect, radius, (true, true, true, true), color);
             }
-            let est_w = if label == "📋" {
-                12.0
+        } else {
+            if let Some(bc) = colors::button_border_color() {
+                ctx.quad(rect, bc);
+                ctx.quad(Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: h - 2.0 }, color);
             } else {
-                crate::widget::display::measure_text_width(label, &font_family, font_size)
-            };
+                ctx.quad(rect, color);
+            }
+        }
+
+        // Centered SVG icon (legacy `extra_quads` tail).
+        if let Some(ref svg) = self.svg {
+            let dx = x + (w - svg.w) / 2.0 - svg.x;
+            let dy = y + (h - svg.h) / 2.0 - svg.y;
+            for q in &svg.quads {
+                ctx.quad(Rect { x: q.0 + dx, y: q.1 + dy, width: q.2, height: q.3 }, q.4);
+            }
+            return; // legacy: an SVG button draws no label text
+        }
+
+        // Label, with per-kind justification/color (legacy `text_labels`).
+        if let Some(ref label) = self.label {
+            let (_, font_size) = self.font();
+            let est_w = self.label_width(label);
             let color = if let Some(lc) = self.label_color {
-                [
-                    (lc[0] * 255.0) as u8,
-                    (lc[1] * 255.0) as u8,
-                    (lc[2] * 255.0) as u8,
-                ]
+                [(lc[0] * 255.0) as u8, (lc[1] * 255.0) as u8, (lc[2] * 255.0) as u8]
             } else {
                 match self.kind {
                     ButtonKind::ListRow | ButtonKind::CopyIcon => {
-                        if self.selected { [230, 230, 242] }
-                        else { [178, 178, 191] }
+                        if self.selected { [230, 230, 242] } else { [178, 178, 191] }
                     }
-                    _ => colors::control_label_color_u8()
+                    _ => colors::control_label_color_u8(),
                 }
             };
             let justify = if self.kind == ButtonKind::ListRow {
@@ -270,144 +321,152 @@ impl Element for Button {
             } else {
                 self.justify
             };
-            let x = match justify {
-                Justification::Left => self.base.x + 8.0,
-                Justification::Right => self.base.x + self.base.w - est_w - 8.0,
-                Justification::Center => self.base.x + (self.base.w - est_w) / 2.0,
+            let tx = match justify {
+                Justification::Left => x + 8.0,
+                Justification::Right => x + w - est_w - 8.0,
+                Justification::Center => x + (w - est_w) / 2.0,
             };
-            labels.push(TextLabel {
-                text: label.clone(),
-                x,
-                y: crate::layout::align_text_y(self.base.y, self.base.h, font_size, 0.0),
+            ctx.text(
+                label.clone(),
+                tx,
+                crate::layout::align_text_y(y, h, font_size, 0.0),
                 font_size,
                 color,
-            });
+            );
         }
-        labels
-    }
-    fn set_selected(&mut self, selected: bool) {
-        self.selected = selected;
     }
+}
 
-    fn all_rounded_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
-        let mut quads = Vec::new();
-        let (r1, r2, r3, r4) = self.rounded_corners();
-        if r1 || r2 || r3 || r4 {
-            let radius = self.corner_radius();
-            let c = self.color();
-            if let Some(bc) = colors::button_border_color() {
-                quads.push((self.base.x, self.base.y, self.base.w, self.base.h, radius, bc, (r1, r2, r3, r4)));
-                quads.push((self.base.x + 1.0, self.base.y + 1.0, self.base.w - 2.0, self.base.h - 2.0, (radius - 1.0).max(0.0), c, (r1, r2, r3, r4)));
-            } else {
-                if c[3].abs() > 0.001 {
-                    quads.push((self.base.x, self.base.y, self.base.w, self.base.h, radius, c, (r1, r2, r3, r4)));
+impl Input for Button {
+    fn on_event(&mut self, event: &Event, rect: Rect) -> bool {
+        match event {
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
+                // Presses are hit-gated by the adapter.
+                self.pressed = true;
+                true
+            }
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x, y, .. } => {
+                // Releases arrive ungated: commit in-rect, cancel anywhere else — the legacy
+                // `mouse_input` released-while-pressed contract.
+                if self.pressed && self.hit(rect, *x, *y) {
+                    self.just_clicked = true;
+                    if let Some(ref cb) = self.on_click_cb {
+                        cb();
+                    }
                 }
+                std::mem::take(&mut self.pressed)
             }
-        }
-        for &child_ptr in &self.children(ctx) {
-            let widget = unsafe { &*child_ptr };
-            quads.extend(widget.all_rounded_quads(ctx));
-        }
-        quads
-    }
-
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let mut quads = Vec::new();
-        if self.corner_radius() <= 0.0 {
-            if let Some(bc) = colors::button_border_color() {
-                quads.push((self.base.x, self.base.y, self.base.w, self.base.h, bc));
-                quads.push((self.base.x + 1.0, self.base.y + 1.0, self.base.w - 2.0, self.base.h - 2.0, self.color()));
-            } else {
-                quads.push((self.base.x, self.base.y, self.base.w, self.base.h, self.color()));
+            Event::MouseEnter => {
+                self.hovered = true;
+                false
             }
-        }
-        if let Some(ref svg) = self.svg {
-            let svg_x = self.base.x + (self.base.w - svg.w) / 2.0;
-            let svg_y = self.base.y + (self.base.h - svg.h) / 2.0;
-            let dx = svg_x - svg.x;
-            let dy = svg_y - svg.y;
-            for q in &svg.quads {
-                quads.push((q.0 + dx, q.1 + dy, q.2, q.3, q.4));
+            Event::MouseLeave => {
+                self.hovered = false;
+                false
             }
-        }
-        quads
-    }
-
-    fn rounded_corners(&self) -> (bool, bool, bool, bool) {
-        let r = crate::layout::button_corner_radius();
-        if r > 0.0 {
-            (true, true, true, true)
-        } else {
-            (false, false, false, false)
+            _ => false,
         }
     }
 
-    /// Content size for the scene layout engine (Phase 2b): the label's measured width plus an
-    /// 8px inset on each side (matching `text_labels`' justify insets), at the configured button
-    /// height. An icon button (SVG, no text) is a square at that height.
-    fn intrinsic_size(&self) -> Option<crate::scene::layout::Size> {
-        let height = crate::layout::button_height();
-        if self.svg.is_some() {
-            return Some(crate::scene::layout::Size::new(height, height));
-        }
-        let label = self.base.label.as_deref().unwrap_or("");
-        let mut font_size = 12.0;
-        let mut font_family = "sans-serif".to_string();
-        if let Some(font_str) = self.widget_font() {
-            let (parsed_fam, parsed_size) = crate::layout::parse_font_string(&font_str);
-            font_family = parsed_fam;
-            if let Some(ps) = parsed_size {
-                font_size = ps;
-            }
-        }
-        let text_w = if label == "📋" {
-            12.0
-        } else {
-            crate::widget::display::measure_text_width(label, &font_family, font_size)
-        };
-        Some(crate::scene::layout::Size::new(text_w + 16.0, height))
+    fn take_click(&mut self) -> bool {
+        std::mem::take(&mut self.just_clicked)
     }
 
-    fn widget_font(&self) -> Option<String> {
-        if self.kind == ButtonKind::ListRow {
-            Some(crate::layout::list_font())
-        } else {
-            Some(crate::layout::button_font())
-        }
+    fn set_selected(&mut self, selected: bool) {
+        self.selected = selected;
     }
+}
 
-    fn corner_radius(&self) -> f32 {
-        crate::layout::button_corner_radius()
+impl Control for Adapted<Button> {
+    fn set_label(&mut self, label: &str) {
+        Adapted::set_label(self, label);
     }
+}
 
-    fn layout_ignore(&self) -> bool {
-        true
-    }
+pub enum PageButton {
+    Active,
+    Inactive,
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::widget::UiContext;
+
+    fn press(x: f32, y: f32) -> Event {
+        Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, local_x: x, local_y: y }
+    }
+    fn release(x: f32, y: f32) -> Event {
+        Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x, y, local_x: x, local_y: y }
+    }
 
     #[test]
     fn intrinsic_size_scales_with_label_and_has_button_height() {
-        let mut short = Button::new(0.0, 0.0, 0.0, 0.0);
-        short.base.label = Some("Hi".to_string());
-        let mut long = Button::new(0.0, 0.0, 0.0, 0.0);
-        long.base.label = Some("A much longer button label".to_string());
+        let short = Button::new(0.0, 0.0, 0.0, 0.0).with_label("Hi");
+        let long = Button::new(0.0, 0.0, 0.0, 0.0).with_label("A much longer button label");
 
-        let s = short.intrinsic_size().unwrap();
-        let l = long.intrinsic_size().unwrap();
+        let s = Element::intrinsic_size(&short).unwrap();
+        let l = Element::intrinsic_size(&long).unwrap();
         assert!(s.width > 16.0, "includes the horizontal insets");
         assert!(l.width > s.width, "longer label measures wider");
-        // Height is the configured button height, independent of the (zero) construction rect.
         assert_eq!(s.height, crate::layout::button_height());
     }
-}
 
-pub enum PageButton {
-    Active,
-    Inactive,
-}
+    /// The legacy press/release contract through the real router: press arms, in-rect release
+    /// clicks (firing the callback), out-of-rect release cancels without clicking.
+    #[test]
+    fn press_release_semantics_match_legacy() {
+        let mut ctx = UiContext::new();
+        let fired = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
+        let fired2 = fired.clone();
+        let mut b = Button::new(10.0, 10.0, 80.0, 24.0)
+            .with_label("Go")
+            .on_click(move || { fired2.fetch_add(1, std::sync::atomic::Ordering::SeqCst); });
+        let (id, ptr) = (b.id(), b.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+
+        // Press in, release in -> click.
+        assert!(ctx.propagate_event(&press(20.0, 20.0), ptr));
+        assert!(ctx.propagate_event(&release(25.0, 20.0), ptr), "release consumed (was pressed)");
+        assert!(Element::take_click(&mut b));
+        assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback fired");
+
+        // Press in, release OUT -> cancelled, no click, but release still consumed.
+        assert!(ctx.propagate_event(&press(20.0, 20.0), ptr));
+        assert!(ctx.propagate_event(&release(500.0, 500.0), ptr), "cancelling release consumed");
+        assert!(!Element::take_click(&mut b), "no click on out-of-rect release");
+        assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback not re-fired");
+
+        // Release without a press is not consumed.
+        assert!(!ctx.propagate_event(&release(20.0, 20.0), ptr));
+    }
+
+    /// Bridge parity for the default config: bg on the rounded or plain path per the configured
+    /// radius, and the label through the prim-derived text bridge with center justification.
+    #[test]
+    fn geometry_and_label_parity() {
+        let ctx = UiContext::new();
+        let b = Button::new(0.0, 0.0, 100.0, 24.0).with_label("Go");
+
+        let radius = crate::layout::button_corner_radius();
+        let rounded = Element::all_rounded_quads(&b, &ctx);
+        let plain = Element::extra_quads(&b);
+        if radius > 0.0 {
+            assert!(!rounded.is_empty() && plain.is_empty(), "rounded config -> rounded path only");
+            assert_eq!(rounded[0].4, radius);
+        } else {
+            assert!(rounded.is_empty() && !plain.is_empty(), "square config -> plain path only");
+        }
+
+        let labels = Element::text_labels(&b);
+        assert_eq!(labels.len(), 1);
+        assert_eq!(labels[0].text, "Go");
+        let est = b.label_width("Go");
+        assert_eq!(labels[0].x, (100.0 - est) / 2.0, "center-justified");
 
-impl Control for Button {}
+        // Selection state flows through the Element forward (list hosts push it).
+        let mut b = b;
+        Element::set_selected(&mut b, true);
+        assert!(b.selected);
+    }
+}
diff --git a/src/widget/input/keybinds_control.rs b/src/widget/input/keybinds_control.rs
index 7977406..3693c25 100644
--- a/src/widget/input/keybinds_control.rs
+++ b/src/widget/input/keybinds_control.rs
@@ -8,7 +8,7 @@ use crate::widget::TextLabel;
 pub struct KeybindRow {
     pub key_input: TextBox,
     pub cmd_input: TextBox,
-    pub remove_button: Button,
+    pub remove_button: Adapted<Button>,
 }
 
 impl KeybindRow {
@@ -37,7 +37,7 @@ impl KeybindRow {
 pub struct KeybindsControl {
     pub base: Widget,
     pub rows: Vec<KeybindRow>,
-    pub add_button: Button,
+    pub add_button: Adapted<Button>,
     pub just_changed: bool,
 }
 
diff --git a/src/widget/input/multi_control.rs b/src/widget/input/multi_control.rs
index 2d77491..0921737 100644
--- a/src/widget/input/multi_control.rs
+++ b/src/widget/input/multi_control.rs
@@ -133,7 +133,7 @@ pub struct MultiControlRow {
     pub key_input: TextBox,
     pub type_dropdown: Dropdown,
     pub value_widget: InstancedWidget,
-    pub remove_button: Button,
+    pub remove_button: Adapted<Button>,
     pub layout_y: f32,
     pub layout_height: f32,
     pub natural_y: f32,
@@ -197,7 +197,7 @@ pub struct MultiControl {
     pub base: Widget,
     pub name: String,
     pub rows: Vec<MultiControlRow>,
-    pub add_button: Button,
+    pub add_button: Adapted<Button>,
     pub just_changed: bool,
     pub add_popover_open: bool,
     pub add_popover_hovered_idx: Option<usize>,
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index be7d8a8..26689ba 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -23,7 +23,7 @@ pub struct ColorRamp {
     pub r_slider: Slider,
     pub g_slider: Slider,
     pub b_slider: Slider,
-    pub del_button: Button,
+    pub del_button: Adapted<Button>,
     
     pub parent: Option<*mut (dyn Element + 'static)>,
 }
@@ -140,7 +140,7 @@ impl Element for ColorRamp {
                 &mut (*self_ptr).r_slider as *mut Slider as *mut (dyn Element + 'static),
                 &mut (*self_ptr).g_slider as *mut Slider as *mut (dyn Element + 'static),
                 &mut (*self_ptr).b_slider as *mut Slider as *mut (dyn Element + 'static),
-                &mut (*self_ptr).del_button as *mut Button as *mut (dyn Element + 'static),
+                (*self_ptr).del_button.as_ptr_mut(),
             ]
         }
     }
@@ -433,7 +433,7 @@ pub struct Ramp {
     
     // Child controls for value editing & deletion
     pub val_slider: Slider,
-    pub del_button: Button,
+    pub del_button: Adapted<Button>,
     pub preset_dropdown: Dropdown,
     pub line_type_dropdown: Dropdown,
     
@@ -634,7 +634,7 @@ impl Element for Ramp {
             ];
             if self.selected_key_idx.is_some() {
                 list.push(&mut (*self_ptr).val_slider as *mut Slider as *mut (dyn Element + 'static));
-                list.push(&mut (*self_ptr).del_button as *mut Button as *mut (dyn Element + 'static));
+                list.push((*self_ptr).del_button.as_ptr_mut());
             }
             list
         }
@@ -665,7 +665,7 @@ impl Element for Ramp {
                 ];
                 if (*self_ptr).selected_key_idx.is_some() {
                     list.push(&mut (*self_ptr).val_slider as *mut Slider as *mut (dyn Element + 'static));
-                    list.push(&mut (*self_ptr).del_button as *mut Button as *mut (dyn Element + 'static));
+                    list.push((*self_ptr).del_button.as_ptr_mut());
                 }
                 list
             };
diff --git a/src/widget/json_layout.rs b/src/widget/json_layout.rs
index 059a662..b3a3dd4 100644
--- a/src/widget/json_layout.rs
+++ b/src/widget/json_layout.rs
@@ -507,10 +507,10 @@ impl Element for JsonLayoutWidget {
                     changed = true;
                 }
                 if w.widget_type == "button" {
-                    if let Some(btn) = w.widget.as_any_mut().downcast_mut::<Button>() {
-                        if w.target_page.is_some() && btn.take_click() {
-                            page_switch = Some(w.target_page.unwrap());
-                        }
+                    // take_click is an Element method; the Phase 5 Button is Adapted, so call it
+                    // on the box directly rather than through a concrete downcast.
+                    if w.target_page.is_some() && w.widget.take_click() {
+                        page_switch = Some(w.target_page.unwrap());
                     }
                 }
             }
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 46fd7d3..231ee8f 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -59,6 +59,13 @@ pub trait Layout {
     fn inline_label(&self) -> bool {
         false
     }
+
+    /// Whether legacy container layout passes should skip this widget (the app positions it
+    /// itself — legacy `Element::layout_ignore`, read by `Plate` and the page layout). Default:
+    /// participate.
+    fn layout_ignore(&self) -> bool {
+        false
+    }
 }
 
 /// The paint concern — a widget's fill color, its own (non-recursive) geometry emission, and
@@ -180,6 +187,9 @@ pub trait Input {
     fn value(&self) -> i32 {
         0
     }
+
+    /// Selection state pushed in by list/row hosts (legacy `Element::set_selected`).
+    fn set_selected(&mut self, _selected: bool) {}
 }
 
 /// Wraps a narrow-trait widget `W` so it lives in the legacy `*mut dyn Element` tree. Carries the
@@ -342,6 +352,9 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     fn layout_children(&self) -> Option<Vec<Style>> {
         Layout::layout_children(&self.inner)
     }
+    fn layout_ignore(&self) -> bool {
+        Layout::layout_ignore(&self.inner)
+    }
 
     // --- Legacy structural conventions the adapter owns on the widget's behalf ---
 
@@ -506,6 +519,9 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     fn value(&self) -> i32 {
         Input::value(&self.inner)
     }
+    fn set_selected(&mut self, selected: bool) {
+        Input::set_selected(&mut self.inner, selected)
+    }
 
     fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
         // Preserve the legacy occlusion check (a covering layer swallows the hit), then delegate
@@ -536,11 +552,19 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
                 }
                 false
             }
-            // Hit-gate pointer-positioned events once, here, so narrow widgets never carry the
+            // Hit-gate PRESSES and wheel once, here, so narrow widgets never carry the
             // per-widget "check hit_test first" boilerplate legacy `mouse_input` overrides do.
-            Event::MouseButton { x: px, y: py, .. } | Event::MouseWheel { x: px, y: py, .. } => {
+            // RELEASES are deliberately NOT gated: a press-tracking widget (Button) must see the
+            // release wherever the cursor ended up, to commit or cancel — exactly what legacy
+            // `mouse_input` overrides did by receiving every release. `on_event` has the rect and
+            // the event coords, so in-rect release checks stay one comparison.
+            Event::MouseButton { state: crate::widget::ElementState::Pressed, x: px, y: py, .. }
+            | Event::MouseWheel { x: px, y: py, .. } => {
                 self.hit_test(*px, *py, ctx) && Input::on_event(&mut self.inner, event, rect)
             }
+            Event::MouseButton { state: crate::widget::ElementState::Released, .. } => {
+                Input::on_event(&mut self.inner, event, rect)
+            }
             // Offer the raw move to the widget; if unconsumed, run the legacy hover bookkeeping
             // (base.hovered + MouseEnter/MouseLeave synthesis, which re-enters this method and
             // reaches `on_event` through the arm below).