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

commit865841686e1a35b5a2d003689ae8df7d2aaaf6bc
parent05af59778e
authorLucas Galante <[email protected]>
date2026-07-08 10:40
feat(widget): migrate InfoBox, FontPreview, Sidebar, Splitter, Panel, Spinbox (Phase 5i)

Adds Input::drag_reposition for self-moving widgets (Panel/Splitter
return a new origin; the adapter applies it to the base rect),
Input::set_drag_bounds, and Layout::detached_label_inset — the legacy
Control::control_label +4px x-offset the default text_labels path
lacked, caught as a 4px label shift in the pixel diff (now 1px
residual). Spinbox ports sub-zone hover, display-click edit mode with
cursor placement + request_focus, and decimals/unit formatting, and
drops its vestigial raw-pointer parent/children fields.

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

 docs/rfc-core-rebuild.md              |  11 +
 src/widget/container/parameters_bg.rs |   4 +-
 src/widget/display/font_preview.rs    | 108 ++---
 src/widget/display/info_box.rs        |  78 ++--
 src/widget/display/panel.rs           | 116 +++--
 src/widget/display/sidebar.rs         |  34 +-
 src/widget/display/splitter.rs        | 105 ++---
 src/widget/input/multi_control.rs     |   2 +-
 src/widget/input/spinbox.rs           | 787 +++++++++++++++-------------------
 src/widget/model.rs                   |  25 +-
 10 files changed, 578 insertions(+), 692 deletions(-)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index 5ca1b73..cd0fc66 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -464,6 +464,17 @@ Constraint respected: **each crate still builds standalone** — the new core is
     flipped quads still rasterized — `content_rect` must not clamp at zero or tracks vanish
     (pixel-diffed to 0 vs baseline after the fix). Slider geometry consolidated into one
     `geom()` helper (legacy re-derived it in five places). 154 tests pass; workspace builds.
+  - **5i — Display leaves + `Spinbox`: InfoBox, FontPreview, Sidebar, Splitter, Panel, Spinbox.
+    DONE.** New adapter machinery: `Input::drag_reposition` (self-moving widgets — Panel,
+    Splitter — return a new origin; the adapter applies it to the base rect the model can't
+    reach), `Input::set_drag_bounds`, and `Layout::detached_label_inset` (the legacy
+    `Control::control_label` +4px x-offset that the default `text_labels` path lacked — caught
+    as a 4px label shift in the pixel diff, fixed to a 1×1-pixel residual). Spinbox ports the
+    sub-zone hover (-/+ buttons) into `PointerMove` handling, display-click edit mode with
+    cursor placement + `request_focus`, decimals/unit value formatting, and drops its vestigial
+    raw-pointer parent/children fields. Skipped for later: `PreviewState` (needs per-label fonts
+    on the `Text` prim), `StatusBar` (bigger custom surface), Float3/LayoutPreview (time).
+    156 tests pass; workspace builds; test-interface pixel-diff vs the 5h baseline: 1 pixel.
   - **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/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 9656e69..c2977eb 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -12,7 +12,7 @@ pub struct ParametersBg {
     mouse_pos: Option<(f32, f32)>,
     pub sliders: Vec<Option<crate::widget::Adapted<Slider>>>,
     pub float3s: Vec<Option<Float3>>,
-    pub spinboxes: Vec<Option<Spinbox>>,
+    pub spinboxes: Vec<Option<crate::widget::Adapted<Spinbox>>>,
     pub buttons: Vec<Option<crate::widget::Adapted<Button>>>,
     pub choices: Vec<Option<Dropdown>>,
     pub texts: Vec<Option<TextBox>>,
@@ -1394,7 +1394,7 @@ impl Element for ParametersBg {
                 }
             } else if p.2.starts_with("spinbox") {
                 if let Some(sb) = &self.spinboxes[i] {
-                    param_quads.push((sb.base.x, sb.base.y, sb.base.w, sb.base.h, sb.color()));
+                    { let (bx, by, bw, bh) = sb.rect(); param_quads.push((bx, by, bw, bh, sb.color())); }
                     param_quads.extend(sb.extra_quads());
                 }
             } else if p.2 == "toggle" || p.2 == "checkbox" {
diff --git a/src/widget/display/font_preview.rs b/src/widget/display/font_preview.rs
index d3525b3..231e796 100644
--- a/src/widget/display/font_preview.rs
+++ b/src/widget/display/font_preview.rs
@@ -1,18 +1,18 @@
-use crate::widget::*;
-use crate::widget::display::TextLabel;
+//! Narrow-trait font preview card (Phase 5i leaf sweep). Its `widget_font` forward makes every
+//! text prim render in the previewed family on the legacy text paths.
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Adapted, Input, Layout, Paint};
 
 #[derive(Debug, Clone)]
 pub struct FontPreview {
-    base: Widget,
     pub font_family: String,
 }
 
 impl FontPreview {
-    pub fn new(font_family: String) -> Self {
-        Self {
-            base: Widget::new(),
-            font_family,
-        }
+    pub fn new(font_family: String) -> Adapted<FontPreview> {
+        Adapted::new(FontPreview { font_family })
     }
 
     pub fn set_font_family(&mut self, font_family: String) {
@@ -20,72 +20,40 @@ impl FontPreview {
     }
 }
 
-impl Element for FontPreview {
-    crate::impl_widget_base!(FontPreview);
-    fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
-    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])>{ None }
-    fn widget_font(&self) -> Option<String> { Some(self.font_family.clone()) }
+impl Layout for FontPreview {
+    fn inline_label(&self) -> bool {
+        true
+    }
+}
+
+impl Paint for FontPreview {
+    fn color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
 
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let (x, y, w, h) = self.rect();
+    fn widget_font(&self) -> Option<String> {
+        Some(self.font_family.clone())
+    }
+
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
         let border_t = 1.0;
         let card_color = [0.10, 0.10, 0.14, 0.3];
         let border_color = [0.25, 0.25, 0.35, 0.5];
-        vec![
-            (x, y, w, h, card_color),
-            (x, y, w, border_t, border_color),
-            (x, y + h - border_t, w, border_t, border_color),
-            (x, y, border_t, h, border_color),
-            (x + w - border_t, y, border_t, h, border_color),
-            (x + 16.0, y + 44.0, w - 32.0, 1.0, [0.22, 0.22, 0.30, 0.8]),
-        ]
-    }
+        ctx.quad(rect, card_color);
+        ctx.quad(Rect { x, y, width: w, height: border_t }, border_color);
+        ctx.quad(Rect { x, y: y + h - border_t, width: w, height: border_t }, border_color);
+        ctx.quad(Rect { x, y, width: border_t, height: h }, border_color);
+        ctx.quad(Rect { x: x + w - border_t, y, width: border_t, height: h }, border_color);
+        ctx.quad(Rect { x: x + 16.0, y: y + 44.0, width: w - 32.0, height: 1.0 }, [0.22, 0.22, 0.30, 0.8]);
 
-    fn text_labels(&self) -> Vec<TextLabel> {
-        let (x, y, _w, _h) = self.rect();
-        vec![
-            TextLabel {
-                text: format!("Family: {}", self.font_family),
-                x: x + 16.0,
-                y: y + 16.0,
-                font_size: 15.0,
-                color: [230, 230, 242],
-            },
-            TextLabel {
-                text: "abcdefghijklmnopqrstuvwxyz".to_string(),
-                x: x + 16.0,
-                y: y + 61.0,
-                font_size: 13.0,
-                color: [191, 191, 204],
-            },
-            TextLabel {
-                text: "ABCDEFGHIJKLMNOPQRSTUVWXYZ".to_string(),
-                x: x + 16.0,
-                y: y + 83.0,
-                font_size: 13.0,
-                color: [191, 191, 204],
-            },
-            TextLabel {
-                text: "0123456789 (!@#$%&*?)".to_string(),
-                x: x + 16.0,
-                y: y + 105.0,
-                font_size: 13.0,
-                color: [191, 191, 204],
-            },
-            TextLabel {
-                text: "The quick brown fox jumps over the lazy dog.".to_string(),
-                x: x + 16.0,
-                y: y + 131.0,
-                font_size: 16.0,
-                color: [230, 230, 242],
-            },
-            TextLabel {
-                text: "The five boxing wizards jump quickly.".to_string(),
-                x: x + 16.0,
-                y: y + 163.0,
-                font_size: 20.0,
-                color: [255, 255, 255],
-            },
-        ]
+        ctx.text(format!("Family: {}", self.font_family), x + 16.0, y + 16.0, 15.0, [230, 230, 242]);
+        ctx.text("abcdefghijklmnopqrstuvwxyz".to_string(), x + 16.0, y + 61.0, 13.0, [191, 191, 204]);
+        ctx.text("ABCDEFGHIJKLMNOPQRSTUVWXYZ".to_string(), x + 16.0, y + 83.0, 13.0, [191, 191, 204]);
+        ctx.text("0123456789 (!@#$%&*?)".to_string(), x + 16.0, y + 105.0, 13.0, [191, 191, 204]);
+        ctx.text("The quick brown fox jumps over the lazy dog.".to_string(), x + 16.0, y + 131.0, 16.0, [230, 230, 242]);
+        ctx.text("The five boxing wizards jump quickly.".to_string(), x + 16.0, y + 163.0, 20.0, [255, 255, 255]);
     }
 }
