Files
ltk/src/widget/button/mod.rs
Pedro M. de Echanove Pasquin 8762ab9ce0
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
text_edit font sizing on Length; add Button::width and Text::line_height
Unify TextEdit's font size with the button label. The button resolved its `font_size` Length in font space (`viewport_logical`, so the `× dpi_scale` at raster lands the right physical size), while `TextEdit::font_size` took a raw `f32` that the draw / hit-test paths treated as a logical size. A caller that resolved a Length against the physical surface and passed the result as that `f32` therefore double-counted `dpi_scale` and got a font that rendered too large. `TextEdit::font_size` now takes `impl Into<Length>` and is resolved against `viewport_logical` exactly like the button, so the two paths agree. The field stores `Option<Length>` (`None` follows the widget-scaling mode at the theme default), retiring the old `f32` sentinel (`0.0` = mode, negative = fluid design px). `font_size_fluid` becomes shorthand for `Length::fluid( n )`. The `Option<Length>` flows through the `WidgetHandlers::TextEdit` snapshot and `text_input_geometry` and is resolved at draw / hit-test time, both of which carry a canvas; the inner measure helpers (`wrapping`, `hit_test`) keep `f32` because they receive the already-resolved value. Backward compatible: `f32` call sites still compile via `f32: Into<Length>` (→ `Length::px`), with the same result as before.
Add `Button::width( impl Into<Length> )`. Text buttons size to their label plus padding; some layouts need a pinned width instead — a full-width or surface-proportional button. The new builder mirrors `height`: resolved in physical layout space, clamped to the available `max_width`, propagated through `map_msg`, and a no-op for icon buttons.
Add `Text::line_height( mult )`. Wrapped multi-line text used the font's declared leading (`new_line_size`), which is tight for some labels; the multiplier scales the gap between wrapped lines (`1.0`, the default, keeps the natural leading — every other `text` is unchanged — and a `0.5` floor keeps lines from overlapping). Applied uniformly in `preferred_size` and `draw` so the reported height and the drawn baselines stay consistent.
Tests: `button` gains a pinned-width and a size-to-content case; `text` gains a line-height default / clamp test and a check that doubling the line height doubles a wrapped block's reported height. Docs: `docs/widgets.md` `text` / `button` / `separator` sections updated for the new builders, and a `CHANGELOG.md` "Unreleased" section covering this batch alongside the responsive work already landed.
2026-07-10 10:38:30 +02:00

546 lines
18 KiB
Rust

// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::{ Color, Length, 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;
#[ cfg( test ) ]
mod tests;
/// 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<Vec<u8>>, 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<Msg: Clone>
{
/// The visual content of this button.
pub( crate ) content: ButtonContent,
/// Message emitted when the button is pressed, or `None` if disabled.
pub( crate ) on_press: Option<Msg>,
/// 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( crate ) on_long_press: Option<Msg>,
/// 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( crate ) on_drag_start: Option<Msg>,
/// Visual variant controlling colors and borders.
pub( crate ) variant: ButtonVariant,
/// Width and height in pixels for icon buttons. The `0.0` default
/// follows the process [`crate::WidgetScaling`] mode (via
/// [`crate::Canvas::geom_px`]); any positive value pins an explicit size.
pub( crate ) icon_size: f32,
/// Optional label font size for text buttons. `None` uses the theme's
/// default (`theme::FONT_SIZE`); a [`Length`] scales the label with the
/// surface (e.g. `Length::vmin( 2.2 ).clamp( 14.0, 22.0 )`).
pub( crate ) font_size: Option<Length>,
/// Optional height for text buttons. `None` uses the theme's default
/// (`theme::HEIGHT`); a [`Length`] scales the button box with the
/// surface so it does not stay frozen while the rest of a fluid layout
/// grows. Resolved in physical layout space, like all geometry.
pub( crate ) height: Option<Length>,
/// Optional fixed width for text buttons. `None` sizes the button to its
/// label plus horizontal padding; a [`Length`] pins the width (e.g. to
/// make a full-width or surface-proportional button), clamped to the
/// available width. Resolved in physical layout space.
pub( crate ) width: Option<Length>,
/// Optional stable identifier for focus management.
pub( crate ) id: Option<WidgetId>,
/// Whether this button participates in keyboard focus (Tab). Default: `true`.
pub( crate ) focusable: bool,
/// Override the pointer cursor shape on hover. `None` falls back
/// to the `Pointer` (hand) default for clickable widgets.
pub( crate ) cursor: Option<crate::types::CursorShape>,
/// 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( crate ) repeating: bool,
pub( crate ) tooltip: Option<String>,
}
impl<Msg: Clone> Button<Msg>
{
/// 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: 0.0,
font_size: None,
height: None,
width: None,
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<String> ) -> 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<Vec<u8>>, 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: 0.0,
font_size: None,
height: None,
width: None,
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<Msg> ) -> 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
}
/// Set the label font size for text buttons. Accepts logical `f32`
/// pixels or any [`Length`] (e.g. `Length::vmin( 2.2 ).clamp( 14.0,
/// 22.0 )` to scale with the surface). No-op for icon buttons.
pub fn font_size( mut self, size: impl Into<Length> ) -> Self
{
self.font_size = Some( size.into() );
self
}
/// Set the button height for text buttons. Accepts logical `f32` pixels
/// or any [`Length`] (e.g. `Length::vmin( 7.0 ).clamp( 40.0, 64.0 )` to
/// scale the box with the surface). No-op for icon buttons, which are
/// sized by [`Self::icon_size`].
pub fn height( mut self, h: impl Into<Length> ) -> Self
{
self.height = Some( h.into() );
self
}
/// Pin the button width for text buttons. Accepts logical `f32` pixels or
/// any [`Length`] (e.g. `Length::orient( 95.0, 25.0 )` for a
/// surface-proportional width). Without this the button sizes to its
/// label. Clamped to the available width. No-op for icon buttons.
pub fn width( mut self, w: impl Into<Length> ) -> Self
{
self.width = Some( w.into() );
self
}
/// Resolve the label font size against the canvas viewport, matching how
/// [`text`](crate::text) sizes its glyphs. An explicit override bypasses
/// the mode; the default follows the process [`crate::WidgetScaling`].
fn label_font_size( &self, canvas: &Canvas ) -> f32
{
self.font_size
.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
.unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) )
}
/// Resolve the button height against the physical layout viewport (like
/// all geometry). An explicit override bypasses the mode; the default
/// follows the process [`crate::WidgetScaling`].
fn resolved_height( &self, canvas: &Canvas ) -> f32
{
self.height
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
.unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) )
}
/// Resolve the icon-button size: a positive [`Self::icon_size`] pins it,
/// the `0.0` sentinel follows the widget-scaling mode.
fn resolved_icon_size( &self, canvas: &Canvas ) -> f32
{
if self.icon_size > 0.0 { self.icon_size } else { canvas.geom_px( theme::HEIGHT ) }
}
/// 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 w = match self.width
{
Some( l ) => l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).min( max_width ),
None =>
{
let text_w = canvas.measure_text( label, self.label_font_size( canvas ) );
( text_w + canvas.geom_px( theme::PAD_H ) * 2.0 ).min( max_width )
}
};
( w, self.resolved_height( canvas ) )
}
ButtonContent::Icon { .. } =>
{
let s = self.resolved_icon_size( canvas ).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 fs = self.label_font_size( canvas );
let text_y = rect.y + (rect.height + fs) / 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, fs );
canvas.draw_text(
label,
rect.x + (rect.width - text_w) / 2.0,
text_y,
fs,
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, fs );
canvas.draw_text(
label,
rect.x + (rect.width - text_w) / 2.0,
text_y,
fs,
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, fs );
canvas.draw_text(
label,
rect.x + (rect.width - text_w) / 2.0,
text_y,
fs,
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<Msg>
{
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<U>( self, f: &super::MapFn<Msg, U> ) -> Button<U>
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,
font_size: self.font_size,
height: self.height,
width: self.width,
id: self.id,
focusable: self.focusable,
cursor: self.cursor,
repeating: self.repeating,
tooltip: self.tooltip,
}
}
}