// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use std::sync::Arc; use crate::types::{ Color, Rect, WidgetId }; use crate::render::Canvas; use super::Element; // Theme colors driven by the process-wide palette (see `ltk::theme`). // Non-colour geometry (radius, font size, focus width, etc.) is static — only // palette tokens respond to light/dark mode. mod theme { use crate::types::Color; /// Primary button background — brand accent. pub fn p_default_bg() -> Color { crate::theme::palette().accent } /// Primary button border — a darker tone of the accent so the pill /// reads as a discrete affordance over surfaces that share the /// background hue. Computed by darkening the linear-RGB components /// of the accent rather than carrying yet another palette slot. pub fn p_default_border() -> Color { let a = crate::theme::palette().accent; Color { r: a.r * 0.55, g: a.g * 0.55, b: a.b * 0.55, a: a.a } } /// Primary button label colour. Falls back to the theme's /// `text_primary`; themes whose accent does not pair well with /// their text-primary token can override the palette so this /// reads cleanly. pub fn p_default_text() -> Color { crate::theme::palette().text_primary } /// Disabled primary background — uses the theme's divider token, /// the lowest-contrast neutral available across modes. pub fn p_disabled_bg() -> Color { crate::theme::palette().divider } /// Disabled primary / secondary / tertiary text — uses the /// theme's secondary text token. pub fn p_disabled_text() -> Color { crate::theme::palette().text_secondary } /// Secondary button default background — matches the surface_alt surface. pub fn s_bg() -> Color { crate::theme::palette().surface_alt } /// Secondary button border. pub fn s_border() -> Color { crate::theme::palette().text_primary } /// Disabled secondary background — slightly lighter than the /// disabled primary so the two states stay visually distinct. pub fn s_disabled_bg() -> Color { crate::theme::palette().surface_alt } /// Disabled secondary border — uses the divider token. pub fn s_disabled_border() -> Color { crate::theme::palette().divider } /// Tertiary button text colour. pub fn t_text() -> Color { crate::theme::palette().text_primary } /// Keyboard focus ring / hover circle. pub fn focus_color() -> Color { crate::theme::palette().accent } pub const S_BORDER_W: f32 = 2.0; pub const P_BORDER_W: f32 = 1.0; pub const FOCUS_W: f32 = 3.0; pub const HEIGHT: f32 = 48.0; pub const RADIUS: f32 = 100.0; pub const FONT_SIZE: f32 = 16.0; pub const PAD_H: f32 = 24.0; } /// Visual style of a text button. #[ derive( Clone, Default ) ] pub enum ButtonVariant { /// Filled with the brand color — use for the primary call-to-action. #[ default ] Primary, /// White background with a dark border — use for secondary actions. Secondary, /// Text-only, no background — use for low-emphasis actions. Tertiary, } /// Internal content of a button — either a text label or a PNG icon. pub enum ButtonContent { /// A text label rendered with the theme font. Text( String ), /// An RGBA image used as the button face (Arc avoids per-frame cloning). Icon { rgba: Arc>, img_w: u32, img_h: u32 }, } /// A pressable button widget. /// /// Create text buttons with [`button()`](crate::button()) and icon buttons with /// [`icon_button()`](crate::icon_button()). Buttons that step a value /// (date / time pickers, numeric spinners) can opt into press-and- /// hold repeat via [`Self::repeating`] — the runtime then re-fires /// `on_press` while the button is held, at the keyboard's repeat /// cadence. pub struct Button { /// The visual content of this button. pub content: ButtonContent, /// Message emitted when the button is pressed, or `None` if disabled. pub on_press: Option, /// Message emitted when the user holds the button for /// [`App::long_press_duration`](crate::app::App::long_press_duration) /// without moving past the tolerance, OR when the user right-clicks /// with the mouse. `None` leaves the button without a context-menu /// equivalent. The fire does NOT by itself put the gesture into /// drag mode — that is governed by [`Self::on_drag_start`]. pub on_long_press: Option, /// Drag-arm message. Fires when the press transitions into a drag: /// touch on hold-timer expiry (in addition to `on_long_press`), /// mouse on motion past the drag-promotion threshold (without /// firing the menu). Independent of `on_long_press` so a button /// can open a menu without becoming draggable, or be draggable /// without showing a menu. pub on_drag_start: Option, /// Visual variant controlling colors and borders. pub variant: ButtonVariant, /// Width and height in pixels for icon buttons. Defaults to `48.0`. pub icon_size: f32, /// Optional stable identifier for focus management. pub id: Option, /// Whether this button participates in keyboard focus (Tab). Default: `true`. pub focusable: bool, /// Override the pointer cursor shape on hover. `None` falls back /// to the `Pointer` (hand) default for clickable widgets. pub cursor: Option, /// When `true`, holding the button down auto-fires the /// `on_press` message: one immediate fire on press, then an /// initial delay (≈ 500 ms — same as the keyboard) followed by /// repeats every ~120 ms (≈ 8 Hz, deliberately slower than the /// keyboard's 30 Hz so a stepper does not whip past the /// target). The runtime cancels the timer on release, on touch /// cancel, and on long-press promotion. Default `false` — most /// buttons fire on tap only. pub repeating: bool, pub tooltip: Option, } impl Button { /// Create a text button with the given label. pub fn new( label: String ) -> Self { Self { content: ButtonContent::Text( label ), on_press: None, on_long_press: None, on_drag_start: None, variant: ButtonVariant::Primary, icon_size: theme::HEIGHT, id: None, focusable: true, cursor: None, repeating: false, tooltip: None, } } /// Hint shown after a 600 ms pointer dwell. Pointer-only. pub fn tooltip( mut self, text: impl Into ) -> Self { self.tooltip = Some( text.into() ); self } /// Override the pointer cursor shape shown on hover. pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self { self.cursor = Some( shape ); self } /// Create an icon button from a shared RGBA buffer. /// /// `img_w` and `img_h` must match the dimensions of `rgba`. pub fn new_icon( rgba: Arc>, img_w: u32, img_h: u32 ) -> Self { Self { content: ButtonContent::Icon { rgba, img_w, img_h }, on_press: None, on_long_press: None, on_drag_start: None, variant: ButtonVariant::Tertiary, icon_size: theme::HEIGHT, id: None, focusable: true, cursor: None, repeating: false, tooltip: None, } } /// Set the message emitted when the button is pressed. pub fn on_press( mut self, msg: Msg ) -> Self { self.on_press = Some( msg ); self } /// Optionally set the message — `None` leaves the button disabled. pub fn on_press_maybe( mut self, msg: Option ) -> Self { self.on_press = msg; self } /// Auto-fire `on_press` while the button is held down. The /// runtime fires once on press, then re-fires after the /// keyboard's repeat *delay* (≈ 500 ms) and at a fixed ~120 ms /// (≈ 8 Hz) interval afterwards — slow enough to release on /// the value the user wants, fast enough to ramp. Each tick /// re-reads `on_press` from the live widget tree, so a /// stepper-style button whose message is `"go to value + 1"` /// keeps stepping correctly as the value updates. /// /// Mutually compatible with `on_long_press` only in spirit — /// once the long-press message fires the gesture machine /// transitions to drag mode and the repeat timer is cancelled /// regardless of `repeating`. Default `false`. pub fn repeating( mut self, on: bool ) -> Self { self.repeating = on; self } /// Attach a long-press message. Fires when the press has been held /// stationary for [`App::long_press_duration`](crate::app::App::long_press_duration), /// or when the user right-clicks with the mouse. By itself this does /// NOT put the gesture into drag mode — that is governed by /// [`Self::on_drag_start`]. The regular `on_press` is suppressed /// only when the press has been promoted to a drag (drag-arm fired). pub fn on_long_press( mut self, msg: Msg ) -> Self { self.on_long_press = Some( msg ); self } /// Attach a drag-arm message. Fires when the press transitions into /// drag mode — touch on hold-timer expiry (alongside `on_long_press`), /// mouse on motion past the drag-promotion threshold (without firing /// `on_long_press`). Independent of the menu so a button can be /// draggable without showing a menu, or open a menu without becoming /// draggable. pub fn on_drag_start( mut self, msg: Msg ) -> Self { self.on_drag_start = Some( msg ); self } /// Control whether this button receives keyboard focus (Tab navigation). /// Set to `false` for purely decorative or status-indicator buttons. pub fn focusable( mut self, yes: bool ) -> Self { self.focusable = yes; self } /// Set the visual variant. pub fn variant( mut self, v: ButtonVariant ) -> Self { self.variant = v; self } /// Set the display size (width = height) for icon buttons in pixels. pub fn icon_size( mut self, size: f32 ) -> Self { self.icon_size = size; self } /// Assign a stable identifier for focus management. pub fn id( mut self, id: WidgetId ) -> Self { self.id = Some( id ); self } /// Bounding box of everything the button can paint at `rect`, across every /// interaction state. This is the sum of: icon-button hover/press circle /// (radius `rect.min_dim / 2 + 8`), focus ring (grows `FOCUS_W + 1` beyond /// that), stroke half-width (`FOCUS_W / 2`), plus ~1 px of antialiasing /// bleed. Text buttons only have the focus ring. /// /// The partial-redraw path uses this to know how much canvas area to /// invalidate when the button transitions in/out of a state. pub fn paint_bounds( &self, rect: crate::types::Rect ) -> crate::types::Rect { let stroke_bleed = theme::FOCUS_W * 0.5 + 1.0; match &self.content { ButtonContent::Icon { .. } => { // The circle grows 8 px beyond the icon rect, the focus ring grows // `FOCUS_W + 1` beyond the circle. let circle_pad = 8.0_f32; let ring_pad = theme::FOCUS_W + 1.0; rect.expand( circle_pad + ring_pad + stroke_bleed ) } ButtonContent::Text( _ ) => match self.variant { ButtonVariant::Primary | ButtonVariant::Secondary => { rect.expand( theme::FOCUS_W + 2.0 + stroke_bleed ) } ButtonVariant::Tertiary => rect.expand( 2.0 + stroke_bleed ), }, } } /// Return the preferred `(width, height)` given available `max_width`. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) { match &self.content { ButtonContent::Text( label ) => { let text_w = canvas.measure_text( label, theme::FONT_SIZE ); let w = (text_w + theme::PAD_H * 2.0).min( max_width ); ( w, theme::HEIGHT ) } ButtonContent::Icon { .. } => { let s = self.icon_size.min( max_width ); ( s, s ) } } } /// Draw the button into `canvas` at `rect`. /// /// `focused` draws a keyboard-focus ring; `hovered` and `pressed` apply /// pointer/touch state overlays (icon buttons only). pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool, hovered: bool, pressed: bool ) { match &self.content { ButtonContent::Text( label ) => { self.draw_text_button( canvas, rect, focused, label ); } ButtonContent::Icon { rgba, img_w, img_h } => { self.draw_icon_button( canvas, rect, focused, hovered, pressed, rgba, *img_w, *img_h ); } } } fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str ) { let is_disabled = self.on_press.is_none(); let text_y = rect.y + (rect.height + theme::FONT_SIZE) / 2.0 - 2.0; match self.variant { ButtonVariant::Primary => { let bg = if is_disabled { theme::p_disabled_bg() } else { theme::p_default_bg() }; let text_c = if is_disabled { theme::p_disabled_text() } else { theme::p_default_text() }; let border_c = theme::p_default_border(); canvas.fill_rect( rect, bg, theme::RADIUS ); if !is_disabled { canvas.stroke_rect( rect, border_c, theme::P_BORDER_W, theme::RADIUS ); } if focused { let ring = rect.expand( theme::FOCUS_W + 2.0 ); canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS + theme::FOCUS_W + 2.0, ); } let text_w = canvas.measure_text( label, theme::FONT_SIZE ); canvas.draw_text( label, rect.x + (rect.width - text_w) / 2.0, text_y, theme::FONT_SIZE, text_c, ); } ButtonVariant::Secondary => { let bg = if is_disabled { theme::s_disabled_bg() } else { theme::s_bg() }; let text_c = if is_disabled { theme::p_disabled_text() } else { theme::t_text() }; let border_c = if is_disabled { theme::s_disabled_border() } else { theme::s_border() }; canvas.fill_rect( rect, bg, theme::RADIUS ); canvas.stroke_rect( rect, border_c, theme::S_BORDER_W, theme::RADIUS ); if focused { let ring = rect.expand( theme::FOCUS_W + 2.0 ); canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS + theme::FOCUS_W + 2.0, ); } let text_w = canvas.measure_text( label, theme::FONT_SIZE ); canvas.draw_text( label, rect.x + (rect.width - text_w) / 2.0, text_y, theme::FONT_SIZE, text_c, ); } ButtonVariant::Tertiary => { let text_c = if is_disabled { theme::p_disabled_text() } else { theme::t_text() }; if focused { let ring = rect.expand( 2.0 ); canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS ); } let text_w = canvas.measure_text( label, theme::FONT_SIZE ); canvas.draw_text( label, rect.x + (rect.width - text_w) / 2.0, text_y, theme::FONT_SIZE, text_c, ); } } } fn draw_icon_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, hovered: bool, pressed: bool, rgba: &[u8], img_w: u32, img_h: u32, ) { // Semi-transparent circular overlay behind the icon for hover / press feedback let circle_pad = 8.0_f32; let r = rect.width.min( rect.height ) / 2.0 + circle_pad; let cx = rect.x + rect.width / 2.0; let cy = rect.y + rect.height / 2.0; let circle = Rect { x: cx - r, y: cy - r, width: r * 2.0, height: r * 2.0, }; // Hover / press feedback is the theme's primary text colour // at low alpha — works as a "lighten" in light mode (where // text_primary tends to be dark and the underlying icon is // dark) and as a subtle wash in dark mode without baking in // a fixed white. let fp = crate::theme::palette().text_primary; if pressed { canvas.fill_rect( circle, Color::rgba( fp.r, fp.g, fp.b, 0.18 ), r ); } else if hovered { canvas.fill_rect( circle, Color::rgba( fp.r, fp.g, fp.b, 0.10 ), r ); } if focused { let ring = circle.expand( theme::FOCUS_W + 1.0 ); canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, r + theme::FOCUS_W + 1.0 ); } canvas.draw_image_data( rgba, img_w, img_h, rect, 1.0 ); } /// Wrap this button in an [`Element`]. pub fn into_element( self ) -> Element { Element::Button( self ) } /// Re-tag this button's three message slots through `f`. Called by /// [`Element::map`] while walking a sub-tree. pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Button where U: Clone + 'static, Msg: 'static, { Button { content: self.content, on_press: self.on_press.map( |m| ( *f )( m ) ), on_long_press: self.on_long_press.map( |m| ( *f )( m ) ), on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ), variant: self.variant, icon_size: self.icon_size, id: self.id, focusable: self.focusable, cursor: self.cursor, repeating: self.repeating, tooltip: self.tooltip, } } } #[ cfg( test ) ] mod tests { use super::*; #[ test ] fn text_button_focusable_by_default() { let b = Button::<()>::new( "ok".into() ); assert!( b.focusable ); } #[ test ] fn icon_button_focusable_by_default() { let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 ); assert!( b.focusable ); } #[ test ] fn focusable_builder_disables_focus() { let b = Button::<()>::new( "ok".into() ).focusable( false ); assert!( !b.focusable ); } #[ test ] fn focusable_builder_re_enables_focus() { let b = Button::<()>::new( "ok".into() ).focusable( false ).focusable( true ); assert!( b.focusable ); } #[ test ] fn on_press_none_by_default() { let b = Button::<()>::new( "ok".into() ); assert!( b.on_press.is_none() ); } #[ test ] fn tooltip_none_by_default() { let b = Button::<()>::new( "ok".into() ); assert!( b.tooltip.is_none() ); let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 ); assert!( b.tooltip.is_none() ); } #[ test ] fn tooltip_builder_sets_text() { let b = Button::<()>::new( "ok".into() ).tooltip( "Save document" ); assert_eq!( b.tooltip.as_deref(), Some( "Save document" ) ); } #[ test ] fn tooltip_survives_map_msg() { let b = Button::<()>::new( "ok".into() ).tooltip( "Hint" ); let f: super::super::MapFn<(), u32> = std::sync::Arc::new( |()| 0u32 ); let mapped = b.map_msg( &f ); assert_eq!( mapped.tooltip.as_deref(), Some( "Hint" ) ); } #[ test ] fn element_tooltip_returns_button_tooltip() { let e: super::super::Element<()> = Button::<()>::new( "ok".into() ).tooltip( "Hi" ).into_element(); assert_eq!( e.tooltip(), Some( "Hi" ) ); } #[ test ] fn element_tooltip_none_for_non_button_widgets() { let t: super::super::Element<()> = super::super::text( "label" ).into(); assert!( t.tooltip().is_none() ); } #[ test ] fn on_press_maybe_none_leaves_disabled() { let b = Button::<()>::new( "ok".into() ).on_press_maybe( None ); assert!( b.on_press.is_none() ); } #[ test ] fn repeating_off_by_default() { let b = Button::<()>::new( "ok".into() ); assert!( !b.repeating ); } #[ test ] fn repeating_builder_sets_flag() { let b = Button::<()>::new( "ok".into() ).repeating( true ); assert!( b.repeating ); let b = b.repeating( false ); assert!( !b.repeating ); } }