+
+impl Input for FontPreview {}
diff --git a/src/widget/display/info_box.rs b/src/widget/display/info_box.rs
index 17cedac..7ae57a7 100644
--- a/src/widget/display/info_box.rs
+++ b/src/widget/display/info_box.rs
@@ -1,71 +1,51 @@
+//! Narrow-trait info box (Phase 5i leaf sweep). Pure display: themed card + border + title/lines.
+
 use crate::colors;
-use crate::widget::*;
-use crate::widget::display::TextLabel;
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Adapted, Input, Layout, Paint};
 
 #[derive(Debug, Clone)]
 pub struct InfoBox {
-    base: Widget,
     pub title: String,
     pub lines: Vec<String>,
 }
 
 impl InfoBox {
-    pub fn new(title: &str, lines: Vec<String>) -> Self {
-        Self {
-            base: Widget::new(),
-            title: title.to_string(),
-            lines,
-        }
+    pub fn new(title: &str, lines: Vec<String>) -> Adapted<InfoBox> {
+        Adapted::new(InfoBox { title: title.to_string(), lines })
+    }
+}
+
+impl Layout for InfoBox {
+    fn inline_label(&self) -> bool {
+        true // draws its own title text
     }
 }
 
-impl Element for InfoBox {
-    crate::impl_widget_base!(InfoBox);
-    fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
-    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])>{ None }
+impl Paint for InfoBox {
+    fn color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
 
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let (x, y, w, h) = self.rect();
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
         let theme = colors::active_theme();
-        let bg_color = theme.surface_bg;
-        let border_color = theme.surface_border;
         let border_t = 1.0;
-        vec![
-            (x, y, w, h, bg_color),
-            (x, y, w, border_t, border_color),
-            (x, y + h - border_t, w, border_t, border_color),
-            (x, y, border_t, h, border_color),
-            (x + w - border_t, y, border_t, h, border_color),
-        ]
-    }
+        ctx.quad(rect, theme.surface_bg);
+        ctx.quad(Rect { x, y, width: w, height: border_t }, theme.surface_border);
+        ctx.quad(Rect { x, y: y + h - border_t, width: w, height: border_t }, theme.surface_border);
+        ctx.quad(Rect { x, y, width: border_t, height: h }, theme.surface_border);
+        ctx.quad(Rect { x: x + w - border_t, y, width: border_t, height: h }, theme.surface_border);
 
-    fn text_labels(&self) -> Vec<TextLabel> {
-        let (x, y, _w, _h) = self.rect();
-        let mut labels = Vec::new();
-        labels.push(TextLabel {
-            text: self.title.clone(),
-            x: x + 16.0,
-            y: y + 12.0,
-            font_size: 12.0,
-            color: [89, 165, 229],
-        });
-        
+        ctx.text(self.title.clone(), x + 16.0, y + 12.0, 12.0, [89, 165, 229]);
         let mut current_y = y + 32.0;
         for (idx, line) in self.lines.iter().enumerate() {
-            let color = if idx == self.lines.len() - 1 {
-                [140, 140, 153]
-            } else {
-                [204, 204, 217]
-            };
-            labels.push(TextLabel {
-                text: line.clone(),
-                x: x + 16.0,
-                y: current_y,
-                font_size: 11.0,
-                color,
-            });
+            let color = if idx == self.lines.len() - 1 { [140, 140, 153] } else { [204, 204, 217] };
+            ctx.text(line.clone(), x + 16.0, current_y, 11.0, color);
             current_y += 16.0;
         }
-        labels
     }
 }
+
+impl Input for InfoBox {}
diff --git a/src/widget/display/panel.rs b/src/widget/display/panel.rs
index 9395457..73eb394 100644
--- a/src/widget/display/panel.rs
+++ b/src/widget/display/panel.rs
@@ -1,30 +1,24 @@
+//! Narrow-trait movable panel (Phase 5i leaf sweep). Self-moving via
+//! [`Input::drag_reposition`], with movement clamped to bounds pushed in through
+//! [`Input::set_drag_bounds`] (or the inherent `set_bounds`).
+
 use crate::colors;
-use crate::widget::*;
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Adapted, Element, ElementState, Event, EventCtx, Input, Layout, MouseButton, Paint};
 
 pub struct Panel {
-    base: Widget,
     dragging: bool,
-    drag_ox: f32, drag_oy: f32,
-    drag_start_x: f32, drag_start_y: f32,
+    drag_ox: f32,
+    drag_oy: f32,
     bounds: Option<(f32, f32, f32, f32)>,
 }
 
 impl Panel {
-    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
-        Self {
-            base: Widget::new_rect(x, y, w, h),
-            dragging: false,
-            drag_ox: 0.0,
-            drag_oy: 0.0,
-            drag_start_x: 0.0,
-            drag_start_y: 0.0,
-            bounds: None,
-        }
-    }
-
-    pub fn with_label(mut self, label: &str) -> Self {
-        self.base.label = Some(label.to_string());
-        self
+    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Adapted<Panel> {
+        let mut p = Adapted::new(Panel { dragging: false, drag_ox: 0.0, drag_oy: 0.0, bounds: None });
+        Element::set_rect(&mut p, x, y, w, h);
+        p
     }
 
     pub fn set_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
@@ -32,63 +26,67 @@ impl Panel {
     }
 }
 
-impl Element for Panel {
-    crate::impl_widget_base!(Panel);
-    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])>{ None }
-
-    fn color(&self) -> [f32; 4] { if self.dragging { colors::PANEL_DRAG } else { colors::PANEL_IDLE } }
+impl Layout for Panel {
+    fn inline_label(&self) -> bool {
+        true // legacy Panel never inflated for its label
+    }
+}
 
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let (x, y, w, h) = self.rect();
-        vec![(x, y, w, h, self.color())]
+impl Paint for Panel {
+    fn color(&self) -> [f32; 4] {
+        if self.dragging { colors::PANEL_DRAG } else { colors::PANEL_IDLE }
     }
 
-    fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
-        self.bounds = Some((bx, by, bw, bh));
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        ctx.quad(rect, self.color());
     }
