GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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).