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