+}
 
-    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.drag_begin(px, py);
-                    return true;
-                }
+impl Input for Panel {
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
+                self.dragging = true;
+                self.drag_ox = x - ectx.rect.x;
+                self.drag_oy = y - ectx.rect.y;
+                true
             }
-            ElementState::Released => {
-                if self.dragging { self.drag_end(); return true; }
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
+                std::mem::take(&mut self.dragging)
             }
+            _ => false,
         }
-        false
     }
 
-    fn is_dragging(&self) -> bool { self.dragging }
-    fn draggable(&self) -> bool { true }
-
-    fn drag_update(&mut self, px: f32, py: f32) -> bool {
+    fn draggable(&self) -> bool {
+        true
+    }
+    fn is_dragging(&self) -> bool {
+        self.dragging
+    }
+    fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
+        self.dragging = true;
+        self.drag_ox = px - rect.x;
+        self.drag_oy = py - rect.y;
+    }
+    fn drag_reposition(&mut self, px: f32, py: f32, rect: Rect) -> Option<(f32, f32)> {
         let nx = px - self.drag_ox;
         let ny = py - self.drag_oy;
         let (nx, ny) = if let Some((bx, by, bw, bh)) = self.bounds {
-            (nx.clamp(bx, bx + bw - self.base.w), ny.clamp(by, by + bh - self.base.h))
+            (nx.clamp(bx, bx + bw - rect.width), ny.clamp(by, by + bh - rect.height))
         } else {
             (nx, ny)
         };
-        if (nx - self.base.x).abs() > 0.01 || (ny - self.base.y).abs() > 0.01 {
-            self.base.x = nx;
-            self.base.y = ny;
-            return true;
+        if (nx - rect.x).abs() > 0.01 || (ny - rect.y).abs() > 0.01 {
+            Some((nx, ny))
+        } else {
+            None
         }
-        false
     }
-
-    fn drag_begin(&mut self, px: f32, py: f32) {
-        self.dragging = true;
-        self.drag_ox = px - self.base.x;
-        self.drag_oy = py - self.base.y;
-        self.drag_start_x = self.base.x;
-        self.drag_start_y = self.base.y;
+    fn drag_end(&mut self) {
+        self.dragging = false;
+    }
+    fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
+        self.bounds = Some((bx, by, bw, bh));
     }
-
-    fn drag_end(&mut self) { self.dragging = false; }
 }
diff --git a/src/widget/display/sidebar.rs b/src/widget/display/sidebar.rs
index 8a3651e..858bc6b 100644
--- a/src/widget/display/sidebar.rs
+++ b/src/widget/display/sidebar.rs
@@ -1,25 +1,25 @@
+//! Narrow-trait sidebar strip (Phase 5i leaf sweep). Pure colored band; the legacy struct's raw
+//! x/y/w/h fields now live on the `Adapted` base.
+
 use crate::colors;
-use crate::widget::*;
+use crate::widget::{Adapted, Element, Input, Layout, Paint};
 
-pub struct Sidebar {
-    x: f32, y: f32, w: f32, h: f32,
-    hovered: bool,
-}
+pub struct Sidebar;
 
 impl Sidebar {
-    pub fn new(w: f32) -> Self { Self { x: 0.0, y: 0.0, w, h: 0.0, hovered: false } }
+    pub fn new(w: f32) -> Adapted<Sidebar> {
+        let mut s = Adapted::new(Sidebar);
+        Element::set_rect(&mut s, 0.0, 0.0, w, 0.0);
+        s
+    }
 }
 
