Skip to main content

rosace_widgets/tree/
button.rs

1use std::sync::Arc;
2
3use rosace_core::types::{Point, Rect, Size};
4use rosace_layout::Constraints;
5use rosace_render::Color;
6use super::{Widget, LayoutCtx, PaintCtx};
7
8#[derive(Debug, Clone, Copy, Default)]
9pub enum ButtonVariant {
10    #[default]
11    Primary,
12    Secondary,
13    Ghost,
14    Danger,
15    Success,
16    Link,
17}
18
19/// A clickable labeled button.
20///
21/// Attach a callback with `.on_press(|| ...)`. The callback fires when the
22/// button is clicked — no boilerplate needed.
23pub struct Button {
24    pub label: String,
25    pub variant: ButtonVariant,
26    pub disabled: bool,
27    pub icon: Option<Box<dyn Widget>>,
28    pub width: Option<f32>,
29    pub height: f32,
30    /// `None` = read from the active theme's `typography.label_large`
31    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
32    /// for the reasoning).
33    pub font_size: Option<f32>,
34    pub radius: f32,
35    background: Option<Color>,
36    color: Option<Color>,
37    on_press: Option<Arc<dyn Fn() + Send + Sync>>,
38}
39
40impl Button {
41    pub fn new(label: impl Into<String>) -> Self {
42        Self {
43            label: label.into(),
44            variant: ButtonVariant::Primary,
45            disabled: false,
46            icon: None,
47            width: None,
48            height: 34.0,
49            font_size: None,
50            radius: 6.0,
51            background: None,
52            color: None,
53            on_press: None,
54        }
55    }
56
57    pub fn variant(mut self, v: ButtonVariant) -> Self { self.variant = v; self }
58    pub fn disabled(mut self) -> Self { self.disabled = true; self }
59    /// Conditional form of [`Self::disabled`] (D116 Phase 28 Step 8) — the
60    /// natural way to gate a submit button on `form.is_valid()` without an
61    /// `if`/`else` at every call site: `Button::new("Submit").disabled_if(!form.is_valid())`.
62    pub fn disabled_if(mut self, condition: bool) -> Self {
63        if condition { self.disabled = true; }
64        self
65    }
66    pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
67    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
68    pub fn font_size(mut self, s: f32) -> Self { self.font_size = Some(s); self }
69
70    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
71        self.font_size.unwrap_or(theme.typography.label_large.size)
72    }
73    /// Overrides the variant's own fill color — for a one-off custom color
74    /// outside the Primary/Secondary/Ghost/Danger/Success/Link palette.
75    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
76    /// Overrides the variant's own label/icon color.
77    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
78    pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
79    pub fn icon(mut self, w: impl Widget + 'static) -> Self { self.icon = Some(Box::new(w)); self }
80
81    /// Set the click handler. The closure is called on every left-click
82    /// inside the button's bounds.
83    pub fn on_press(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
84        self.on_press = Some(Arc::new(f));
85        self
86    }
87}
88
89impl Widget for Button {
90    fn layout(&self, ctx: &LayoutCtx) -> Size {
91        let constraints = ctx.constraints;
92        let font_size = self.resolved_font_size(ctx.theme);
93        let text_w = self.label.len() as f32 * font_size * 0.6;
94        // Same rough per-char estimate `layout()` already used for the
95        // label — an icon's own reported size is available at `paint()`
96        // time via a real `LayoutCtx`, unavailable here without one; the
97        // fixed `font_size + 4.0` box matches what `paint()` lays it out
98        // at, plus the same gap.
99        let icon_w = if self.icon.is_some() { font_size + 4.0 + 6.0 } else { 0.0 };
100        let w = self.width.unwrap_or(text_w + icon_w + 32.0);
101        constraints.constrain(Size { width: w, height: self.height })
102    }
103
104    fn paint(&self, ctx: &mut PaintCtx) {
105        ctx.semantics(super::Semantics::new(rosace_core::Role::Button).label(&self.label));
106        let t = &ctx.theme.colors;
107        let variant = if self.disabled { ButtonVariant::Secondary } else { self.variant };
108
109        let (bg, fg, border) = match variant {
110            ButtonVariant::Primary   => (ctx.tc(t.primary),    ctx.tc(t.on_primary),   None),
111            ButtonVariant::Secondary => (ctx.tc(t.secondary),  ctx.tc(t.on_secondary), None),
112            ButtonVariant::Ghost     => (Color::rgba(0,0,0,0), ctx.tc(t.primary),      Some(ctx.tc(t.outline))),
113            ButtonVariant::Link      => (Color::rgba(0,0,0,0), ctx.tc(t.primary),      None),
114            ButtonVariant::Danger    => (Color::rgb(180, 50,  50), Color::rgb(255, 230, 230), None),
115            ButtonVariant::Success   => (Color::rgb( 40, 160, 80), Color::rgb(220, 255, 230), None),
116        };
117
118        let bg = if self.disabled { bg } else { self.background.unwrap_or(bg) };
119        let fg = if self.disabled { ctx.tc(t.outline) } else { self.color.unwrap_or(fg) };
120
121        // Hover/press feedback: lift the fill toward white (opaque variants)
122        // or add a faint wash (ghost/link), eased between three levels (D108
123        // Phase 26 Step 1) — idle, hover (matches the old flat lift), press
124        // (double it, so a tap reads as visually distinct from a hover).
125        let target = if self.disabled { 0.0 } else if ctx.pressed() { 1.0 } else if ctx.hovered() { 0.5 } else { 0.0 };
126        let emphasis = ctx.animate_to(target, 0.0);
127        let bg = if emphasis > 0.0 {
128            if bg.a == 0 {
129                Color::rgba(255, 255, 255, (22.0 * emphasis * 2.0).min(255.0) as u8)
130            } else {
131                lighten(bg, (0.12 * emphasis * 2.0).min(1.0))
132            }
133        } else {
134            bg
135        };
136
137        let r = ctx.rect;
138        super::container::draw_rounded_rect_pub(ctx, r, bg, self.radius);
139
140        if let Some(bc) = border {
141            ctx.stroke_rrect(r, self.radius, bc, 1.0);
142        }
143
144        let font_size = self.resolved_font_size(&ctx.theme);
145        let text_w = ctx.font.measure_text(&self.label, font_size);
146        let line_h = ctx.font.line_height(font_size);
147        let ty = ((r.size.height - line_h) / 2.0).max(0.0);
148
149        // `.icon()` was settable but never actually painted — the field
150        // existed, `paint()` just never read it (found live: a showcase
151        // AppBar button set one and nothing showed).
152        const ICON_GAP: f32 = 6.0;
153        if let Some(icon) = &self.icon {
154            let is = icon.layout(&ctx.layout_ctx(Constraints::loose(font_size + 4.0, font_size + 4.0)));
155            let content_w = is.width + ICON_GAP + text_w;
156            let start_x = ((r.size.width - content_w) / 2.0).max(4.0);
157            let iy = r.origin.y + (r.size.height - is.height) / 2.0;
158            icon.paint(&mut ctx.child(Rect {
159                origin: Point { x: r.origin.x + start_x, y: iy },
160                size: is,
161            }));
162            ctx.text(&self.label, start_x + is.width + ICON_GAP, ty, fg, font_size);
163        } else {
164            let tx = ((r.size.width - text_w) / 2.0).max(4.0);
165            ctx.text(&self.label, tx, ty, fg, font_size);
166        }
167
168        // Interactive-by-identity (Phase 32, user directive): a Button
169        // ALWAYS owns its hit region, wired or not — a click on it must
170        // never fall through to whatever positional region (drag-to-pan)
171        // sits behind it. Unwired = absorb, do nothing.
172        if !self.disabled {
173            match &self.on_press {
174                Some(cb) => ctx.register_hit(Arc::clone(cb)),
175                None => ctx.register_hit(Arc::new(|| {})),
176            }
177        }
178    }
179}
180
181/// Blend a color toward white by `t` (0..1) — hover/pressed lift.
182pub(super) fn lighten(c: Color, t: f32) -> Color {
183    let mix = |v: u8| (v as f32 + (255.0 - v as f32) * t).round() as u8;
184    Color::rgba(mix(c.r), mix(c.g), mix(c.b), c.a)
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use rosace_layout::Constraints;
191
192    #[test]
193    fn background_and_color_builders_do_not_change_layout_size() {
194        let font = rosace_render::FontCache::embedded();
195        let theme = rosace_theme::built_in::dark_theme();
196        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
197        let base = Button::new("Save").width(90.0);
198        let customized = Button::new("Save").width(90.0)
199            .background(Color::rgb(20, 20, 20))
200            .color(Color::rgb(255, 255, 255));
201        assert_eq!(base.layout(&ctx), customized.layout(&ctx));
202    }
203}