-impl Element for Sidebar {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn color(&self) -> [f32; 4] { colors::sidebar_bg_color() }
-    fn as_ptr(&self) -> *mut (dyn Element + 'static) {
-        self as *const Self as *mut Self as *mut (dyn Element + 'static)
-    }
-    fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
-        self as *mut Self as *mut (dyn Element + 'static)
+impl Layout for Sidebar {}
+
+impl Paint for Sidebar {
+    fn color(&self) -> [f32; 4] {
+        colors::sidebar_bg_color()
     }
-    fn set_hovered(&mut self, v: bool) { self.hovered = v; }
-    fn hovered(&self) -> bool { self.hovered }
 }
+
+impl Input for Sidebar {}
diff --git a/src/widget/display/splitter.rs b/src/widget/display/splitter.rs
index 22a165b..e4de131 100644
--- a/src/widget/display/splitter.rs
+++ b/src/widget/display/splitter.rs
@@ -1,74 +1,81 @@
+//! Narrow-trait pane splitter (Phase 5i leaf sweep). A self-moving widget: dragging repositions
+//! the splitter itself via [`Input::drag_reposition`] (the adapter applies the new origin to the
+//! base rect). Hover/drag drive the color, tracked from the forwarded events.
+
 use crate::colors;
-use crate::widget::*;
+use crate::scene::layout::Rect;
+use crate::widget::{Adapted, Element, ElementState, Event, EventCtx, Input, Layout, MouseButton, Paint};
 
 pub struct Splitter {
-    x: f32, y: f32, w: f32, h: f32,
     hovered: bool,
     dragging: bool,
     drag_ox: f32,
 }
 
 impl Splitter {
-    pub fn new(w: f32) -> Self {
-        Self { x: 0.0, y: 0.0, w, h: 0.0, hovered: false, dragging: false, drag_ox: 0.0 }
+    pub fn new(w: f32) -> Adapted<Splitter> {
+        let mut s = Adapted::new(Splitter { hovered: false, dragging: false, drag_ox: 0.0 });
+        Element::set_rect(&mut s, 0.0, 0.0, w, 0.0);
+        s
     }
 }
 
-impl Element for Splitter {
-    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
-    fn as_ptr(&self) -> *mut (dyn Element + 'static) {
-        self as *const Self as *mut Self as *mut (dyn Element + 'static)
-    }
-    fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
-        self as *mut Self as *mut (dyn Element + 'static)
-    }
-    fn color(&self) -> [f32; 4] {
-        if self.dragging { colors::SPLITTER_DRAG }
-        else if self.hovered { colors::SPLITTER_HOVER }
-        else { colors::SPLITTER_IDLE }
-    }
-    fn set_hovered(&mut self, v: bool) { self.hovered = v; }
-    fn hovered(&self) -> bool { self.hovered }
+impl Layout for Splitter {}
 
-    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        let was = self.hovered;
-        self.hovered = self.hit_test(px, py, ctx);
-        was != self.hovered
+impl Paint for Splitter {
+    fn color(&self) -> [f32; 4] {
+        if self.dragging {
+            colors::SPLITTER_DRAG
+        } else if self.hovered {
+            colors::SPLITTER_HOVER
+        } else {
+            colors::SPLITTER_IDLE
+        }
     }
+}
 
-    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.drag_begin(px, py);
-                    return true;
-                }
+impl Input for Splitter {
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, .. } => {
+                self.dragging = true;
+                self.drag_ox = x - ectx.rect.x;
+                true
+            }
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
+                std::mem::take(&mut self.dragging)
             }
-            ElementState::Released => {
-                if self.dragging { self.drag_end(); return true; }
+            Event::MouseEnter => {
+                self.hovered = true;
+                false
             }
+            Event::MouseLeave => {
+                self.hovered = false;
+                false
+            }
+            _ => false,
         }
-        false
     }
 
-    fn is_dragging(&self) -> bool { self.dragging }
-    fn draggable(&self) -> bool { true }
-
-    fn drag_update(&mut self, px: f32, _py: f32) -> bool {
+    fn draggable(&self) -> bool {
+        true
+    }
+    fn is_dragging(&self) -> bool {
+        self.dragging
+    }
+    fn drag_begin(&mut self, px: f32, _py: f32, rect: Rect) {
+        self.dragging = true;
+        self.drag_ox = px - rect.x;
+    }
+    fn drag_reposition(&mut self, px: f32, _py: f32, rect: Rect) -> Option<(f32, f32)> {
         let new_x = px - self.drag_ox;
-        if (new_x - self.x).abs() > 0.5 {
-            self.x = new_x;
-            return true;
+        if (new_x - rect.x).abs() > 0.5 {
+            Some((new_x, rect.y))
+        } else {
+            None
         }
-        false
     }
-
-    fn drag_begin(&mut self, px: f32, _py: f32) {
-        self.dragging = true;
-        self.drag_ox = px - self.x;
+    fn drag_end(&mut self) {
+        self.dragging = false;
     }
-
-    fn drag_end(&mut self) { self.dragging = false; }
 }
diff --git a/src/widget/input/multi_control.rs b/src/widget/input/multi_control.rs
index 9e91968..1e93544 100644
--- a/src/widget/input/multi_control.rs
+++ b/src/widget/input/multi_control.rs
@@ -13,7 +13,7 @@ pub struct InstancedControl {
 #[derive(Clone, Debug)]
 pub enum InstancedWidget {
     TextBox(TextBox),
-    Spinbox(Spinbox),
+    Spinbox(Adapted<Spinbox>),
     Toggle(Adapted<Toggle>),
     Slider(Adapted<Slider>),
 }
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 00c3829..6fdfaeb 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -1,28 +1,59 @@
+//! Narrow-trait `Spinbox` (Phase 5i). Slider-style label convention (no rect inflation; label
+//! eats into the assigned rect, side-label inset computed from the synced label). Sub-zone
+//! hover (the -/+ buttons) is tracked from `PointerMove` against the content rect; a click on
+//! the display area enters edit mode and takes focus via `EventCtx::request_focus`.
+
 use crate::colors;
-use crate::widget::*;
+use crate::scene::layout::{Rect, Size};
+use crate::scene::paint::PaintCtx;
+use crate::widget::{
+    Adapted, Control, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton, NamedKey,
+    Paint, TextEditorState,
+};
+
+fn side_offset(label: &Option<String>) -> f32 {
+    if crate::layout::control_label_layout() == "side" && label.is_some() {
+        90.0
+    } else {
+        0.0
+    }
+}
 
 #[derive(Debug, Clone)]
 pub struct Spinbox {
-    pub(crate) base: Widget,
     pub value: i32,
-    pub(crate) min: i32, pub(crate) max: i32, pub(crate) step: i32,
+    pub(crate) min: i32,
+    pub(crate) max: i32,
+    pub(crate) step: i32,
     pub editing: bool,
     pub edit_buffer: String,
     pub cursor_idx: usize,
     hover_dec: bool,
     hover_inc: bool,
+    hovered: bool,
     unit: Option<String>,
     pub decimals: u32,
-    pub parent: Option<*mut (dyn Element + 'static)>,
-    pub children: Vec<*mut (dyn Element + 'static)>,
     pub editor_state: TextEditorState,
     pub just_changed: bool,
+    label: Option<String>,
+}
+
+/// The zone geometry shared by paint and input, derived from the content rect.
+struct SpinGeom {
+    x: f32,
+    y: f32,
+    w: f32,
+    h: f32,
+    split_dec: f32,
+    btn_y: f32,
+    btn_h: f32,
+    btn_w: f32,
+    pad: f32,
 }
 
 impl Spinbox {
-    pub fn new(value: i32, min: i32, max: i32, step: i32) -> Self {
-        Self {
-            base: Widget::new(),
+    pub fn new(value: i32, min: i32, max: i32, step: i32) -> Adapted<Spinbox> {
+        Adapted::new(Spinbox {
             value,
             min,
             max,
@@ -32,544 +63,412 @@ impl Spinbox {
             cursor_idx: 0,
             hover_dec: false,
             hover_inc: false,
+            hovered: false,
             unit: None,
             decimals: 0,
-            parent: None,
-            children: Vec::new(),
             editor_state: TextEditorState::new(String::new()),
             just_changed: false,
-        }
-    }
-
-    pub fn with_label(mut self, label: &str) -> Self {
-        self.base.label = Some(label.to_string());
-        self
-    }
-
-    pub fn with_config(mut self, file: &str, key: &str) -> Self {
-        self.base.config_file = Some(file.to_string());
-        self.base.config_key = Some(key.to_string());
-        self
-    }
-
-    pub fn set_label(&mut self, label: &str) {
-        self.base.label = Some(label.to_string());
-    }
-
-    pub fn with_unit(mut self, unit: &str) -> Self {
-        self.unit = Some(unit.to_string());
-        self
+            label: None,
+        })
     }
 
     pub fn set_unit(&mut self, unit: &str) {
         self.unit = Some(unit.to_string());
     }
 
-    pub fn with_decimals(mut self, decimals: u32) -> Self {
-        self.decimals = decimals;
-        self
-    }
-
     pub fn range(&self) -> (i32, i32) {
         (self.min, self.max)
     }
-}
 
-impl Element for Spinbox {
-    crate::impl_widget_base!(Spinbox);
+    fn geom(&self, rect: Rect) -> SpinGeom {
+        let side = side_offset(&self.label);
+        let x = rect.x + side;
+        let w = rect.width - side;
+        let pad = crate::layout::spinbox_button_padding();
+        SpinGeom {
+            x,
+            y: rect.y,
+            w,
+            h: rect.height,
+            split_dec: x + w * 0.55,
+            btn_y: rect.y + pad,
+            btn_h: (rect.height - 2.0 * pad).max(0.0),
+            btn_w: ((w * 0.45 - 2.0 * pad).max(0.0)) / 2.0,
+            pad,
+        }
+    }
 
-    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;
-     }
+    fn value_text(&self) -> String {
+        if self.editing {
+            self.edit_buffer.clone()
+        } else if self.decimals > 0 {
+            let divisor = 10.0f32.powi(self.decimals as i32);
+            format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize)
+        } else {
+            self.value.to_string()
+        }
+    }
 
-    fn get_value_string(&self) -> Option<String> {
+    fn formatted_value(&self) -> String {
         if self.decimals > 0 {
             let divisor = 10.0f32.powi(self.decimals as i32);
-            Some(format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize))
+            format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize)
         } else {
-            Some(self.value.to_string())
+            self.value.to_string()
         }
     }
 
-    fn set_value_string(&mut self, val: &str) -> bool {
-        let val = val.trim();
+    fn parse_into_value(&mut self, text: &str) {
         let old_val = self.value;
         if self.decimals > 0 {
-            if let Ok(val_f) = val.parse::<f32>() {
+            if let Ok(val_f) = text.parse::<f32>() {
                 let divisor = 10.0f32.powi(self.decimals as i32);
-                self.value = (val_f * divisor).round() as i32;
-                self.value = self.value.clamp(self.min, self.max);
-            }
-        } else {
-            if let Ok(val_i) = val.parse::<i32>() {
-                self.value = val_i.clamp(self.min, self.max);
+                self.value = ((val_f * divisor).round() as i32).clamp(self.min, self.max);
             }
+        } else if let Ok(val) = text.parse::<i32>() {
+            self.value = val.clamp(self.min, self.max);
         }
         if self.value != old_val {
             self.just_changed = true;
-            if self.editing {
-                self.edit_buffer = self.get_value_string().unwrap_or_default();
-                self.cursor_idx = self.edit_buffer.chars().count();
-            }
-            return true;
         }
+    }
+
+    fn begin_edit(&mut self, cursor_at_end: bool) {
+        self.editing = true;
+        self.edit_buffer = self.formatted_value();
+        if cursor_at_end {
+            self.cursor_idx = self.edit_buffer.chars().count();
+        }
+    }
+}
+
+impl Adapted<Spinbox> {
+    pub fn with_unit(mut self, unit: &str) -> Self {
+        self.set_unit(unit);
+        self
+    }
+
+    pub fn with_decimals(mut self, decimals: u32) -> Self {
+        self.decimals = decimals;
+        self
+    }
+}
+
+impl Layout for Spinbox {
+    fn inflates_label_rect(&self) -> bool {
         false
     }
 
-    fn take_change(&mut self) -> bool {
-        let ret = self.just_changed;
-        self.just_changed = false;
-        ret
+    fn layout_ignore(&self) -> bool {
+        true
     }
 
-    fn preferred_height(&self) -> Option<f32> {
-        Some(crate::layout::spinbox_height())
+    fn detached_label_inset(&self) -> f32 {
+        4.0 // legacy Control::control_label x offset
     }
 
-    fn rounded_corners(&self) -> (bool, bool, bool, bool) {
+    fn intrinsic_size(&self) -> Option<Size> {
+        Some(Size::new(0.0, crate::layout::spinbox_height()))
+    }
+}
+
+impl Paint for Spinbox {
+    fn color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
+
+    fn corner_style(&self) -> Option<(f32, (bool, bool, bool, bool))> {
         let r = crate::layout::spinbox_corner_radius();
         if r > 0.0 {
-            (true, true, true, true)
+            Some((r, (true, true, true, true)))
         } else {
-            (false, false, false, false)
+            None
         }
     }
 
-    fn corner_radius(&self) -> f32 {
-        crate::layout::spinbox_corner_radius()
+    fn widget_font(&self) -> Option<String> {
+        Some(crate::layout::control_label_font_detached())
     }
 
-    fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
-    fn value(&self) -> i32 { self.value }
-    fn widget_font(&self) -> Option<String> { Some(crate::layout::control_label_font_detached()) }
-
-    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        let was = self.base.hovered;
-        self.base.hovered = self.hit_test(px, py, ctx);
-        if !self.base.hovered {
-            let changed = self.hover_dec || self.hover_inc;
-            self.hover_dec = false;
-            self.hover_inc = false;
-            return changed || was != self.base.hovered;
-        }
-        let top = self.base.label_offset();
-        let visual_h = self.base.h - top;
-        let p = crate::layout::spinbox_button_padding();
-        let btn_y = self.base.y + top + p;
-        let btn_h = (visual_h - 2.0 * p).max(0.0);
-        let label_x = self.label_x_offset();
-        let x = self.base.x + label_x;
-        let w = self.base.w - label_x;
-        let split_dec = x + w * 0.55;
-        
-        let in_y = py >= btn_y && py < btn_y + btn_h;
-        let hd = in_y && px >= split_dec + p && px < x + w * 0.775;
-        let hi = in_y && px >= x + w * 0.775 && px < x + w - p;
-        let changed = hd != self.hover_dec || hi != self.hover_inc;
-        self.hover_dec = hd;
-        self.hover_inc = hi;
-        changed || was != self.base.hovered
+    fn sync_label(&mut self, label: &str) {
+        self.label = Some(label.to_string());
     }
 
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        if button == MouseButton::Right && state == ElementState::Pressed {
-            if self.hit_test(px, py, ctx) {
-                ctx.handle_right_click(self.as_ptr_mut(), px, py);
-                return true;
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        let g = self.geom(rect);
+        let radius = crate::layout::spinbox_corner_radius();
+        let rounded = radius > 0.0;
+        let display_bg = if self.editing { [0.06, 0.10, 0.18, 1.0] } else { colors::spinbox_display() };
+        let inc_col = if self.hover_inc { colors::spinbox_button_hover() } else { colors::spinbox_button() };
+        let dec_col = if self.hover_dec { colors::spinbox_button_hover() } else { colors::spinbox_button() };
+
+        if rounded {
+            let rc = (true, true, true, true);
+            let border_color = if self.editing {
+                [0.20, 0.50, 0.85, 1.0]
+            } else if self.hovered {
+                [0.25, 0.25, 0.35, 1.0]
+            } else {
+                [0.18, 0.18, 0.24, 1.0]
+            };
+            ctx.rounded_rect(Rect { x: g.x, y: g.y, width: g.w, height: g.h }, radius, rc, border_color);
+            ctx.rounded_rect(
+                Rect { x: g.x + 1.0, y: g.y + 1.0, width: g.w - 2.0, height: g.h - 2.0 },
+                radius - 1.0,
+                rc,
+                display_bg,
+            );
+            if g.btn_h > 0.0 && g.btn_w > 0.0 {
+                ctx.rounded_rect(
+                    Rect { x: g.split_dec + g.pad, y: g.btn_y, width: g.btn_w, height: g.btn_h },
+                    0.0,
+                    (false, false, false, false),
+                    dec_col,
+                );
+                ctx.rounded_rect(
+                    Rect { x: g.split_dec + g.pad + g.btn_w, y: g.btn_y, width: g.btn_w, height: g.btn_h },
+                    radius,
+                    (false, true, true, false),
+                    inc_col,
+                );
+            }
+            if self.editing {
+                let char_width = 8.4;
+                let cursor_x = (g.x + 4.0 + self.cursor_idx as f32 * char_width).min(g.x + g.w * 0.55 - 4.0);
+                let cursor_y = g.y + (g.h - 14.0) / 2.0;
+                ctx.rounded_rect(
+                    Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 },
+                    0.0,
+                    (false, false, false, false),
+                    [0.80, 0.80, 0.85, 1.0],
+                );
+            }
+        } else {
+            ctx.quad(Rect { x: g.x, y: g.y, width: g.w, height: g.h }, display_bg);
+            if g.btn_h > 0.0 && g.btn_w > 0.0 {
+                ctx.quad(Rect { x: g.split_dec + g.pad, y: g.btn_y, width: g.btn_w, height: g.btn_h }, dec_col);
+                ctx.quad(Rect { x: g.split_dec + g.pad + g.btn_w, y: g.btn_y, width: g.btn_w, height: g.btn_h }, inc_col);
+            }
+            if self.editing {
+                let border_color = [0.20, 0.50, 0.85, 1.0];
+                ctx.quad(Rect { x: g.x, y: g.y, width: g.w, height: 1.0 }, border_color);
+                ctx.quad(Rect { x: g.x, y: g.y + g.h - 1.0, width: g.w, height: 1.0 }, border_color);
+                ctx.quad(Rect { x: g.x, y: g.y, width: 1.0, height: g.h }, border_color);
+                ctx.quad(Rect { x: g.x + g.w - 1.0, y: g.y, width: 1.0, height: g.h }, border_color);
+
+                let char_width = 8.4;
+                let cursor_x = (g.x + 4.0 + self.cursor_idx as f32 * char_width).min(g.x + g.w * 0.55 - 4.0);
+                let cursor_y = g.y + (g.h - 14.0) / 2.0;
+                ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
             }
         }
-        if button != MouseButton::Left { return false; }
-        if !self.hit_test(px, py, ctx) { return false; }
-        match state {
-            ElementState::Pressed => {
-                let top = self.base.label_offset();
-                let visual_h = self.base.h - top;
-                let p = crate::layout::spinbox_button_padding();
-                let btn_y = self.base.y + top + p;
-                let btn_h = (visual_h - 2.0 * p).max(0.0);
-                let label_x = self.label_x_offset();
-                let x = self.base.x + label_x;
-                let w = self.base.w - label_x;
-                let split_dec = x + w * 0.55;
-                
-                let in_y = py >= btn_y && py < btn_y + btn_h;
-                if in_y && px >= split_dec + p && px < x + w * 0.775 {
+
+        // Value, unit, and -/+ glyphs.
+        let tc = colors::spinbox_text_color();
+        let text_color = [(tc[0] * 255.0) as u8, (tc[1] * 255.0) as u8, (tc[2] * 255.0) as u8];
+        ctx.text(self.value_text(), g.x + 4.0, crate::layout::align_text_y(g.y, g.h, 14.0, 0.0), 14.0, text_color);
+        if let Some(ref unit) = self.unit {
+            ctx.text(unit.clone(), g.x + 4.0 + 36.0, crate::layout::align_text_y(g.y, g.h, 11.0, 0.0), 11.0, [0x73, 0x73, 0x7a]);
+        }
+        if g.btn_w > 0.0 {
+            let dec_center_x = g.split_dec + g.pad + g.btn_w * 0.5;
+            let inc_center_x = g.split_dec + g.pad + g.btn_w * 1.5;
+            let ty = crate::layout::align_text_y(g.y, g.h, 12.0, 0.0);
+            ctx.text("-".to_string(), dec_center_x - 4.0, ty, 12.0, text_color);
+            ctx.text("+".to_string(), inc_center_x - 4.0, ty, 12.0, text_color);
+        }
+    }
+}
+
+impl Input for Spinbox {
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::PointerMove { x: px, y: py, .. } => {
+                let r = ectx.rect;
+                let was = self.hovered;
+                self.hovered = *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
+                if !self.hovered {
+                    let changed = self.hover_dec || self.hover_inc;
+                    self.hover_dec = false;
+                    self.hover_inc = false;
+                    return changed || was != self.hovered;
+                }
+                let g = self.geom(r);
+                let in_y = *py >= g.btn_y && *py < g.btn_y + g.btn_h;
+                let hd = in_y && *px >= g.split_dec + g.pad && *px < g.x + g.w * 0.775;
+                let hi = in_y && *px >= g.x + g.w * 0.775 && *px < g.x + g.w - g.pad;
+                let changed = hd != self.hover_dec || hi != self.hover_inc;
+                self.hover_dec = hd;
+                self.hover_inc = hi;
+                changed || was != self.hovered
+            }
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, .. } => {
+                let g = self.geom(ectx.rect);
+                let in_y = *py >= g.btn_y && *py < g.btn_y + g.btn_h;
+                if in_y && *px >= g.split_dec + g.pad && *px < g.x + g.w * 0.775 {
                     let old_val = self.value;
                     self.value = (self.value - self.step).max(self.min);
                     if self.value != old_val {
                         self.just_changed = true;
                     }
                     true
-                } else if in_y && px >= x + w * 0.775 && px < x + w - p {
+                } else if in_y && *px >= g.x + g.w * 0.775 && *px < g.x + g.w - g.pad {
                     let old_val = self.value;
                     self.value = (self.value + self.step).min(self.max);
                     if self.value != old_val {
                         self.just_changed = true;
                     }
                     true
-                } else if px < split_dec {
-                    self.editing = true;
-                    if self.decimals > 0 {
-                        let divisor = 10.0f32.powi(self.decimals as i32);
-                        self.edit_buffer = format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize);
-                    } else {
-                        self.edit_buffer = self.value.to_string();
-                    }
+                } else if *px < g.split_dec {
+                    self.begin_edit(false);
                     let char_width = 8.4;
-                    let click_idx = (((px - (x + 4.0)) / char_width).round() as isize)
+                    self.cursor_idx = (((px - (g.x + 4.0)) / char_width).round() as isize)
                         .max(0)
                         .min(self.edit_buffer.chars().count() as isize) as usize;
-                    self.cursor_idx = click_idx;
-                    focus::set_focused(self);
+                    ectx.request_focus();
                     true
                 } else {
                     false
                 }
             }
-            ElementState::Released => {
-                false
-            }
-        }
-    }
-
-    fn focus(&mut self) {
-        self.editing = true;
-        if self.decimals > 0 {
-            let divisor = 10.0f32.powi(self.decimals as i32);
-            self.edit_buffer = format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize);
-        } else {
-            self.edit_buffer = self.value.to_string();
-        }
-        self.cursor_idx = self.edit_buffer.chars().count();
-        focus::set_focused(self);
-    }
-
-    fn unfocus(&mut self) {
-        if self.editing {
-            self.editing = false;
-            let old_val = self.value;
-            if self.decimals > 0 {
-                if let Ok(val_f) = self.edit_buffer.parse::<f32>() {
-                    let divisor = 10.0f32.powi(self.decimals as i32);
-                    self.value = (val_f * divisor).round() as i32;
-                    self.value = self.value.clamp(self.min, self.max);
-                }
-            } else {
-                if let Ok(val) = self.edit_buffer.parse::<i32>() {
-                    self.value = val.clamp(self.min, self.max);
+            Event::KeyInput(key_event) => {
+                if !self.editing || key_event.state != ElementState::Pressed {
+                    return false;
                 }
-            }
-            if self.value != old_val {
-                self.just_changed = true;
-            }
-        }
-    }
-
-    fn keyboard_input(&mut self, event: &KeyEvent, _ctx: &mut UiContext) -> bool {
-        if !self.editing { return false; }
-        if event.state != ElementState::Pressed { return false; }
-        
-        let mut state = TextEditorState {
-            buffer: self.edit_buffer.clone(),
-            cursor_idx: self.cursor_idx,
-            select_anchor: None,
-            all_selected: false,
-        };
-        
-        let mut handled = false;
-        match &event.logical_key {
-            Key::Named(NamedKey::Backspace) => {
-                handled = state.delete_backwards();
-            }
-            Key::Named(NamedKey::Delete) => {
-                handled = state.delete_forwards();
-            }
-            Key::Named(NamedKey::ArrowLeft) => {
-                handled = state.move_cursor_left(false);
-            }
-            Key::Named(NamedKey::ArrowRight) => {
-                handled = state.move_cursor_right(false);
-            }
-            Key::Named(NamedKey::Enter) => {
-                let old_val = self.value;
-                if self.decimals > 0 {
-                    if let Ok(val_f) = state.buffer.parse::<f32>() {
-                        let divisor = 10.0f32.powi(self.decimals as i32);
-                        self.value = (val_f * divisor).round() as i32;
-                        self.value = self.value.clamp(self.min, self.max);
+                let mut state = TextEditorState {
+                    buffer: self.edit_buffer.clone(),
+                    cursor_idx: self.cursor_idx,
+                    select_anchor: None,
+                    all_selected: false,
+                };
+                let mut handled = false;
+                match &key_event.logical_key {
+                    Key::Named(NamedKey::Backspace) => handled = state.delete_backwards(),
+                    Key::Named(NamedKey::Delete) => handled = state.delete_forwards(),
+                    Key::Named(NamedKey::ArrowLeft) => handled = state.move_cursor_left(false),
+                    Key::Named(NamedKey::ArrowRight) => handled = state.move_cursor_right(false),
+                    Key::Named(NamedKey::Enter) => {
+                        let text = state.buffer.clone();
+                        self.parse_into_value(&text);
+                        self.editing = false;
+                        handled = true;
                     }
-                } else {
-                    if let Ok(val) = state.buffer.parse::<i32>() {
-                        self.value = val.clamp(self.min, self.max);
+                    Key::Named(NamedKey::Escape) => {
+                        self.editing = false;
+                        handled = true;
+                    }
+                    _ => {
+                        if let Some(text) = &key_event.text {
+                            for ch in text.chars() {
+                                match ch {
+                                    '-' if state.cursor_idx == 0 && !state.buffer.starts_with('-') => {
+                                        state.insert_text("-");
+                                        handled = true;
+                                    }
+                                    '.' if self.decimals > 0 && !state.buffer.contains('.') => {
+                                        state.insert_text(".");
+                                        handled = true;
+                                    }
+                                    '0'..='9' => {
+                                        state.insert_text(&ch.to_string());
+                                        handled = true;
+                                    }
+                                    _ => {}
+                                }
+                            }
+                        }
                     }
                 }
-                if self.value != old_val {
-                    self.just_changed = true;
+                if self.editing {
+                    self.edit_buffer = state.buffer;
+                    self.cursor_idx = state.cursor_idx;
                 }
-                self.editing = false;
-                handled = true;
+                handled
             }
-            Key::Named(NamedKey::Escape) => {
-                self.editing = false;
-                handled = true;
+            // Focus gained programmatically enters edit mode (legacy `focus()` override);
+            // focus loss commits (legacy `unfocus`).
+            Event::FocusIn => {
+                self.begin_edit(true);
+                false
             }
-            _ => {
-                if let Some(text) = &event.text {
-                    for ch in text.chars() {
-                        match ch {
-                            '-' if state.cursor_idx == 0 && !state.buffer.starts_with('-') => {
-                                state.insert_text("-");
-                                handled = true;
-                            }
-                            '.' if self.decimals > 0 && !state.buffer.contains('.') => {
-                                state.insert_text(".");
-                                handled = true;
-                            }
-                            '0'..='9' => {
-                                state.insert_text(&ch.to_string());
-                                handled = true;
-                            }
-                            _ => {}
-                        }
-                    }
+            Event::FocusOut => {
+                if self.editing {
+                    self.editing = false;
+                    let text = self.edit_buffer.clone();
+                    self.parse_into_value(&text);
                 }
+                false
             }
+            _ => false,
         }
-        
-        if self.editing {
-            self.edit_buffer = state.buffer;
-            self.cursor_idx = state.cursor_idx;
-        }
-        handled
     }
 
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let mut quads = Vec::new();
-        let (r1, r2, r3, r4) = self.rounded_corners();
-        let has_rounded = r1 || r2 || r3 || r4;
-        if has_rounded {
-            return quads;
-        }
-
-        let top = self.base.label_offset();
-        let visual_h = self.base.h - top;
-        let label_x = self.label_x_offset();
-        let x = self.base.x + label_x;
-        let w = self.base.w - label_x;
-        let display_w = w * 0.55;
-
-        let p = crate::layout::spinbox_button_padding();
-        let split_dec = x + display_w;
-        let inc_col = if self.hover_inc { colors::spinbox_button_hover() } else { colors::spinbox_button() };
-        let dec_col = if self.hover_dec { colors::spinbox_button_hover() } else { colors::spinbox_button() };
-        
-        let display_bg = if self.editing {
-            [0.06, 0.10, 0.18, 1.0]
-        } else {
-            colors::spinbox_display()
-        };
-        
-        quads.push((x, self.base.y + top, w, visual_h, display_bg));
-        
-        let btn_y = self.base.y + top + p;
-        let btn_h = (visual_h - 2.0 * p).max(0.0);
-        let pair_w = (w * 0.45 - 2.0 * p).max(0.0);
-        let btn_w_padded = pair_w / 2.0;
-        
-        if btn_h > 0.0 && btn_w_padded > 0.0 {
-            quads.push((split_dec + p, btn_y, btn_w_padded, btn_h, dec_col));
-            quads.push((split_dec + p + btn_w_padded, btn_y, btn_w_padded, btn_h, inc_col));
-        }
-        
-        if self.editing {
-            let border_color = [0.20, 0.50, 0.85, 1.0];
-            quads.push((x, self.base.y + top, w, 1.0, border_color));
-            quads.push((x, self.base.y + top + visual_h - 1.0, w, 1.0, border_color));
-            quads.push((x, self.base.y + top, 1.0, visual_h, border_color));
-            quads.push((x + w - 1.0, self.base.y + top, 1.0, visual_h, border_color));
-
-            let char_width = 8.4;
-            let cursor_x = x + 4.0 + (self.cursor_idx as f32 * char_width);
-            let max_cursor_x = x + display_w - 4.0;
-            let final_cursor_x = cursor_x.min(max_cursor_x);
-            let cursor_y = self.base.y + top + (visual_h - 14.0) / 2.0;
-            quads.push((final_cursor_x, cursor_y, 1.5, 14.0, [0.80, 0.80, 0.85, 1.0]));
-        }
-        
-        quads
+    fn opens_context_menu(&self) -> bool {
+        true
     }
 
-    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();
-        let has_rounded = r1 || r2 || r3 || r4;
-        if !has_rounded {
-            return quads;
-        }
-
-        let top = self.base.label_offset();
-        let visual_h = self.base.h - top;
-        let radius = self.corner_radius();
-        
-        let display_bg = if self.editing {
-            [0.06, 0.10, 0.18, 1.0]
-        } else {
-            colors::spinbox_display()
-        };
-
-
-        let border_color = if self.editing {
-            [0.20, 0.50, 0.85, 1.0]
-        } else if self.base.hovered {
-            [0.25, 0.25, 0.35, 1.0]
-        } else {
-            [0.18, 0.18, 0.24, 1.0]
-        };
-
-        let label_x = self.label_x_offset();
-        let x = self.base.x + label_x;
-        let w = self.base.w - label_x;
-        let display_w = w * 0.55;
-
-        // Draw display border (outer) and background (inner)
-        quads.push((x, self.base.y + top, w, visual_h, radius, border_color, (r1, r2, r3, r4)));
-        quads.push((x + 1.0, self.base.y + top + 1.0, w - 2.0, visual_h - 2.0, radius - 1.0, display_bg, (r1, r2, r3, r4)));
-
-        // Increment/decrement buttons
-        let p = crate::layout::spinbox_button_padding();
-        let btn_y = self.base.y + top + p;
-        let btn_h = (visual_h - 2.0 * p).max(0.0);
-        let pair_w = (w * 0.45 - 2.0 * p).max(0.0);
-        let btn_w_padded = pair_w / 2.0;
-        let split_dec = x + display_w;
-        let inc_col = if self.hover_inc { colors::spinbox_button_hover() } else { colors::spinbox_button() };
-        let dec_col = if self.hover_dec { colors::spinbox_button_hover() } else { colors::spinbox_button() };
-
-        if btn_h > 0.0 && btn_w_padded > 0.0 {
-            // Decrement button (middle, no rounded corners)
-            quads.push((split_dec + p, btn_y, btn_w_padded, btn_h, 0.0, dec_col, (false, false, false, false)));
-            // Increment button (right, rounded top-right and bottom-right)
-            quads.push((split_dec + p + btn_w_padded, btn_y, btn_w_padded, btn_h, radius, inc_col, (false, r2, r3, false)));
-        }
-
-        // Draw cursor if editing and rounded
-        if self.editing {
-            let char_width = 8.4;
-            let cursor_x = x + 4.0 + (self.cursor_idx as f32 * char_width);
-            let max_cursor_x = x + display_w - 4.0;
-            let final_cursor_x = cursor_x.min(max_cursor_x);
-            let cursor_y = self.base.y + top + (visual_h - 14.0) / 2.0;
-            quads.push((final_cursor_x, cursor_y, 1.5, 14.0, 0.0, [0.80, 0.80, 0.85, 1.0], (false, false, false, false)));
-        }
+    fn take_change(&mut self) -> bool {
+        std::mem::take(&mut self.just_changed)
+    }
 
-        quads
+    fn value_string(&self) -> Option<String> {
+        Some(self.formatted_value())
     }
 
-    fn text_labels(&self) -> Vec<TextLabel> {
-        let mut labels = Vec::new();
-        if let Some(lbl) = self.control_label() {
-            labels.push(lbl);
-        }
-        let value_text = if self.editing {
-            self.edit_buffer.clone()
-        } else if self.decimals > 0 {
-            let divisor = 10.0f32.powi(self.decimals as i32);
-            format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize)
+    fn set_value_string(&mut self, val: &str) -> bool {
+        let old_val = self.value;
+        self.parse_into_value(val.trim());
+        if self.value != old_val {
+            if self.editing {
+                self.edit_buffer = self.formatted_value();
+                self.cursor_idx = self.edit_buffer.chars().count();
+            }
+            true
         } else {
-            self.value.to_string()
-        };
-        
-        let top = self.base.label_offset();
-        let label_x = self.label_x_offset();
-        let x = self.base.x + label_x;
-        let w = self.base.w - label_x;
-        
-        let tc = colors::spinbox_text_color();
-        let text_color_u8 = [(tc[0]*255.0) as u8, (tc[1]*255.0) as u8, (tc[2]*255.0) as u8];
-
-        labels.push(TextLabel {
-            text: value_text,
-            x: x + 4.0,
-            y: crate::layout::align_text_y(self.base.y, self.base.h, 14.0, top),
-            font_size: 14.0,
-            color: text_color_u8,
-        });
-        if let Some(ref unit) = self.unit {
-            labels.push(TextLabel {
-                text: unit.clone(),
-                x: x + 4.0 + 36.0,
-                y: crate::layout::align_text_y(self.base.y, self.base.h, 11.0, top),
-                font_size: 11.0,
-                color: [0x73, 0x73, 0x7a],
-            });
-        }
-
-        let p = crate::layout::spinbox_button_padding();
-        let split_dec = x + w * 0.55;
-        let pair_w = (w * 0.45 - 2.0 * p).max(0.0);
-        let btn_w_padded = pair_w / 2.0;
-
-        if btn_w_padded > 0.0 {
-            let dec_center_x = split_dec + p + btn_w_padded * 0.5;
-            let inc_center_x = split_dec + p + btn_w_padded * 1.5;
-
-            labels.push(TextLabel {
-                text: "-".to_string(),
-                x: dec_center_x - 4.0,
-                y: crate::layout::align_text_y(self.base.y, self.base.h, 12.0, top),
-                font_size: 12.0,
-                color: text_color_u8,
-            });
-            labels.push(TextLabel {
-                text: "+".to_string(),
-                x: inc_center_x - 4.0,
-                y: crate::layout::align_text_y(self.base.y, self.base.h, 12.0, top),
-                font_size: 12.0,
-                color: text_color_u8,
-            });
+            false
         }
-        labels
     }
 
-    fn layout_ignore(&self) -> bool {
-        true
+    fn value(&self) -> i32 {
+        self.value
     }
 }
 
-impl Drop for Spinbox {
-    fn drop(&mut self) {
-        clear_widget_references(self);
+impl Control for Adapted<Spinbox> {
+    fn set_label(&mut self, label: &str) {
+        Adapted::set_label(self, label);
     }
 }
 
-impl Control for Spinbox {}
-
-unsafe impl Send for Spinbox {}
-unsafe impl Sync for Spinbox {}
-
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::widget::{Element, UiContext};
 
     #[test]
-    fn test_spinbox_click() {
-        let mut ui_context = UiContext::new();
+    fn spinbox_button_zones_step_the_value() {
+        let mut ctx = UiContext::new();
         let mut sb = Spinbox::new(0, -100, 100, 1);
-        sb.set_rect(10.0, 20.0, 100.0, 26.0);
-
-        // Click on the decrease button (-)
-        let handled = sb.mouse_input(
-            MouseButton::Left,
-            ElementState::Pressed,
-            75.0,
-            33.0,
-            &mut ui_context
-        );
-        assert!(handled);
+        let (id, ptr) = (sb.id(), sb.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        Element::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
+
+        // Legacy test: click at (75, 33) lands in the decrement zone.
+        assert!(Element::mouse_input(&mut sb, MouseButton::Left, ElementState::Pressed, 75.0, 33.0, &mut ctx));
         assert_eq!(sb.value, -1);
+        assert!(Element::take_change(&mut sb));
+
+        // Increment zone (past 77.5% of the width).
+        assert!(Element::mouse_input(&mut sb, MouseButton::Left, ElementState::Pressed, 92.0, 33.0, &mut ctx));
+        assert_eq!(sb.value, 0);
     }
-}
 
+    #[test]
+    fn spinbox_value_string_decimals_round_trip() {
+        let mut sb = Spinbox::new(150, 0, 1000, 5).with_decimals(2);
+        assert_eq!(Element::get_value_string(&sb), Some("1.50".to_string()));
+        assert!(Element::set_value_string(&mut sb, "2.75"));
+        assert_eq!(sb.value, 275);
+        assert_eq!(Element::value(&sb), 275);
+    }
+}
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 37aa5b0..d1f27db 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -73,6 +73,13 @@ pub trait Layout {
     fn inflates_label_rect(&self) -> bool {
         true
     }
+
+    /// Horizontal inset of the detached base label. Legacy split: `Control::control_label`
+    /// added +4px (Spinbox et al., explicitly zeroed for Slider/RangeSlider); the default
+    /// `text_labels` used none (ProgressBar). Default: none.
+    fn detached_label_inset(&self) -> f32 {
+        0.0
+    }
 }
 
 /// The paint concern — a widget's fill color, its own (non-recursive) geometry emission, and
@@ -239,7 +246,14 @@ pub trait Input {
     fn drag_update(&mut self, _px: f32, _py: f32, _rect: Rect) -> bool {
         false
     }
+    /// For self-moving widgets (Panel, Splitter): the new origin this drag step wants, or `None`
+    /// if unmoved. The adapter applies it to the base rect (the model cannot reach it).
+    fn drag_reposition(&mut self, _px: f32, _py: f32, _rect: Rect) -> Option<(f32, f32)> {
+        None
+    }
     fn drag_end(&mut self) {}
+    /// Movement bounds pushed in by hosts (legacy `Element::set_drag_bounds`).
+    fn set_drag_bounds(&mut self, _bx: f32, _by: f32, _bw: f32, _bh: f32) {}
 }
 
 /// Wraps a narrow-trait widget `W` so it lives in the legacy `*mut dyn Element` tree. Carries the
@@ -351,7 +365,8 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
                     return vec![TextLabel { text: label.clone(), x: b.x + 4.0, y: y_pos, font_size, color }];
                 }
             }
-            return vec![TextLabel { text: label.clone(), x: b.x, y: b.y, font_size, color }];
+            let inset = Layout::detached_label_inset(&self.inner);
+            return vec![TextLabel { text: label.clone(), x: b.x + inset, y: b.y, font_size, color }];
         }
         Vec::new()
     }
@@ -601,11 +616,19 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     }
     fn drag_update(&mut self, px: f32, py: f32) -> bool {
         let rect = self.content_rect();
+        if let Some((nx, ny)) = Input::drag_reposition(&mut self.inner, px, py, rect) {
+            self.base.x = nx;
+            self.base.y = ny;
+            return true;
+        }
         Input::drag_update(&mut self.inner, px, py, rect)
     }
     fn drag_end(&mut self) {
         Input::drag_end(&mut self.inner)
     }
+    fn set_drag_bounds(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
+        Input::set_drag_bounds(&mut self.inner, bx, by, bw, bh)
+    }
 
     // --- Legacy direct-dispatch entry points. Hosts (treelist's add-key button, parameters_bg's
     // checkboxes, app pages) call these ON the widget instead of routing an Event through