First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

View File

@@ -0,0 +1,124 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Position a child element below the on-screen rect of another widget,
//! looked up from the *previous* frame's [`LaidOutWidget`] snapshot.
//!
//! The classic use case is a combo / dropdown popup that should appear
//! flush below its trigger. The trigger's rect is not known in `view()`
//! time — it's assigned during the layout pass — so the popup widget
//! cannot place itself there directly. `AnchoredOverlay` solves this
//! with a one-frame-old anchor: the trigger carries a stable
//! [`WidgetId`], the popup wraps in `AnchoredOverlay` referencing the
//! same id, and at draw time the wrapper looks up the trigger's rect
//! from the runtime's persisted `widget_rects` (frame N 1) and
//! overrides the rect that its parent gave it.
//!
//! When the anchor is not found (first frame after open, missing id,
//! widget went off-screen) the wrapper falls back to drawing the child
//! in the parent-supplied rect — which, for a typical Stack-overlay
//! root in a `view()`, means the full surface, giving a sane modal
//! fallback until the next frame fixes the position.
use crate::types::{ Rect, WidgetId };
use super::Element;
/// A wrapper that re-positions its child relative to an anchor widget
/// found in the previous frame's layout snapshot.
pub struct AnchoredOverlay<Msg: Clone>
{
/// The element to draw at the anchored position.
pub child: Box<Element<Msg>>,
/// Stable identifier of the widget whose rect provides the anchor.
pub anchor_id: WidgetId,
/// Vertical pixel gap between the bottom of the anchor and the top
/// of the child.
pub gap: f32,
}
impl<Msg: Clone> AnchoredOverlay<Msg>
{
/// Wrap `child` so it draws anchored below the widget that carries
/// `anchor_id` in its `.id( … )` builder. `gap` is the vertical
/// space (logical pixels) between the anchor's bottom edge and the
/// child's top edge.
pub fn new( child: impl Into<Element<Msg>>, anchor_id: WidgetId, gap: f32 ) -> Self
{
Self
{
child: Box::new( child.into() ),
anchor_id,
gap,
}
}
/// Compute the draw rect for the child, given the anchor rect (when
/// available) and the parent-supplied fallback.
///
/// Anchor available → place the child flush below the anchor with
/// `gap` spacing, preserving the anchor's width.
/// Anchor missing → return the parent rect verbatim, so the child
/// renders modal-style as a fallback.
pub fn resolve_rect( anchor: Option<Rect>, gap: f32, fallback: Rect ) -> Rect
{
match anchor
{
Some( a ) => Rect
{
x: a.x,
y: a.y + a.height + gap,
width: a.width,
height: fallback.height,
},
None => fallback,
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> AnchoredOverlay<U>
where
U: Clone + 'static,
Msg: 'static,
{
AnchoredOverlay
{
child: Box::new( self.child.map_arc( f ) ),
anchor_id: self.anchor_id,
gap: self.gap,
}
}
}
impl<Msg: Clone + 'static> From<AnchoredOverlay<Msg>> for Element<Msg>
{
fn from( a: AnchoredOverlay<Msg> ) -> Self
{
Element::AnchoredOverlay( a )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn resolve_rect_uses_anchor_when_present()
{
let anchor = Rect { x: 100.0, y: 50.0, width: 200.0, height: 40.0 };
let fallback = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
let r = AnchoredOverlay::<()>::resolve_rect( Some( anchor ), 8.0, fallback );
assert_eq!( r.x, 100.0 );
assert_eq!( r.y, 98.0 ); // anchor.y + height + gap
assert_eq!( r.width, 200.0 ); // anchor width
assert_eq!( r.height, 600.0 ); // fallback height
}
#[ test ]
fn resolve_rect_falls_back_when_anchor_missing()
{
let fallback = Rect { x: 5.0, y: 6.0, width: 7.0, height: 8.0 };
let r = AnchoredOverlay::<()>::resolve_rect( None, 8.0, fallback );
assert_eq!( r, fallback );
}
}

553
src/widget/button.rs Normal file
View File

@@ -0,0 +1,553 @@
// 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, 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<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 content: ButtonContent,
/// Message emitted when the button is pressed, or `None` if disabled.
pub 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 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 on_drag_start: Option<Msg>,
/// 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<WidgetId>,
/// 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<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 repeating: bool,
}
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: theme::HEIGHT,
id: None,
focusable: true,
cursor: None,
repeating: false,
}
}
/// 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: theme::HEIGHT,
id: None,
focusable: true,
cursor: None,
repeating: false,
}
}
/// 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
}
/// 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<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,
id: self.id,
focusable: self.focusable,
cursor: self.cursor,
repeating: self.repeating,
}
}
}
#[ 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 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 );
}
}

217
src/widget/checkbox.rs Normal file
View File

@@ -0,0 +1,217 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn box_border() -> Color { crate::theme::palette().divider }
pub fn box_checked() -> Color { crate::theme::palette().accent }
/// Tick mark — uses the page-background colour so it reads as
/// the inverse of the accent fill regardless of mode.
pub fn check_color() -> Color { crate::theme::palette().bg }
pub fn focus_color() -> Color { crate::theme::palette().accent }
pub fn label_color() -> Color { crate::theme::palette().text_primary }
pub const BOX_SIZE: f32 = 24.0;
pub const RADIUS: f32 = 4.0;
pub const BORDER_W: f32 = 2.0;
pub const GAP: f32 = 12.0;
pub const HEIGHT: f32 = 48.0;
pub const FOCUS_W: f32 = 3.0;
pub const CHECK_W: f32 = 2.5;
pub const FONT_SIZE: f32 = 16.0;
}
/// A two-state opt-in control with a square box and a check glyph.
///
/// Use for individual binary choices inside a form (terms acceptance,
/// multi-select lists, "remember me"). The widget is stateless — the
/// application owns `checked` and rebuilds the checkbox from current state
/// on every frame.
///
/// ```rust,no_run
/// # use ltk::{ checkbox, Checkbox };
/// # #[ derive( Clone ) ] enum Msg { ToggleTerms }
/// # struct App { accept_terms: bool }
/// # impl App { fn _ex( &self ) -> Checkbox<Msg> {
/// // In view():
/// checkbox( self.accept_terms )
/// .label( "I accept the terms" )
/// .on_toggle( Msg::ToggleTerms )
/// # }}
/// ```
///
/// See also [`Toggle`](super::toggle::Toggle) for prominent on / off
/// switches (settings panels, system toggles) and
/// [`Radio`](super::radio::Radio) for mutually-exclusive selection in a
/// group.
pub struct Checkbox<Msg: Clone>
{
/// Current checked state. Drawn from this field every frame; the
/// runtime never mutates it.
pub checked: bool,
/// Message emitted on activation. `None` leaves the checkbox inert.
pub on_toggle: Option<Msg>,
/// Optional label drawn to the right of the box.
pub label: Option<String>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
}
impl<Msg: Clone> Checkbox<Msg>
{
/// Create a checkbox in the given state, with no label and no
/// callback. Wire activation through [`Self::on_toggle`] before adding
/// it to a widget tree.
pub fn new( checked: bool ) -> Self
{
Self { checked, on_toggle: None, label: None, id: None }
}
/// Set the message emitted when the checkbox is activated. The
/// application's `update` is responsible for flipping `checked` in
/// response.
pub fn on_toggle( mut self, msg: Msg ) -> Self
{
self.on_toggle = Some( msg );
self
}
/// Set a text label rendered to the right of the box. The checkbox's
/// preferred width grows to fit `box_size + gap + label_width`,
/// clamped to `max_width`.
pub fn label( mut self, label: impl Into<String> ) -> Self
{
self.label = Some( label.into() );
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let w = if let Some( ref label ) = self.label
{
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
( theme::BOX_SIZE + theme::GAP + text_w ).min( max_width )
} else {
theme::BOX_SIZE.min( max_width )
};
( w, theme::HEIGHT )
}
/// Focus ring on the box extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` beyond
/// the box edge (which sits flush with the widget's left edge).
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W + 2.0 + theme::FOCUS_W * 0.5 + 1.0 )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
{
let box_y = rect.y + ( rect.height - theme::BOX_SIZE ) / 2.0;
let box_rect = Rect
{
x: rect.x,
y: box_y,
width: theme::BOX_SIZE,
height: theme::BOX_SIZE,
};
if self.checked
{
canvas.fill_rect( box_rect, theme::box_checked(), theme::RADIUS );
let cx = rect.x + theme::BOX_SIZE / 2.0;
let cy = box_y + theme::BOX_SIZE / 2.0;
let s = theme::BOX_SIZE * 0.3;
canvas.draw_line( cx - s, cy, cx - s * 0.3, cy + s * 0.7, theme::check_color(), theme::CHECK_W );
canvas.draw_line( cx - s * 0.3, cy + s * 0.7, cx + s, cy - s * 0.5, theme::check_color(), theme::CHECK_W );
} else {
canvas.stroke_rect( box_rect, theme::box_border(), theme::BORDER_W, theme::RADIUS );
}
if focused
{
let ring = box_rect.expand( theme::FOCUS_W + 2.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS + theme::FOCUS_W + 2.0 );
}
if let Some( ref label ) = self.label
{
let text_x = rect.x + theme::BOX_SIZE + theme::GAP;
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0;
canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Checkbox<U>
where
U: Clone + 'static,
Msg: 'static,
{
Checkbox
{
checked: self.checked,
on_toggle: self.on_toggle.map( |m| ( *f )( m ) ),
label: self.label,
id: self.id,
}
}
}
/// Create a [`Checkbox`] in the given state.
///
/// Shorthand for [`Checkbox::new`]. Wire activation with
/// [`Checkbox::on_toggle`] and add a label with [`Checkbox::label`]:
///
/// ```rust,no_run
/// # use ltk::{ checkbox, Checkbox };
/// # #[ derive( Clone ) ] enum Msg { ToggleAccept }
/// # struct App { accept: bool }
/// # impl App { fn _ex( &self ) -> Checkbox<Msg> {
/// checkbox( self.accept )
/// .label( "I accept the terms" )
/// .on_toggle( Msg::ToggleAccept )
/// # }}
/// ```
pub fn checkbox<Msg: Clone>( checked: bool ) -> Checkbox<Msg>
{
Checkbox::new( checked )
}
impl<Msg: Clone + 'static> From<Checkbox<Msg>> for Element<Msg>
{
fn from( c: Checkbox<Msg> ) -> Self
{
Element::Checkbox( c )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn checkbox_default_state()
{
let c = checkbox::<()>( true );
assert!( c.checked );
assert!( c.on_toggle.is_none() );
}
#[test]
fn checkbox_unchecked()
{
let c = checkbox::<()>( false );
assert!( !c.checked );
}
}

545
src/widget/color_picker.rs Normal file
View File

@@ -0,0 +1,545 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! ColorPicker — RGBA sliders + hex input + preview swatch + a
//! continuous hue strip for picking arbitrary colours.
//!
//! Stateless — the application owns the current [`Color`] and updates
//! it from [`ColorPicker::on_change`]. Four sliders cover R / G / B
//! and (when [`ColorPicker::show_alpha`]) A; a hex input lets the user
//! type or paste `#RRGGBB` / `#RRGGBBAA`; a hue slider with a rainbow
//! track lets the user grab any pure hue at full saturation / value
//! in one drag, and the RGB sliders then fine-tune it.
//!
//! ```rust,no_run
//! # use ltk::{ color_picker, Color, ColorPicker };
//! # #[ derive( Clone ) ] enum Msg { AccentChanged( Color ) }
//! # struct App { accent: Color }
//! # impl App { fn _ex( &self ) -> ColorPicker<Msg> {
//! color_picker( self.accent )
//! .show_alpha( false )
//! .on_change( Msg::AccentChanged )
//! # }}
//! ```
use std::sync::Arc;
use crate::types::Color;
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint };
use super::Element;
mod theme
{
use crate::types::Color;
pub fn surface_alt() -> Color { crate::theme::palette().surface_alt }
pub fn divider() -> Color { crate::theme::palette().divider }
pub fn text_muted() -> Color { crate::theme::palette().text_secondary }
pub const PADDING: f32 = 16.0;
pub const RADIUS: f32 = 16.0;
pub const SWATCH_SZ: f32 = 40.0;
pub const LABEL_FS: f32 = 12.0;
pub const SPACING: f32 = 8.0;
}
/// Format a [`Color`] as `#RRGGBB` (or `#RRGGBBAA` when `with_alpha`
/// is true and the colour is not fully opaque). Bytes are clamped to
/// `0..=255`.
pub fn color_to_hex( c: Color, with_alpha: bool ) -> String
{
let to_byte = | f: f32 | ( f.clamp( 0.0, 1.0 ) * 255.0 ).round() as u8;
let r = to_byte( c.r );
let g = to_byte( c.g );
let b = to_byte( c.b );
let a = to_byte( c.a );
if with_alpha && a != 255
{
format!( "#{:02X}{:02X}{:02X}{:02X}", r, g, b, a )
} else {
format!( "#{:02X}{:02X}{:02X}", r, g, b )
}
}
/// Parse a hex colour string (`"#RGB"` / `"#RGBA"` / `"#RRGGBB"` /
/// `"#RRGGBBAA"`, with or without the leading `#`, case-insensitive)
/// into a [`Color`]. Returns `None` for malformed input.
pub fn parse_hex( s: &str ) -> Option<Color>
{
let s = s.trim();
let s = s.strip_prefix( '#' ).unwrap_or( s );
let parse_byte = | hi: char, lo: char | -> Option<u8>
{
let h = hi.to_digit( 16 )?;
let l = lo.to_digit( 16 )?;
Some( ( ( h << 4 ) | l ) as u8 )
};
let chars: Vec<char> = s.chars().collect();
let to_color = | r: u8, g: u8, b: u8, a: u8 | Color::rgba(
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
a as f32 / 255.0,
);
match chars.len()
{
3 =>
{
let r = parse_byte( chars[0], chars[0] )?;
let g = parse_byte( chars[1], chars[1] )?;
let b = parse_byte( chars[2], chars[2] )?;
Some( to_color( r, g, b, 255 ) )
}
4 =>
{
let r = parse_byte( chars[0], chars[0] )?;
let g = parse_byte( chars[1], chars[1] )?;
let b = parse_byte( chars[2], chars[2] )?;
let a = parse_byte( chars[3], chars[3] )?;
Some( to_color( r, g, b, a ) )
}
6 =>
{
let r = parse_byte( chars[0], chars[1] )?;
let g = parse_byte( chars[2], chars[3] )?;
let b = parse_byte( chars[4], chars[5] )?;
Some( to_color( r, g, b, 255 ) )
}
8 =>
{
let r = parse_byte( chars[0], chars[1] )?;
let g = parse_byte( chars[2], chars[3] )?;
let b = parse_byte( chars[4], chars[5] )?;
let a = parse_byte( chars[6], chars[7] )?;
Some( to_color( r, g, b, a ) )
}
_ => None,
}
}
/// RGBA colour selector with sliders, hex input, preview swatch and
/// a continuous hue strip.
pub struct ColorPicker<Msg: Clone>
{
pub value: Color,
pub on_change: Option<Arc<dyn Fn( Color ) -> Msg>>,
/// When `true` the alpha slider is shown and the hex input
/// accepts `#RRGGBBAA`. Default: `false` — most "pick a theme
/// colour" flows are opaque.
pub show_alpha: bool,
}
impl<Msg: Clone + 'static> ColorPicker<Msg>
{
pub fn new( value: Color ) -> Self
{
Self
{
value,
on_change: None,
show_alpha: false,
}
}
pub fn on_change( mut self, f: impl Fn( Color ) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
pub fn show_alpha( mut self, on: bool ) -> Self
{
self.show_alpha = on;
self
}
/// Build the `Element` tree representing this color picker.
pub fn build( self ) -> Element<Msg>
{
use super::{ container, text, text_edit };
use super::slider::slider;
let value = self.value;
let on_chg = self.on_change.clone();
let show_alpha = self.show_alpha;
// Preview swatch.
let swatch: Element<Msg> = container::<Msg>( spacer() )
.background( value )
.border( theme::divider(), 1.0 )
.radius( 12.0 )
.padding( 0.0 )
.into();
let mut preview_row = row::<Msg>().spacing( theme::SPACING ).push(
container::<Msg>( swatch )
.padding( 0.0 )
.radius( 12.0 ),
);
// We size the swatch via an explicit container holding the
// preview rect — the parent row will negotiate the space.
let _ = theme::SWATCH_SZ;
// Hex input — the `on_change` parses the typed string and
// only fires the picker's callback on a successful parse, so
// in-progress typing does not blank the preview every key.
let hex_value = color_to_hex( value, show_alpha );
let mut hex_edit = text_edit::<Msg>( "#RRGGBB", hex_value );
if let Some( ref cb ) = on_chg
{
let cb = cb.clone();
hex_edit = hex_edit.on_change( move |s|
{
match parse_hex( &s )
{
Some( c ) => cb( c ),
None => cb( value ), // hold the previous value
}
} );
}
preview_row = preview_row.push( hex_edit );
// One slider per channel. Each slider's on_change rebuilds
// the colour by replacing only its channel — the others
// snapshot at view-build time, which is correct because
// every change goes through `on_change` and re-renders.
let chan_slider = | label: &str, current: f32, build_color: Arc<dyn Fn( f32 ) -> Color> | -> Element<Msg>
{
let mut s = slider::<Msg>( current );
if let Some( ref cb ) = on_chg
{
let cb_outer = cb.clone();
s = s.on_change( move |v|
{
let c = build_color( v );
cb_outer( c )
} );
}
column::<Msg>().spacing( 4.0 )
.push( text( label ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
.push( s )
.into()
};
let r_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( v, value.g, value.b, value.a ) );
let g_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, v, value.b, value.a ) );
let b_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) );
let a_build: Arc<dyn Fn( f32 ) -> Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) );
let mut sliders = column::<Msg>().spacing( theme::SPACING )
.push( chan_slider( "R", value.r, r_build ) )
.push( chan_slider( "G", value.g, g_build ) )
.push( chan_slider( "B", value.b, b_build ) );
if show_alpha
{
sliders = sliders.push( chan_slider( "A", value.a, a_build ) );
}
// Hue strip: a slider whose track is a multi-stop rainbow
// gradient. Position 0 maps to red, position 1 to a hue
// just *short* of the full wheel (see `HUE_RANGE` below)
// so that dragging to the right edge does not snap the
// thumb back to the left on the next render. Moving the
// thumb fires `on_change` with the picked hue at full
// saturation / value, preserving the current alpha — RGB
// sliders fine-tune brightness afterwards.
//
// `HUE_RANGE = 359.0` instead of 360 closes the
// round-trip: at position 1.0 we pick hue 359°, the colour
// is almost-red (one degree off pure red), `rgb_to_hue`
// returns ~359°, and `position = 359 / 359 = 1.0` lands
// back where the user was. Mapping to 360° instead would
// snap to hue 0 (pure red, same as position 0) and the
// slider thumb would teleport to the left — the wheel
// closes on itself, but a linear slider cannot represent
// both endpoints. The 1° colour gap between the two ends
// is imperceptible.
//
// Software backend: `fill_paint_rect` falls back to the
// gradient's first stop (a flat red), so the slider still
// works functionally; only the rainbow visual is missing
// off the GLES path. Acceptable trade-off given the
// software path is the fallback for compositors without GL.
const HUE_RANGE: f32 = 359.0;
let current_hue = rgb_to_hue( value.r, value.g, value.b );
let hue_alpha = value.a;
let mut hue_slider = slider::<Msg>( ( current_hue / HUE_RANGE ).clamp( 0.0, 1.0 ) )
.track_paint( rainbow_gradient() );
if let Some( ref cb ) = on_chg
{
let cb = cb.clone();
hue_slider = hue_slider.on_change( move |v|
{
let ( r, g, b ) = hue_to_rgb( v.clamp( 0.0, 1.0 ) * HUE_RANGE );
cb( Color::rgba( r, g, b, hue_alpha ) )
} );
}
let hue_row: Element<Msg> = column::<Msg>().spacing( 4.0 )
.push( text( "Hue" ).size( theme::LABEL_FS ).color( theme::text_muted() ) )
.push( hue_slider )
.into();
let body = column::<Msg>().spacing( theme::SPACING * 2.0 )
.push( preview_row )
.push( sliders )
.push( hue_row );
container::<Msg>( body )
.background( theme::surface_alt() )
.padding( theme::PADDING )
.radius( theme::RADIUS )
.into()
}
}
/// Build the rainbow [`Paint::Linear`] used as the hue strip's track.
/// Seven stops across the wheel with a final repeat of red so the
/// gradient closes cleanly. CSS angle convention: `90deg` sweeps
/// left-to-right, matching the slider's value axis.
fn rainbow_gradient() -> Paint
{
let stop = | pos: f32, c: Color | ColorStop { position: pos, color: c };
Paint::Linear( LinearGradient
{
angle_deg: 90.0,
stops: vec!
[
stop( 0.000, Color::rgba( 1.0, 0.0, 0.0, 1.0 ) ), // red
stop( 1.0 / 6.0, Color::rgba( 1.0, 1.0, 0.0, 1.0 ) ), // yellow
stop( 2.0 / 6.0, Color::rgba( 0.0, 1.0, 0.0, 1.0 ) ), // green
stop( 3.0 / 6.0, Color::rgba( 0.0, 1.0, 1.0, 1.0 ) ), // cyan
stop( 4.0 / 6.0, Color::rgba( 0.0, 0.0, 1.0, 1.0 ) ), // blue
stop( 5.0 / 6.0, Color::rgba( 1.0, 0.0, 1.0, 1.0 ) ), // magenta
stop( 1.000, Color::rgba( 1.0, 0.0, 0.0, 1.0 ) ), // red (closes the wheel)
],
space: GradientSpace::Srgb,
} )
}
/// Compute the HSV hue (in degrees, `0..360`) of an RGB triple. Returns
/// `0.0` for greys (where hue is undefined) so the slider reads at a
/// stable position when the user dials saturation down to zero via the
/// RGB sliders.
pub fn rgb_to_hue( r: f32, g: f32, b: f32 ) -> f32
{
let max = r.max( g ).max( b );
let min = r.min( g ).min( b );
let delta = max - min;
if delta <= f32::EPSILON { return 0.0; }
let h = if max == r
{
60.0 * ( ( ( g - b ) / delta ).rem_euclid( 6.0 ) )
}
else if max == g
{
60.0 * ( ( b - r ) / delta + 2.0 )
}
else
{
60.0 * ( ( r - g ) / delta + 4.0 )
};
if h < 0.0 { h + 360.0 } else { h }
}
/// Build an RGB triple at full saturation and value from a hue in
/// degrees (`0..360`). Returns each channel in `0.0..=1.0`.
pub fn hue_to_rgb( hue_deg: f32 ) -> ( f32, f32, f32 )
{
let h = hue_deg.rem_euclid( 360.0 ) / 60.0;
let c = 1.0_f32;
let x = c * ( 1.0 - ( ( h.rem_euclid( 2.0 ) ) - 1.0 ).abs() );
let ( r, g, b ) = match h as u32
{
0 => ( c, x, 0.0 ),
1 => ( x, c, 0.0 ),
2 => ( 0.0, c, x ),
3 => ( 0.0, x, c ),
4 => ( x, 0.0, c ),
_ => ( c, 0.0, x ),
};
( r, g, b )
}
impl<Msg: Clone + 'static> From<ColorPicker<Msg>> for Element<Msg>
{
fn from( c: ColorPicker<Msg> ) -> Self { c.build() }
}
/// Create a [`ColorPicker`] starting from the given colour.
///
/// ```rust,no_run
/// # use ltk::{ color_picker, Color, ColorPicker };
/// # #[ derive( Clone ) ] enum Msg { AccentChanged( Color ) }
/// # struct App { accent: Color }
/// # impl App { fn _ex( &self ) -> ColorPicker<Msg> {
/// color_picker( self.accent )
/// .show_alpha( true )
/// .on_change( Msg::AccentChanged )
/// # }}
/// ```
pub fn color_picker<Msg: Clone + 'static>( value: Color ) -> ColorPicker<Msg>
{
ColorPicker::new( value )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
// ── hex serialization ─────────────────────────────────────────────────────
#[ test ]
fn color_to_hex_uppercase_six_digits_for_opaque()
{
let c = Color::rgba( 1.0, 0.0, 0.5, 1.0 );
assert_eq!( color_to_hex( c, false ), "#FF0080" );
assert_eq!( color_to_hex( c, true ), "#FF0080" ); // alpha=255 stripped
}
#[ test ]
fn color_to_hex_emits_eight_digits_when_alpha_kept_and_present()
{
let c = Color::rgba( 1.0, 0.0, 0.5, 0.5 );
// 0.5 × 255 = 127.5 → rounds to 128 = 0x80.
assert_eq!( color_to_hex( c, true ), "#FF008080" );
}
#[ test ]
fn color_to_hex_clamps_out_of_range()
{
let c = Color::rgba( 1.5, -0.1, 0.0, 1.0 );
assert_eq!( color_to_hex( c, false ), "#FF0000" );
}
// ── hex parsing ───────────────────────────────────────────────────────────
#[ test ]
fn parse_hex_six_digits()
{
let c = parse_hex( "#FF0080" ).expect( "ok" );
assert!( ( c.r - 1.0 ).abs() < 1e-3 );
assert_eq!( c.g, 0.0 );
assert!( ( c.b - 0.502 ).abs() < 1e-2 );
assert_eq!( c.a, 1.0 );
}
#[ test ]
fn parse_hex_three_digit_shorthand()
{
let c = parse_hex( "#F08" ).expect( "ok" );
// "F" → 0xFF. "0" → 0x00. "8" → 0x88.
assert_eq!( ( c.r * 255.0 ).round() as u8, 0xFF );
assert_eq!( ( c.g * 255.0 ).round() as u8, 0x00 );
assert_eq!( ( c.b * 255.0 ).round() as u8, 0x88 );
}
#[ test ]
fn parse_hex_eight_digit_includes_alpha()
{
let c = parse_hex( "#FF008080" ).expect( "ok" );
assert!( ( c.a * 255.0 ).round() as u8 == 0x80 );
}
#[ test ]
fn parse_hex_optional_leading_hash()
{
assert!( parse_hex( "FF0080" ).is_some() );
assert!( parse_hex( "#FF0080" ).is_some() );
}
#[ test ]
fn parse_hex_case_insensitive()
{
let upper = parse_hex( "#A1B2C3" ).unwrap();
let lower = parse_hex( "#a1b2c3" ).unwrap();
assert_eq!( upper, lower );
}
#[ test ]
fn parse_hex_rejects_garbage()
{
assert!( parse_hex( "" ).is_none() );
assert!( parse_hex( "#" ).is_none() );
assert!( parse_hex( "#XYZ" ).is_none() );
// 5 / 7 / 9 digits — not one of the valid widths (3/4/6/8).
assert!( parse_hex( "#12345" ).is_none() );
assert!( parse_hex( "#1234567" ).is_none() );
// Non-hex character inside a valid-length string.
assert!( parse_hex( "#GG0000" ).is_none() );
}
#[ test ]
fn parse_hex_round_trips_through_color_to_hex()
{
let original = Color::rgba( 0.2, 0.4, 0.6, 1.0 );
let s = color_to_hex( original, false );
let parsed = parse_hex( &s ).expect( "ok" );
// Allow ±1/255 quantisation slack.
assert!( ( parsed.r - original.r ).abs() < 1.0 / 255.0 + 1e-6 );
assert!( ( parsed.g - original.g ).abs() < 1.0 / 255.0 + 1e-6 );
assert!( ( parsed.b - original.b ).abs() < 1.0 / 255.0 + 1e-6 );
}
// ── builders ──────────────────────────────────────────────────────────────
#[ derive( Clone, Debug, PartialEq ) ]
enum Msg { Pick( Color ) }
#[ test ]
fn defaults()
{
let p: ColorPicker<Msg> = color_picker( Color::WHITE );
assert_eq!( p.value, Color::WHITE );
assert!( !p.show_alpha );
assert!( p.on_change.is_none() );
}
#[ test ]
fn build_does_not_panic_minimal()
{
let _: Element<Msg> = color_picker::<Msg>( Color::WHITE ).build();
}
#[ test ]
fn build_does_not_panic_with_alpha_and_callback()
{
let _: Element<Msg> = color_picker::<Msg>( Color::rgba( 0.2, 0.4, 0.6, 0.8 ) )
.show_alpha( true )
.on_change( Msg::Pick )
.build();
}
// ── Hue arithmetic ────────────────────────────────────────────────────────
#[ test ]
fn rgb_to_hue_pure_primaries()
{
assert!( ( rgb_to_hue( 1.0, 0.0, 0.0 ) - 0.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 1.0, 1.0, 0.0 ) - 60.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 0.0, 1.0, 0.0 ) - 120.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 0.0, 1.0, 1.0 ) - 180.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 0.0, 0.0, 1.0 ) - 240.0 ).abs() < 1e-3 );
assert!( ( rgb_to_hue( 1.0, 0.0, 1.0 ) - 300.0 ).abs() < 1e-3 );
}
#[ test ]
fn rgb_to_hue_grey_returns_zero()
{
assert_eq!( rgb_to_hue( 0.5, 0.5, 0.5 ), 0.0 );
assert_eq!( rgb_to_hue( 0.0, 0.0, 0.0 ), 0.0 );
}
#[ test ]
fn hue_to_rgb_round_trips_at_primaries()
{
for deg in [ 0.0, 60.0, 120.0, 180.0, 240.0, 300.0 ]
{
let ( r, g, b ) = hue_to_rgb( deg );
let h = rgb_to_hue( r, g, b );
let diff = ( ( h - deg + 540.0 ) % 360.0 - 180.0 ).abs();
assert!( diff < 1e-3, "hue {deg} degrees did not round-trip (got {h})" );
}
}
}

977
src/widget/combo.rs Normal file
View File

@@ -0,0 +1,977 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Combo / select / dropdown widget.
//!
//! Editable, searchable, multi-select, scrollable. Built as a pure
//! composition over existing widgets ([`text_edit`](super::text_edit),
//! [`list_item`](super::list_item), [`pressable`](super::pressable),
//! [`scroll`](super::scroll), [`viewport`](super::viewport),
//! [`container`](super::container), [`stack`](crate::layout::stack)),
//! so the runtime needs no special layout / dispatch path **and the
//! widget works in plain `xdg-shell` application windows** — the popup
//! is a `Stack` overlay layered into the same surface as the trigger,
//! not a separate Wayland surface.
//!
//! ## Mental model
//!
//! A `Combo` is a *projection* over a [`ComboState`] that the
//! application owns and mutates from `update()`. The widget itself is
//! stateless: every frame the app rebuilds it from current state and
//! consumes the messages the user produces.
//!
//! Two pieces flow into the app's view tree:
//!
//! 1. The **trigger** (returned by [`Combo::trigger`]) lives wherever the
//! app puts a normal widget: a column, a row, inside a container. It
//! paints the labelled pill, the optional chips for multi-select
//! selections, the query text edit, the down-arrow toggle, and the
//! helper / error rows.
//! 2. The **popup** (returned by [`Combo::popup`]) is `None` when the
//! combo is closed and `Some(Element)` when open. The application
//! layers it on top of the rest of the view via [`stack`](crate::stack).
//! The returned element already contains a full-surface dismiss
//! layer behind the panel so a tap outside fires
//! [`Combo::on_dismiss`].
//!
//! ## Wiring
//!
//! ```rust,no_run
//! # #[ derive( Clone ) ] enum Msg {
//! # FruitQuery( String ), FruitToggle,
//! # FruitSelect( usize ), FruitUnselect( usize ), FruitDismiss,
//! # }
//! use ltk::{ App, Combo, ComboState, Element, combo, column, stack };
//!
//! struct AppState
//! {
//! fruits: ComboState,
//! }
//!
//! impl AppState
//! {
//! fn build_combo( &self ) -> Combo<Msg>
//! {
//! combo( self.fruits.clone(), [ "Apple", "Banana", "Cherry", "Date" ]
//! .iter().map( |s| s.to_string() ).collect() )
//! .label( "Fruits" )
//! .placeholder( "Pick one or more…" )
//! .multi_select( true )
//! .searchable( true )
//! .on_query_change( Msg::FruitQuery )
//! .on_toggle_open( Msg::FruitToggle )
//! .on_select_idx( Msg::FruitSelect )
//! .on_unselect_idx( Msg::FruitUnselect )
//! .on_dismiss( Msg::FruitDismiss )
//! }
//! }
//!
//! impl App for AppState
//! {
//! type Message = Msg;
//! fn view( &self ) -> Element<Msg>
//! {
//! let combo = self.build_combo();
//! let mut s = stack::<Msg>().push( column().push( combo.trigger() ) );
//! if let Some( p ) = combo.popup() { s = s.push( p ); }
//! s.into()
//! }
//! # fn update( &mut self, _msg: Msg ) {}
//! }
//! ```
//!
//! `update()` then handles the messages: append to / remove from
//! `fruits.selected` on `FruitSelect` / `FruitUnselect`, flip
//! `fruits.is_open` on `FruitToggle` / `FruitDismiss`, and copy the new
//! query string into `fruits.query` on `FruitQuery`.
//!
//! ## Dismiss behaviour
//!
//! The runtime fires [`Combo::on_dismiss`] in three situations: the
//! compositor sends `xdg_popup.popup_done`; the user taps the main
//! surface outside the trigger pill while the popup is mapped; or the
//! user presses Escape. The app's only job is to flip `is_open` to
//! `false` in `update()` — the runtime is idempotent if the message
//! arrives more than once for the same open / close cycle. See
//! [`crate::app::OverlaySpec::on_dismiss`] for the full contract.
use std::sync::Arc;
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::WidgetId;
use super::Element;
/// Stable identifier of the trigger pill used as the popup's anchor.
/// Read by [`crate::widget::anchored_overlay::AnchoredOverlay`] in the
/// draw pass to position the popup flush below the trigger. A single
/// constant is enough as long as only one combo is open at a time —
/// the popup of a closed combo contributes nothing to the view tree,
/// so two combos with the same anchor id coexist fine when they don't
/// open simultaneously. Apps that need overlapping open combos should
/// switch to [`Combo::anchor_id`] with distinct ids.
const COMBO_ANCHOR_DEFAULT: WidgetId = WidgetId( "ltk-combo-trigger" );
/// Application-owned state for a [`Combo`].
///
/// Lives on the app struct and is mutated from `update()`. The widget
/// reads it once per frame to render the trigger and popup; nothing
/// inside ltk keeps a copy.
#[ derive( Debug, Clone, Default ) ]
pub struct ComboState
{
/// Current text in the trigger's search field. Drives item filtering.
/// Empty string means "no filter — show every item".
pub query: String,
/// `true` when the popup is visible.
pub is_open: bool,
/// Indices into the `items` slice currently selected. In single-select
/// mode this is empty or has a single entry; in multi-select mode it
/// can hold any subset.
pub selected: Vec<usize>,
}
impl ComboState
{
/// Empty state: no query, popup closed, nothing selected.
pub fn new() -> Self { Self::default() }
/// Convenience: toggle `is_open`.
pub fn toggle_open( &mut self ) { self.is_open = !self.is_open; }
/// Convenience: add `idx` to `selected` if not present.
pub fn select( &mut self, idx: usize )
{
if !self.selected.contains( &idx ) { self.selected.push( idx ); }
}
/// Convenience: remove `idx` from `selected` if present.
pub fn unselect( &mut self, idx: usize )
{
self.selected.retain( |&i| i != idx );
}
}
/// A combo / select / dropdown widget.
///
/// See the module-level documentation for the full wiring pattern.
/// Build via [`combo`] and configure with the chained builders.
pub struct Combo<Msg: Clone>
{
state: ComboState,
items: Vec<String>,
label: Option<String>,
description: Option<String>,
placeholder: Option<String>,
helper: Option<String>,
error: Option<String>,
disabled: bool,
multi_select: bool,
searchable: bool,
max_chips_visible: usize,
anchor_id: WidgetId,
popup_gap: f32,
popup_width: f32,
popup_max_height: f32,
on_query_change: Option<Arc<dyn Fn( String ) -> Msg>>,
on_toggle_open: Option<Msg>,
on_select_idx: Option<Arc<dyn Fn( usize ) -> Msg>>,
on_unselect_idx: Option<Arc<dyn Fn( usize ) -> Msg>>,
on_dismiss: Option<Msg>,
}
impl<Msg: Clone> Combo<Msg>
{
/// Construct a combo over `items` driven by `state`. Both arguments
/// are taken by value because the widget tree is rebuilt every
/// frame; clone the app's state slice into here.
pub fn new( state: ComboState, items: Vec<String> ) -> Self
{
Self
{
state,
items,
label: None,
description: None,
placeholder: None,
helper: None,
error: None,
disabled: false,
multi_select: false,
searchable: false,
max_chips_visible: 4,
anchor_id: COMBO_ANCHOR_DEFAULT,
popup_gap: 4.0,
popup_width: 320.0,
popup_max_height: 280.0,
on_query_change: None,
on_toggle_open: None,
on_select_idx: None,
on_unselect_idx: None,
on_dismiss: None,
}
}
/// Bold label drawn above the trigger.
pub fn label( mut self, s: impl Into<String> ) -> Self
{
self.label = Some( s.into() );
self
}
/// Optional descriptive paragraph drawn below the label.
pub fn description( mut self, s: impl Into<String> ) -> Self
{
self.description = Some( s.into() );
self
}
/// Placeholder for the trigger's text edit when the query is empty.
pub fn placeholder( mut self, s: impl Into<String> ) -> Self
{
self.placeholder = Some( s.into() );
self
}
/// Helper / informative text below the trigger. Hidden when an
/// error message is set.
pub fn helper( mut self, s: impl Into<String> ) -> Self
{
self.helper = Some( s.into() );
self
}
/// Error message below the trigger. Replaces the helper text and
/// applies the destructive theme tokens to the trigger surface.
pub fn error( mut self, s: impl Into<String> ) -> Self
{
self.error = Some( s.into() );
self
}
/// Render in the disabled style. Activations / selections still emit
/// messages — the consumer is responsible for ignoring them in
/// `update()`.
pub fn disabled( mut self, yes: bool ) -> Self
{
self.disabled = yes;
self
}
/// Allow multiple items to be selected at once. Selected items are
/// rendered as chips above the trigger.
pub fn multi_select( mut self, yes: bool ) -> Self
{
self.multi_select = yes;
self
}
/// Make the trigger an editable text field that filters the popup
/// list as the user types. Without `searchable`, the trigger is a
/// pressable button that displays the current selection.
pub fn searchable( mut self, yes: bool ) -> Self
{
self.searchable = yes;
self
}
/// Cap on the number of selection chips drawn above the trigger
/// before falling back to a "+N more" indicator. Default `4`.
pub fn max_chips_visible( mut self, n: usize ) -> Self
{
self.max_chips_visible = n;
self
}
/// Stable identifier of the trigger pill. The popup looks the rect
/// of this widget up in the previous frame's layout snapshot to
/// place itself flush below the trigger. Apps with several combos
/// that may open simultaneously must give each a distinct id.
pub fn anchor_id( mut self, id: WidgetId ) -> Self
{
self.anchor_id = id;
self
}
/// Vertical gap (logical pixels) between the bottom of the trigger
/// and the top of the popup panel. Default `4`.
pub fn popup_gap( mut self, px: f32 ) -> Self
{
self.popup_gap = px.max( 0.0 );
self
}
/// Width of the popup in logical pixels. Default `320`.
pub fn popup_width( mut self, px: f32 ) -> Self
{
self.popup_width = px.max( 80.0 );
self
}
/// Maximum height of the popup before it scrolls internally. Default
/// `280`. The popup never grows past this height; it shrinks to fit
/// the filtered item list when shorter.
pub fn popup_max_height( mut self, px: f32 ) -> Self
{
self.popup_max_height = px.max( 80.0 );
self
}
/// Callback fired with the new query string on every keystroke
/// inside the trigger's search field. Required when `searchable( true )`.
pub fn on_query_change( mut self, f: impl Fn( String ) -> Msg + 'static ) -> Self
{
self.on_query_change = Some( Arc::new( f ) );
self
}
/// Message emitted when the user activates the trigger to toggle the
/// popup open / closed (tap on the trigger pill or press the
/// down-arrow icon button).
pub fn on_toggle_open( mut self, msg: Msg ) -> Self
{
self.on_toggle_open = Some( msg );
self
}
/// Callback fired with the index of the item the user selects from
/// the popup. The application's `update()` should add that index to
/// `state.selected` (multi-select) or replace it (single-select).
pub fn on_select_idx( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_select_idx = Some( Arc::new( f ) );
self
}
/// Callback fired with the index of a chip the user dismisses
/// (tapping its `×`). Multi-select only.
pub fn on_unselect_idx( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_unselect_idx = Some( Arc::new( f ) );
self
}
/// Message emitted when the user taps outside the popup. Typically
/// flips `state.is_open` back to `false` in `update()`.
pub fn on_dismiss( mut self, msg: Msg ) -> Self
{
self.on_dismiss = Some( msg );
self
}
// ── filtering / item lookup ──────────────────────────────────────────
/// Return the indices of items that match the current query (case
/// insensitive `contains` match). Empty query passes every item.
pub fn filtered_indices( &self ) -> Vec<usize>
{
let q = self.state.query.to_lowercase();
if q.is_empty()
{
return ( 0..self.items.len() ).collect();
}
self.items.iter().enumerate()
.filter( |( _, item )| item.to_lowercase().contains( &q ) )
.map( |( i, _ )| i )
.collect()
}
fn first_selected_label( &self ) -> Option<&str>
{
self.state.selected.first()
.and_then( |&i| self.items.get( i ) )
.map( |s| s.as_str() )
}
}
// Methods that build widget trees. Split into a `'static` impl block so
// `From<Combo<Msg>>` and the constructor functions don't fight over
// generic bounds.
impl<Msg: Clone + 'static> Combo<Msg>
{
/// Build the trigger: label / description / chips (multi-select) /
/// pill with text-edit + arrow / helper or error row. Returns an
/// [`Element`] ready to drop into [`App::view`](crate::App::view).
pub fn trigger( &self ) -> Element<Msg>
{
use super::{ container, text, text_edit };
use super::flex::flex;
use super::pressable::pressable;
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
let palette = crate::theme::palette();
let mut col = column::<Msg>().padding( 0.0 ).spacing( 8.0 ).align_center_x( false );
// Label.
if let Some( ref s ) = self.label
{
let c = if self.disabled { palette.text_secondary } else { palette.text_primary };
col = col.push( text( s.clone() ).size( 16.0 ).color( c ) );
}
// Descriptive text.
if let Some( ref s ) = self.description
{
col = col.push( text( s.clone() ).size( 16.0 ).color( palette.text_secondary ) );
}
// Chip strip (multi-select with at least one selection).
if self.multi_select && !self.state.selected.is_empty()
{
const CHIP_X_PX: u32 = 14;
let chip_x = crate::theme::icon_rgba( "window/close", CHIP_X_PX )
.map( | ( rgba, w, h ) |
{
let tinted = std::sync::Arc::new(
crate::theme::tint_symbolic( &rgba, palette.text_primary ),
);
( tinted, w, h )
} );
let mut chips = row::<Msg>().padding( 0.0 ).spacing( 6.0 );
let visible = self.state.selected.iter().take( self.max_chips_visible );
for &idx in visible
{
if let Some( label ) = self.items.get( idx ).cloned()
{
let label_color = palette.text_primary;
let mut chip_row = row::<Msg>().padding( 0.0 ).spacing( 10.0 )
.push( text( label ).size( 14.0 ).color( label_color ) );
if let Some( cb ) = self.on_unselect_idx.as_ref()
{
let msg = cb( idx );
if let Some( ( ref rgba, w, h ) ) = chip_x
{
let btn = crate::widget::icon_button::<Msg>( std::sync::Arc::clone( rgba ), w, h )
.icon_size( CHIP_X_PX as f32 )
.on_press( msg );
chip_row = chip_row.push( btn );
}
}
let chip: Element<Msg> = container::<Msg>( chip_row )
.background( palette.surface_alt )
.padding_h( 10.0 )
.padding_v( 4.0 )
.radius( 16.0 )
.into();
chips = chips.push( chip );
}
}
let extra = self.state.selected.len().saturating_sub( self.max_chips_visible );
if extra > 0
{
chips = chips.push(
text( format!( "+{extra}" ) ).size( 14.0 ).color( palette.text_secondary ),
);
}
col = col.push( chips );
}
// Trigger pill: text-edit (if searchable) + arrow toggle.
let trigger_inner: Element<Msg> = {
let mut r = row::<Msg>().padding( 0.0 ).spacing( 8.0 );
if self.searchable
{
let placeholder = self.placeholder.clone().unwrap_or_default();
let value = self.state.query.clone();
let mut te = text_edit::<Msg>( placeholder, value );
if let Some( cb ) = self.on_query_change.as_ref()
{
let cb = Arc::clone( cb );
te = te.on_change( move |s| cb( s ) );
}
// Wrap in `flex` so the text edit consumes the leftover
// width inside the row instead of claiming `max_width` for
// itself and pushing the down-arrow off-screen.
r = r.push( flex( te ) );
}
else
{
let display = self.first_selected_label()
.map( |s| s.to_string() )
.or_else( || self.placeholder.clone() )
.unwrap_or_default();
let c = if self.first_selected_label().is_some()
{
palette.text_primary
}
else
{
palette.text_secondary
};
r = r.push( text( display ).size( 16.0 ).color( c ) );
// Push the down-arrow to the right edge.
r = r.push( spacer() );
}
const CHEVRON_PX: u32 = 18;
let icon_name = if self.state.is_open
{
"general/up-simple"
} else {
"general/down-simple"
};
if let Some( ( rgba, w, h ) ) = crate::theme::icon_rgba( icon_name, CHEVRON_PX )
{
let tinted = std::sync::Arc::new(
crate::theme::tint_symbolic( &rgba, palette.text_primary ),
);
let toggle_button = crate::widget::icon_button::<Msg>( tinted, w, h )
.icon_size( CHEVRON_PX as f32 );
if let Some( ref msg ) = self.on_toggle_open
{
r = r.push( toggle_button.on_press( msg.clone() ) );
}
else
{
r = r.push( toggle_button );
}
}
r.into()
};
let ( pill_bg, pill_border ) = if self.error.is_some()
{
( palette.danger_bg, palette.danger )
}
else
{
( palette.surface_alt, palette.divider )
};
let pill: Element<Msg> = container::<Msg>( trigger_inner )
.background( pill_bg )
.border( pill_border, 1.0 )
.padding_h( 16.0 )
.padding_v( 12.0 )
.radius( 32.0 )
.into();
// Always wrap the pill in a pressable so the popup toggles when
// the user taps anywhere on the pill chrome. Inner interactive
// widgets (text_edit, button) keep priority because the layout
// pass pushes the pressable's hit rect *before* recursing into
// the children — `find_widget_at` iterates the rect list in
// reverse, so deeper widgets win on overlap. Without this
// fallback, clicks that hit the gap between the text edit and
// the chevron button (or the rounded corners outside any child
// rect) silently land on nothing.
//
// The pressable also carries the anchor id so the popup can
// look the trigger pill's rect up at draw time and place
// itself flush below.
let pill: Element<Msg> = {
let p = pressable::<Msg>( pill ).id( self.anchor_id );
let p = if let Some( ref msg ) = self.on_toggle_open
{
p.on_press( msg.clone() )
} else { p };
p.into()
};
col = col.push( pill );
// Helper / error row.
if let Some( ref err ) = self.error
{
col = col.push( text( err.clone() ).size( 14.0 ).color( palette.danger ) );
}
else if let Some( ref h ) = self.helper
{
col = col.push( text( h.clone() ).size( 14.0 ).color( palette.text_secondary ) );
}
col.into()
}
/// Build the popup as a [`Stack`](crate::Stack)-overlayable element.
/// Returns `None` when the combo is closed.
///
/// The returned element is meant to be layered **on top of the rest
/// of the application's view tree** in the same surface — typically
/// by wrapping the app's `view()` output in a [`stack`](crate::stack)
/// and pushing this element when it is `Some`. It already contains:
///
/// 1. A full-surface dismiss layer behind the panel — a transparent
/// [`pressable`](super::pressable::Pressable) that fires
/// [`Combo::on_dismiss`] when the user taps anywhere outside the
/// panel.
/// 2. The panel itself, centred horizontally and anchored 80 px from
/// the top of the available rect, with the filtered item list
/// inside a scrolling viewport bounded by [`Combo::popup_width`]
/// and [`Combo::popup_max_height`].
///
/// The popup centres modal-style — it does not anchor to the trigger
/// position because the trigger's screen-space rect is not known at
/// `view()` time. For a trigger-anchored popup, drive the popup's
/// position from a runtime hint the application stores after the
/// first hit-test.
pub fn popup( &self ) -> Option<Element<Msg>>
{
if !self.state.is_open { return None; }
use super::container;
use super::list_item::list_item;
use super::pressable::pressable;
use super::scroll::scroll;
use super::text;
use super::viewport::viewport;
use crate::layout::column::column;
use crate::layout::spacer::spacer;
use crate::layout::stack::{ stack, HAlign, VAlign };
let palette = crate::theme::palette();
// Build the filtered list of items. The list_item widget paints
// its own hover / pressed surfaces; `selected( true )` overrides
// both with the dark surface + white text variant the design
// system specifies for the picked option.
let mut list = column::<Msg>().padding( 4.0 ).spacing( 0.0 ).align_center_x( false );
let filtered = self.filtered_indices();
for idx in &filtered
{
let label = self.items[ *idx ].clone();
let is_selected = self.state.selected.contains( idx );
let mut li = list_item::<Msg>( label ).selected( is_selected );
if let Some( cb ) = self.on_select_idx.as_ref()
{
li = li.on_press( cb( *idx ) );
}
list = list.push( li );
}
// Empty-list hint when no item matches the query.
if filtered.is_empty()
{
list = list.push(
text( "No matches" ).size( 14.0 ).color( palette.text_secondary ),
);
}
// Wrap the list in a viewport that pins the popup's max height
// and lets the inner scroll do its thing when the content
// overflows.
let scroller: Element<Msg> = scroll::<Msg>( list ).into();
let bounded: Element<Msg> = viewport::<Msg>( scroller )
.width( self.popup_width )
.height( self.popup_max_height )
.into();
let panel: Element<Msg> = container::<Msg>( bounded )
.background( palette.surface_alt )
.border( palette.divider, 1.0 )
.padding( 8.0 )
.radius( 32.0 )
.into();
// Build the popup as a Stack with two layers:
// - layer 0 (Fill, Fill): full-surface dismiss layer. Built
// as `pressable( spacer() )` because Spacer reports zero
// intrinsic size and Stack's `Fill` alignment grows it to
// the full Stack rect (which the user installs at the root
// of `view()`, so it spans the whole surface).
// - layer 1 (Start, Top): the actual panel. The outer
// `AnchoredOverlay` below relocates this layer to flush
// below the trigger; without an anchor (first frame after
// open) the layer renders at the surface's top-left, which
// is acceptable as a one-frame artefact.
let mut s = stack::<Msg>();
if let Some( ref dismiss ) = self.on_dismiss
{
let backdrop: Element<Msg> = pressable::<Msg>( spacer() )
.on_press( dismiss.clone() )
.into();
s = s.push( backdrop );
}
// The dismiss layer wants the FULL surface; the panel wants the
// anchored rect under the trigger. They cannot share a single
// `AnchoredOverlay` — wrapping the Stack root would relocate
// both layers and break the dismiss layer's full-coverage
// requirement.
//
// Solution: keep the dismiss layer at root level, and wrap
// only the panel in `AnchoredOverlay` so it (alone) reads
// the trigger's anchor.
let anchored_panel: Element<Msg> = super::anchored_overlay::AnchoredOverlay::new(
panel,
self.anchor_id,
self.popup_gap,
).into();
s = s.push_aligned( anchored_panel, HAlign::Start, VAlign::Top );
Some( s.into() )
}
/// Build the [`OverlaySpec`] that the application should return from
/// [`App::overlays`](crate::App::overlays) when this combo is open.
///
/// Returns `None` when the combo is closed.
///
/// Unlike [`Combo::popup`], the overlay is rendered as a real Wayland
/// **xdg-popup** child of the application's main window — it can extend
/// outside the parent surface (the canonical select / dropdown
/// behaviour) and is positioned by the compositor relative to the
/// trigger pill rect from the previous frame's layout. The
/// [`OverlayId`] is derived from [`Combo::anchor_id`] so two combos
/// with distinct anchor ids get distinct overlay ids automatically.
///
/// The spec's `view` is the panel itself (background, border, padding,
/// rounded corners and the bounded scrolling item list). No extra
/// dismiss layer is needed: tap-outside dismissal is handled by the
/// usual ltk mechanism — wire [`App::on_tap`](crate::App::on_tap) to
/// close the combo, or rely on the spec's `on_dismiss` which fires
/// when the compositor sends `popup_done`.
pub fn overlay( &self ) -> Option<OverlaySpec<Msg>>
{
if !self.state.is_open { return None; }
use super::container;
use super::list_item::list_item;
use super::scroll::scroll;
use super::text;
use super::viewport::viewport;
use crate::layout::column::column;
let palette = crate::theme::palette();
let mut list = column::<Msg>().padding( 4.0 ).spacing( 0.0 ).align_center_x( false );
let filtered = self.filtered_indices();
for idx in &filtered
{
let label = self.items[ *idx ].clone();
let is_selected = self.state.selected.contains( idx );
let mut li = list_item::<Msg>( label ).selected( is_selected );
if let Some( cb ) = self.on_select_idx.as_ref()
{
li = li.on_press( cb( *idx ) );
}
list = list.push( li );
}
if filtered.is_empty()
{
list = list.push(
text( "No matches" ).size( 14.0 ).color( palette.text_secondary ),
);
}
let scroller: Element<Msg> = scroll::<Msg>( list ).into();
let bounded: Element<Msg> = viewport::<Msg>( scroller )
.width( self.popup_width )
.height( self.popup_max_height )
.into();
let panel: Element<Msg> = container::<Msg>( bounded )
.background( palette.surface_alt )
.border( palette.divider, 1.0 )
.padding( 8.0 )
.radius( 32.0 )
.into();
// Derive the OverlayId from the anchor_id's static-str so apps
// don't have to manually pick non-colliding ids for each combo.
let mut hasher = DefaultHasher::new();
self.anchor_id.0.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 );
Some( OverlaySpec
{
id: overlay_id,
// `layer` / `anchor` / `exclusive_zone` / `keyboard_exclusive`
// are ignored on the xdg-popup path; the values below are the
// neutral defaults a layer-shell fallback would expect.
layer: Layer::Overlay,
anchor: Anchor::ALL,
// `size.0 == 0` asks the runtime to size the popup to the
// trigger pill width (the canonical select / dropdown
// behaviour). Height stays capped at `popup_max_height`.
size: ( 0, self.popup_max_height as u32 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: panel,
on_dismiss: self.on_dismiss.clone(),
anchor_widget_id: Some( self.anchor_id ),
} )
}
}
/// Create a [`Combo`] over `items` driven by `state`.
pub fn combo<Msg: Clone>( state: ComboState, items: Vec<String> ) -> Combo<Msg>
{
Combo::new( state, items )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
fn st() -> ComboState { ComboState::new() }
#[ test ]
fn defaults_match_documented_values()
{
let c: Combo<()> = combo( st(), vec![] );
assert!( c.label.is_none() );
assert!( !c.disabled );
assert!( !c.multi_select );
assert!( !c.searchable );
assert_eq!( c.max_chips_visible, 4 );
assert_eq!( c.popup_width, 320.0 );
assert_eq!( c.popup_max_height, 280.0 );
}
#[ test ]
fn builders_round_trip_through_struct_fields()
{
let c: Combo<()> = combo( st(), vec![] )
.label( "Fruit" )
.description( "Choose" )
.placeholder( "Pick…" )
.helper( "Type to search" )
.disabled( true )
.multi_select( true )
.searchable( true )
.popup_width( 400.0 )
.popup_max_height( 320.0 )
.max_chips_visible( 6 );
assert_eq!( c.label.as_deref(), Some( "Fruit" ) );
assert_eq!( c.description.as_deref(), Some( "Choose" ) );
assert_eq!( c.placeholder.as_deref(), Some( "Pick…" ) );
assert_eq!( c.helper.as_deref(), Some( "Type to search" ) );
assert!( c.disabled );
assert!( c.multi_select );
assert!( c.searchable );
assert_eq!( c.popup_width, 400.0 );
assert_eq!( c.popup_max_height, 320.0 );
assert_eq!( c.max_chips_visible, 6 );
}
#[ test ]
fn popup_width_is_clamped_to_eighty_minimum()
{
let c: Combo<()> = combo( st(), vec![] ).popup_width( 10.0 );
assert_eq!( c.popup_width, 80.0 );
}
#[ test ]
fn popup_max_height_is_clamped_to_eighty_minimum()
{
let c: Combo<()> = combo( st(), vec![] ).popup_max_height( 10.0 );
assert_eq!( c.popup_max_height, 80.0 );
}
#[ test ]
fn empty_query_returns_every_index()
{
let items = vec![ "Apple".into(), "Banana".into(), "Cherry".into() ];
let c: Combo<()> = combo( st(), items );
assert_eq!( c.filtered_indices(), vec![ 0, 1, 2 ] );
}
#[ test ]
fn case_insensitive_contains_filter()
{
let items = vec![ "Apple".into(), "Banana".into(), "Avocado".into(), "Cherry".into() ];
let mut state = st();
state.query = "a".into();
let c: Combo<()> = combo( state, items );
// "Apple", "Banana", "Avocado" all contain `a` in some case;
// "Cherry" does not.
assert_eq!( c.filtered_indices(), vec![ 0, 1, 2 ] );
}
#[ test ]
fn filter_match_is_case_insensitive_for_query_uppercase()
{
let items = vec![ "apple".into(), "BANANA".into(), "Cherry".into() ];
let mut state = st();
state.query = "RY".into();
let c: Combo<()> = combo( state, items );
assert_eq!( c.filtered_indices(), vec![ 2 ] );
}
#[ test ]
fn filter_with_no_matches_returns_empty()
{
let items = vec![ "Apple".into(), "Banana".into() ];
let mut state = st();
state.query = "zzz".into();
let c: Combo<()> = combo( state, items );
assert!( c.filtered_indices().is_empty() );
}
#[ test ]
fn closed_state_yields_no_popup()
{
let c: Combo<()> = combo( st(), vec![ "x".into() ] );
assert!( c.popup().is_none() );
}
#[ test ]
fn open_state_yields_popup_element()
{
let mut state = st();
state.is_open = true;
let c: Combo<()> = combo( state, vec![ "x".into() ] );
assert!( c.popup().is_some(), "open combo must produce a popup element" );
}
#[ test ]
fn combo_state_helpers_select_and_unselect()
{
let mut s = ComboState::new();
s.select( 1 );
s.select( 3 );
s.select( 1 ); // duplicate should be a no-op
assert_eq!( s.selected, vec![ 1, 3 ] );
s.unselect( 1 );
assert_eq!( s.selected, vec![ 3 ] );
s.unselect( 99 ); // missing index is a no-op
assert_eq!( s.selected, vec![ 3 ] );
}
#[ test ]
fn combo_state_toggle_flips_open_flag()
{
let mut s = ComboState::new();
assert!( !s.is_open );
s.toggle_open();
assert!( s.is_open );
s.toggle_open();
assert!( !s.is_open );
}
#[ test ]
fn first_selected_label_returns_none_when_empty()
{
let c: Combo<()> = combo( st(), vec![ "Apple".into() ] );
assert!( c.first_selected_label().is_none() );
}
#[ test ]
fn first_selected_label_returns_first_selected_item()
{
let mut state = st();
state.selected = vec![ 1 ];
let c: Combo<()> = combo( state, vec![ "Apple".into(), "Banana".into() ] );
assert_eq!( c.first_selected_label(), Some( "Banana" ) );
}
#[ test ]
fn first_selected_label_handles_stale_index()
{
let mut state = st();
state.selected = vec![ 99 ]; // stale index — no panic
let c: Combo<()> = combo( state, vec![ "Apple".into() ] );
assert!( c.first_selected_label().is_none() );
}
}

374
src/widget/container.rs Normal file
View File

@@ -0,0 +1,374 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Color, Corners };
use crate::render::Canvas;
use super::Element;
/// A transparent wrapper that adds a background color or a themed
/// surface and padding around any child [`Element`].
///
/// Does not consume a flat index — it is invisible to focus/hit-testing.
///
/// Two background styles. [`Container::background`] paints a flat
/// colour rounded rect. [`Container::surface`] names a theme slot (a
/// `"type": "surface"` entry in the active `ThemeDocument`) which
/// resolves at paint time to a full Glass stack: gradient / solid
/// fill, outer drop shadow, inset shadows, backdrop blur. `surface`
/// takes precedence when both are set, and degrades to `background`
/// (or to no background at all, when neither is set) if the slot is
/// absent from the active theme — third-party themes that do not
/// ship the named surface still render the content, just without
/// the Glass chrome.
///
/// ```rust,no_run
/// # use ltk::{ column, container, row, text, Color, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex(
/// # icon: Element<Msg>,
/// # title: Element<Msg>,
/// # subtitle: Element<Msg>,
/// # ) -> ( Element<Msg>, Element<Msg> ) {
/// // Flat colour
/// let flat = container( text( "Hello" ) )
/// .background( Color::rgb( 0.2, 0.2, 0.25 ) )
/// .padding( 12.0 );
///
/// // Glass card backed by a named theme surface
/// let card = container(
/// row()
/// .push( icon )
/// .push( column().push( title ).push( subtitle ) )
/// )
/// .surface( "surface-card" )
/// .radius( 32.0 )
/// .padding_h( 16.5 )
/// .padding_v( 24.0 );
/// # ( flat.into(), card.into() )
/// # }
/// ```
pub struct Container<Msg: Clone>
{
pub child: Box<Element<Msg>>,
pub background: Option<Color>,
/// Slot id of a themed surface (resolved via
/// [`crate::theme::resolve_surface`]). When set, takes precedence
/// over `background` and paints the full Glass stack instead of a
/// flat colour fill.
pub surface: Option<String>,
/// Per-corner radii applied to every painted layer of the
/// container chrome — flat fill, themed surface (gradient + outer
/// shadows + insets + backdrop blur). Stored as [`Corners`] so
/// callers can pin the rounded shape to one or two corners (a
/// panel pinned to the screen bottom, a side panel pinned to the
/// left edge, …) without hitting the renderer with an offset
/// trick.
pub corners: Corners,
/// Padding on the top edge in logical px — gap between the
/// container's top boundary and its child.
pub pad_top: f32,
/// Padding on the right edge in logical px.
pub pad_right: f32,
/// Padding on the bottom edge in logical px.
pub pad_bottom: f32,
/// Padding on the left edge in logical px.
pub pad_left: f32,
pub opacity: f32,
/// Optional `( color, width_px )` border stroke painted around the
/// container's rounded rectangle, after the fill / surface and
/// before the child draws. `None` leaves the chrome flat.
pub border: Option<( Color, f32 )>,
}
impl<Msg: Clone> Container<Msg>
{
pub fn new( child: impl Into<Element<Msg>> ) -> Self
{
Self
{
child: Box::new( child.into() ),
background: None,
surface: None,
corners: Corners::ZERO,
pad_top: 0.0,
pad_right: 0.0,
pad_bottom: 0.0,
pad_left: 0.0,
opacity: 1.0,
border: None,
}
}
/// Paint a rounded-rect stroke around the container with the given
/// colour and pixel width. Useful for input fields, popovers and
/// any chrome the design system specifies as outlined rather than
/// filled.
pub fn border( mut self, color: Color, width: f32 ) -> Self
{
self.border = Some( ( color, width.max( 0.0 ) ) );
self
}
/// Set the background fill color. Ignored at paint time if a
/// themed [`surface`](Self::surface) is set and resolves against
/// the active theme.
pub fn background( mut self, color: Color ) -> Self
{
self.background = Some( color );
self
}
/// Back the container with a themed surface slot. The slot id is
/// resolved against the active `ThemeDocument` at paint time via
/// [`crate::theme::resolve_surface`]; missing slots fall through
/// to [`background`](Self::background) or to no background at all.
///
/// Slot ids are documented by the theme. The default theme ships
/// `surface-card` (generic Glass container) and the slider-specific
/// slots; downstream themes are free to add their own.
pub fn surface( mut self, slot: impl Into<String> ) -> Self
{
self.surface = Some( slot.into() );
self
}
/// Set the corner radii for every painted layer of the container
/// chrome. Accepts a single `f32` (uniform radius — the common
/// case, equivalent to `Corners::all( r )`), a tuple `( tl, tr,
/// br, bl )` (CSS shorthand order), or any explicit
/// [`Corners`] value.
///
/// ```rust,no_run
/// # use ltk::{ container, text, Corners, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> ( Element<Msg>, Element<Msg>, Element<Msg> ) {
/// // Uniform 16 px on all corners (single-value form).
/// let a = container( text( "child" ) ).radius( 16.0 );
///
/// // Rounded top corners only — for a panel pinned flush against
/// // the bottom edge of the screen.
/// let b = container( text( "child" ) ).radius( Corners::top( 16.0 ) );
///
/// // Custom four-corner radii.
/// let c = container( text( "child" ) ).radius( ( 16.0, 16.0, 0.0, 0.0 ) );
/// # ( a.into(), b.into(), c.into() )
/// # }
/// ```
pub fn radius( mut self, corners: impl Into<Corners> ) -> Self
{
self.corners = corners.into();
self
}
/// Set uniform padding on all four sides — equivalent to setting
/// `padding_top`, `padding_right`, `padding_bottom`, and
/// `padding_left` to `p`. Asymmetric variants
/// ([`padding_top`](Self::padding_top), …) override individual
/// edges, so calling this first and then a per-edge setter is the
/// idiomatic way to express "uniform padding except for one
/// edge".
pub fn padding( mut self, p: f32 ) -> Self
{
self.pad_top = p;
self.pad_right = p;
self.pad_bottom = p;
self.pad_left = p;
self
}
/// Set horizontal padding (left + right each).
pub fn padding_h( mut self, p: f32 ) -> Self
{
self.pad_left = p;
self.pad_right = p;
self
}
/// Set vertical padding (top + bottom each).
pub fn padding_v( mut self, p: f32 ) -> Self
{
self.pad_top = p;
self.pad_bottom = p;
self
}
/// Set the top edge padding only. Pairs with
/// [`padding_bottom`](Self::padding_bottom) for asymmetric
/// vertical insets.
pub fn padding_top( mut self, p: f32 ) -> Self
{
self.pad_top = p;
self
}
/// Set the right edge padding only.
pub fn padding_right( mut self, p: f32 ) -> Self
{
self.pad_right = p;
self
}
/// Set the bottom edge padding only.
pub fn padding_bottom( mut self, p: f32 ) -> Self
{
self.pad_bottom = p;
self
}
/// Set the left edge padding only.
pub fn padding_left( mut self, p: f32 ) -> Self
{
self.pad_left = p;
self
}
/// Set opacity for the entire container and its contents (0.0 = transparent, 1.0 = opaque).
pub fn opacity( mut self, alpha: f32 ) -> Self
{
self.opacity = alpha.clamp( 0.0, 1.0 );
self
}
/// Return the preferred `(width, height)` accounting for padding.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let pad_x = self.pad_left + self.pad_right;
let pad_y = self.pad_top + self.pad_bottom;
let inner_w = ( max_width - pad_x ).max( 0.0 );
let ( cw, ch ) = self.child.preferred_size( inner_w, canvas );
( cw + pad_x, ch + pad_y )
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Container<U>
where
U: Clone + 'static,
Msg: 'static,
{
Container
{
child: Box::new( self.child.map_arc( f ) ),
background: self.background,
surface: self.surface,
corners: self.corners,
pad_top: self.pad_top,
pad_right: self.pad_right,
pad_bottom: self.pad_bottom,
pad_left: self.pad_left,
opacity: self.opacity,
border: self.border,
}
}
}
/// Create a [`Container`] that wraps `child`.
pub fn container<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Container<Msg>
{
Container::new( child )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
#[ test ]
fn default_no_background()
{
let c = container::<()>( spacer() );
assert!( c.background.is_none() );
}
#[ test ]
fn padding_sets_all_four_sides()
{
let c = container::<()>( spacer() ).padding( 10.0 );
assert_eq!( c.pad_top, 10.0 );
assert_eq!( c.pad_right, 10.0 );
assert_eq!( c.pad_bottom, 10.0 );
assert_eq!( c.pad_left, 10.0 );
}
#[ test ]
fn padding_h_only_touches_left_and_right()
{
let c = container::<()>( spacer() ).padding_h( 8.0 );
assert_eq!( c.pad_left, 8.0 );
assert_eq!( c.pad_right, 8.0 );
assert_eq!( c.pad_top, 0.0 );
assert_eq!( c.pad_bottom, 0.0 );
}
#[ test ]
fn padding_v_only_touches_top_and_bottom()
{
let c = container::<()>( spacer() ).padding_v( 6.0 );
assert_eq!( c.pad_top, 6.0 );
assert_eq!( c.pad_bottom, 6.0 );
assert_eq!( c.pad_left, 0.0 );
assert_eq!( c.pad_right, 0.0 );
}
#[ test ]
fn per_edge_overrides_uniform_padding()
{
// Idiomatic "uniform with one override".
let c = container::<()>( spacer() )
.padding( 12.0 )
.padding_bottom( 22.0 );
assert_eq!( c.pad_top, 12.0 );
assert_eq!( c.pad_right, 12.0 );
assert_eq!( c.pad_bottom, 22.0 );
assert_eq!( c.pad_left, 12.0 );
}
#[ test ]
fn background_set()
{
use crate::types::Color;
let c = container::<()>( spacer() ).background( Color::BLACK );
assert!( c.background.is_some() );
}
#[ test ]
fn radius_set_uniform()
{
let c = container::<()>( spacer() ).radius( 12.0 );
assert_eq!( c.corners, Corners::all( 12.0 ) );
}
#[ test ]
fn radius_set_per_corner()
{
let c = container::<()>( spacer() ).radius( Corners::top( 16.0 ) );
assert_eq!( c.corners.tl, 16.0 );
assert_eq!( c.corners.tr, 16.0 );
assert_eq!( c.corners.br, 0.0 );
assert_eq!( c.corners.bl, 0.0 );
}
#[ test ]
fn radius_set_tuple()
{
let c = container::<()>( spacer() ).radius( ( 8.0, 4.0, 2.0, 1.0 ) );
assert_eq!( c.corners.tl, 8.0 );
assert_eq!( c.corners.tr, 4.0 );
assert_eq!( c.corners.br, 2.0 );
assert_eq!( c.corners.bl, 1.0 );
}
#[ test ]
fn default_no_surface()
{
let c = container::<()>( spacer() );
assert!( c.surface.is_none() );
}
#[ test ]
fn surface_stores_slot_id()
{
let c = container::<()>( spacer() ).surface( "surface-card" );
assert_eq!( c.surface.as_deref(), Some( "surface-card" ) );
}
}

617
src/widget/date_picker.rs Normal file
View File

@@ -0,0 +1,617 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! DatePicker — month-grid calendar widget.
//!
//! Stateless: the application owns the selected date and the
//! currently-visible month. Two callbacks bridge widget → app:
//!
//! - [`DatePicker::on_change`] fires when the user taps a day cell;
//! the message carries the picked [`Date`].
//! - [`DatePicker::on_navigate`] fires when the user taps the
//! previous / next-month arrows; the message carries the new
//! `(year, month)` that the calendar should display.
//!
//! `on_navigate` is optional — when not wired, the navigation arrows
//! render disabled. The application typically stores both `date:
//! Date` and a `view: Date` (or `(view_year, view_month)`) in its
//! state and updates `view` from `on_navigate` to let the user scroll
//! through months without changing the selection.
//!
//! Date arithmetic (leap years, day-of-week, month wraparound) is
//! built in — no `chrono` / `time` dependency. Limited to the
//! Gregorian calendar from year 1 onwards (Zeller's congruence).
//!
//! ```rust,no_run
//! # use ltk::{ date_picker, Date, DatePicker };
//! # #[ derive( Clone ) ] enum Msg { DateChanged( Date ), DateView( i32, u8 ) }
//! # struct App { date: Date, view_year: i32, view_month: u8, today: Date }
//! # impl App { fn _ex( &self ) -> DatePicker<Msg> {
//! date_picker( self.date )
//! .view( self.view_year, self.view_month )
//! .today( self.today )
//! .on_change( Msg::DateChanged )
//! .on_navigate( |y, m| Msg::DateView( y, m ) )
//! # }}
//! ```
use std::sync::Arc;
use crate::layout::column::column;
use crate::layout::spacer::spacer;
use crate::layout::wrap_grid::grid;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn surface() -> Color { crate::theme::palette().surface }
pub fn surface_alt() -> Color { crate::theme::palette().surface_alt }
pub fn text() -> Color { crate::theme::palette().text_primary }
pub fn text_muted() -> Color { crate::theme::palette().text_secondary }
pub fn accent() -> Color { crate::theme::palette().accent }
pub const PADDING: f32 = 16.0;
pub const RADIUS: f32 = 16.0;
pub const HEADER_FS: f32 = 16.0;
pub const DOW_FS: f32 = 12.0;
pub const DAY_FS: f32 = 14.0;
pub const CELL_SIZE: f32 = 36.0;
pub const SPACING: f32 = 4.0;
}
/// A calendar date in the proleptic Gregorian calendar. No time
/// component, no timezone. `month` is 112, `day` is 131 (further
/// constrained by [`days_in_month`]).
#[ derive( Clone, Copy, Debug, PartialEq, Eq, Hash ) ]
pub struct Date
{
pub year: i32,
pub month: u8,
pub day: u8,
}
impl Date
{
/// Construct a [`Date`] without validating bounds. Callers that
/// might pass user-controlled data should run [`Self::is_valid`]
/// first.
pub const fn new( year: i32, month: u8, day: u8 ) -> Self
{
Self { year, month, day }
}
/// `true` when `month` is in `1..=12` and `day` is a real day of
/// that month / year (29-Feb in leap years, etc.).
pub fn is_valid( self ) -> bool
{
( 1..=12 ).contains( &self.month )
&& self.day >= 1
&& self.day <= days_in_month( self.year, self.month )
}
}
/// `true` when `year` is a leap year in the proleptic Gregorian
/// calendar.
pub fn is_leap_year( year: i32 ) -> bool
{
( year % 4 == 0 && year % 100 != 0 ) || year % 400 == 0
}
/// Number of days in `month` of `year` (1-indexed month). Returns 0
/// for invalid month numbers.
pub fn days_in_month( year: i32, month: u8 ) -> u8
{
match month
{
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => if is_leap_year( year ) { 29 } else { 28 },
_ => 0,
}
}
/// Day of the week for `(year, month, day)`. `0 = Sunday`,
/// `1 = Monday`, …, `6 = Saturday`. Uses Zeller's congruence;
/// undefined for years before AD 1.
pub fn day_of_week( year: i32, month: u8, day: u8 ) -> u8
{
let ( m, y ) = if month < 3 { ( month as i32 + 12, year - 1 ) } else { ( month as i32, year ) };
let k = y.rem_euclid( 100 );
let j = y.div_euclid( 100 );
let h = ( day as i32 + ( 13 * ( m + 1 ) ) / 5 + k + k / 4 + j / 4 + 5 * j ).rem_euclid( 7 );
// h: 0=Saturday, 1=Sunday, 2=Monday, ..., 6=Friday → re-map to 0=Sun..6=Sat
( ( h + 6 ).rem_euclid( 7 ) ) as u8
}
/// Add `delta` months to `(year, month)` with wraparound. Negative
/// deltas walk backwards; year crosses are handled.
pub fn add_months( year: i32, month: u8, delta: i32 ) -> ( i32, u8 )
{
let total = year * 12 + ( month as i32 ) - 1 + delta;
let new_year = total.div_euclid( 12 );
let new_month = ( total.rem_euclid( 12 ) + 1 ) as u8;
( new_year, new_month )
}
/// Locale settings for the date picker. Month names and day-of-week
/// labels are pulled from the i18n registry via
/// [`rust_i18n::t!`] so changing the active locale (e.g. `set_locale("es")`)
/// flips the calendar without recreating the widget. The remaining piece is
/// the **first day of the week**, which varies independently of language
/// (US starts on Sunday, ES / FR / DE on Monday) — that one stays as a
/// builder field.
#[ derive( Clone, Copy, Debug ) ]
pub struct Locale
{
/// First day of the week (0 = Sunday, 1 = Monday). Default 1
/// (Monday) — the convention used across most of Europe.
pub first_dow: u8,
}
impl Locale
{
/// Locale starting the week on Monday. The default.
pub const MONDAY_FIRST: Self = Self { first_dow: 1 };
/// Locale starting the week on Sunday — common in US English.
pub const SUNDAY_FIRST: Self = Self { first_dow: 0 };
}
impl Default for Locale
{
fn default() -> Self { Self::MONDAY_FIRST }
}
/// Translated month name (1-indexed: `month = 1` → January, …,
/// `month = 12` → December). Resolves through the i18n registry, so
/// `set_locale("es")` returns `"Enero"`, etc.
fn month_name( month: u8 ) -> String
{
match month
{
1 => rust_i18n::t!( "date_picker.month_1" ).to_string(),
2 => rust_i18n::t!( "date_picker.month_2" ).to_string(),
3 => rust_i18n::t!( "date_picker.month_3" ).to_string(),
4 => rust_i18n::t!( "date_picker.month_4" ).to_string(),
5 => rust_i18n::t!( "date_picker.month_5" ).to_string(),
6 => rust_i18n::t!( "date_picker.month_6" ).to_string(),
7 => rust_i18n::t!( "date_picker.month_7" ).to_string(),
8 => rust_i18n::t!( "date_picker.month_8" ).to_string(),
9 => rust_i18n::t!( "date_picker.month_9" ).to_string(),
10 => rust_i18n::t!( "date_picker.month_10" ).to_string(),
11 => rust_i18n::t!( "date_picker.month_11" ).to_string(),
12 => rust_i18n::t!( "date_picker.month_12" ).to_string(),
_ => "?".to_string(),
}
}
/// Translated single-letter day-of-week label keyed by `dow` where
/// `0 = Sunday`, …, `6 = Saturday`. The display order in the calendar
/// header is rotated by [`Locale::first_dow`].
fn dow_short( dow: u8 ) -> String
{
match dow
{
0 => rust_i18n::t!( "date_picker.dow_short_0" ).to_string(),
1 => rust_i18n::t!( "date_picker.dow_short_1" ).to_string(),
2 => rust_i18n::t!( "date_picker.dow_short_2" ).to_string(),
3 => rust_i18n::t!( "date_picker.dow_short_3" ).to_string(),
4 => rust_i18n::t!( "date_picker.dow_short_4" ).to_string(),
5 => rust_i18n::t!( "date_picker.dow_short_5" ).to_string(),
6 => rust_i18n::t!( "date_picker.dow_short_6" ).to_string(),
_ => "?".to_string(),
}
}
/// Calendar date selector.
pub struct DatePicker<Msg: Clone>
{
pub value: Date,
pub view_year: i32,
pub view_month: u8,
pub today: Option<Date>,
pub on_change: Option<Arc<dyn Fn( Date ) -> Msg>>,
pub on_navigate: Option<Arc<dyn Fn( i32, u8 ) -> Msg>>,
pub locale: Locale,
}
impl<Msg: Clone + 'static> DatePicker<Msg>
{
/// Create a date picker with the given selected date. The view
/// month defaults to the same month as `value`; override with
/// [`Self::view`] when the user is browsing without selecting.
pub fn new( value: Date ) -> Self
{
Self
{
value,
view_year: value.year,
view_month: value.month,
today: None,
on_change: None,
on_navigate: None,
locale: Locale::default(),
}
}
/// Override the visible month. Call this from your view function
/// with whatever `(year, month)` your application state stores
/// for "the calendar's current page".
pub fn view( mut self, year: i32, month: u8 ) -> Self
{
self.view_year = year;
self.view_month = month;
self
}
/// Mark a specific date as "today" — drawn with a subtle accent
/// ring even if it is not selected.
pub fn today( mut self, today: Date ) -> Self
{
self.today = Some( today );
self
}
/// Day-tap callback. Required for the picker to be interactive.
pub fn on_change( mut self, f: impl Fn( Date ) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
/// Arrow-tap callback. The runtime calls `f(new_year, new_month)`
/// when the user taps prev / next. Wire to your view-month state.
pub fn on_navigate( mut self, f: impl Fn( i32, u8 ) -> Msg + 'static ) -> Self
{
self.on_navigate = Some( Arc::new( f ) );
self
}
/// Override the locale (month / day-of-week names + week start).
pub fn locale( mut self, l: Locale ) -> Self
{
self.locale = l;
self
}
/// Build the `Element` tree representing this date picker.
pub fn build( self ) -> Element<Msg>
{
use super::{ button, container, icon_button, text };
use super::pressable::pressable;
use super::button::ButtonVariant;
use crate::layout::stack::{ stack, HAlign, VAlign };
let view_y = self.view_year;
let view_m = self.view_month.clamp( 1, 12 );
let today = self.today;
let value = self.value;
let on_chg = self.on_change.clone();
let on_nav = self.on_navigate.clone();
let first = self.locale.first_dow;
// Header chevrons load from the active theme as SVG icons
// (`icons/catalogue/filled/multimedia/{previous,next}.svg`),
// tinted to the primary text colour so they read against
// either light or dark surfaces. Sized small so the buttons
// sit visually inside the grid's first / last column.
// Falls back to the matching Unicode glyph if the icon is
// missing from the theme.
const CHEVRON_PX: u32 = 18;
let nav_button = | name: &str, fallback: &str | -> super::button::Button<Msg>
{
match crate::theme::icon_rgba( name, CHEVRON_PX )
{
Some( ( rgba, w, h ) ) =>
{
let tinted = std::sync::Arc::new(
crate::theme::tint_symbolic( &rgba, theme::text() ),
);
icon_button::<Msg>( tinted, w, h ).icon_size( CHEVRON_PX as f32 )
}
None => button::<Msg>( fallback ).variant( ButtonVariant::Tertiary ),
}
};
let title = format!( "{} {}", month_name( view_m ), view_y );
let mut prev = nav_button( "multimedia/previous", "" );
let mut next = nav_button( "multimedia/next", "" );
if let Some( ref nav ) = on_nav
{
let ( py, pm ) = add_months( view_y, view_m, -1 );
let ( ny, nm ) = add_months( view_y, view_m, 1 );
let nav_p = nav.clone();
let nav_n = nav.clone();
prev = prev.on_press( nav_p( py, pm ) );
next = next.on_press( nav_n( ny, nm ) );
}
// Header: title centred horizontally; prev pinned to the left
// edge, next pinned to the right edge — both at the same
// padding offset as the calendar grid below, so they align
// vertically with the first and last day columns. Built as a
// `Stack` (the only layout that lets independent children
// sit at left / centre / right of the same rect) wrapped in a
// container that fixes the row height.
let header_height = ( CHEVRON_PX as f32 + 16.0 ).max( theme::HEADER_FS + 16.0 );
let header_inner: Element<Msg> = stack::<Msg>()
.push_aligned( prev, HAlign::Start, VAlign::Center )
.push_aligned(
text( title ).size( theme::HEADER_FS ).color( theme::text() ),
HAlign::Center, VAlign::Center,
)
.push_aligned( next, HAlign::End, VAlign::Center )
.into();
let header: Element<Msg> = container::<Msg>( header_inner )
.padding_v( ( header_height - CHEVRON_PX as f32 ) * 0.5 )
.padding_h( 0.0 )
.into();
// Day-of-week row in the locale's display order. Built as a
// 7-column `wrap_grid` so the columns share the same
// equal-width slots that the day-cell grid below will use,
// guaranteeing letter-and-number alignment frame to frame
// regardless of glyph width (`W` is wider than `M`, etc.).
let mut dow_row = grid::<Msg>( 7 ).spacing( theme::SPACING );
for slot in 0..7u8
{
// Convert the column index (display order) back to a
// weekday number (0 = Sunday) honouring `first_dow`.
let dow = ( ( slot + first ) % 7 ) as u8;
let cell: Element<Msg> = container::<Msg>(
text( dow_short( dow ) )
.size( theme::DOW_FS )
.color( theme::text_muted() )
.align_center(),
)
.padding_h( 0.0 )
.padding_v( 4.0 )
.into();
dow_row = dow_row.push( cell );
}
// Day grid: 7 columns × 6 rows of equal-width cells. Off-month
// slots stay empty so the calendar always reserves the same
// height; the `wrap_grid` lays out each cell in its own slot
// so vertical alignment with the DOW row above is automatic.
let dim = days_in_month( view_y, view_m );
let first_dow = day_of_week( view_y, view_m, 1 );
let leading_blanks = ( ( first_dow as i32 - first as i32 ).rem_euclid( 7 ) ) as u8;
let total_cells = 6 * 7;
let mut day_grid = grid::<Msg>( 7 ).spacing( theme::SPACING );
for slot in 0..total_cells
{
let day_num = slot as i32 - leading_blanks as i32 + 1;
let cell: Element<Msg> = if day_num < 1 || day_num > dim as i32
{
container::<Msg>( spacer() ).padding( 0.0 ).into()
} else {
let day = day_num as u8;
let date = Date::new( view_y, view_m, day );
let is_selected = date == value;
let is_today = today == Some( date );
let label_color = if is_selected { theme::surface() } else { theme::text() };
let bg = if is_selected
{
Some( theme::accent() )
} else if is_today {
Some( theme::surface_alt() )
} else {
None
};
let mut card = container::<Msg>(
text( format!( "{}", day ) )
.size( theme::DAY_FS )
.color( label_color )
.align_center(),
)
.padding_h( 4.0 )
.padding_v( 8.0 )
.radius( theme::CELL_SIZE * 0.5 );
if let Some( c ) = bg { card = card.background( c ); }
if let Some( ref cb ) = on_chg
{
let m = cb( date );
pressable( card ).on_press( m ).into()
} else {
card.into()
}
};
day_grid = day_grid.push( cell );
}
container::<Msg>(
column::<Msg>()
.spacing( 0.0 )
.push( header )
// Extra breathing space between the month title and
// the day-of-week header so the row separation reads
// cleanly. Tuned by feedback — the previous shared
// `SPACING * 2.0` was too tight.
.push( spacer().height( theme::SPACING * 4.0 ) )
.push( dow_row )
.push( spacer().height( theme::SPACING * 1.5 ) )
.push( day_grid ),
)
.background( theme::surface_alt() )
.padding( theme::PADDING )
.radius( theme::RADIUS )
.into()
}
}
impl<Msg: Clone + 'static> From<DatePicker<Msg>> for Element<Msg>
{
fn from( d: DatePicker<Msg> ) -> Self { d.build() }
}
/// Create a [`DatePicker`] with the given selected date.
///
/// ```rust,no_run
/// # use ltk::{ date_picker, Date, DatePicker };
/// # #[ derive( Clone ) ] enum Msg { DateChanged( Date ), DateView( i32, u8 ) }
/// # struct App { date: Date, today: Date }
/// # impl App { fn _ex( &self ) -> DatePicker<Msg> {
/// date_picker( self.date )
/// .today( self.today )
/// .on_change( Msg::DateChanged )
/// .on_navigate( Msg::DateView )
/// # }}
/// ```
pub fn date_picker<Msg: Clone + 'static>( value: Date ) -> DatePicker<Msg>
{
DatePicker::new( value )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
// ── leap years ────────────────────────────────────────────────────────────
#[ test ]
fn leap_year_rules()
{
assert!( is_leap_year( 2024 ) );
assert!( is_leap_year( 2000 ) ); // div by 400
assert!( !is_leap_year( 1900 ) ); // div by 100 but not 400
assert!( !is_leap_year( 2023 ) );
assert!( is_leap_year( -4 ) ); // proleptic
}
#[ test ]
fn days_in_month_handles_leap_february()
{
assert_eq!( days_in_month( 2024, 2 ), 29 );
assert_eq!( days_in_month( 2023, 2 ), 28 );
assert_eq!( days_in_month( 2024, 4 ), 30 );
assert_eq!( days_in_month( 2024, 7 ), 31 );
}
#[ test ]
fn days_in_month_zero_for_invalid_month()
{
assert_eq!( days_in_month( 2024, 0 ), 0 );
assert_eq!( days_in_month( 2024, 13 ), 0 );
}
// ── day of week ───────────────────────────────────────────────────────────
#[ test ]
fn day_of_week_known_dates()
{
// 1970-01-01 was a Thursday → 4.
assert_eq!( day_of_week( 1970, 1, 1 ), 4 );
// 2000-01-01 was a Saturday → 6.
assert_eq!( day_of_week( 2000, 1, 1 ), 6 );
// 2024-02-29 was a Thursday → 4 (leap day).
assert_eq!( day_of_week( 2024, 2, 29 ), 4 );
// 2026-05-02 (today, per user clock) was a Saturday → 6.
assert_eq!( day_of_week( 2026, 5, 2 ), 6 );
}
// ── month arithmetic ──────────────────────────────────────────────────────
#[ test ]
fn add_months_within_year()
{
assert_eq!( add_months( 2024, 3, 1 ), ( 2024, 4 ) );
assert_eq!( add_months( 2024, 3, -1 ), ( 2024, 2 ) );
}
#[ test ]
fn add_months_wraps_into_next_year()
{
assert_eq!( add_months( 2024, 12, 1 ), ( 2025, 1 ) );
}
#[ test ]
fn add_months_wraps_into_previous_year()
{
assert_eq!( add_months( 2024, 1, -1 ), ( 2023, 12 ) );
}
#[ test ]
fn add_months_handles_large_deltas()
{
assert_eq!( add_months( 2024, 1, 25 ), ( 2026, 2 ) );
assert_eq!( add_months( 2024, 1, -25 ), ( 2021, 12 ) );
}
// ── Date validity ─────────────────────────────────────────────────────────
#[ test ]
fn date_is_valid_on_realistic_inputs()
{
assert!( Date::new( 2024, 2, 29 ).is_valid() );
assert!( !Date::new( 2023, 2, 29 ).is_valid() ); // not leap
assert!( !Date::new( 2024, 4, 31 ).is_valid() ); // april has 30
assert!( !Date::new( 2024, 0, 1 ).is_valid() );
assert!( !Date::new( 2024, 13, 1 ).is_valid() );
assert!( !Date::new( 2024, 1, 0 ).is_valid() );
}
// ── builders ──────────────────────────────────────────────────────────────
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Pick( Date ),
Nav( i32, u8 ),
}
#[ test ]
fn defaults_view_to_value_month()
{
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) );
assert_eq!( d.view_year, 2024 );
assert_eq!( d.view_month, 7 );
}
#[ test ]
fn view_builder_overrides()
{
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) ).view( 2025, 1 );
assert_eq!( d.view_year, 2025 );
assert_eq!( d.view_month, 1 );
}
#[ test ]
fn on_change_callback_invokes_with_date()
{
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) )
.on_change( Msg::Pick );
let cb = d.on_change.as_ref().expect( "set" );
assert_eq!( cb( Date::new( 2024, 7, 16 ) ), Msg::Pick( Date::new( 2024, 7, 16 ) ) );
}
#[ test ]
fn on_navigate_callback_invokes_with_year_month()
{
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) )
.on_navigate( Msg::Nav );
let cb = d.on_navigate.as_ref().expect( "set" );
assert_eq!( cb( 2025, 1 ), Msg::Nav( 2025, 1 ) );
}
#[ test ]
fn build_does_not_panic_on_minimal_config()
{
let _: Element<Msg> = date_picker::<Msg>( Date::new( 2024, 7, 15 ) ).build();
}
#[ test ]
fn build_does_not_panic_when_view_month_is_clamped()
{
// `view_month = 0` is invalid — build clamps to 1 instead of
// indexing month_names at -1.
let mut d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) );
d.view_month = 0;
let _: Element<Msg> = d.build();
}
}

451
src/widget/dialog.rs Normal file
View File

@@ -0,0 +1,451 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Modal / non-modal centered dialog: a darkened scrim with a card
//! holding a title, an optional subtitle, an optional body, and a row
//! of action buttons.
//!
//! The widget is implemented as a thin builder over existing
//! primitives: at conversion time
//! ([`From<Dialog<Msg>> for Element<Msg>`](Dialog#impl-From%3CDialog%3CMsg%3E%3E-for-Element%3CMsg%3E))
//! it lowers itself to a [`Stack`](crate::layout::stack::Stack) of
//!
//! 1. a full-surface [`Pressable`](crate::widget::pressable::Pressable)
//! scrim — `swallow=true` so it absorbs every pointer event that
//! misses the card (so widgets behind the dialog cannot be clicked),
//! `on_escape=cancel_msg` so the keyboard ESC handler can find it,
//! and `on_press=dismiss_msg` only when the dialog is non-modal and
//! a `dismiss_on_scrim` was configured;
//! 2. a centered card backed by a flat opaque fill (`palette.surface`
//! with alpha forced to 1.0 — themed `surface-card` / similar
//! Glass surfaces ship as translucent for the rest of the toolkit,
//! but a confirmation dialog must read against any background, so
//! the dialog opts out of the Glass chrome). The card wraps a
//! *card-area* `Pressable( swallow=true )` so the body itself
//! silently absorbs taps and only clicks strictly outside the card
//! can dismiss the dialog. Inside the card sits a column with the
//! title (700-weight, wraps), the subtitle
//! (`text_secondary`, wraps), the user-supplied body, and a
//! right-aligned actions row.
//!
//! ## Example
//!
//! ```rust,no_run
//! # use ltk::{ button, dialog, ButtonVariant, Element };
//! # #[ derive( Clone ) ] enum Msg { Cancel, Confirm }
//! # fn _ex() -> Element<Msg> {
//! dialog()
//! .title( "Delete partition?" )
//! .subtitle( "This will erase every file on /dev/sda2." )
//! .cancel( Msg::Cancel )
//! .action( button::<Msg>( "Cancel" )
//! .variant( ButtonVariant::Tertiary )
//! .on_press( Msg::Cancel ) )
//! .action( button::<Msg>( "Delete" )
//! .variant( ButtonVariant::Primary )
//! .on_press( Msg::Confirm ) )
//! .into()
//! # }
//! ```
//!
//! ## Modality and dismissal
//!
//! `modal` is `true` by default — every pointer event outside the card
//! is silently swallowed and underlying widgets cannot be reached. Set
//! `modal( false )` to let pointer events pass through to the
//! application **except** that you can still wire a
//! [`Dialog::dismiss_on_scrim`] message that fires only when the user
//! taps strictly outside the card. `dismiss_on_scrim` is rejected at
//! build time for a modal dialog (the contract is "modality means no
//! escape", so an "escape via tap-outside" message is contradictory).
//!
//! `Esc` always fires the [`Dialog::cancel`] message — independent of
//! modality. Wire `cancel` to the same message your "Cancel" /
//! "Dismiss" action button uses so keyboard ESC matches the click
//! behaviour.
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
use crate::layout::stack::stack;
use crate::types::{ Color, Corners };
use super::container::container;
use super::pressable::pressable;
use super::text;
use super::Element;
/// Default scrim opacity over the underlying surface.
pub const SCRIM_ALPHA: f32 = 0.45;
/// Default card max-width (logical pixels). Override with
/// [`Dialog::max_width`].
pub const DEFAULT_MAX_WIDTH: f32 = 480.0;
/// Default card corner radius.
pub const CARD_RADIUS: f32 = 16.0;
/// Default card padding (uniform).
pub const CARD_PADDING: f32 = 24.0;
/// Default vertical gap between the title, subtitle, body, and
/// actions row.
pub const SECTION_GAP: f32 = 12.0;
/// Default horizontal gap between action buttons.
pub const ACTION_GAP: f32 = 8.0;
/// Default title font size.
pub const TITLE_SIZE: f32 = 22.0;
/// Default title weight.
pub const TITLE_WEIGHT: u16 = 700;
/// Default subtitle font size.
pub const SUBTITLE_SIZE: f32 = 14.0;
/// A centered confirmation dialog with optional title, subtitle, body
/// and action buttons.
pub struct Dialog<Msg: Clone>
{
pub title: Option<String>,
pub subtitle: Option<String>,
/// User-supplied body element rendered between the subtitle and
/// the action row. Use this for sliders, lists, spinners or any
/// other custom content.
pub body: Option<Box<Element<Msg>>>,
pub actions: Vec<Element<Msg>>,
pub modal: bool,
/// Optional message dispatched when the user taps the scrim
/// (strictly outside the card). Always `None` when [`Self::modal`]
/// is `true`. Construction panics if both are set together.
pub dismiss_msg: Option<Msg>,
/// Optional message dispatched when the user presses `Escape`
/// while the dialog is on screen. Wire this to the same message
/// your "Cancel" action button uses.
pub cancel_msg: Option<Msg>,
pub max_width: f32,
}
impl<Msg: Clone> Default for Dialog<Msg>
{
fn default() -> Self
{
Self::new()
}
}
impl<Msg: Clone> Dialog<Msg>
{
/// Construct a default modal dialog with no title, subtitle,
/// body, or actions. Build it up with the chained setters.
pub fn new() -> Self
{
Self
{
title: None,
subtitle: None,
body: None,
actions: Vec::new(),
modal: true,
dismiss_msg: None,
cancel_msg: None,
max_width: DEFAULT_MAX_WIDTH,
}
}
/// Set the dialog title. Wraps across multiple lines if it does
/// not fit on a single one.
pub fn title( mut self, t: impl Into<String> ) -> Self
{
self.title = Some( t.into() );
self
}
/// Set the dialog subtitle. Wraps across multiple lines if it
/// does not fit on a single one.
pub fn subtitle( mut self, s: impl Into<String> ) -> Self
{
self.subtitle = Some( s.into() );
self
}
/// Replace the dialog body with a custom element — slider,
/// progress indicator, list, anything. Rendered between the
/// subtitle and the action row.
pub fn body( mut self, e: impl Into<Element<Msg>> ) -> Self
{
self.body = Some( Box::new( e.into() ) );
self
}
/// Append an action element to the right-aligned action row.
/// Typically a [`Button`](crate::widget::button::Button); any
/// `Element` is accepted.
pub fn action( mut self, e: impl Into<Element<Msg>> ) -> Self
{
self.actions.push( e.into() );
self
}
/// Toggle modality. Default: `true` (every pointer event outside
/// the card is silently absorbed).
pub fn modal( mut self, on: bool ) -> Self
{
self.modal = on;
self
}
/// Bind a message dispatched when the user taps the scrim
/// (outside the card). Only valid for non-modal dialogs;
/// construction panics if combined with `modal( true )`.
pub fn dismiss_on_scrim( mut self, msg: Msg ) -> Self
{
self.dismiss_msg = Some( msg );
self
}
/// Bind a message dispatched when the user presses `Escape`.
/// Wire the same message your "Cancel" / "Dismiss" action button
/// uses so keyboard and pointer behaviour match.
pub fn cancel( mut self, msg: Msg ) -> Self
{
self.cancel_msg = Some( msg );
self
}
/// Override the card's maximum width in logical pixels. Default
/// is `480.0`.
pub fn max_width( mut self, w: f32 ) -> Self
{
self.max_width = w;
self
}
}
impl<Msg: Clone + 'static> From<Dialog<Msg>> for Element<Msg>
{
fn from( d: Dialog<Msg> ) -> Element<Msg>
{
assert!(
!( d.modal && d.dismiss_msg.is_some() ),
"dialog: dismiss_on_scrim is not valid when modal=true",
);
let palette = crate::theme::palette();
// 1. Inner card column: title, subtitle, body, actions.
let mut card_col = column::<Msg>().spacing( SECTION_GAP );
if let Some( title ) = d.title
{
card_col = card_col.push(
text( title )
.size( TITLE_SIZE )
.weight( TITLE_WEIGHT )
.color( palette.text_primary )
.wrap( true ),
);
}
if let Some( subtitle ) = d.subtitle
{
card_col = card_col.push(
text( subtitle )
.size( SUBTITLE_SIZE )
.color( palette.text_secondary )
.wrap( true ),
);
}
if let Some( body ) = d.body
{
card_col = card_col.push( *body );
}
if !d.actions.is_empty()
{
let mut actions_row = row::<Msg>().spacing( ACTION_GAP ).push( spacer() );
for a in d.actions
{
actions_row = actions_row.push( a );
}
card_col = card_col.push( actions_row );
}
// 2. Card surface — flat opaque fill, no themed surface stack.
// Themed surfaces (`surface-card` / `surface-dialog`) ship as
// translucent Glass for the rest of the toolkit; for a
// confirmation dialog the body must read against any
// background, so we force `palette.surface` to full opacity
// and skip the Glass chrome.
let card_bg = Color { a: 1.0, ..palette.surface };
let card = container::<Msg>( card_col )
.background( card_bg )
.radius( Corners::all( CARD_RADIUS ) )
.padding( CARD_PADDING );
// 3. Card-area swallow: a Pressable wrapping the card so
// taps on the body silently absorb without firing the
// scrim's `dismiss_msg`. Always armed (modal or not) — the
// card never dismisses.
let card_swallow = pressable::<Msg>( card ).swallow( true );
// 4. Center the card on the screen. The outer column claims
// the full surface; `center_y` + `align_center_x` keep the
// card vertically and horizontally centered, and `max_width`
// caps it at `d.max_width` even on ultra-wide layouts.
let centered = column::<Msg>()
.center_y( true )
.align_center_x( true )
.max_width( d.max_width )
.push( card_swallow );
// 5. Scrim — a full-bleed Pressable with the dim layer
// rendered behind it. `swallow=true` means it always shows
// up in `widget_rects` (modal dialogs need this so missing
// hits do not fall through to the underlying app); when
// non-modal AND a `dismiss_msg` was configured, taps fire
// the message. The cancel-on-ESC binding lives here too.
let scrim_bg = container::<Msg>( spacer() )
.background( Color { r: 0.0, g: 0.0, b: 0.0, a: SCRIM_ALPHA } )
.radius( Corners::ZERO );
let mut scrim_press = pressable::<Msg>( scrim_bg ).swallow( true );
if let Some( msg ) = d.dismiss_msg
{
scrim_press = scrim_press.on_press( msg );
}
if let Some( msg ) = d.cancel_msg
{
scrim_press = scrim_press.on_escape( msg );
}
// 6. Stack scrim + centered card. Layout walker pushes
// children in order, so the scrim's `flat_idx` < the card
// area's < each action button's; `iter().rev()` hit-testing
// therefore finds buttons first, then the card-area
// swallow, and finally the scrim outside the card.
stack::<Msg>()
.push( scrim_press )
.push( centered )
.into()
}
}
/// Construct a [`Dialog`]. See the type's documentation for the full
/// builder API and the lowering details.
pub fn dialog<Msg: Clone>() -> Dialog<Msg>
{
Dialog::new()
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Cancel, Dismiss }
#[ test ]
fn new_defaults_are_modal_with_no_content()
{
let d = Dialog::<Msg>::new();
assert!( d.modal );
assert!( d.title.is_none() );
assert!( d.subtitle.is_none() );
assert!( d.body.is_none() );
assert!( d.actions.is_empty() );
assert!( d.dismiss_msg.is_none() );
assert!( d.cancel_msg.is_none() );
assert_eq!( d.max_width, DEFAULT_MAX_WIDTH );
}
#[ test ]
fn title_and_subtitle_builders_set_strings()
{
let d = Dialog::<Msg>::new()
.title( "Hello" )
.subtitle( "World" );
assert_eq!( d.title.as_deref(), Some( "Hello" ) );
assert_eq!( d.subtitle.as_deref(), Some( "World" ) );
}
#[ test ]
fn body_builder_replaces_existing_body()
{
let d = Dialog::<Msg>::new()
.body( spacer() )
.body( spacer() );
assert!( d.body.is_some() );
}
#[ test ]
fn action_builder_appends_in_order()
{
let d = Dialog::<Msg>::new()
.action( spacer() )
.action( spacer() )
.action( spacer() );
assert_eq!( d.actions.len(), 3 );
}
#[ test ]
fn modal_builder_toggles_flag()
{
assert!( !Dialog::<Msg>::new().modal( false ).modal );
assert!( Dialog::<Msg>::new().modal( true ).modal );
}
#[ test ]
fn dismiss_on_scrim_builder_records_message()
{
let d = Dialog::<Msg>::new()
.modal( false )
.dismiss_on_scrim( Msg::Dismiss );
assert_eq!( d.dismiss_msg, Some( Msg::Dismiss ) );
}
#[ test ]
fn cancel_builder_records_escape_message()
{
let d = Dialog::<Msg>::new().cancel( Msg::Cancel );
assert_eq!( d.cancel_msg, Some( Msg::Cancel ) );
}
#[ test ]
fn max_width_builder_overrides_default()
{
let d = Dialog::<Msg>::new().max_width( 720.0 );
assert_eq!( d.max_width, 720.0 );
}
#[ test ]
#[ should_panic( expected = "dismiss_on_scrim is not valid when modal=true" ) ]
fn modal_with_dismiss_panics_at_lower()
{
// Construction allows the combination; only the lowering
// to `Element` enforces the contract — that is where the
// invariant matters because that is where input dispatch
// would have to honour two contradictory rules.
let d = Dialog::<Msg>::new()
.modal( true )
.dismiss_on_scrim( Msg::Dismiss );
let _: Element<Msg> = d.into();
}
#[ test ]
fn non_modal_with_dismiss_lowers_cleanly()
{
let d = Dialog::<Msg>::new()
.modal( false )
.dismiss_on_scrim( Msg::Dismiss )
.cancel( Msg::Cancel )
.title( "Title" );
// Lowering must not panic.
let _: Element<Msg> = d.into();
}
#[ test ]
fn modal_with_cancel_only_lowers_cleanly()
{
// modal=true + cancel(...) (no dismiss_on_scrim) is the
// canonical confirm-dialog shape.
let d = Dialog::<Msg>::new()
.title( "Confirm" )
.subtitle( "Are you sure?" )
.cancel( Msg::Cancel );
let _: Element<Msg> = d.into();
}
}

182
src/widget/external.rs Normal file
View File

@@ -0,0 +1,182 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Widget that hosts content rendered by an external GL producer.
//!
//! Reserves layout space and, at draw time, samples a caller-provided GL
//! texture into the LTK canvas via [`Canvas::draw_external_texture`]. The
//! producer (a web engine, a video decoder, …) keeps ownership of the
//! texture and updates it on its own cadence; LTK only composites.
//!
//! # Source closure contract
//!
//! [`ExternalSource::Texture`] carries an
//! `Arc<dyn Fn(&glow::Context, Rect) -> Option<glow::Texture>>` that LTK
//! invokes once per frame, with its GLES context current. The producer:
//!
//! 1. Reads `Rect` (in physical pixels of the host surface) — useful for
//! sizing the inner viewport (e.g. resizing a `WPEToplevel` to match)
//! and for translating input coordinates by `rect.origin`.
//! 2. May allocate / update GL textures against the supplied
//! `glow::Context`.
//! 3. May bind extension-imported EGLImages onto a persistent texture.
//! 4. Returns the `glow::Texture` to sample, or `None` to paint
//! transparent (e.g. while the first frame is still being produced).
//!
//! Returning `None` for one frame and `Some` for the next is fine; LTK
//! re-invokes on every redraw.
//!
//! # Use cases
//!
//! * `ltk-webkit` hosting a `WPEView`-rendered page.
//! * Video / media playback widgets that decode into a GL texture.
//! * Any embedding that already has a GLES producer and wants its output
//! in-line with the rest of the LTK widget tree.
//!
//! # Example
//!
//! ```rust,no_run
//! # use std::sync::{ Arc, Mutex };
//! # use ltk::{ External, ExternalSource, Element };
//! # #[ derive( Clone ) ] enum Msg {}
//! # fn _ex() -> Element<Msg> {
//! let cached_texture: Arc<Mutex<Option<glow::Texture>>> = Arc::new( Mutex::new( None ) );
//! let cached = Arc::clone( &cached_texture );
//! External::new(
//! 800.0, 600.0,
//! ExternalSource::Texture( Arc::new( move | _gl, _rect | -> Option<glow::Texture>
//! {
//! *cached.lock().ok()?
//! } ) ),
//! ).into()
//! # }
//! ```
use std::sync::Arc;
use crate::render::Canvas;
use crate::types::Rect;
/// A widget that defers its pixels to an external GL texture producer.
///
/// The widget itself is non-interactive and produces no messages. Wrap it
/// in a [`crate::widget::pressable::Pressable`] (or similar) when input
/// capture is needed.
pub struct External
{
/// Reserved width in logical pixels.
pub width: f32,
/// Reserved height in logical pixels.
pub height: f32,
/// Source of the GL texture sampled at draw time.
pub source: ExternalSource,
/// Opacity multiplier in `[0.0, 1.0]`.
pub opacity: f32,
}
/// Backends an [`External`] widget can pull pixels from.
///
/// The only variant today is [`ExternalSource::Texture`], a closure
/// returning the current GL texture name. Future variants (DMA-BUF
/// imports, software pixmaps for the CPU backend, …) can be added
/// without changing call sites.
#[ derive( Clone ) ]
pub enum ExternalSource
{
/// Closure invoked once per frame with LTK's [`glow::Context`] current
/// and the widget's laid-out rect (in physical pixels of the host
/// surface). The producer can allocate textures, bind extension-
/// imported EGLImages, etc., and returns the texture name to sample.
/// Returning `None` paints transparent — useful while the producer
/// is still warming up its first frame. The rect is exposed so
/// embedders can size their inner viewport to match LTK's actual
/// allocation (e.g. resize a WPEToplevel on layout change) and
/// translate input coordinates by `rect.origin`.
Texture( Arc<dyn Fn( &glow::Context, Rect ) -> Option<glow::Texture> + Send + Sync> ),
}
impl External
{
/// Build a new external-content widget reserving `width × height`
/// logical pixels.
pub fn new( width: f32, height: f32, source: ExternalSource ) -> Self
{
Self { width, height, source, opacity: 1.0 }
}
/// Override the opacity multiplier. Default: `1.0`.
pub fn opacity( mut self, opacity: f32 ) -> Self
{
self.opacity = opacity.clamp( 0.0, 1.0 );
self
}
pub fn preferred_size( &self, _max_width: f32 ) -> ( f32, f32 )
{
( self.width, self.height )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
// SAFETY-ish: this is the only call site of the source closure;
// LTK's draw pass guarantees the GLES context is current and
// the canvas is bound to its FBO. The rect we pass is the
// widget's laid-out rect in physical pixels — the same
// coordinate space pointer events arrive in, so the producer
// can use `rect.origin` to translate input.
// External content only renders against the GLES backend — the
// software backend has no GL texture to sample. Skip silently.
let gl = match canvas
{
Canvas::Software( _ ) => return,
Canvas::Gles( c ) => c.gl.clone(),
};
match &self.source
{
ExternalSource::Texture( get ) =>
{
if let Some( tex ) = get( &gl, rect )
{
canvas.draw_external_texture( tex, rect, self.opacity );
}
}
}
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use std::sync::Arc;
fn dummy_source() -> ExternalSource
{
ExternalSource::Texture( Arc::new( | _gl, _rect | None ) )
}
#[ test ]
fn preferred_size_returns_constructed_dimensions()
{
let w = External::new( 320.0, 200.0, dummy_source() );
assert_eq!( w.preferred_size( 9999.0 ), ( 320.0, 200.0 ) );
}
#[ test ]
fn opacity_default_is_one()
{
let w = External::new( 1.0, 1.0, dummy_source() );
assert_eq!( w.opacity, 1.0 );
}
#[ test ]
fn opacity_setter_clamps_to_unit_range()
{
let above = External::new( 1.0, 1.0, dummy_source() ).opacity( 1.7 );
let below = External::new( 1.0, 1.0, dummy_source() ).opacity( -0.4 );
let mid = External::new( 1.0, 1.0, dummy_source() ).opacity( 0.5 );
assert_eq!( above.opacity, 1.0 );
assert_eq!( below.opacity, 0.0 );
assert_eq!( mid.opacity, 0.5 );
}
}

138
src/widget/flex.rs Normal file
View File

@@ -0,0 +1,138 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use super::Element;
/// Wraps an [`Element`] so that a [`Row`](crate::layout::row::Row) treats it
/// like a [`Spacer`](crate::layout::spacer::Spacer) for leftover-width
/// distribution, but draws the child inside the allocated rect.
///
/// Use this to make a non-trivial child fill remaining width without resorting
/// to a hard-coded `max_width`. The row computes how much width is left after
/// the fixed-size siblings, splits it across all flex / spacer children
/// proportionally to their `weight`, and gives the flex its share. The wrapped
/// child sees that share as its layout rect — text inside it triggers its own
/// elide path on overflow, columns adjust their inner width, etc.
///
/// ```rust,no_run
/// # use ltk::{ column, flex, row, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex(
/// # icon: Element<Msg>,
/// # title: Element<Msg>,
/// # subtitle: Element<Msg>,
/// # ) -> Element<Msg> {
/// row()
/// .push( icon )
/// .push( flex( column().push( title ).push( subtitle ) ) )
/// .into()
/// # }
/// ```
///
/// Currently only [`Row`](crate::layout::row::Row) honours flex distribution.
/// Inside a [`Column`](crate::layout::column::Column) a flex child behaves
/// like a regular zero-width child along the main axis — vertical flex is not
/// yet implemented.
pub struct Flex<Msg: Clone>
{
pub child: Box<Element<Msg>>,
pub weight: u32,
}
impl<Msg: Clone> Flex<Msg>
{
pub fn new( child: impl Into<Element<Msg>> ) -> Self
{
Self
{
child: Box::new( child.into() ),
weight: 1,
}
}
/// Relative weight when sharing leftover width with other flex / spacer
/// children in the same row (default `1`). A flex with `weight = 2`
/// claims twice the space of a sibling with `weight = 1`.
pub fn weight( mut self, w: u32 ) -> Self
{
self.weight = w;
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
// Width contribution to the parent row is zero — the actual width
// comes from the flex distribution. Height comes from the child so
// the row's row-height calculation still picks us up.
let ( _, h ) = self.child.preferred_size( max_width, canvas );
( 0.0, h )
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Flex<U>
where
U: Clone + 'static,
Msg: 'static,
{
Flex
{
child: Box::new( self.child.map_arc( f ) ),
weight: self.weight,
}
}
}
pub fn flex<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Flex<Msg>
{
Flex::new( child )
}
impl<Msg: Clone + 'static> From<Flex<Msg>> for Element<Msg>
{
fn from( f: Flex<Msg> ) -> Self
{
Element::Flex( f )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn new_defaults_to_weight_one()
{
let f = Flex::<()>::new( spacer() );
assert_eq!( f.weight, 1 );
}
#[ test ]
fn weight_builder_sets_relative_weight()
{
let f = Flex::<()>::new( spacer() ).weight( 5 );
assert_eq!( f.weight, 5 );
}
#[ test ]
fn preferred_size_reports_zero_width_so_row_treats_it_as_filler()
{
let canvas = make_canvas();
let f = Flex::<()>::new( spacer().width( 100.0 ).height( 40.0 ) );
let ( w, _ ) = f.preferred_size( 600.0, &canvas );
assert_eq!( w, 0.0, "flex must contribute zero width to the row's intrinsic-width sum" );
}
#[ test ]
fn preferred_size_passes_child_height_through()
{
let canvas = make_canvas();
let f = Flex::<()>::new( spacer().width( 100.0 ).height( 40.0 ) );
let ( _, h ) = f.preferred_size( 600.0, &canvas );
assert_eq!( h, 40.0 );
}
}

217
src/widget/image.rs Normal file
View File

@@ -0,0 +1,217 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::Rect;
use crate::render::Canvas;
/// A static image widget that renders RGBA pixel data.
///
/// Images are scaled to fill their allocated rect. Alpha blending against the
/// background is handled automatically (straight → premultiplied conversion).
///
/// The pixel buffer is shared via `Arc` so reusing the same image across
/// frames (e.g. a background decoded once at startup) is a cheap pointer
/// copy instead of a full `Vec<u8>` clone.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ img_widget, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex( rgba_bytes: Arc<Vec<u8>>, width: u32, height: u32 ) -> Element<Msg> {
/// img_widget( rgba_bytes, width, height )
/// .opacity( 0.8 )
/// .into()
/// # }
/// ```
pub struct Image
{
/// Raw RGBA pixel data (4 bytes per pixel, straight alpha).
pub rgba: Arc<Vec<u8>>,
/// Pixel width of the source image.
pub width: u32,
/// Pixel height of the source image.
pub height: u32,
/// When `true` the image scales to fill the available width (cover mode).
pub cover: bool,
/// Optional explicit display size in logical pixels.
pub display_size: Option<( f32, f32 )>,
/// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`.
pub opacity: f32,
}
impl Image
{
/// Create an image from a shared RGBA buffer.
///
/// `width` and `height` must match the dimensions of `rgba`.
pub fn new( rgba: Arc<Vec<u8>>, width: u32, height: u32 ) -> Self
{
Self { rgba, width, height, cover: false, display_size: None, opacity: 1.0 }
}
/// Load an image from a file path. Supports PNG, JPEG, and other formats
/// supported by the [`image`](https://crates.io/crates/image) crate.
pub fn from_path( path: &str ) -> Result<Self, Box<dyn std::error::Error>>
{
let img = image::open( path )?.into_rgba8();
let ( width, height ) = img.dimensions();
Ok( Self { rgba: Arc::new( img.into_raw() ), width, height, cover: false, display_size: None, opacity: 1.0 } )
}
/// Scale the image to fill the available width, preserving aspect ratio (cover mode).
pub fn cover( mut self ) -> Self
{
self.cover = true;
self
}
/// Set an explicit display size in logical pixels.
pub fn size( mut self, width: f32, height: f32 ) -> Self
{
self.display_size = Some( ( width.max( 0.0 ), height.max( 0.0 ) ) );
self
}
/// Set the opacity multiplier. Clamped to `[0.0, 1.0]`.
pub fn opacity( mut self, o: f32 ) -> Self
{
self.opacity = o.clamp( 0.0, 1.0 );
self
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32)
{
if let Some( size ) = self.display_size
{
return size;
}
if self.cover
{
( max_width, max_width * self.height as f32 / self.width as f32 )
} else {
let scale = max_width / self.width as f32;
( max_width, self.height as f32 * scale )
}
}
/// Draw the image into `canvas` at `rect`.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
canvas.draw_image_data( &self.rgba[..], self.width, self.height, rect, self.opacity );
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
fn solid_rgba( w: u32, h: u32 ) -> Arc<Vec<u8>>
{
Arc::new( vec![ 255u8; ( w * h * 4 ) as usize ] )
}
// ── builders / defaults ───────────────────────────────────────────────────
#[ test ]
fn new_uses_documented_defaults()
{
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 );
assert_eq!( img.width, 4 );
assert_eq!( img.height, 4 );
assert!( !img.cover );
assert!( img.display_size.is_none() );
assert_eq!( img.opacity, 1.0 );
}
#[ test ]
fn cover_builder_sets_cover_flag()
{
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).cover();
assert!( img.cover );
}
#[ test ]
fn size_builder_sets_explicit_display_size()
{
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( 120.0, 80.0 );
assert_eq!( img.display_size, Some( ( 120.0, 80.0 ) ) );
}
#[ test ]
fn size_builder_clamps_negative_dimensions_to_zero()
{
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( -10.0, -20.0 );
assert_eq!( img.display_size, Some( ( 0.0, 0.0 ) ) );
}
#[ test ]
fn opacity_clamps_to_unit_interval()
{
assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( -0.5 ).opacity, 0.0 );
assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( 1.5 ).opacity, 1.0 );
assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( 0.5 ).opacity, 0.5 );
}
// ── preferred_size ────────────────────────────────────────────────────────
#[ test ]
fn preferred_size_default_scales_height_to_match_width_aspect()
{
// 200×100 source image at max_width = 400 → scale = 2 → height 200.
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 );
let ( w, h ) = img.preferred_size( 400.0 );
assert_eq!( w, 400.0 );
assert_eq!( h, 200.0 );
}
#[ test ]
fn preferred_size_with_explicit_size_wins_over_aspect_logic()
{
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).size( 50.0, 25.0 );
assert_eq!( img.preferred_size( 1000.0 ), ( 50.0, 25.0 ) );
}
#[ test ]
fn preferred_size_cover_mode_keeps_aspect_ratio()
{
// 100×50 source in cover mode at max_width = 300 → height = 300 * (50/100) = 150.
let img = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover();
let ( w, h ) = img.preferred_size( 300.0 );
assert_eq!( w, 300.0 );
assert_eq!( h, 150.0 );
}
#[ test ]
fn preferred_size_cover_and_default_match_for_uniform_scaling()
{
// When the source aspect matches the requested width, cover and the
// default fit-width path produce identical sizes — they only diverge
// when the source is taller than wide.
let normal = Image::new( solid_rgba( 100, 50 ), 100, 50 );
let covered = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover();
assert_eq!( normal.preferred_size( 200.0 ), covered.preferred_size( 200.0 ) );
}
// ── Arc sharing ───────────────────────────────────────────────────────────
#[ test ]
fn rgba_buffer_is_shared_via_arc_not_cloned_per_image()
{
let bytes = solid_rgba( 8, 8 );
let strong_before = Arc::strong_count( &bytes );
let img = Image::new( bytes.clone(), 8, 8 );
assert_eq!( Arc::strong_count( &bytes ), strong_before + 1 );
// Same buffer goes into a second image — strong count rises again.
let img2 = Image::new( bytes.clone(), 8, 8 );
assert_eq!( Arc::strong_count( &bytes ), strong_before + 2 );
drop( img );
drop( img2 );
assert_eq!( Arc::strong_count( &bytes ), strong_before );
}
}

268
src/widget/list_item.rs Normal file
View File

@@ -0,0 +1,268 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn label_color() -> Color { crate::theme::palette().text_primary }
pub fn subtitle_color() -> Color { crate::theme::palette().text_secondary }
pub fn trailing_color() -> Color { crate::theme::palette().text_secondary }
/// Alpha-only overlay tuned for the active mode (white on dark, navy on light).
pub fn hover_bg() -> Color
{
let p = crate::theme::palette();
Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.06 }
}
pub fn press_bg() -> Color
{
let p = crate::theme::palette();
Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.12 }
}
/// Solid dark surface for the [`ListItem::selected`](super::ListItem::selected) state.
/// `palette.text_primary` happens to be the right colour for both
/// modes (navy on light, white on dark) so the white-on-dark contrast
/// pattern flips into a navy-on-white contrast in dark mode without
/// needing a separate slot.
pub fn selected_bg() -> Color { crate::theme::palette().text_primary }
pub fn focus_color() -> Color { crate::theme::palette().accent }
pub const LABEL_SIZE: f32 = 16.0;
pub const SUBTITLE_SIZE: f32 = 13.0;
pub const TRAILING_SIZE: f32 = 14.0;
pub const HEIGHT: f32 = 56.0;
pub const HEIGHT_SUB: f32 = 68.0;
pub const PAD_H: f32 = 16.0;
pub const RADIUS: f32 = 12.0;
pub const FOCUS_W: f32 = 2.0;
}
/// A row inside a list with a primary label and optional subtitle / trailing
/// text.
///
/// Use to build settings menus, navigation lists, contact rows or any other
/// vertically-stacked tappable content. The widget paints its own hover and
/// pressed surfaces and a rounded focus ring; wrap a column of `ListItem`s
/// inside a [`scroll`](crate::scroll) for scrollable lists.
///
/// ```rust,no_run
/// # use ltk::{ column, list_item, scroll, Element };
/// # #[ derive( Clone ) ] enum Msg { OpenWifi, OpenBluetooth, OpenDisplay }
/// # fn _ex() -> Element<Msg> {
/// // In view():
/// scroll(
/// column()
/// .push( list_item( "Wi-Fi" ).trailing( "Eduroam" ).on_press( Msg::OpenWifi ) )
/// .push( list_item( "Bluetooth" ).subtitle( "AirPods Pro" ).on_press( Msg::OpenBluetooth ) )
/// .push( list_item( "Display" ).trailing( "Light" ).on_press( Msg::OpenDisplay ) ),
/// )
/// .into()
/// # }
/// ```
pub struct ListItem<Msg: Clone>
{
/// Primary label (always visible, top-aligned when a subtitle is
/// present).
pub label: String,
/// Optional secondary line drawn below the label in muted colour.
/// Doubles the row height when set.
pub subtitle: Option<String>,
/// Optional right-aligned text (current setting, badge count, ""
/// disclosure). Drawn in muted colour.
pub trailing: Option<String>,
/// Message emitted on tap. `None` keeps the item visible but inert.
pub on_press: Option<Msg>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
/// `true` paints the row with the dark selected surface and white
/// text, regardless of hover / press state. Use to indicate the
/// active item in a list of choices (combo dropdown, segmented
/// picker, settings group with a single active value).
pub selected: bool,
}
impl<Msg: Clone> ListItem<Msg>
{
/// Create a list item with the given primary label, no subtitle, no
/// trailing text and no callback.
pub fn new( label: impl Into<String> ) -> Self
{
Self
{
label: label.into(),
subtitle: None,
trailing: None,
on_press: None,
id: None,
selected: false,
}
}
/// Mark this row as the currently-selected option in its list.
/// Selected rows paint with a dark surface and white text and
/// override hover / press visuals.
pub fn selected( mut self, yes: bool ) -> Self
{
self.selected = yes;
self
}
/// Add a secondary line below the label. Doubles the row height to
/// fit both lines comfortably.
pub fn subtitle( mut self, s: impl Into<String> ) -> Self
{
self.subtitle = Some( s.into() );
self
}
/// Add right-aligned text (settings value, badge, disclosure arrow).
pub fn trailing( mut self, s: impl Into<String> ) -> Self
{
self.trailing = Some( s.into() );
self
}
/// Set the message emitted when the row is tapped.
pub fn on_press( mut self, msg: Msg ) -> Self
{
self.on_press = Some( msg );
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
let h = if self.subtitle.is_some() { theme::HEIGHT_SUB } else { theme::HEIGHT };
( max_width, h )
}
/// Focus stroke is centered on `rect`, so half the stroke width plus ~1 px
/// of antialiasing bleed sits outside.
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W * 0.5 + 1.0 )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool, hovered: bool, pressed: bool )
{
// Selected wins over hover and press: a chosen item stays
// chosen even when the pointer is over it.
let ( label_color, subtitle_color, trailing_color ) = if self.selected
{
canvas.fill_rect( rect, theme::selected_bg(), theme::RADIUS );
// Selected row is filled with `text_primary` colour;
// label / subtitle / trailing read as the inverse via
// `bg` so they stay legible.
let inverse = crate::theme::palette().bg;
( inverse, inverse, inverse )
}
else
{
if pressed
{
canvas.fill_rect( rect, theme::press_bg(), theme::RADIUS );
}
else if hovered
{
canvas.fill_rect( rect, theme::hover_bg(), theme::RADIUS );
}
( theme::label_color(), theme::subtitle_color(), theme::trailing_color() )
};
if focused
{
canvas.stroke_rect( rect, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
}
let has_sub = self.subtitle.is_some();
let label_y = if has_sub
{
rect.y + rect.height * 0.35 + theme::LABEL_SIZE * 0.3
} else {
rect.y + ( rect.height + theme::LABEL_SIZE ) / 2.0 - 2.0
};
canvas.draw_text( &self.label, rect.x + theme::PAD_H, label_y, theme::LABEL_SIZE, label_color );
if let Some( ref sub ) = self.subtitle
{
let sub_y = rect.y + rect.height * 0.62 + theme::SUBTITLE_SIZE * 0.3;
canvas.draw_text( sub, rect.x + theme::PAD_H, sub_y, theme::SUBTITLE_SIZE, subtitle_color );
}
if let Some( ref trail ) = self.trailing
{
let tw = canvas.measure_text( trail, theme::TRAILING_SIZE );
let tx = rect.x + rect.width - theme::PAD_H - tw;
let ty = rect.y + ( rect.height + theme::TRAILING_SIZE ) / 2.0 - 2.0;
canvas.draw_text( trail, tx, ty, theme::TRAILING_SIZE, trailing_color );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> ListItem<U>
where
U: Clone + 'static,
Msg: 'static,
{
ListItem
{
label: self.label,
subtitle: self.subtitle,
trailing: self.trailing,
on_press: self.on_press.map( |m| ( *f )( m ) ),
id: self.id,
selected: self.selected,
}
}
}
/// Create a [`ListItem`] with the given primary label.
///
/// Add detail and behaviour through the chained builders:
///
/// ```rust,no_run
/// # use ltk::{ list_item, ListItem };
/// # #[ derive( Clone ) ] enum Msg { OpenDisplay }
/// # fn _ex() -> ListItem<Msg> {
/// list_item( "Display" )
/// .subtitle( "Resolution, brightness, night mode" )
/// .trailing( "" )
/// .on_press( Msg::OpenDisplay )
/// # }
/// ```
pub fn list_item<Msg: Clone>( label: impl Into<String> ) -> ListItem<Msg>
{
ListItem::new( label )
}
impl<Msg: Clone + 'static> From<ListItem<Msg>> for Element<Msg>
{
fn from( l: ListItem<Msg> ) -> Self
{
Element::ListItem( l )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn list_item_default()
{
let l = list_item::<()>( "Test" );
assert_eq!( l.label, "Test" );
assert!( l.subtitle.is_none() );
assert!( l.trailing.is_none() );
assert!( l.on_press.is_none() );
}
}

923
src/widget/mod.rs Executable file
View File

@@ -0,0 +1,923 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Widgets — the interactive and decorative leaves of the [`Element`] tree.
//!
//! Each widget lives in its own submodule and is reached through the
//! crate-root re-exports (`button`, `text`, `text_edit`, `slider`, …) plus
//! the `img_widget` alias for [`image::Image`]. Construct one from its
//! free constructor function, configure it through builder-style methods,
//! and convert it into [`Element<Msg>`] via `.into()` when pushing it
//! into a layout.
//!
//! ```rust,no_run
//! # use ltk::{ button, column, slider, text, Element };
//! # #[ derive( Clone ) ] enum Msg { SetVolume( f32 ), Mute }
//! # struct App { volume: f32 }
//! # impl App { fn _ex( &self ) -> Element<Msg> {
//! column()
//! .push( text( "Volume" ) )
//! .push( slider( self.volume ).on_change( |v| Msg::SetVolume( v ) ) )
//! .push( button( "Mute" ).on_press( Msg::Mute ) )
//! .into()
//! # }}
//! ```
//!
//! ## What lives here
//!
//! * **Buttons / activations**: [`button::Button`],
//! [`pressable::Pressable`], [`window_button::WindowButton`],
//! [`list_item::ListItem`].
//! * **Stateful binary controls**: [`toggle::Toggle`],
//! [`checkbox::Checkbox`], [`radio::Radio`].
//! * **Continuous controls**: [`slider::Slider`], [`vslider::VSlider`],
//! [`progress_bar::ProgressBar`].
//! * **Text**: [`text::Text`], [`text_edit::TextEdit`].
//! * **Images / decoration**: [`image::Image`], [`separator::Separator`],
//! [`container::Container`].
//! * **Clipping wrappers**: [`scroll::Scroll`] (with gesture-driven
//! scrolling), [`viewport::Viewport`] (passive clip / fade), and
//! [`flex::Flex`] (treats a non-spacer child as a row filler).
//! * **Overlays**: [`dialog::Dialog`] (modal / non-modal centered
//! confirmation card with built-in scrim, ESC-to-cancel, and
//! tap-outside-to-dismiss for the non-modal variant).
//!
//! Layouts ([`column`](crate::column), [`row`](crate::row),
//! [`stack`](crate::stack), [`grid`](crate::grid),
//! [`spacer`](crate::spacer)) live in [`crate::layout`]; they share the
//! same [`Element`] tree but are kept separate to make the "what does
//! this paint" / "how is this arranged" distinction explicit.
//!
//! ## Per-leaf handler snapshot
//!
//! [`WidgetHandlers`] is the snapshot the layout pass takes of every
//! interactive widget so the input handlers can dispatch in O(1) without
//! re-walking the [`Element`] tree. It is `pub( crate )` plumbing for the
//! runtime; downstream apps usually never see it. The `test_support`
//! module re-exports it for integration tests that want to assert on the
//! handler shape.
pub mod button;
pub mod container;
pub mod text_edit;
pub mod image;
pub mod text;
pub mod scroll;
pub mod viewport;
pub mod slider;
pub mod vslider;
pub mod toggle;
pub mod separator;
pub mod progress_bar;
pub mod checkbox;
pub mod radio;
pub mod list_item;
pub mod window_button;
pub mod pressable;
pub mod flex;
pub mod combo;
pub mod anchored_overlay;
pub mod spinner;
pub mod tab_bar;
pub mod toast;
pub mod tooltip;
pub mod notebook;
pub mod date_picker;
pub mod time_picker;
pub mod color_picker;
pub mod dialog;
pub mod external;
use std::sync::Arc;
use crate::types::{ Point, Rect, WidgetId };
use crate::render::Canvas;
/// Per-leaf interaction snapshot captured during layout. One variant per
/// interactive widget kind; the layout pass clones the relevant callbacks /
/// values from the [`Element`] tree into here so input handlers can dispatch
/// in O(1) without re-walking the tree.
///
/// `None` is used for focusable widgets that emit no message (e.g. a disabled
/// button or a focusable container) — the entry still appears in
/// `widget_rects` for hit testing and focus traversal.
pub enum WidgetHandlers<Msg: Clone>
{
None,
Button
{
on_press: Option<Msg>,
on_long_press: Option<Msg>,
on_drag_start: Option<Msg>,
/// Keyboard `Escape`-key message — the runtime scans every laid-out
/// `Button` snapshot in reverse and fires the first non-`None`
/// `on_escape` it finds before the default ESC fallthrough chain.
/// Currently sourced from
/// [`crate::widget::pressable::Pressable::on_escape`]; native
/// [`crate::widget::button::Button`] always sets this to `None`.
on_escape: Option<Msg>,
repeating: bool,
},
Toggle { on_toggle: Option<Msg> },
Checkbox { on_toggle: Option<Msg> },
Radio { on_select: Option<Msg> },
ListItem { on_press: Option<Msg> },
WindowButton { on_press: Option<Msg> },
TextEdit
{
value: String,
on_change: Option<Arc<dyn Fn( String ) -> Msg>>,
on_submit: Option<Msg>,
/// `true` when the source `TextEdit` was built with
/// `.secure( true )`. Propagates the wipe-on-drop behaviour to
/// every per-frame handler snapshot so credential text does not
/// linger across multiple cloned `WidgetHandlers` allocations.
secure: bool,
/// `true` when the source `TextEdit` was built with
/// `.multiline( true )`. The keyboard dispatch reads this so
/// pressing Enter inserts a `\n` instead of firing
/// [`Self::submit_msg`]. Mutually exclusive with `secure`.
multiline: bool,
/// Horizontal alignment snapshot — needed by the runtime's
/// hit-testing path so a click on a centred / right-aligned
/// field lands on the correct glyph.
align: text::TextAlign,
/// Font size snapshot — needed by the hit-testing path so
/// the runtime measures glyphs at the same size the renderer
/// drew them. Always the default `theme::FONT_SIZE` for
/// fields that do not call `.font_size( … )`.
font_size: f32,
/// `true` when the source field opted into select-all-on-
/// focus. The runtime reads this in `set_focus` to decide
/// whether the new selection should anchor at `0` (replace
/// on next keystroke) or at the cursor (insert).
select_on_focus: bool,
/// Snapshot of the
/// [`crate::widget::text_edit::TextEdit::password_toggle`]
/// callback. When `Some`, pointer / touch dispatch checks
/// the eye-icon hit zone (via
/// [`crate::widget::text_edit::password_toggle_hit_zone`])
/// before falling through to cursor placement and fires
/// this message instead.
password_toggle_msg: Option<Msg>,
},
Slider
{
on_change: Option<Arc<dyn Fn( f32 ) -> Msg>>,
axis: slider::SliderAxis,
},
}
impl<Msg: Clone> Drop for WidgetHandlers<Msg>
{
/// Mirror the wipe-on-drop behaviour of [`text_edit::TextEdit`] for the
/// per-frame handler snapshots the runtime keeps. When the snapshot
/// was built from a secure text edit we scrub the value bytes here so
/// the heap allocation that backs the cloned `String` is overwritten
/// before it is returned to the allocator. Non-secure variants run an
/// inert path; the field-level drops that fire after this fn handle
/// the rest of the data.
fn drop( &mut self )
{
if let WidgetHandlers::TextEdit { value, secure: true, .. } = self
{
// SAFETY: identical reasoning to `TextEdit::drop` — we only
// write zeros into the underlying byte buffer, leaving valid
// UTF-8 (NUL bytes) in place until the auto-drop frees it.
let bytes = unsafe { value.as_mut_vec() };
crate::secure_mem::secure_zero( bytes );
}
}
}
impl<Msg: Clone> Clone for WidgetHandlers<Msg>
{
fn clone( &self ) -> Self
{
match self
{
WidgetHandlers::None => WidgetHandlers::None,
WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape, repeating } => WidgetHandlers::Button
{
on_press: on_press.clone(),
on_long_press: on_long_press.clone(),
on_drag_start: on_drag_start.clone(),
on_escape: on_escape.clone(),
repeating: *repeating,
},
WidgetHandlers::Toggle { on_toggle } => WidgetHandlers::Toggle { on_toggle: on_toggle.clone() },
WidgetHandlers::Checkbox { on_toggle } => WidgetHandlers::Checkbox { on_toggle: on_toggle.clone() },
WidgetHandlers::Radio { on_select } => WidgetHandlers::Radio { on_select: on_select.clone() },
WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() },
WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() },
WidgetHandlers::TextEdit { value, on_change, on_submit, secure, multiline, align, font_size, select_on_focus, password_toggle_msg } =>
{
WidgetHandlers::TextEdit
{
value: value.clone(),
on_change: on_change.clone(),
on_submit: on_submit.clone(),
secure: *secure,
multiline: *multiline,
align: *align,
font_size: *font_size,
select_on_focus: *select_on_focus,
password_toggle_msg: password_toggle_msg.clone(),
}
}
WidgetHandlers::Slider { on_change, axis } =>
{
WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis }
}
}
}
}
impl<Msg: Clone> WidgetHandlers<Msg>
{
pub fn is_text_input( &self ) -> bool
{
matches!( self, WidgetHandlers::TextEdit { .. } )
}
/// `true` when this is a [`WidgetHandlers::TextEdit`] whose source
/// widget was built with `.multiline( true )`. The keyboard
/// dispatch reads this so pressing Enter inserts a `\n` instead of
/// submitting.
pub fn is_multiline_text_input( &self ) -> bool
{
matches!( self, WidgetHandlers::TextEdit { multiline: true, .. } )
}
pub fn is_slider( &self ) -> bool
{
matches!( self, WidgetHandlers::Slider { .. } )
}
/// `true` when this widget is a row inside a scrollable list that
/// keyboard arrow navigation should treat as a stepping point.
/// Currently restricted to [`ListItem`](crate::ListItem); buttons,
/// toggles, sliders, etc. are not stepped over by Arrow Up/Down so
/// they do not interfere with the row-by-row navigation pattern in
/// combo popups, settings menus and similar lists.
pub fn is_navigable_list_item( &self ) -> bool
{
matches!( self, WidgetHandlers::ListItem { .. } )
}
/// Convenience: extract the press / activation message for the variants
/// that have one (Button, Toggle, Checkbox, Radio, ListItem). Returns
/// `None` for sliders / text edits / `None` / disabled widgets.
pub fn press_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::Button { on_press, .. } => on_press.clone(),
WidgetHandlers::Toggle { on_toggle } => on_toggle.clone(),
WidgetHandlers::Checkbox { on_toggle } => on_toggle.clone(),
WidgetHandlers::Radio { on_select } => on_select.clone(),
WidgetHandlers::ListItem { on_press } => on_press.clone(),
WidgetHandlers::WindowButton { on_press } => on_press.clone(),
_ => None,
}
}
/// Submit message (Enter on a focused TextEdit). `None` for every other
/// variant.
pub fn submit_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::TextEdit { on_submit, .. } => on_submit.clone(),
_ => None,
}
}
/// Build the `on_change` message for a TextEdit given the new value.
pub fn text_change_msg( &self, new_value: &str ) -> Option<Msg>
{
match self
{
WidgetHandlers::TextEdit { on_change: Some( f ), .. } => Some( f( new_value.to_string() ) ),
_ => None,
}
}
/// Build the `on_change` message for a Slider given a value in `[0,1]`.
pub fn slider_change_msg( &self, value: f32 ) -> Option<Msg>
{
match self
{
WidgetHandlers::Slider { on_change: Some( f ), .. } => Some( f( value ) ),
_ => None,
}
}
/// Compute the `[0.0, 1.0]` value for the slider this handler belongs to,
/// given a pointer position inside its layout rect. Dispatches on the
/// stored [`slider::SliderAxis`] so the same call site in `input.rs` drives
/// both horizontal [`Slider`](slider::Slider) and vertical
/// [`VSlider`](vslider::VSlider).
///
/// Returns `0.0` for non-slider variants — callers combine this with
/// [`Self::slider_change_msg`], which also gates on the variant, so the
/// zero is never consumed in practice.
pub fn slider_value_from_pos( &self, rect: Rect, pos: Point ) -> f32
{
match self
{
WidgetHandlers::Slider { axis, .. } =>
{
slider::value_from_pos_in_rect( rect, pos, *axis )
}
_ => 0.0,
}
}
/// `true` when this is a [`WidgetHandlers::Button`] whose source
/// widget opted into press-and-hold repeat. The runtime reads
/// this on press to decide whether to fire `press_msg`
/// immediately + arm a calloop repeat timer, and on release to
/// suppress the regular tap-on-release fire.
pub fn is_repeating( &self ) -> bool
{
matches!( self, WidgetHandlers::Button { repeating: true, .. } )
}
/// Long-press / right-click message for this widget, or `None` if
/// none configured. Currently only [`WidgetHandlers::Button`]
/// carries one. Firing this does not by itself put the press into
/// drag mode — see [`Self::drag_start_msg`] for that.
pub fn long_press_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::Button { on_long_press, .. } => on_long_press.clone(),
_ => None,
}
}
/// Drag-arm message for this widget, or `None` if none configured.
/// Fired by the touch hold-timer alongside `long_press_msg`, and
/// by mouse left-button motion past the drag-promotion threshold
/// (without firing the menu). Promotes the gesture into drag mode.
pub fn drag_start_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::Button { on_drag_start, .. } => on_drag_start.clone(),
_ => None,
}
}
/// Keyboard `Escape` message for this widget, or `None` if none
/// configured. Used by the keyboard ESC handler to scan
/// `widget_rects` for a [`crate::widget::dialog::Dialog`] (or other
/// `Pressable::on_escape`-bearing wrapper) and fire its cancel
/// message before the default ESC fallthrough chain.
pub fn escape_msg( &self ) -> Option<Msg>
{
match self
{
WidgetHandlers::Button { on_escape, .. } => on_escape.clone(),
_ => None,
}
}
/// Current text-edit value (for cursor placement on focus, backspace
/// rebuild, etc.). `None` for non-text-edit variants.
pub fn current_value( &self ) -> Option<&str>
{
match self
{
WidgetHandlers::TextEdit { value, .. } => Some( value.as_str() ),
_ => None,
}
}
}
/// Result of laying out one *interactive* widget — i.e. a widget that should
/// receive pointer / touch hit-testing. Captures both the *hit rect* (where
/// input lands) and the *paint rect* (the bounding box of everything the
/// widget actually paints, including hover circles, focus rings, shadows…).
/// The paint rect is used by the partial-redraw path to know how much of the
/// canvas must be invalidated when a widget transitions in/out of a given
/// state — it is always `>= rect`.
///
/// `handlers` carries the snapshot of the widget's callbacks/values at layout
/// time so input dispatch is O(1) instead of re-walking the [`Element`] tree.
///
/// `keyboard_focusable` snapshots whether the widget should participate in the
/// Tab / Shift+Tab cycle. Most interactive widgets are also keyboard-focusable
/// (`Button`, `TextEdit`, `Slider`, …) but window-decoration chrome
/// (`WindowButton`) is interactive without taking keyboard focus by default —
/// matching the convention of macOS / GNOME / Windows title bars.
pub struct LaidOutWidget<Msg: Clone>
{
pub rect: Rect,
pub flat_idx: usize,
pub id: Option<WidgetId>,
pub paint_rect: Rect,
pub handlers: WidgetHandlers<Msg>,
pub keyboard_focusable: bool,
/// Cursor shape this widget wants to show on hover. Snapshotted
/// from [`Element::cursor_shape`] at layout time so the input
/// dispatch can pick the right shape without re-walking the
/// element tree.
pub cursor: crate::types::CursorShape,
}
impl<Msg: Clone> Clone for LaidOutWidget<Msg>
{
fn clone( &self ) -> Self
{
Self
{
rect: self.rect,
flat_idx: self.flat_idx,
id: self.id,
paint_rect: self.paint_rect,
handlers: self.handlers.clone(),
keyboard_focusable: self.keyboard_focusable,
cursor: self.cursor,
}
}
}
pub enum Element<Msg: Clone>
{
Button( button::Button<Msg> ),
Container( container::Container<Msg> ),
TextEdit( text_edit::TextEdit<Msg> ),
Image( image::Image ),
Column( crate::layout::column::Column<Msg> ),
Row( crate::layout::row::Row<Msg> ),
Stack( crate::layout::stack::Stack<Msg> ),
Text( text::Text ),
Spacer( crate::layout::spacer::Spacer ),
Scroll( scroll::Scroll<Msg> ),
Viewport( viewport::Viewport<Msg> ),
WrapGrid( crate::layout::wrap_grid::WrapGrid<Msg> ),
Slider( slider::Slider<Msg> ),
VSlider( vslider::VSlider<Msg> ),
Toggle( toggle::Toggle<Msg> ),
Separator( separator::Separator ),
ProgressBar( progress_bar::ProgressBar ),
Checkbox( checkbox::Checkbox<Msg> ),
Radio( radio::Radio<Msg> ),
ListItem( list_item::ListItem<Msg> ),
WindowButton( window_button::WindowButton<Msg> ),
Pressable( pressable::Pressable<Msg> ),
Flex( flex::Flex<Msg> ),
AnchoredOverlay( anchored_overlay::AnchoredOverlay<Msg> ),
Spinner( spinner::Spinner ),
External( external::External ),
}
impl<Msg: Clone> Element<Msg>
{
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
match self
{
Element::Button( b ) => b.preferred_size( max_width, canvas ),
Element::TextEdit( t ) => t.preferred_size( max_width, canvas ),
Element::Image( i ) => i.preferred_size( max_width ),
Element::Column( c ) => c.preferred_size( max_width, canvas ),
Element::Row( r ) => r.preferred_size( max_width, canvas ),
Element::Stack( s ) => s.preferred_size( max_width, canvas ),
Element::Text( t ) => t.preferred_size( max_width, canvas ),
Element::Spacer( s ) => s.preferred_size(),
Element::Scroll( s ) => s.preferred_size( max_width, canvas ),
Element::Viewport( v ) => v.preferred_size( max_width, canvas ),
Element::WrapGrid( g ) => g.preferred_size( max_width, canvas ),
Element::Slider( s ) => s.preferred_size( max_width, canvas ),
Element::VSlider( s ) => s.preferred_size( max_width, canvas ),
Element::Container( c ) => c.preferred_size( max_width, canvas ),
Element::Toggle( t ) => t.preferred_size( max_width, canvas ),
Element::Separator( s ) => s.preferred_size( max_width ),
Element::ProgressBar( p ) => p.preferred_size( max_width ),
Element::Checkbox( c ) => c.preferred_size( max_width, canvas ),
Element::Radio( r ) => r.preferred_size( max_width, canvas ),
Element::ListItem( l ) => l.preferred_size( max_width, canvas ),
Element::WindowButton( b ) => b.preferred_size( max_width, canvas ),
Element::Pressable( p ) => p.preferred_size( max_width, canvas ),
Element::Flex( f ) => f.preferred_size( max_width, canvas ),
Element::AnchoredOverlay( a ) => a.child.preferred_size( max_width, canvas ),
Element::Spinner( s ) => s.preferred_size( max_width ),
Element::External( e ) => e.preferred_size( max_width ),
}
}
pub fn draw(
&self,
canvas: &mut Canvas,
rect: Rect,
focused: bool,
hovered: bool,
pressed: bool,
cursor_pos: usize,
selection_anchor: usize,
)
{
match self
{
Element::Button( b ) => b.draw( canvas, rect, focused, hovered, pressed ),
Element::TextEdit( t ) => t.draw( canvas, rect, focused, cursor_pos, selection_anchor ),
Element::Image( i ) => i.draw( canvas, rect ),
Element::Column( c ) => c.draw( canvas, rect, focused ),
Element::Row( r ) => r.draw( canvas, rect, focused ),
Element::Stack( s ) => s.draw( canvas, rect, focused ),
Element::Text( t ) => t.draw( canvas, rect, focused ),
Element::Spacer( _ ) => {}
Element::Scroll( _ ) => {}
Element::Viewport( _ ) => {}
Element::WrapGrid( _ ) => {}
Element::Slider( s ) => s.draw( canvas, rect, focused ),
Element::VSlider( s ) => s.draw( canvas, rect, focused ),
Element::Container( _ ) => {}
Element::Toggle( t ) => t.draw( canvas, rect, focused ),
Element::Separator( s ) => s.draw( canvas, rect ),
Element::ProgressBar( p ) => p.draw( canvas, rect ),
Element::Checkbox( c ) => c.draw( canvas, rect, focused ),
Element::Radio( r ) => r.draw( canvas, rect, focused ),
Element::ListItem( l ) => l.draw( canvas, rect, focused, hovered, pressed ),
Element::WindowButton( b ) => b.draw( canvas, rect, focused, hovered, pressed ),
Element::Pressable( _ ) => {}
Element::Flex( _ ) => {}
Element::AnchoredOverlay( _ ) => {}
Element::Spinner( s ) => s.draw( canvas, rect ),
Element::External( e ) => e.draw( canvas, rect ),
}
}
pub fn hit_test( &self, rect: Rect, pos: Point ) -> bool
{
rect.contains( pos )
}
/// Bounding box of every pixel this widget may paint given `rect` as its
/// layout rect. Must enclose the `draw` output in every possible state
/// (hover, focus, press). Widgets that paint outside their layout rect
/// (hover halos, focus rings, drop shadows…) must override this; the
/// default returns `rect` unchanged.
///
/// Used by the partial-redraw path to invalidate exactly the pixels that
/// might change when a widget transitions in/out of a state.
pub( crate ) fn paint_bounds( &self, rect: Rect ) -> Rect
{
match self
{
Element::Button( b ) => b.paint_bounds( rect ),
Element::Toggle( t ) => t.paint_bounds( rect ),
Element::Radio( r ) => r.paint_bounds( rect ),
Element::Checkbox( c ) => c.paint_bounds( rect ),
Element::TextEdit( e ) => e.paint_bounds( rect ),
Element::ListItem( l ) => l.paint_bounds( rect ),
Element::WindowButton( b ) => b.paint_bounds( rect ),
Element::Slider( s ) => s.paint_bounds( rect ),
Element::VSlider( s ) => s.paint_bounds( rect ),
_ => rect,
}
}
/// Whether the widget should be included in the per-surface
/// `widget_rects` list — i.e. whether pointer / touch hit testing must be
/// able to land on it. This is the predicate that gates the layout pass's
/// push to `DrawCtx::widget_rects` in the draw pass.
///
/// Defaults to [`Self::is_focusable`] for every widget that takes keyboard
/// focus (those are interactive by definition). Widgets that are
/// click/touch-only without taking keyboard focus — currently
/// [`Element::WindowButton`] — opt in here without opting in to the Tab
/// cycle.
pub fn is_interactive( &self ) -> bool
{
match self
{
// Always hit-testable regardless of `focusable`. Lets callers
// opt out of keyboard focus (no Tab target, no lingering focus
// ring after a press) while keeping clicks / taps working.
Element::Button( _ ) | Element::WindowButton( _ ) => true,
// Pressable wrappers participate in hit testing only when they
// carry a handler — a no-op pressable is invisible to input.
Element::Pressable( p ) => p.has_handler(),
_ => self.is_focusable(),
}
}
/// Whether the widget participates in the Tab / Shift+Tab focus cycle.
/// Snapshotted onto [`LaidOutWidget::keyboard_focusable`] at layout time so
/// `next_focusable_index` can iterate without the [`Element`] tree.
///
/// Hit-testable chrome that should *not* steal keyboard focus from window
/// content (e.g. [`Element::WindowButton`]) returns `false` here while
/// still returning `true` from [`Self::is_interactive`].
pub fn is_focusable( &self ) -> bool
{
match self
{
Element::Button( b ) => b.focusable,
Element::TextEdit( _ ) | Element::Slider( _ ) | Element::VSlider( _ ) => true,
Element::Toggle( _ ) | Element::Checkbox( _ ) | Element::Radio( _ ) | Element::ListItem( _ ) => true,
Element::WindowButton( b ) => b.focusable,
_ => false,
}
}
pub fn is_text_input( &self ) -> bool
{
matches!( self, Element::TextEdit( _ ) )
}
/// Cursor shape to display while the pointer is over this widget.
/// Per-widget defaults match desktop conventions:
///
/// * Text inputs → `Text` (I-beam)
/// * Clickables (Button, Pressable, Toggle, Checkbox, Radio,
/// ListItem, WindowButton) → `Pointer` (hand)
/// * Anything else → `Default`
///
/// Widgets that carry a per-instance override (set via the
/// `.cursor( shape )` builder) return the override; otherwise they
/// return their type-based default. The runtime snapshots this onto
/// [`LaidOutWidget::cursor`] at layout time and dispatches the
/// shape via `wp_cursor_shape_v1` on hover transitions.
pub fn cursor_shape( &self ) -> crate::types::CursorShape
{
use crate::types::CursorShape::*;
match self
{
Element::TextEdit( t ) => t.cursor.unwrap_or( Text ),
Element::Button( b ) => b.cursor.unwrap_or( Pointer ),
Element::Pressable( p ) => p.cursor.unwrap_or( Pointer ),
Element::Toggle( _ ) => Pointer,
Element::Checkbox( _ ) => Pointer,
Element::Radio( _ ) => Pointer,
Element::ListItem( _ ) => Pointer,
Element::WindowButton( _ ) => Pointer,
// Sliders read as buttons on hover (`Pointer`) and as
// `Grabbing` during a drag — the runtime swaps to
// `Grabbing` itself when `gesture.dragging_slider` is
// set, so the static value here is just the hover state.
Element::Slider( _ ) => Pointer,
Element::VSlider( _ ) => Pointer,
_ => Default,
}
}
/// Snapshot the widget's callbacks/value into a [`WidgetHandlers`] for
/// O(1) dispatch later. Called once per focusable leaf during the layout
/// pass; the handlers are then stored alongside the rect in
/// [`LaidOutWidget`] so input handlers don't need the [`Element`] tree.
///
/// Containers and layouts that delegate to children return
/// [`WidgetHandlers::None`] — only leaf widgets actually carry payload.
pub( crate ) fn handlers( &self ) -> WidgetHandlers<Msg>
{
match self
{
Element::Button( b ) => WidgetHandlers::Button
{
on_press: b.on_press.clone(),
on_long_press: b.on_long_press.clone(),
on_drag_start: b.on_drag_start.clone(),
on_escape: None,
repeating: b.repeating,
},
Element::Pressable( p ) => WidgetHandlers::Button
{
on_press: p.on_press.clone(),
on_long_press: p.on_long_press.clone(),
on_drag_start: p.on_drag_start.clone(),
on_escape: p.on_escape.clone(),
repeating: false,
},
Element::Toggle( t ) => WidgetHandlers::Toggle { on_toggle: t.on_toggle.clone() },
Element::Checkbox( c ) => WidgetHandlers::Checkbox { on_toggle: c.on_toggle.clone() },
Element::Radio( r ) => WidgetHandlers::Radio { on_select: r.on_select.clone() },
Element::ListItem( l ) => WidgetHandlers::ListItem { on_press: l.on_press.clone() },
Element::WindowButton( b ) => WidgetHandlers::WindowButton { on_press: b.on_press.clone() },
Element::TextEdit( t ) =>
{
WidgetHandlers::TextEdit
{
value: t.value.clone(),
on_change: t.on_change.clone(),
on_submit: t.on_submit.clone(),
// `secure` here drives memory wipe-on-drop and
// the IME bypass — so any password field (with
// or without a toggle) opts in, regardless of
// the user's current visibility choice.
secure: t.secure || t.password_toggle.is_some(),
multiline: t.is_multiline(),
align: t.align,
font_size: t.font_size,
select_on_focus: t.select_on_focus,
password_toggle_msg: t.password_toggle.as_ref().map( |( _, m )| m.clone() ),
}
}
Element::Slider( s ) =>
{
WidgetHandlers::Slider
{
on_change: s.on_change.clone(),
axis: slider::SliderAxis::Horizontal,
}
}
Element::VSlider( s ) =>
{
WidgetHandlers::Slider
{
on_change: s.on_change.clone(),
axis: slider::SliderAxis::Vertical,
}
}
_ => WidgetHandlers::None,
}
}
}
/// Type alias for the message-mapping closure shared across an
/// [`Element::map`] walk. Stored as `Arc<dyn Fn>` so every per-widget
/// `map_msg` can clone and re-share it without copying the closure body
/// — the same closure is invoked once per emitted message, regardless
/// of how many leaves the sub-tree has.
pub( crate ) type MapFn<Msg, U> = Arc<dyn Fn( Msg ) -> U>;
impl<Msg: Clone + 'static> Element<Msg>
{
/// Re-tag every message a sub-view emits.
///
/// Walks the tree once and rewrites every per-leaf message store —
/// `Button::on_press`, `Slider::on_change`, the children of
/// `Column`/`Row`/`Stack`/`WrapGrid`, and so on — so the returned
/// `Element<U>` no longer references `Msg`. The standard Elm /
/// `iced` shape: a sub-view defined as `fn view( …) -> Element<SubMsg>`
/// can be embedded inside a parent that produces `Element<AppMsg>`
/// by calling `.map( AppMsg::Sub )`.
///
/// Cost: `O( leaves )` allocations for the closure-wrapping in the
/// `Arc<dyn Fn(...)>` callbacks (`text_edit`, `slider`, `vslider`),
/// and the closure itself runs an extra indirect call per emitted
/// message — both per-`map`-layer. Trees built fresh every frame
/// (the typical case) absorb this in the same allocator pressure
/// `view()` already produces, so the overhead is in the noise.
///
/// ```rust,no_run
/// # use ltk::{ button, text, Element };
/// # #[ derive( Clone ) ] enum SubMsg { Save }
/// # #[ derive( Clone ) ] enum AppMsg { Sub( SubMsg ) }
/// # fn sub_view() -> Element<SubMsg> {
/// # button( "Save" ).on_press( SubMsg::Save ).into()
/// # }
/// # fn _ex() -> Element<AppMsg> {
/// sub_view().map( AppMsg::Sub )
/// # }
/// ```
pub fn map<U, F>( self, f: F ) -> Element<U>
where
U: Clone + 'static,
F: Fn( Msg ) -> U + 'static,
{
let f: MapFn<Msg, U> = Arc::new( f );
self.map_arc( &f )
}
pub( crate ) fn map_arc<U>( self, f: &MapFn<Msg, U> ) -> Element<U>
where
U: Clone + 'static,
{
match self
{
Element::Button( b ) => Element::Button( b.map_msg( f ) ),
Element::Container( c ) => Element::Container( c.map_msg( f ) ),
Element::TextEdit( t ) => Element::TextEdit( t.map_msg( f ) ),
Element::Image( i ) => Element::Image( i ),
Element::Column( c ) => Element::Column( c.map_msg( f ) ),
Element::Row( r ) => Element::Row( r.map_msg( f ) ),
Element::Stack( s ) => Element::Stack( s.map_msg( f ) ),
Element::Text( t ) => Element::Text( t ),
Element::Spacer( s ) => Element::Spacer( s ),
Element::Scroll( s ) => Element::Scroll( s.map_msg( f ) ),
Element::Viewport( v ) => Element::Viewport( v.map_msg( f ) ),
Element::WrapGrid( g ) => Element::WrapGrid( g.map_msg( f ) ),
Element::Slider( s ) => Element::Slider( s.map_msg( f ) ),
Element::VSlider( s ) => Element::VSlider( s.map_msg( f ) ),
Element::Toggle( t ) => Element::Toggle( t.map_msg( f ) ),
Element::Separator( s ) => Element::Separator( s ),
Element::ProgressBar( p ) => Element::ProgressBar( p ),
Element::Checkbox( c ) => Element::Checkbox( c.map_msg( f ) ),
Element::Radio( r ) => Element::Radio( r.map_msg( f ) ),
Element::ListItem( l ) => Element::ListItem( l.map_msg( f ) ),
Element::WindowButton( b ) => Element::WindowButton( b.map_msg( f ) ),
Element::Pressable( p ) => Element::Pressable( p.map_msg( f ) ),
Element::Flex( fx ) => Element::Flex( fx.map_msg( f ) ),
Element::AnchoredOverlay( a ) => Element::AnchoredOverlay( a.map_msg( f ) ),
Element::Spinner( s ) => Element::Spinner( s ),
Element::External( e ) => Element::External( e ),
}
}
}
impl<Msg: Clone> From<container::Container<Msg>> for Element<Msg>
{
fn from( c: container::Container<Msg> ) -> Self
{
Element::Container( c )
}
}
impl<Msg: Clone> From<button::Button<Msg>> for Element<Msg>
{
fn from( b: button::Button<Msg> ) -> Self
{
Element::Button( b )
}
}
impl<Msg: Clone> From<text_edit::TextEdit<Msg>> for Element<Msg>
{
fn from( t: text_edit::TextEdit<Msg> ) -> Self
{
Element::TextEdit( t )
}
}
impl<Msg: Clone> From<image::Image> for Element<Msg>
{
fn from( i: image::Image ) -> Self
{
Element::Image( i )
}
}
impl<Msg: Clone> From<external::External> for Element<Msg>
{
fn from( e: external::External ) -> Self
{
Element::External( e )
}
}
impl<Msg: Clone> From<crate::layout::column::Column<Msg>> for Element<Msg>
{
fn from( c: crate::layout::column::Column<Msg> ) -> Self
{
Element::Column( c )
}
}
impl<Msg: Clone> From<crate::layout::row::Row<Msg>> for Element<Msg>
{
fn from( r: crate::layout::row::Row<Msg> ) -> Self
{
Element::Row( r )
}
}
impl<Msg: Clone> From<crate::layout::stack::Stack<Msg>> for Element<Msg>
{
fn from( s: crate::layout::stack::Stack<Msg> ) -> Self
{
Element::Stack( s )
}
}
pub fn button<Msg: Clone>( label: impl Into<String> ) -> button::Button<Msg>
{
button::Button::new( label.into() )
}
pub fn icon_button<Msg: Clone>( rgba: Arc<Vec<u8>>, img_w: u32, img_h: u32 ) -> button::Button<Msg>
{
button::Button::new_icon( rgba, img_w, img_h )
}
pub fn text_edit<Msg: Clone>(
placeholder: impl Into<String>,
value: impl Into<String>,
) -> text_edit::TextEdit<Msg>
{
text_edit::TextEdit::new( placeholder.into(), value.into() )
}
pub fn image( rgba: Arc<Vec<u8>>, width: u32, height: u32 ) -> image::Image
{
image::Image::new( rgba, width, height )
}
pub fn text( content: impl Into<String> ) -> text::Text
{
text::Text::new( content )
}
pub fn container<Msg: Clone>( child: impl Into<Element<Msg>> ) -> container::Container<Msg>
{
container::Container::new( child )
}
/// Build an [`external::External`] widget that hosts content rendered by
/// a caller-managed GL texture producer.
pub fn external( width: f32, height: f32, source: external::ExternalSource ) -> external::External
{
external::External::new( width, height, source )
}

249
src/widget/notebook.rs Normal file
View File

@@ -0,0 +1,249 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Notebook — paginated tabs with a content area.
//!
//! Different from `tab_bar` (which is just a segmented selector — the
//! application is responsible for rendering whatever content
//! corresponds to the active tab). A `Notebook` owns the
//! pages: each page bundles a label *and* its content, and only the
//! active page's content is laid out / drawn each frame.
//!
//! The widget itself is stateless: the application owns
//! `self.tab: usize` and updates it from the `on_select` callback. The
//! pages can be built fresh every frame (typical) or memoised on the
//! application side.
//!
//! ```rust,no_run
//! # use ltk::{ notebook, text, Element, Notebook };
//! # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
//! # struct App { tab: usize }
//! # impl App {
//! # fn general_view( &self ) -> Element<Msg> { text( "g" ).into() }
//! # fn network_view( &self ) -> Element<Msg> { text( "n" ).into() }
//! # fn audio_view( &self ) -> Element<Msg> { text( "a" ).into() }
//! # fn _ex( &self ) -> Notebook<Msg> {
//! notebook()
//! .page( "General", self.general_view() )
//! .page( "Network", self.network_view() )
//! .page( "Audio", self.audio_view() )
//! .selected( self.tab )
//! .on_select( Msg::SelectTab )
//! # }
//! # }
//! ```
use std::sync::Arc;
use crate::layout::column::column;
use crate::layout::spacer::spacer;
use super::Element;
mod theme
{
pub const SPACING: f32 = 12.0;
}
/// One page of a [`Notebook`] — a label for the tab strip and the
/// element to show when this page is active.
pub struct NotebookPage<Msg: Clone>
{
pub label: String,
pub view: Element<Msg>,
}
/// Paginated tab container.
///
/// Renders a `tab_bar` strip at the top followed by the
/// active page's content. Pages whose index is not [`Self::selected`]
/// are dropped before draw so they do not consume layout time — a
/// notebook with 50 pages costs the same to draw as one with 5.
pub struct Notebook<Msg: Clone>
{
pub pages: Vec<NotebookPage<Msg>>,
pub selected: usize,
pub on_select: Option<Arc<dyn Fn( usize ) -> Msg>>,
}
impl<Msg: Clone + 'static> Notebook<Msg>
{
/// Create an empty notebook with no pages and no selection.
pub fn new() -> Self
{
Self
{
pages: Vec::new(),
selected: 0,
on_select: None,
}
}
/// Append a page. Returns `Self` for chaining.
pub fn page(
mut self,
label: impl Into<String>,
view: impl Into<Element<Msg>>,
) -> Self
{
self.pages.push( NotebookPage
{
label: label.into(),
view: view.into(),
} );
self
}
/// Index of the currently active page. Out-of-range values fall
/// back to page 0; a notebook with no pages renders an empty
/// container.
pub fn selected( mut self, idx: usize ) -> Self
{
self.selected = idx;
self
}
/// Callback invoked with the index of the tab the user tapped.
pub fn on_select( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_select = Some( Arc::new( f ) );
self
}
/// Build the `Element` tree representing this notebook.
pub fn build( self ) -> Element<Msg>
{
use super::tab_bar::tabs;
let labels: Vec<String> = self.pages.iter().map( |p| p.label.clone() ).collect();
let selected = if self.selected < self.pages.len() { self.selected } else { 0 };
let n_pages = self.pages.len();
// Build the tab strip; wire on_select if the caller provided one.
let mut strip = tabs::<Msg, _, _>( labels ).selected( selected );
if let Some( cb ) = self.on_select.clone()
{
strip = strip.on_select( move |i| cb( i ) );
}
// Drain to extract the active page's view by index.
let mut pages = self.pages;
let active_view: Element<Msg> = if pages.is_empty()
{
spacer().into()
} else {
let _ = n_pages;
pages.swap_remove( selected )
.view
};
column()
.spacing( theme::SPACING )
.push( strip )
.push( active_view )
.into()
}
}
impl<Msg: Clone + 'static> Default for Notebook<Msg>
{
fn default() -> Self { Self::new() }
}
impl<Msg: Clone + 'static> From<Notebook<Msg>> for Element<Msg>
{
fn from( n: Notebook<Msg> ) -> Self { n.build() }
}
/// Create an empty [`Notebook`].
///
/// ```rust,no_run
/// # use ltk::{ notebook, text, Element, Notebook };
/// # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
/// # struct App { tab: usize }
/// # impl App {
/// # fn inbox_view( &self ) -> Element<Msg> { text( "i" ).into() }
/// # fn sent_view( &self ) -> Element<Msg> { text( "s" ).into() }
/// # fn _ex( &self ) -> Notebook<Msg> {
/// notebook()
/// .page( "Inbox", self.inbox_view() )
/// .page( "Sent", self.sent_view() )
/// .selected( self.tab )
/// .on_select( Msg::SelectTab )
/// # }
/// # }
/// ```
pub fn notebook<Msg: Clone + 'static>() -> Notebook<Msg>
{
Notebook::new()
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Pick( usize ) }
#[ test ]
fn defaults_have_no_pages()
{
let n: Notebook<Msg> = notebook();
assert_eq!( n.pages.len(), 0 );
assert_eq!( n.selected, 0 );
assert!( n.on_select.is_none() );
}
#[ test ]
fn page_appends_in_order()
{
let n: Notebook<Msg> = notebook()
.page( "A", spacer() )
.page( "B", spacer() )
.page( "C", spacer() );
assert_eq!( n.pages.len(), 3 );
assert_eq!( n.pages[0].label, "A" );
assert_eq!( n.pages[1].label, "B" );
assert_eq!( n.pages[2].label, "C" );
}
#[ test ]
fn selected_builder_records_index()
{
let n: Notebook<Msg> = notebook()
.page( "A", spacer() )
.page( "B", spacer() )
.selected( 1 );
assert_eq!( n.selected, 1 );
}
#[ test ]
fn on_select_callback_is_invoked_for_index()
{
let n: Notebook<Msg> = notebook()
.page( "A", spacer() )
.on_select( Msg::Pick );
let cb = n.on_select.as_ref().expect( "callback present" );
assert_eq!( cb( 7 ), Msg::Pick( 7 ) );
}
#[ test ]
fn build_does_not_panic_on_empty_pages()
{
let _: Element<Msg> = notebook().build();
}
#[ test ]
fn build_does_not_panic_on_out_of_range_selected()
{
// Out-of-range `selected` must fall back to page 0 instead of
// panicking with an index error in the swap_remove.
let n: Notebook<Msg> = notebook()
.page( "A", spacer() )
.page( "B", spacer() )
.selected( 99 );
let _: Element<Msg> = n.build();
}
}

296
src/widget/pressable.rs Normal file
View File

@@ -0,0 +1,296 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::WidgetId;
use super::Element;
/// Wraps any [`Element`] and emits a message on tap. Use when you want
/// click-to-emit on something richer than a [`Button`](super::button::Button)
/// — for example a [`Container`](super::container::Container) styled as a
/// card holding a row of icon + labels.
///
/// The wrapper is invisible to drawing: it delegates `preferred_size` and
/// rendering to the child. It does record a hit rect covering its full
/// allocated rect so taps anywhere inside fire `on_press`. Inner widgets
/// that are themselves interactive (e.g. a button nested inside the
/// pressable) keep priority — the layout pass pushes the wrapper's hit
/// rect *before* recursing into the child, and hit testing iterates in
/// reverse, so deeper widgets win.
///
/// No visual press feedback is applied — for state-driven appearance
/// changes use a [`Button`](super::button::Button) or compose with a
/// container that reacts to focus/press signals.
///
/// ```rust,no_run
/// # use ltk::{ column, container, pressable, row, Element, Pressable };
/// # #[ derive( Clone ) ] enum Msg { OpenWifiPicker }
/// # fn _ex(
/// # icon: Element<Msg>,
/// # title: Element<Msg>,
/// # subtitle: Element<Msg>,
/// # ) -> Pressable<Msg> {
/// pressable(
/// container( row()
/// .push( icon )
/// .push( column().push( title ).push( subtitle ) ) )
/// .surface( "surface-card" )
/// .radius( 32.0 )
/// .padding_h( 16.5 )
/// .padding_v( 24.0 ),
/// )
/// .on_press( Msg::OpenWifiPicker )
/// # }
/// ```
pub struct Pressable<Msg: Clone>
{
pub child: Box<Element<Msg>>,
pub on_press: Option<Msg>,
pub on_long_press: Option<Msg>,
/// Drag-arm message. Fired alongside `on_long_press` when the touch
/// hold timer elapses, AND fired by mouse left-button motion past
/// the drag-promotion threshold without waiting for the timer. The
/// caller uses this to arm any per-app drag state (in crustace,
/// `dragging_item`); a widget that opens a context menu but isn't
/// draggable leaves this `None`.
pub on_drag_start: Option<Msg>,
/// Keyboard `Escape`-key message. The runtime scans every laid-out
/// pressable's snapshot and fires the topmost (highest `flat_idx`)
/// `on_escape` it finds before the default ESC fallthrough chain.
/// Used by [`crate::widget::dialog::Dialog`] to make `Esc` cancel a
/// modal dialog without each app having to wire a global keyboard
/// hook — but available to any composite that needs the same
/// semantics.
pub on_escape: Option<Msg>,
/// Make the pressable hit-testable even when no callback is set.
/// A `swallow=true` pressable consumes pointer events at its hit
/// rect and emits no message — used by
/// [`crate::widget::dialog::Dialog`] for the modal scrim plus the
/// card-area swallow that prevents `dismiss_on_scrim` from firing
/// when the user clicks on the dialog body itself. Has no effect
/// when any handler is also set; in that case the handler determines
/// the message and `swallow` is implicit.
pub swallow: bool,
pub id: Option<WidgetId>,
pub cursor: Option<crate::types::CursorShape>,
}
impl<Msg: Clone> Pressable<Msg>
{
pub fn new( child: impl Into<Element<Msg>> ) -> Self
{
Self
{
child: Box::new( child.into() ),
on_press: None,
on_long_press: None,
on_drag_start: None,
on_escape: None,
swallow: false,
id: None,
cursor: None,
}
}
/// Override the pointer cursor shape shown on hover.
pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self
{
self.cursor = Some( shape );
self
}
pub fn on_press( mut self, msg: Msg ) -> Self
{
self.on_press = Some( msg );
self
}
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, mouse on motion past the
/// drag-promotion threshold. Independent of `on_long_press` so a
/// widget can open a menu without becoming draggable, or be
/// draggable without opening a menu.
pub fn on_drag_start( mut self, msg: Msg ) -> Self
{
self.on_drag_start = Some( msg );
self
}
/// Bind a keyboard-`Escape` message to this pressable. See
/// [`Pressable::on_escape`] for the dispatch order semantics.
pub fn on_escape( mut self, msg: Msg ) -> Self
{
self.on_escape = Some( msg );
self
}
/// Make the pressable hit-testable even when no `on_press` /
/// `on_long_press` / `on_drag_start` is configured. See
/// [`Pressable::swallow`].
pub fn swallow( mut self, on: bool ) -> Self
{
self.swallow = on;
self
}
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
self.child.preferred_size( max_width, canvas )
}
/// True when the wrapper participates in hit-testing — has at least
/// one pointer / keyboard handler set, or has been opted in to
/// silent swallow via [`Pressable::swallow`]. Used by the layout
/// pass to skip pushing a hit rect for a no-op pressable.
pub fn has_handler( &self ) -> bool
{
self.on_press.is_some()
|| self.on_long_press.is_some()
|| self.on_drag_start.is_some()
|| self.on_escape.is_some()
|| self.swallow
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Pressable<U>
where
U: Clone + 'static,
Msg: 'static,
{
Pressable
{
child: Box::new( self.child.map_arc( f ) ),
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 ) ),
on_escape: self.on_escape.map( |m| ( *f )( m ) ),
swallow: self.swallow,
id: self.id,
cursor: self.cursor,
}
}
}
pub fn pressable<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Pressable<Msg>
{
Pressable::new( child )
}
impl<Msg: Clone + 'static> From<Pressable<Msg>> for Element<Msg>
{
fn from( p: Pressable<Msg> ) -> Self
{
Element::Pressable( p )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
use crate::types::WidgetId;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Tap, Hold, Drag, Cancel }
#[ test ]
fn new_defaults_have_no_handlers()
{
let p = Pressable::<Msg>::new( spacer() );
assert!( p.on_press.is_none() );
assert!( p.on_long_press.is_none() );
assert!( p.on_drag_start.is_none() );
assert!( p.on_escape.is_none() );
assert!( !p.swallow );
assert!( p.id.is_none() );
assert!( !p.has_handler() );
}
#[ test ]
fn on_press_builder_arms_tap_message()
{
let p = Pressable::new( spacer() ).on_press( Msg::Tap );
assert_eq!( p.on_press, Some( Msg::Tap ) );
assert!( p.has_handler() );
}
#[ test ]
fn on_long_press_builder_arms_long_press_message()
{
let p = Pressable::new( spacer() ).on_long_press( Msg::Hold );
assert_eq!( p.on_long_press, Some( Msg::Hold ) );
assert!( p.has_handler() );
}
#[ test ]
fn on_drag_start_builder_arms_drag_message()
{
let p = Pressable::new( spacer() ).on_drag_start( Msg::Drag );
assert_eq!( p.on_drag_start, Some( Msg::Drag ) );
assert!( p.has_handler() );
}
#[ test ]
fn has_handler_is_true_when_any_callback_is_set()
{
assert!( Pressable::<Msg>::new( spacer() ).on_press( Msg::Tap ).has_handler() );
assert!( Pressable::<Msg>::new( spacer() ).on_long_press( Msg::Hold ).has_handler() );
assert!( Pressable::<Msg>::new( spacer() ).on_drag_start( Msg::Drag ).has_handler() );
assert!( Pressable::<Msg>::new( spacer() )
.on_press( Msg::Tap ).on_long_press( Msg::Hold ).on_drag_start( Msg::Drag ).has_handler() );
}
#[ test ]
fn on_escape_builder_arms_escape_message()
{
let p = Pressable::new( spacer() ).on_escape( Msg::Cancel );
assert_eq!( p.on_escape, Some( Msg::Cancel ) );
assert!( p.has_handler() );
}
#[ test ]
fn swallow_builder_makes_pressable_hit_testable_without_callbacks()
{
let p = Pressable::<Msg>::new( spacer() ).swallow( true );
assert!( p.swallow );
assert!( p.on_press.is_none() );
assert!( p.has_handler() );
}
#[ test ]
fn swallow_off_with_no_callbacks_is_invisible_to_input()
{
let p = Pressable::<Msg>::new( spacer() ).swallow( false );
assert!( !p.has_handler() );
}
#[ test ]
fn id_builder_assigns_widget_id()
{
let id = WidgetId( "my_card" );
let p = Pressable::<Msg>::new( spacer() ).id( id );
assert_eq!( p.id, Some( id ) );
}
#[ test ]
fn preferred_size_delegates_to_child()
{
let canvas = Canvas::new( 800, 600 );
let child = spacer().width( 50.0 ).height( 30.0 );
let p = Pressable::<Msg>::new( child );
assert_eq!( p.preferred_size( 400.0, &canvas ), ( 50.0, 30.0 ) );
}
}

143
src/widget/progress_bar.rs Normal file
View File

@@ -0,0 +1,143 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Color, Rect };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn track_bg() -> Color { crate::theme::palette().divider }
pub fn fill() -> Color { crate::theme::palette().accent }
pub const TRACK_H: f32 = 6.0;
pub const HEIGHT: f32 = 20.0;
}
/// A linear progress indicator for determinate operations.
///
/// Renders a horizontal track with a coloured fill from the left edge to
/// `value × width`. `value` is clamped to `[0.0, 1.0]` at construction.
/// For indeterminate progress (the operation has no ETA) prefer an
/// animated spinner — `ltk` has no built-in spinner widget yet; build one
/// from a [`Container`](super::container::Container) that rotates a glyph
/// while [`crate::App::is_animating`] returns `true`.
///
/// ```rust,no_run
/// # use ltk::{ column, progress_bar, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # struct App { progress: f32 }
/// # impl App { fn _ex( &self ) -> Element<Msg> {
/// // In view():
/// column()
/// .push( text( format!( "Downloading… {}%", ( self.progress * 100.0 ) as u32 ) ) )
/// .push( progress_bar( self.progress ) )
/// .into()
/// # }}
/// ```
pub struct ProgressBar
{
/// Current progress in `[0.0, 1.0]`. Always clamped at construction.
pub value: f32,
/// Fill colour. Defaults to the theme's `accent` palette slot.
pub fill: Color,
}
impl ProgressBar
{
/// Create a progress bar at the given fraction. `value` outside
/// `[0.0, 1.0]` is clamped silently.
pub fn new( value: f32 ) -> Self
{
Self { value: value.clamp( 0.0, 1.0 ), fill: theme::fill() }
}
/// Override the fill colour. Useful for "danger" / "success" variants
/// (red for nearly-full disks, green for completed work).
pub fn color( mut self, color: Color ) -> Self
{
self.fill = color;
self
}
/// Return the preferred `(width, height)`. Width fills the available
/// `max_width`; height is the theme-defined row height.
pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32)
{
( max_width, theme::HEIGHT )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
let track_y = rect.y + ( rect.height - theme::TRACK_H ) / 2.0;
let track_r = theme::TRACK_H / 2.0;
let track_rect = Rect
{
x: rect.x,
y: track_y,
width: rect.width,
height: theme::TRACK_H,
};
canvas.fill_rect( track_rect, theme::track_bg(), track_r );
let fill_w = rect.width * self.value;
if fill_w > 0.0
{
let fill_rect = Rect
{
x: rect.x,
y: track_y,
width: fill_w,
height: theme::TRACK_H,
};
canvas.fill_rect( fill_rect, self.fill, track_r );
}
}
}
/// Create a [`ProgressBar`] at the given fraction (clamped to
/// `[0.0, 1.0]`).
///
/// ```rust,no_run
/// # use ltk::{ progress_bar, ProgressBar };
/// # struct App { download_fraction: f32 }
/// # impl App { fn _ex( &self ) -> ProgressBar {
/// progress_bar( self.download_fraction )
/// # }}
/// ```
pub fn progress_bar( value: f32 ) -> ProgressBar
{
ProgressBar::new( value )
}
impl<Msg: Clone> From<ProgressBar> for Element<Msg>
{
fn from( p: ProgressBar ) -> Self
{
Element::ProgressBar( p )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn value_clamped_on_creation()
{
let p = progress_bar( 1.5 );
assert_eq!( p.value, 1.0 );
let p = progress_bar( -0.5 );
assert_eq!( p.value, 0.0 );
}
#[test]
fn preferred_width_fills_available()
{
let p = progress_bar( 0.5 );
let ( w, _ ) = p.preferred_size( 300.0 );
assert_eq!( w, 300.0 );
}
}

212
src/widget/radio.rs Normal file
View File

@@ -0,0 +1,212 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn ring_color() -> Color { crate::theme::palette().divider }
pub fn selected() -> Color { crate::theme::palette().accent }
/// Inner dot — uses the page-background colour so it reads as
/// the inverse of the accent fill regardless of mode.
pub fn dot_color() -> Color { crate::theme::palette().bg }
pub fn focus_color() -> Color { crate::theme::palette().accent }
pub fn label_color() -> Color { crate::theme::palette().text_primary }
pub const OUTER_SIZE: f32 = 24.0;
pub const DOT_SIZE: f32 = 12.0;
pub const BORDER_W: f32 = 2.0;
pub const GAP: f32 = 12.0;
pub const HEIGHT: f32 = 48.0;
pub const FOCUS_W: f32 = 3.0;
pub const FONT_SIZE: f32 = 16.0;
}
/// One option inside a mutually-exclusive group.
///
/// Renders a circle with a centred dot when selected. Unlike
/// [`Checkbox`](super::checkbox::Checkbox), a radio is meaningful only as
/// part of a group: the group is the application's responsibility — define
/// an enum for the choices, store the current value, and build one `Radio`
/// per variant with `selected = current == this_variant`.
///
/// ```rust,no_run
/// # use ltk::{ column, radio, Element };
/// #[ derive( Clone, PartialEq ) ]
/// enum Priority { Low, Medium, High }
/// # #[ derive( Clone ) ] enum Msg { SetPriority( Priority ) }
/// # struct App { priority: Priority }
/// # impl App { fn _ex( &self ) -> Element<Msg> {
/// // In view():
/// column()
/// .push( radio( self.priority == Priority::Low ).label( "Low" ).on_select( Msg::SetPriority( Priority::Low ) ) )
/// .push( radio( self.priority == Priority::Medium ).label( "Medium" ).on_select( Msg::SetPriority( Priority::Medium ) ) )
/// .push( radio( self.priority == Priority::High ).label( "High" ).on_select( Msg::SetPriority( Priority::High ) ) )
/// .into()
/// # }}
/// ```
///
/// `ltk` does not enforce mutual exclusion automatically; the application's
/// `update` decides which variant becomes active when a radio is selected.
pub struct Radio<Msg: Clone>
{
/// Whether this option is currently selected. Drawn from this field
/// every frame.
pub selected: bool,
/// Message emitted when the user picks this option. `None` leaves the
/// radio inert.
pub on_select: Option<Msg>,
/// Optional label drawn to the right of the circle.
pub label: Option<String>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
}
impl<Msg: Clone> Radio<Msg>
{
/// Create a radio in the given state, with no label and no callback.
pub fn new( selected: bool ) -> Self
{
Self { selected, on_select: None, label: None, id: None }
}
/// Set the message emitted when this option is picked. The
/// application's `update` is responsible for flipping the group's
/// current value.
pub fn on_select( mut self, msg: Msg ) -> Self
{
self.on_select = Some( msg );
self
}
/// Set a text label rendered to the right of the circle.
pub fn label( mut self, label: impl Into<String> ) -> Self
{
self.label = Some( label.into() );
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let w = if let Some( ref label ) = self.label
{
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
( theme::OUTER_SIZE + theme::GAP + text_w ).min( max_width )
} else {
theme::OUTER_SIZE.min( max_width )
};
( w, theme::HEIGHT )
}
/// Focus ring on the outer circle extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px`
/// beyond the circle (which sits flush with the widget's left edge).
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W + 2.0 + theme::FOCUS_W * 0.5 + 1.0 )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
{
let circle_y = rect.y + ( rect.height - theme::OUTER_SIZE ) / 2.0;
let outer_r = theme::OUTER_SIZE / 2.0;
let outer_rect = Rect
{
x: rect.x,
y: circle_y,
width: theme::OUTER_SIZE,
height: theme::OUTER_SIZE,
};
if self.selected
{
canvas.fill_rect( outer_rect, theme::selected(), outer_r );
let dot_r = theme::DOT_SIZE / 2.0;
let cx = rect.x + outer_r;
let cy = circle_y + outer_r;
let dot_rect = Rect
{
x: cx - dot_r,
y: cy - dot_r,
width: theme::DOT_SIZE,
height: theme::DOT_SIZE,
};
canvas.fill_rect( dot_rect, theme::dot_color(), dot_r );
} else {
canvas.stroke_rect( outer_rect, theme::ring_color(), theme::BORDER_W, outer_r );
}
if focused
{
let ring = outer_rect.expand( theme::FOCUS_W + 2.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, outer_r + theme::FOCUS_W + 2.0 );
}
if let Some( ref label ) = self.label
{
let text_x = rect.x + theme::OUTER_SIZE + theme::GAP;
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0;
canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Radio<U>
where
U: Clone + 'static,
Msg: 'static,
{
Radio
{
selected: self.selected,
on_select: self.on_select.map( |m| ( *f )( m ) ),
label: self.label,
id: self.id,
}
}
}
/// Create a [`Radio`] option in the given state.
///
/// Shorthand for [`Radio::new`]. See the [`Radio`] type-level docs for
/// the full mutual-exclusion pattern across multiple options.
pub fn radio<Msg: Clone>( selected: bool ) -> Radio<Msg>
{
Radio::new( selected )
}
impl<Msg: Clone + 'static> From<Radio<Msg>> for Element<Msg>
{
fn from( r: Radio<Msg> ) -> Self
{
Element::Radio( r )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn radio_default_state()
{
let r = radio::<()>( true );
assert!( r.selected );
assert!( r.on_select.is_none() );
}
#[test]
fn radio_unselected()
{
let r = radio::<()>( false );
assert!( !r.selected );
}
}

156
src/widget/scroll.rs Normal file
View File

@@ -0,0 +1,156 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::WidgetId;
use crate::widget::Element;
/// A vertically scrollable viewport that clips its child to its allocated rect.
///
/// The child can be any element — typically a [`Column`](crate::layout::column::Column)
/// for lists or a [`WrapGrid`](crate::layout::wrap_grid::WrapGrid) for icon grids.
///
/// Scroll offset is driven by touch/pointer drag gestures within the viewport.
/// Dragging inside a `Scroll` does **not** trigger the app-level swipe-up gesture.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ column, grid, icon_button, scroll, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex( rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> ( Element<Msg>, Element<Msg> ) {
/// // Scrollable list
/// let list = scroll( column().spacing( 8.0 ).push( text( "Item 1" ) ).push( text( "Item 2" ) ) );
///
/// // App-drawer style grid
/// let drawer = scroll( grid( 4 ).padding( 16.0 ).spacing( 12.0 ).push( icon_button( rgba, w, h ) ) );
/// # ( list.into(), drawer.into() )
/// # }
/// ```
pub struct Scroll<Msg: Clone>
{
/// The single child element drawn inside the scrollable viewport.
pub child: Box<Element<Msg>>,
/// Optional stable identifier — used as scroll state key.
pub id: Option<WidgetId>,
}
impl<Msg: Clone> Scroll<Msg>
{
/// Assign a stable identifier to this scroll widget.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
/// Returns `(max_width, 0.0)` — the Scroll node claims all remaining space in
/// the parent layout, exactly like a [`Spacer`](crate::layout::spacer::Spacer).
/// The actual viewport height is determined at render time from the rect the
/// parent assigns.
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
( max_width, 0.0 )
}
/// No-op — rendering is handled entirely by `layout_and_draw` in `draw.rs`.
pub fn draw( &self ) {}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Scroll<U>
where
U: Clone + 'static,
Msg: 'static,
{
Scroll
{
child: Box::new( self.child.map_arc( f ) ),
id: self.id,
}
}
}
impl<Msg: Clone + 'static> From<Scroll<Msg>> for Element<Msg>
{
fn from( s: Scroll<Msg> ) -> Self
{
Element::Scroll( s )
}
}
/// Compute the clamped scroll offset given raw offset, content height, and viewport height.
///
/// Extracted as a pure function so it can be unit-tested without a Wayland surface.
pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f32
{
let max = (content_h - viewport_h).max( 0.0 );
offset.clamp( 0.0, max )
}
#[cfg(test)]
mod tests
{
use super::clamp_offset;
#[test]
fn offset_zero_when_content_fits()
{
// Content shorter than viewport — no scrolling possible
assert_eq!( clamp_offset( 50.0, 300.0, 500.0 ), 0.0 );
}
#[test]
fn offset_clamped_to_zero_when_negative()
{
assert_eq!( clamp_offset( -10.0, 600.0, 400.0 ), 0.0 );
}
#[test]
fn offset_clamped_to_max()
{
// max = 600 - 400 = 200; offset 999 → clamped to 200
assert_eq!( clamp_offset( 999.0, 600.0, 400.0 ), 200.0 );
}
#[test]
fn offset_within_range_unchanged()
{
// max = 600 - 400 = 200; offset 100 stays 100
assert_eq!( clamp_offset( 100.0, 600.0, 400.0 ), 100.0 );
}
#[test]
fn zero_offset_stays_zero()
{
assert_eq!( clamp_offset( 0.0, 600.0, 400.0 ), 0.0 );
}
#[test]
fn exact_max_offset_is_valid()
{
assert_eq!( clamp_offset( 200.0, 600.0, 400.0 ), 200.0 );
}
#[test]
fn content_equal_to_viewport_gives_zero_max()
{
// No overflow — max = 0
assert_eq!( clamp_offset( 10.0, 400.0, 400.0 ), 0.0 );
}
}
/// Create a scrollable viewport wrapping `child`.
///
/// The parent layout controls the viewport size by assigning a rect to this widget.
/// Content that overflows vertically is scrolled via drag gesture.
///
/// ```rust,no_run
/// # use ltk::{ column, scroll, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// scroll( column().push( text( "A" ) ).push( text( "B" ) ) )
/// .into()
/// # }
/// ```
pub fn scroll<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Scroll<Msg>
{
Scroll { child: Box::new( child.into() ), id: None }
}

137
src/widget/separator.rs Normal file
View File

@@ -0,0 +1,137 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Color, Rect };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn color() -> Color { crate::theme::palette().divider }
pub const THICKNESS: f32 = 1.0;
pub const PAD_V: f32 = 8.0;
}
/// A horizontal divider line.
///
/// Renders a 1 px (default) line across the full width of its layout rect,
/// with vertical padding above and below. Use to break a column into
/// visual sections — between settings groups, list categories or content
/// blocks. The line takes the divider colour from the active theme by
/// default; override with [`Self::color`] for custom palettes.
///
/// ```rust,no_run
/// # use ltk::{ column, separator, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// // In view():
/// column()
/// .push( text( "General" ) )
/// .push( separator() )
/// .push( text( "Network" ) )
/// .into()
/// # }
/// ```
pub struct Separator
{
/// Stroke colour. Defaults to the theme's `divider` palette slot.
pub color: Color,
/// Line thickness in logical pixels.
pub thickness: f32,
/// Vertical padding above and below the line, baked into
/// `preferred_size` so the divider visually centres in the row a
/// parent column allocates.
pub pad_v: f32,
}
impl Separator
{
/// Create a separator with the theme's default divider colour and
/// 1 px thickness.
pub fn new() -> Self
{
Self
{
color: theme::color(),
thickness: theme::THICKNESS,
pad_v: theme::PAD_V,
}
}
/// Override the stroke colour. Useful for emphasised dividers between
/// destructive actions.
pub fn color( mut self, color: Color ) -> Self
{
self.color = color;
self
}
/// Override the line thickness. Defaults to 1 logical pixel.
pub fn thickness( mut self, t: f32 ) -> Self
{
self.thickness = t;
self
}
/// Return the preferred `(width, height)`. Width is `max_width`;
/// height is `thickness + 2 × pad_v`.
pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32)
{
( max_width, self.thickness + self.pad_v * 2.0 )
}
/// Draw the divider line into `canvas` at `rect`.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
let y = rect.y + rect.height / 2.0;
canvas.draw_line( rect.x, y, rect.x + rect.width, y, self.color, self.thickness );
}
}
/// Create a default [`Separator`] (theme divider colour, 1 px thickness).
///
/// ```rust,no_run
/// # use ltk::{ column, separator, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "Section A" ) )
/// .push( separator() )
/// .push( text( "Section B" ) )
/// .into()
/// # }
/// ```
pub fn separator() -> Separator
{
Separator::new()
}
impl<Msg: Clone> From<Separator> for Element<Msg>
{
fn from( s: Separator ) -> Self
{
Element::Separator( s )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn default_thickness()
{
let s = separator();
assert_eq!( s.thickness, theme::THICKNESS );
}
#[test]
fn preferred_height_includes_padding()
{
let s = separator();
let ( _, h ) = s.preferred_size( 200.0 );
assert_eq!( h, theme::THICKNESS + theme::PAD_V * 2.0 );
}
}

541
src/widget/slider.rs Normal file
View File

@@ -0,0 +1,541 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::{ Point, Rect };
use crate::render::Canvas;
use super::Element;
/// Which axis a slider tracks. Used by input dispatch to pick the right
/// `value_from_*_in_rect` formula for a [`Slider`] (horizontal) or
/// [`crate::widget::vslider::VSlider`] (vertical).
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum SliderAxis
{
Horizontal,
Vertical,
}
/// Compute the slider value `[0.0, 1.0]` from a pointer position within a
/// slider's layout rect, dispatching to the axis-specific formula.
///
/// Exposed alongside [`value_from_x_in_rect`] so `input.rs` can drive both
/// [`Slider`] and [`crate::widget::vslider::VSlider`] through the same call
/// site by consulting the [`SliderAxis`] stored in the widget's handler
/// snapshot.
pub fn value_from_pos_in_rect( rect: Rect, pos: Point, axis: SliderAxis ) -> f32
{
match axis
{
SliderAxis::Horizontal => value_from_x_in_rect( rect, pos.x ),
SliderAxis::Vertical => crate::widget::vslider::value_from_y_in_rect( rect, pos.y ),
}
}
mod theme
{
use crate::types::Color;
pub fn track_bg() -> Color { crate::theme::palette().divider }
pub fn track_fill() -> Color { crate::theme::palette().accent }
/// Thumb — uses the page-background colour so it reads as the
/// inverse of the accent fill regardless of mode.
pub fn thumb() -> Color { crate::theme::palette().bg }
pub fn thumb_border() -> Color { crate::theme::palette().text_primary }
pub const TRACK_H: f32 = 10.0;
pub const THUMB_SIZE: f32 = 24.0;
pub const HEIGHT: f32 = 36.0;
pub const THUMB_BORDER_W: f32 = 2.0;
/// White ring + inner coloured pill of the [`super::Slider::accent_thumb`]
/// variant. Outer diameter is [`ACCENT_THUMB_OUTER`]; the visible
/// pill is smaller than [`THUMB_SIZE`], which is the hit-target /
/// reserved layout footprint shared with the default thumb.
pub const ACCENT_THUMB_BORDER_W: f32 = 4.0;
pub const ACCENT_THUMB_OUTER: f32 = 20.0;
/// Default theme slot id for the [`Slider`](super::Slider) track
/// background. Mirrors the equivalent constant in `vslider::theme`
/// so the same default-theme entries cover both axes — only the
/// rendering geometry differs between the widgets.
pub const SURFACE_TRACK: &str = "surface-slider-track";
/// Default theme slot id for the [`Slider`](super::Slider) fill.
pub const SURFACE_FILL: &str = "surface-slider-fill";
}
/// Intersect `inner` with the `saved` outer clip and return the rect
/// list to install with [`crate::render::Canvas::set_clip_rects`].
///
/// `saved` is the snapshot returned by
/// [`crate::render::Canvas::clip_bounds`]: empty means "no clip is
/// installed" (paint everywhere), non-empty is the outer scissor list
/// — typically the partial-redraw damage rects.
///
/// `Slider` / [`crate::widget::vslider::VSlider`] use this when they
/// need to clip the active-side fill paint to a band tighter than the
/// widget rect. Calling `set_clip_rects( &[ band ] )` directly would
/// REPLACE the outer clip, causing the fill to repaint outside the
/// damage region during partial redraws and clobbering pixels (most
/// visibly the thumb's white interior left from the previous frame).
/// Routing through this helper preserves the outer clip by
/// intersecting with it.
///
/// Returns `Vec::new()` when there is no overlap; callers must skip
/// the paint entirely in that case — installing an empty rect list
/// means "no clip" which would paint outside the damage region.
pub( crate ) fn intersect_clip( saved: &[ Rect ], inner: Rect ) -> Vec<Rect>
{
if saved.is_empty()
{
return vec![ inner ];
}
saved.iter().filter_map( |r|
{
let x0 = inner.x.max( r.x );
let y0 = inner.y.max( r.y );
let x1 = ( inner.x + inner.width ).min( r.x + r.width );
let y1 = ( inner.y + inner.height ).min( r.y + r.height );
if x1 <= x0 || y1 <= y0
{
None
}
else
{
Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } )
}
} ).collect()
}
/// Compute the slider value `[0.0, 1.0]` from a tap/drag x position within a
/// slider's layout rect. Pure — depends only on `theme::THUMB_SIZE`. Lifted
/// out of [`Slider`] so input handlers can call it directly from
/// [`crate::widget::LaidOutWidget`] without needing the [`Element`] tree.
pub fn value_from_x_in_rect( rect: Rect, x: f32 ) -> f32
{
let pad = theme::THUMB_SIZE / 2.0;
let track_start = rect.x + pad;
let track_end = rect.x + rect.width - pad;
let track_w = ( track_end - track_start ).max( 1.0 );
( ( x - track_start ) / track_w ).clamp( 0.0, 1.0 )
}
/// A horizontal slider for selecting a value in a range.
///
/// The track defaults to a theme-resolved surface; pass
/// [`Self::track_paint`] to override with a custom
/// [`Paint`](crate::theme::Paint) — typically a multi-stop linear
/// gradient for a hue / spectrum picker. When `track_paint` is set
/// the active-side fill is suppressed (the spectrum already
/// communicates the position visually through colour).
///
/// ```rust,no_run
/// # use ltk::{ slider, Slider };
/// # #[ derive( Clone ) ] enum Msg { SetBrightness( f32 ) }
/// # struct App { brightness: f32 }
/// # impl App { fn _ex( &self ) -> Slider<Msg> {
/// slider( self.brightness )
/// .on_change( |v| Msg::SetBrightness( v ) )
/// # }}
/// ```
pub struct Slider<Msg: Clone>
{
/// Current value in `[0.0, 1.0]`.
pub value: f32,
/// Callback invoked with the new value when the slider is dragged.
/// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf
/// handler snapshot for O(1) dispatch on input events.
pub on_change: Option<Arc<dyn Fn(f32) -> Msg>>,
/// Theme slot id for the track background pill. Defaults to the
/// generic `surface-slider-track`; override per-instance to opt
/// into the `-flat` (no per-surface backdrop) variant when the
/// slider already lives inside a panel-wide blur, or any other
/// custom slot.
pub track_surface: &'static str,
/// Theme slot id for the rising / leftward fill. Same shape as
/// [`Self::track_surface`] but for the active portion.
pub fill_surface: &'static str,
/// Paint the thumb with the active palette's `accent` colour and
/// a thicker white border (accent inner pill + 4 px white ring)
/// instead of the default white-on-text-primary thumb.
pub accent_thumb: bool,
/// Override the track paint with a custom
/// [`Paint`](crate::theme::Paint) — typically a
/// [`Paint::Linear`](crate::theme::Paint::Linear) for spectrum /
/// hue pickers. When set, the active-side fill is suppressed
/// because a spectrum already conveys position information
/// through colour and the thumb shows where the user is.
pub track_paint: Option<crate::theme::Paint>,
}
impl<Msg: Clone> Slider<Msg>
{
/// Create a slider with the given value (clamped to `[0.0, 1.0]`).
pub fn new( value: f32 ) -> Self
{
Self
{
value: value.clamp( 0.0, 1.0 ),
on_change: None,
track_surface: theme::SURFACE_TRACK,
fill_surface: theme::SURFACE_FILL,
accent_thumb: false,
track_paint: None,
}
}
/// Override the track paint with a custom
/// [`Paint`](crate::theme::Paint) — e.g. a multi-stop linear
/// gradient for a hue / spectrum picker. Disables the active-
/// side fill (the spectrum already encodes the position
/// visually).
pub fn track_paint( mut self, paint: crate::theme::Paint ) -> Self
{
self.track_paint = Some( paint );
self
}
/// Set the callback invoked when the slider value changes.
pub fn on_change( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
/// Override the theme slot id used to paint the track background
/// — pass `surface-slider-track-flat` (or any other surface) to
/// drop the per-instance backdrop blur when the slider already
/// lives inside a panel-wide blur. See
/// [`crate::widget::vslider::VSlider::track_surface`] for the use
/// case.
pub fn track_surface( mut self, id: &'static str ) -> Self
{
self.track_surface = id;
self
}
/// Override the theme slot id used to paint the fill. Mirrors
/// [`Self::track_surface`].
pub fn fill_surface( mut self, id: &'static str ) -> Self
{
self.fill_surface = id;
self
}
/// Switch the thumb to the accent-pill style: accent-coloured inner
/// + 4 px white border. Default thumb stays white-on-text-primary
/// for shells that haven't opted in.
pub fn accent_thumb( mut self, on: bool ) -> Self
{
self.accent_thumb = on;
self
}
/// Return the preferred `(width, height)`.
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
( max_width, theme::HEIGHT )
}
/// Compute the value `[0.0, 1.0]` from a tap/drag x position within `rect`.
pub fn value_from_x( &self, rect: Rect, x: f32 ) -> f32
{
value_from_x_in_rect( rect, x )
}
/// Thumb border stroke is centered on the thumb edge (which touches the
/// widget's left/right edges at value 0 / 1), so half the stroke width
/// plus ~1 px of antialiasing sits outside `rect`.
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
let border_w = if self.accent_thumb { theme::ACCENT_THUMB_BORDER_W } else { theme::THUMB_BORDER_W };
rect.expand( ( border_w * 0.5 + 1.0 ).max( surface_shadow_margin( self.track_surface ) ) )
}
/// Draw the slider into `canvas` at `rect`.
///
/// Track + fill paint through theme slots ([`Self::track_surface`]
/// / [`Self::fill_surface`]) when those slots resolve in the
/// active theme — the track pill spans the full inner width and
/// the fill is anchored to the same pill but scissor-clipped to
/// the left band so insets stay positioned against the track rect
/// rather than a shrinking fill rect. Falls back to
/// `theme::track_bg()` / `theme::track_fill()` when the active
/// theme has no entry for either slot — a bare-bones third-party
/// theme still paints a usable slider.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
{
let pad = theme::THUMB_SIZE / 2.0;
let track_y = rect.y + (rect.height - theme::TRACK_H) / 2.0;
let track_start = rect.x + pad;
let track_end = rect.x + rect.width - pad;
let track_w = track_end - track_start;
let radius_bg = theme::TRACK_H / 2.0;
// Background track — full pill across the inner span.
let track_rect = Rect
{
x: track_start,
y: track_y,
width: track_w,
height: theme::TRACK_H,
};
if let Some( paint ) = self.track_paint.as_ref()
{
// Custom paint override (hue spectrum, custom gradient, …)
// short-circuits the theme-slot resolution. The
// active-side fill is suppressed below because a
// spectrum already encodes the position via colour.
canvas.fill_paint_rect( track_rect, paint, radius_bg );
}
else if let Some( ( surf, outer ) ) = crate::theme::resolve_surface( self.track_surface )
{
canvas.fill_surface
(
track_rect,
&surf.fill,
&outer,
&surf.inset_shadows,
radius_bg,
);
}
else
{
canvas.fill_rect( track_rect, theme::track_bg(), radius_bg );
}
// Filled portion. Paint the surface across the FULL track rect
// and clip the visible band to the left-aligned slice. This
// keeps inset shadows / glass rims anchored to the track pill
// instead of to a shrinking fill rect — the highlights don't
// shift as the user drags.
//
// Suppressed entirely when `track_paint` is set: a custom
// spectrum already conveys position through colour, and a
// solid accent band painted over part of it would obscure
// half the spectrum and read as a rendering bug.
let fill_w = track_w * self.value;
if self.track_paint.is_none() && fill_w > 0.5
{
let visible = Rect
{
x: track_start,
y: track_y,
width: fill_w,
height: theme::TRACK_H,
};
let saved_clip = canvas.clip_bounds();
let band = intersect_clip( &saved_clip, visible );
if !band.is_empty()
{
canvas.set_clip_rects( &band );
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface )
{
canvas.fill_paint_rect( track_rect, &surf.fill, radius_bg );
// Drop insets with negative-Y offset — they live
// near the top of the track pill and would slice
// the fill at the active band edge. See
// `VSlider::draw` for the full rationale.
for inset in surf.inset_shadows.iter().filter( |s| s.offset[1] >= 0.0 )
{
canvas.fill_shadow_inset( track_rect, inset, radius_bg );
}
}
else
{
canvas.fill_rect( visible, theme::track_fill(), radius_bg );
}
canvas.set_clip_rects( &saved_clip );
}
}
// Thumb.
let thumb_cx = track_start + fill_w;
let thumb_cy = rect.y + rect.height / 2.0;
if self.accent_thumb
{
// Inner pill paints with the track's active fill surface
// so the thumb's centre always matches the line colour.
let outer_r = theme::ACCENT_THUMB_OUTER / 2.0;
let inner_size = ( theme::ACCENT_THUMB_OUTER - 2.0 * theme::ACCENT_THUMB_BORDER_W ).max( 0.0 );
let inner_r = inner_size / 2.0;
let outer_rect = Rect
{
x: thumb_cx - outer_r,
y: thumb_cy - outer_r,
width: theme::ACCENT_THUMB_OUTER,
height: theme::ACCENT_THUMB_OUTER,
};
let inner_rect = Rect
{
x: thumb_cx - inner_r,
y: thumb_cy - inner_r,
width: inner_size,
height: inner_size,
};
canvas.fill_rect( outer_rect, crate::theme::palette().bg, outer_r );
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface )
{
canvas.fill_paint_rect( inner_rect, &surf.fill, inner_r );
}
else
{
canvas.fill_rect( inner_rect, crate::theme::palette().accent, inner_r );
}
}
else
{
let thumb_r = theme::THUMB_SIZE / 2.0;
let thumb_rect = Rect
{
x: thumb_cx - thumb_r,
y: thumb_cy - thumb_r,
width: theme::THUMB_SIZE,
height: theme::THUMB_SIZE,
};
canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r );
canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Slider<U>
where
U: Clone + 'static,
Msg: 'static,
{
// Wrap the existing Arc<dyn Fn(f32) -> Msg> in a fresh closure
// that pipes its result through the user's mapper. The original
// closure is captured by the new one through `Arc::clone` so the
// original Slider's storage stays valid.
let on_change = self.on_change.map( |old| -> Arc<dyn Fn( f32 ) -> U>
{
let mapper = Arc::clone( f );
Arc::new( move |v| ( *mapper )( ( *old )( v ) ) )
} );
Slider
{
value: self.value,
on_change,
track_surface: self.track_surface,
fill_surface: self.fill_surface,
accent_thumb: self.accent_thumb,
track_paint: self.track_paint,
}
}
}
/// Create a [`Slider`] with the given value (clamped to `[0.0, 1.0]`).
pub fn slider<Msg: Clone>( value: f32 ) -> Slider<Msg>
{
Slider::new( value )
}
fn surface_shadow_margin( surface: &str ) -> f32
{
crate::theme::resolve_surface( surface )
.map( |( _, shadows )|
{
shadows.iter()
.map( |s| s.offset[0].abs().max( s.offset[1].abs() ) + s.blur.max( 0.0 ) + s.spread.max( 0.0 ) + 1.0 )
.fold( 0.0, f32::max )
} )
.unwrap_or( 0.0 )
}
impl<Msg: Clone + 'static> From<Slider<Msg>> for Element<Msg>
{
fn from( s: Slider<Msg> ) -> Self
{
Element::Slider( s )
}
}
#[cfg(test)]
mod tests
{
use super::*;
use crate::types::Rect;
#[test]
fn value_clamped_on_creation()
{
let s = slider::<()>( 1.5 );
assert_eq!( s.value, 1.0 );
let s = slider::<()>( -0.5 );
assert_eq!( s.value, 0.0 );
}
#[test]
fn value_from_x_left_edge()
{
let s = slider::<()>( 0.5 );
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 };
let v = s.value_from_x( rect, 0.0 );
assert_eq!( v, 0.0 );
}
#[test]
fn value_from_x_right_edge()
{
let s = slider::<()>( 0.5 );
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 };
let v = s.value_from_x( rect, 200.0 );
assert_eq!( v, 1.0 );
}
#[test]
fn value_from_x_center()
{
let s = slider::<()>( 0.0 );
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 };
let v = s.value_from_x( rect, 100.0 );
assert!( (v - 0.5).abs() < 0.1 );
}
#[test]
fn axis_dispatch_horizontal_uses_x()
{
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 };
let v = value_from_pos_in_rect(
rect,
Point { x: 200.0, y: 9999.0 },
SliderAxis::Horizontal,
);
assert_eq!( v, 1.0 );
}
#[test]
fn axis_dispatch_vertical_uses_y()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
// y=0 at the top of a vertical rect is value=1.0, regardless of x.
let v = value_from_pos_in_rect(
rect,
Point { x: 9999.0, y: 0.0 },
SliderAxis::Vertical,
);
assert_eq!( v, 1.0 );
}
#[test]
fn track_paint_default_is_none()
{
let s = slider::<()>( 0.5 );
assert!( s.track_paint.is_none() );
}
#[test]
fn track_paint_builder_stores_paint()
{
use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint };
use crate::types::Color;
let g = Paint::Linear( LinearGradient
{
angle_deg: 90.0,
stops: vec![
ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) },
ColorStop { position: 1.0, color: Color::rgba( 0.0, 0.0, 1.0, 1.0 ) },
],
space: GradientSpace::Srgb,
} );
let s = slider::<()>( 0.5 ).track_paint( g );
assert!( s.track_paint.is_some() );
}
}

223
src/widget/spinner.rs Normal file
View File

@@ -0,0 +1,223 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Spinner — indeterminate progress indicator.
//!
//! A rotating arc that the renderer draws by stroking a fraction of a
//! circle and offsetting its starting angle by [`Spinner::phase`]. The
//! widget is *stateless*: the application owns the phase variable and
//! advances it from a clock or animation tick. Pair with
//! [`App::is_animating`](crate::App::is_animating) so the run loop keeps
//! requesting redraws while the spinner is on screen.
//!
//! ```rust,no_run
//! # use ltk::{ row, spinner, text, Element };
//! # #[ derive( Clone ) ] enum Msg {}
//! # struct App { tick: f32 }
//! # impl App { fn _ex( &self ) -> Element<Msg> {
//! // In view():
//! row()
//! .push( spinner().phase( self.tick ) )
//! .push( text( "Loading…" ) )
//! .into()
//! # }}
//! ```
use crate::types::{ Color, Rect };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn fill() -> Color { crate::theme::palette().accent }
pub fn track() -> Color
{
// Quarter-opacity copy of the accent so the moving arc reads
// against a faint guide ring instead of empty surface.
let a = crate::theme::palette().accent;
Color::rgba( a.r, a.g, a.b, 0.20 )
}
pub const SIZE: f32 = 32.0;
pub const STROKE_W: f32 = 3.0;
/// Fraction of the full circle that the moving arc covers.
pub const ARC_FRAC: f32 = 0.30;
/// Number of straight segments used to approximate one full circle.
/// 36 is enough for a smooth arc at the default 32 px diameter and
/// keeps the per-frame draw call count low.
pub const SEGMENTS: u32 = 36;
}
/// An indeterminate progress spinner.
///
/// Renders a rotating arc inside a square layout rect. The application
/// drives the rotation by passing a monotonically-increasing `phase`
/// value (any units — only the fractional part of `phase` is used).
pub struct Spinner
{
/// Rotation phase. Only the fractional part is consumed, so any
/// monotonically increasing source works (`elapsed.as_secs_f32()`,
/// frame count divided by FPS, etc.).
pub phase: f32,
/// Arc / ring colour. Defaults to the theme's `accent` palette slot.
pub color: Color,
/// Square diameter in logical pixels. Both width and height of the
/// laid-out rect target this size.
pub size: f32,
/// Stroke width of the arc and the dim guide ring.
pub stroke_w: f32,
}
impl Spinner
{
/// Create a spinner with the theme's accent colour and default size.
pub fn new() -> Self
{
Self
{
phase: 0.0,
color: theme::fill(),
size: theme::SIZE,
stroke_w: theme::STROKE_W,
}
}
/// Set the rotation phase. The widget consumes only the fractional
/// part, so callers can pass an unbounded monotonic clock value.
pub fn phase( mut self, p: f32 ) -> Self
{
self.phase = p;
self
}
/// Override the arc colour.
pub fn color( mut self, c: Color ) -> Self
{
self.color = c;
self
}
/// Set the square diameter in logical pixels.
pub fn size( mut self, s: f32 ) -> Self
{
self.size = s;
self
}
/// Set the arc / ring stroke width in logical pixels.
pub fn stroke_width( mut self, w: f32 ) -> Self
{
self.stroke_w = w;
self
}
/// Return the preferred `(width, height)` — the spinner is square.
pub fn preferred_size( &self, max_width: f32 ) -> ( f32, f32 )
{
let s = self.size.min( max_width );
( s, s )
}
/// Draw the spinner into `canvas` at `rect`. The arc is centred on
/// `rect`'s minor diameter so the widget renders correctly even when
/// laid out into a non-square cell.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
{
let cx = rect.x + rect.width * 0.5;
let cy = rect.y + rect.height * 0.5;
let r = ( rect.width.min( rect.height ) - self.stroke_w ) * 0.5;
if r <= 0.0 { return; }
let track = theme::track();
let n = theme::SEGMENTS as i32;
let two_pi = std::f32::consts::TAU;
let dim = two_pi / n as f32;
// Dim guide ring (full circle as polyline).
for i in 0..n
{
let a0 = i as f32 * dim;
let a1 = ( i + 1 ) as f32 * dim;
let x0 = cx + r * a0.cos();
let y0 = cy + r * a0.sin();
let x1 = cx + r * a1.cos();
let y1 = cy + r * a1.sin();
canvas.draw_line( x0, y0, x1, y1, track, self.stroke_w );
}
// Moving arc. `phase` is consumed modulo 1.0.
let frac = self.phase - self.phase.floor();
let start_ang = frac * two_pi;
let end_ang = start_ang + theme::ARC_FRAC * two_pi;
let arc_segs = ( ( theme::ARC_FRAC * n as f32 ).round() as i32 ).max( 1 );
for i in 0..arc_segs
{
let a0 = start_ang + i as f32 * dim;
let a1 = ( start_ang + ( i + 1 ) as f32 * dim ).min( end_ang );
let x0 = cx + r * a0.cos();
let y0 = cy + r * a0.sin();
let x1 = cx + r * a1.cos();
let y1 = cy + r * a1.sin();
canvas.draw_line( x0, y0, x1, y1, self.color, self.stroke_w );
}
}
}
impl Default for Spinner
{
fn default() -> Self { Self::new() }
}
/// Create a [`Spinner`] with default size and theme colour.
pub fn spinner() -> Spinner
{
Spinner::new()
}
impl<Msg: Clone> From<Spinner> for Element<Msg>
{
fn from( s: Spinner ) -> Self
{
Element::Spinner( s )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn defaults()
{
let s = spinner();
assert_eq!( s.phase, 0.0 );
assert_eq!( s.size, theme::SIZE );
assert_eq!( s.stroke_w, theme::STROKE_W );
}
#[ test ]
fn builders_apply()
{
let s = spinner().phase( 0.42 ).size( 64.0 ).stroke_width( 5.0 );
assert_eq!( s.phase, 0.42 );
assert_eq!( s.size, 64.0 );
assert_eq!( s.stroke_w, 5.0 );
}
#[ test ]
fn preferred_size_clamps_to_max_width()
{
let s = spinner().size( 100.0 );
assert_eq!( s.preferred_size( 40.0 ), ( 40.0, 40.0 ) );
assert_eq!( s.preferred_size( 200.0 ), ( 100.0, 100.0 ) );
}
#[ test ]
fn preferred_size_is_square()
{
let s = spinner();
let ( w, h ) = s.preferred_size( 999.0 );
assert_eq!( w, h );
}
}

208
src/widget/tab_bar.rs Normal file
View File

@@ -0,0 +1,208 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! TabBar — segmented selector built as a composition over existing
//! widgets. The widget itself is stateless: the application owns the
//! current selection (`usize`) and calls back through
//! [`TabBar::on_select`] when the user taps another tab.
//!
//! Returns an [`Element`] directly via [`TabBar::build`] / `Into`, so it
//! drops into any layout that accepts a child widget.
//!
//! ```rust,no_run
//! # use ltk::{ tabs, TabBar };
//! # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
//! # struct App { tab: usize }
//! # impl App { fn _ex( &self ) -> TabBar<Msg> {
//! tabs( [ "General", "Network", "Audio" ] )
//! .selected( self.tab )
//! .on_select( Msg::SelectTab )
//! # }}
//! ```
use crate::types::Color;
use super::Element;
mod theme
{
pub const RADIUS: f32 = 100.0;
pub const PADDING: f32 = 4.0;
pub const SPACING: f32 = 4.0;
}
/// Segmented horizontal tab selector. One row of pressable cells with
/// the active cell painted as a filled pill and inactive cells as plain
/// text. Build it from a slice of labels; produce an [`Element`] with
/// [`Self::build`] (or by `.into()`-ing it where an `Element` is
/// expected).
pub struct TabBar<Msg: Clone>
{
pub labels: Vec<String>,
pub selected: usize,
pub on_select: Option<std::sync::Arc<dyn Fn( usize ) -> Msg>>,
/// Background colour of the strip itself. `None` paints no
/// background, letting the parent surface show through.
pub strip_bg: Option<Color>,
}
impl<Msg: Clone + 'static> TabBar<Msg>
{
/// Build a tab bar from an iterable of labels.
pub fn new<I, S>( labels: I ) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self
{
labels: labels.into_iter().map( Into::into ).collect(),
selected: 0,
on_select: None,
strip_bg: Some( crate::theme::palette().surface_alt ),
}
}
/// Index of the currently selected tab. Out-of-range values are
/// drawn as "no tab active".
pub fn selected( mut self, idx: usize ) -> Self
{
self.selected = idx;
self
}
/// Callback invoked with the index of the tapped tab.
pub fn on_select( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_select = Some( std::sync::Arc::new( f ) );
self
}
/// Override the strip background colour. Pass `None` (via
/// [`Self::strip_bg_none`]) to disable the background entirely.
pub fn strip_bg( mut self, c: Color ) -> Self
{
self.strip_bg = Some( c );
self
}
/// Paint no strip background. Useful inside containers that already
/// provide their own surface chrome.
pub fn strip_bg_none( mut self ) -> Self
{
self.strip_bg = None;
self
}
/// Build the [`Element`] tree representing this tab bar. Equivalent
/// to `Element::from( self )`.
pub fn build( self ) -> Element<Msg>
{
use super::{ button, container };
use super::button::ButtonVariant;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
let selected = self.selected;
let cb = self.on_select.clone();
let labels = self.labels;
let mut r = row::<Msg>().padding( theme::PADDING ).spacing( theme::SPACING );
for ( i, label ) in labels.into_iter().enumerate()
{
let is_active = i == selected;
let mut btn = button( label );
if is_active
{
btn = btn.variant( ButtonVariant::Primary );
} else {
btn = btn.variant( ButtonVariant::Tertiary );
}
if let Some( ref f ) = cb
{
let f = f.clone();
let msg = f( i );
btn = btn.on_press( msg );
}
r = r.push( btn );
}
// Trailing spacer so the strip claims the full row width and the
// chips left-align inside it. Callers that want full-width tabs
// can wrap the result in a Row of `flex` children themselves.
r = r.push( spacer() );
match self.strip_bg
{
Some( bg ) => container( r ).background( bg ).radius( theme::RADIUS ).into(),
None => Element::Row( r ),
}
}
}
impl<Msg: Clone + 'static> From<TabBar<Msg>> for Element<Msg>
{
fn from( t: TabBar<Msg> ) -> Self
{
t.build()
}
}
/// Create a [`TabBar`] from any iterable of label-likes.
///
/// ```rust,no_run
/// # use ltk::{ tabs, TabBar };
/// # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) }
/// # struct App { tab: usize }
/// # impl App { fn _ex( &self ) -> TabBar<Msg> {
/// tabs( [ "Inbox", "Sent", "Drafts" ] )
/// .selected( self.tab )
/// .on_select( Msg::SelectTab )
/// # }}
/// ```
pub fn tabs<Msg, I, S>( labels: I ) -> TabBar<Msg>
where
Msg: Clone + 'static,
I: IntoIterator<Item = S>,
S: Into<String>,
{
TabBar::new( labels )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Pick( usize ) }
#[ test ]
fn defaults()
{
let t: TabBar<Msg> = TabBar::new( vec![ "a", "b", "c" ] );
assert_eq!( t.labels.len(), 3 );
assert_eq!( t.selected, 0 );
assert!( t.on_select.is_none() );
}
#[ test ]
fn selected_builder()
{
let t: TabBar<Msg> = TabBar::new( vec![ "a", "b" ] ).selected( 1 );
assert_eq!( t.selected, 1 );
}
#[ test ]
fn on_select_invokes_callback()
{
let t: TabBar<Msg> = TabBar::new( vec![ "a", "b" ] ).on_select( Msg::Pick );
let cb = t.on_select.as_ref().expect( "callback set" );
assert_eq!( cb( 1 ), Msg::Pick( 1 ) );
}
#[ test ]
fn strip_bg_none_disables_background()
{
let t: TabBar<Msg> = TabBar::new( vec![ "a" ] ).strip_bg_none();
assert!( t.strip_bg.is_none() );
}
}

384
src/widget/text.rs Normal file
View File

@@ -0,0 +1,384 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use fontdue::Font;
use crate::theme::FontStyle;
use crate::types::{ Color, Rect };
use crate::render::Canvas;
use super::Element;
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum TextAlign
{
Left,
Center,
Right,
}
pub struct Text
{
pub content: String,
pub size: f32,
pub color: Color,
pub align: TextAlign,
pub wrap: bool,
/// Optional `(family, weight, style)` override resolved through
/// the active theme's font registry on every draw. `None` keeps
/// the canvas default font (Sora Regular in `ltk-theme-default`).
pub font: Option<( String, u16, FontStyle )>,
}
impl Text
{
pub fn new( content: impl Into<String> ) -> Self
{
Self
{
content: content.into(),
size: 16.0,
color: Color::WHITE,
align: TextAlign::Left,
wrap: false,
font: None,
}
}
/// Set a `(family, weight, style)` override for this text node.
/// The triple is resolved through [`Canvas::font_for`] at draw
/// time, so missing weights fall through the registry's nearest-
/// match precedence.
pub fn font( mut self, family: impl Into<String>, weight: u16, style: FontStyle ) -> Self
{
self.font = Some( ( family.into(), weight, style ) );
self
}
/// Shorthand for [`Self::font`] when the desired override is just
/// a weight on the default Sora family (the family `ltk-theme-
/// default` declares).
pub fn weight( mut self, weight: u16 ) -> Self
{
self.font = Some( ( "sora".to_string(), weight, FontStyle::Normal ) );
self
}
pub fn size( mut self, s: f32 ) -> Self
{
self.size = s;
self
}
pub fn color( mut self, c: Color ) -> Self
{
self.color = c;
self
}
pub fn align( mut self, a: TextAlign ) -> Self
{
self.align = a;
self
}
pub fn align_center( mut self ) -> Self
{
self.align = TextAlign::Center;
self
}
/// Enable word-wrapping. With `wrap = true` the text breaks on
/// whitespace at `max_width` and the widget reports the natural
/// height of all the resulting lines. With `wrap = false` (the
/// default) the text stays on one line and is truncated with an
/// ellipsis when it overflows.
pub fn wrap( mut self, w: bool ) -> Self
{
self.wrap = w;
self
}
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<Font>>
{
self.font.as_ref().map( |( family, weight, style )|
{
canvas.font_for( family, *weight, *style )
} )
}
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
{
match font
{
Some( f ) => canvas.measure_text_with_font( text, self.size, f ),
None => canvas.measure_text( text, self.size ),
}
}
fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
{
match font
{
Some( f ) => f.metrics( ch, self.size * canvas.dpi_scale() ).advance_width,
None => canvas.font_metrics( ch, self.size ).advance_width,
}
}
fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc<Font>> )
{
match font
{
Some( f ) => canvas.draw_text_with_font( text, x, y, self.size, self.color, f ),
None => canvas.draw_text( text, x, y, self.size, self.color ),
}
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let line_h = canvas.font_line_metrics( self.size )
.map( |m| m.ascent - m.descent )
.unwrap_or( self.size );
let font = self.resolve_font( canvas );
if self.wrap
{
let lines = wrap_lines( &self.content, self.size, max_width, canvas, font.as_ref() );
let h = line_h * lines.len().max( 1 ) as f32;
( max_width, h )
}
else
{
let w = ( self.measure( &self.content, canvas, font.as_ref() ) + 8.0 ).min( max_width );
( w, line_h )
}
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
{
let ascent = canvas.font_line_metrics( self.size )
.map( |m| m.ascent )
.unwrap_or( self.size * 0.8 );
let line_h = canvas.font_line_metrics( self.size )
.map( |m| m.ascent - m.descent )
.unwrap_or( self.size );
let font = self.resolve_font( canvas );
if self.wrap
{
let lines = wrap_lines( &self.content, self.size, rect.width, canvas, font.as_ref() );
for ( i, line ) in lines.iter().enumerate()
{
let line_w = self.measure( line, canvas, font.as_ref() );
let slack = ( rect.width - line_w ).max( 0.0 );
let pad = 4.0_f32.min( slack );
let tx = match self.align
{
TextAlign::Left => rect.x + pad,
TextAlign::Center => rect.x + slack / 2.0,
TextAlign::Right => rect.x + rect.width - line_w - pad,
};
let ty = rect.y + ascent + line_h * i as f32;
self.paint( canvas, line, tx, ty, font.as_ref() );
}
return;
}
let text_w = self.measure( &self.content, canvas, font.as_ref() );
let display = if text_w > rect.width && rect.width > 0.0
{
let ellipsis = "...";
let ell_w = self.measure( ellipsis, canvas, font.as_ref() );
let budget = rect.width - ell_w;
if budget <= 0.0
{
ellipsis.to_string()
}
else
{
let mut accum = 0.0_f32;
let truncated: String = self.content.chars().take_while( |ch|
{
let cw = self.measure_char( *ch, canvas, font.as_ref() );
accum += cw;
accum <= budget
} ).collect();
format!( "{truncated}{ellipsis}" )
}
}
else
{
self.content.clone()
};
let disp_w = self.measure( &display, canvas, font.as_ref() );
let slack = ( rect.width - disp_w ).max( 0.0 );
let pad = 4.0_f32.min( slack );
let tx = match self.align
{
TextAlign::Left => rect.x + pad,
TextAlign::Center => rect.x + slack / 2.0,
TextAlign::Right => rect.x + rect.width - disp_w - pad,
};
let ty = rect.y + ascent;
self.paint( canvas, &display, tx, ty, font.as_ref() );
}
}
/// Greedy word-wrap: split `text` on whitespace and pack words into
/// lines whose total width stays under `max_width`. A word longer
/// than `max_width` overflows on its own line rather than being
/// hyphenated. Routes through the font override when one is set so
/// measurement and rendering agree on advance widths.
fn wrap_lines( text: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc<Font>> ) -> Vec<String>
{
if max_width <= 0.0 || text.is_empty()
{
return vec![ text.to_string() ];
}
let measure = |s: &str| -> f32
{
match font
{
Some( f ) => canvas.measure_text_with_font( s, size, f ),
None => canvas.measure_text( s, size ),
}
};
let space_w = measure( " " );
let mut lines = Vec::new();
let mut current = String::new();
let mut current_w = 0.0_f32;
for word in text.split_whitespace()
{
let word_w = measure( word );
if current.is_empty()
{
current.push_str( word );
current_w = word_w;
}
else if current_w + space_w + word_w <= max_width
{
current.push( ' ' );
current.push_str( word );
current_w += space_w + word_w;
}
else
{
lines.push( std::mem::take( &mut current ) );
current.push_str( word );
current_w = word_w;
}
}
if !current.is_empty() { lines.push( current ); }
if lines.is_empty() { lines.push( String::new() ); }
lines
}
impl<Msg: Clone + 'static> From<Text> for Element<Msg>
{
fn from( t: Text ) -> Self
{
Element::Text( t )
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn new_uses_documented_defaults()
{
let t = Text::new( "hello" );
assert_eq!( t.content, "hello" );
assert_eq!( t.size, 16.0 );
assert_eq!( t.color, Color::WHITE );
assert_eq!( t.align, TextAlign::Left );
}
#[ test ]
fn size_builder_overrides_default()
{
let t = Text::new( "" ).size( 32.0 );
assert_eq!( t.size, 32.0 );
}
#[ test ]
fn color_builder_overrides_default()
{
let t = Text::new( "" ).color( Color::rgb( 1.0, 0.0, 0.0 ) );
assert_eq!( t.color, Color::rgb( 1.0, 0.0, 0.0 ) );
}
#[ test ]
fn align_builder_can_target_each_variant()
{
assert_eq!( Text::new( "" ).align( TextAlign::Left ).align, TextAlign::Left );
assert_eq!( Text::new( "" ).align( TextAlign::Center ).align, TextAlign::Center );
assert_eq!( Text::new( "" ).align( TextAlign::Right ).align, TextAlign::Right );
}
#[ test ]
fn align_center_shorthand_matches_explicit_align()
{
let a = Text::new( "" ).align_center();
let b = Text::new( "" ).align( TextAlign::Center );
assert_eq!( a.align, b.align );
}
#[ test ]
fn preferred_size_returns_non_negative_dimensions()
{
let canvas = make_canvas();
let ( w, h ) = Text::new( "ltk" ).preferred_size( 200.0, &canvas );
assert!( w >= 0.0 );
assert!( h > 0.0, "non-empty text reports a positive line height" );
}
#[ test ]
fn preferred_size_caps_width_at_max_width()
{
// A long string must not exceed the parent-supplied bound — text
// elides at draw time, so the layout pass needs to see at most
// `max_width`.
let canvas = make_canvas();
let long = "the quick brown fox jumps over the lazy dog";
let ( w, _ ) = Text::new( long ).size( 24.0 ).preferred_size( 64.0, &canvas );
assert!( w <= 64.0, "preferred width must be clamped to max_width (got {w})" );
}
#[ test ]
fn preferred_size_height_scales_with_font_size()
{
let canvas = make_canvas();
let ( _, h_small ) = Text::new( "x" ).size( 12.0 ).preferred_size( 200.0, &canvas );
let ( _, h_large ) = Text::new( "x" ).size( 48.0 ).preferred_size( 200.0, &canvas );
assert!( h_large > h_small, "larger font must report taller line height" );
}
#[ test ]
fn empty_content_still_reports_a_line_height()
{
let canvas = make_canvas();
let ( _, h ) = Text::new( "" ).preferred_size( 200.0, &canvas );
// Even an empty string keeps the baseline metrics so a hidden field
// keeps its row height inside a column.
assert!( h > 0.0 );
}
#[ test ]
fn text_align_enum_implements_partial_eq()
{
// Compile-time guard: TextAlign must keep deriving PartialEq so
// downstream theming code can compare alignments.
assert_eq!( TextAlign::Left, TextAlign::Left );
assert_ne!( TextAlign::Left, TextAlign::Right );
}
}

2029
src/widget/text_edit.rs Normal file

File diff suppressed because it is too large Load Diff

615
src/widget/time_picker.rs Normal file
View File

@@ -0,0 +1,615 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! TimePicker — HH:MM (and optionally :SS / AM-PM) stepper widget.
//!
//! Stateless — the application owns a [`Time`] and updates it from
//! the [`TimePicker::on_change`] callback. Each unit (hour, minute,
//! second) is a small stepper: an up arrow above the digit cell,
//! a down arrow below. Each digit cell is itself an editable
//! [`crate::widget::text_edit::TextEdit`] with `select_on_focus` —
//! click (or Tab) into it and type to set the value with the
//! keyboard, parser-clamped to the unit's valid range.
//!
//! Stepper buttons opt into press-and-hold repeat
//! ([`crate::widget::button::Button::repeating`]) so a held arrow
//! ramps through values at ~8 Hz. The minute / second steppers
//! honour [`TimePicker::minute_step`] (default 1, common values 5 /
//! 15) and **snap to the next or previous multiple of the step**
//! rather than adding the step verbatim — so `:32` with
//! `minute_step( 5 )` jumps to `:35` (next multiple) rather than
//! `:37`. Typed input bypasses the snap so the user can still type
//! any minute they want.
//!
//! ```rust,no_run
//! # use ltk::{ time_picker, Time, TimePicker };
//! # #[ derive( Clone ) ] enum Msg { TimeChanged( Time ) }
//! # struct App { time: Time }
//! # impl App { fn _ex( &self ) -> TimePicker<Msg> {
//! time_picker( self.time )
//! .minute_step( 5 )
//! .twelve_hour( true )
//! .on_change( Msg::TimeChanged )
//! # }}
//! ```
use std::sync::Arc;
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn text() -> Color { crate::theme::palette().text_primary }
pub fn text_muted() -> Color { crate::theme::palette().text_secondary }
pub fn surface_alt()-> Color { crate::theme::palette().surface_alt }
pub const PADDING: f32 = 16.0;
pub const RADIUS: f32 = 16.0;
pub const VAL_FS: f32 = 28.0;
pub const SEP_FS: f32 = 28.0;
pub const SPACING: f32 = 8.0;
}
/// A wall-clock time, no date / no timezone. `hour` is 023 (24-hour
/// representation regardless of [`TimePicker::twelve_hour`] display
/// mode), `minute` and `second` are 059.
#[ derive( Clone, Copy, Debug, PartialEq, Eq, Hash, Default ) ]
pub struct Time
{
pub hour: u8,
pub minute: u8,
pub second: u8,
}
impl Time
{
pub const fn new( hour: u8, minute: u8 ) -> Self
{
Self { hour, minute, second: 0 }
}
pub const fn with_seconds( hour: u8, minute: u8, second: u8 ) -> Self
{
Self { hour, minute, second }
}
/// Total seconds from 00:00:00. Used internally for arithmetic
/// that needs to wrap correctly across midnight.
pub fn as_seconds( self ) -> i32
{
self.hour as i32 * 3600 + self.minute as i32 * 60 + self.second as i32
}
/// Reconstruct a [`Time`] from a (signed) seconds-of-day count,
/// wrapping on the 24-hour boundary in either direction.
pub fn from_seconds( s: i32 ) -> Self
{
let total = s.rem_euclid( 24 * 3600 );
let hour = ( total / 3600 ) as u8;
let minute = ( ( total % 3600 ) / 60 ) as u8;
let second = ( total % 60 ) as u8;
Self { hour, minute, second }
}
/// `true` when the values are in the canonical ranges (h: 0..24,
/// m / s: 0..60).
pub fn is_valid( self ) -> bool
{
self.hour < 24 && self.minute < 60 && self.second < 60
}
}
/// Compute the signed delta needed to step `value` to the next
/// (`dir = 1`) or previous (`dir = -1`) multiple of `step`. The
/// returned delta carries the right sign already, so the caller just
/// adds it to its working seconds-of-day count.
///
/// Designed for the minute / second steppers in
/// [`TimePicker`](crate::widget::time_picker::TimePicker) — it ignores
/// the wraparound at 60 (the picker always feeds the result through
/// [`Time::from_seconds`], which handles the `:00` → next-hour
/// rollover via `rem_euclid`).
///
/// Examples (`step = 5`):
/// * `value = 30, dir = 1` → `+5` → next aligned at `35`
/// * `value = 32, dir = 1` → `+3` → next aligned at `35`
/// * `value = 32, dir = -1` → `-2` → previous aligned at `30`
/// * `value = 30, dir = -1` → `-5` → previous aligned at `25`
/// Filter `s` down to its ASCII digit characters, parse them as a
/// decimal integer and clamp the result to `0..=max`. Returns `None`
/// only when the filtered string is empty so the caller can detect
/// "the user erased the field" and skip the on-change message rather
/// than committing a spurious zero. Anything else — leading zeros,
/// punctuation, alpha characters, values past `max` — is forgiven and
/// folded into a valid `u8`.
fn parse_clamped_digits( s: &str, max: u8 ) -> Option<u8>
{
let digits: String = s.chars().filter( |c| c.is_ascii_digit() ).collect();
if digits.is_empty() { return None; }
// `u32` instead of `u8::from_str` so a typed "099" or "100" does
// not overflow the parse and bail out — we want to clamp, not
// reject.
let n: u32 = digits.parse().ok()?;
Some( n.min( max as u32 ) as u8 )
}
fn snap_step_delta( value: i32, step: i32, dir: i32 ) -> i32
{
let step = step.max( 1 );
if dir >= 0
{
// Up: smallest multiple of `step` strictly greater than
// `value`. `value % step == 0` ⇒ jump a full step;
// otherwise round up to the next multiple.
let rem = value.rem_euclid( step );
if rem == 0 { step } else { step - rem }
}
else
{
// Down: largest multiple of `step` strictly less than
// `value`. Symmetric to the up case.
let rem = value.rem_euclid( step );
if rem == 0 { -step } else { -rem }
}
}
/// Time-of-day selector with up / down steppers per unit.
pub struct TimePicker<Msg: Clone>
{
pub value: Time,
pub on_change: Option<Arc<dyn Fn( Time ) -> Msg>>,
pub minute_step: u8,
pub second_step: u8,
pub seconds: bool,
pub twelve_hour: bool,
}
impl<Msg: Clone + 'static> TimePicker<Msg>
{
/// Create a time picker with the given current time.
pub fn new( value: Time ) -> Self
{
Self
{
value,
on_change: None,
minute_step: 1,
second_step: 1,
seconds: false,
twelve_hour: false,
}
}
pub fn on_change( mut self, f: impl Fn( Time ) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
/// Step size for the minute up / down buttons. Common values: 1
/// (default), 5, 15. Clamped to `1..=30`.
pub fn minute_step( mut self, step: u8 ) -> Self
{
self.minute_step = step.clamp( 1, 30 );
self
}
/// Step size for the second up / down buttons. Only meaningful
/// when [`Self::seconds`] is on. Clamped to `1..=30`.
pub fn second_step( mut self, step: u8 ) -> Self
{
self.second_step = step.clamp( 1, 30 );
self
}
/// Show / hide the seconds stepper. Default `false`.
pub fn seconds( mut self, on: bool ) -> Self
{
self.seconds = on;
self
}
/// Display the hour as 12-hour with an AM / PM toggle. Internal
/// storage stays 24-hour. Default `false`.
pub fn twelve_hour( mut self, on: bool ) -> Self
{
self.twelve_hour = on;
self
}
/// Build the `Element` tree representing this time picker.
pub fn build( self ) -> Element<Msg>
{
use super::{ button, container, icon_button, text, text_edit };
use super::button::ButtonVariant;
use super::text::TextAlign;
let on_chg = self.on_change.clone();
let value = self.value;
let twelve = self.twelve_hour;
// Up / down arrows load from the active theme as SVG icons
// (`icons/catalogue/filled/general/{up,down}-simple.svg`)
// tinted with `palette.text_primary` so they read against
// either light or dark surfaces. Falls back to the matching
// Unicode glyph when the icon is missing — the literal
// `▲` / `▼` characters are not present in every system
// font, so without the SVG path the buttons would render
// as missing-glyph boxes (see issue with stock fonts that
// lack U+25B2 / U+25BC).
const ARROW_PX: u32 = 16;
let arrow = | name: &str, fallback: &str | -> super::button::Button<Msg>
{
let btn = match crate::theme::icon_rgba( name, ARROW_PX )
{
Some( ( rgba, w, h ) ) =>
{
let tinted = std::sync::Arc::new(
crate::theme::tint_symbolic( &rgba, theme::text() ),
);
icon_button::<Msg>( tinted, w, h ).icon_size( ARROW_PX as f32 )
}
None => button::<Msg>( fallback ).variant( ButtonVariant::Tertiary ),
};
// Press-and-hold steps through values continuously, like
// holding an arrow key. The runtime fires `on_press`
// immediately on press and then re-fires at the same
// cadence as keyboard repeat while the button is held.
btn.repeating( true )
};
// Build a stepper column for one unit. The middle element is
// supplied by the caller (a static `text` for read-only mode,
// or an editable `text_edit` with the appropriate `on_change`
// parser when a callback is wired) so each unit can carry its
// own typing semantics.
//
// `fit_content()` + `padding(0.0)` are critical: without them
// each column would claim the full row width as its preferred
// size (the `Column` default), pushing the colon and the
// minute / second columns off-screen to the right.
let stepper = | middle: Element<Msg>, up_secs: i32, down_secs: i32 | -> crate::layout::column::Column<Msg>
{
let mut col = column::<Msg>()
.spacing( 4.0 )
.align_center_x( true )
.padding( 0.0 )
.fit_content();
let mut up = arrow( "general/up-simple", "" );
let mut dn = arrow( "general/down-simple", "" );
if let Some( ref cb ) = on_chg
{
let cb_up = cb.clone();
let cb_dn = cb.clone();
let new_up = Time::from_seconds( value.as_seconds() + up_secs );
let new_dn = Time::from_seconds( value.as_seconds() + down_secs );
up = up.on_press( cb_up( new_up ) );
dn = dn.on_press( cb_dn( new_dn ) );
}
col = col.push( up );
col = col.push( middle );
col = col.push( dn );
col
};
// Build the editable digit field for one unit. When no
// `on_change` is wired, falls back to a static `text` so the
// digits still render but the picker is read-only. With
// `on_change` wired, returns a borderless centred
// `text_edit` with `select_on_focus` so the user can click
// (or Tab) to focus and immediately retype the value.
// `parser` translates the typed string into a fresh `Time`,
// returning `None` when the input is invalid (empty / non-
// numeric) so the existing value is preserved.
let digit_field = | display: String, parser: Box<dyn Fn( &str ) -> Option<Time> > | -> Element<Msg>
{
match on_chg.as_ref()
{
None =>
{
text( display )
.size( theme::VAL_FS )
.color( theme::text() )
.align_center()
.into()
}
Some( cb ) =>
{
let cb = cb.clone();
let snapshot = value;
text_edit::<Msg>( "", display )
.borderless( true )
.fixed_width( 72.0 )
.font_size( theme::VAL_FS )
.align( TextAlign::Center )
.select_on_focus( true )
.on_change( move |s: String|
{
let new = parser( &s ).unwrap_or( snapshot );
cb( new )
} )
.into()
}
}
};
// Hour: respect 12-hour toggle for *display only*; storage
// stays 023. 12-hour displays 12 instead of 0.
let display_hour = if twelve
{
let h12 = value.hour % 12;
if h12 == 0 { 12 } else { h12 }
} else {
value.hour
};
let hour_field = digit_field(
format!( "{:02}", display_hour ),
Box::new( move |s: &str| -> Option<Time>
{
let typed = parse_clamped_digits( s, if twelve { 12 } else { 23 } )?;
let hour24 = if twelve
{
// 12-hour input: 12 with PM stays 12, 12 with AM
// becomes 0; 111 keep the AM / PM the user
// already had so retyping a value during a PM
// hour does not silently roll back to AM.
let is_pm = value.hour >= 12;
match ( typed, is_pm )
{
( 12, false ) => 0,
( 12, true ) => 12,
( h, false ) => h,
( h, true ) => h.saturating_add( 12 ).min( 23 ),
}
} else { typed };
Some( Time::with_seconds( hour24, value.minute, value.second ) )
} ),
);
let hour_col = stepper( hour_field, 3600, -3600 );
// Snap minutes / seconds to the next (or previous) multiple
// of the configured step instead of adding or subtracting the
// step verbatim. With `minute_step( 5 )` and a starting value
// of :32, the up arrow takes the user to :35 and the down
// arrow to :30 rather than perpetuating the off-step :37 /
// :27 — so the picker can never produce a value that is not a
// multiple of the step. Same logic for seconds. Typed input
// goes straight into the field — only the steppers enforce
// the snap, the user can still type any valid minute.
let minute_step = self.minute_step as i32;
let minute_up = snap_step_delta( value.minute as i32, minute_step, 1 ) * 60;
let minute_dn = snap_step_delta( value.minute as i32, minute_step, -1 ) * 60;
let minute_field = digit_field(
format!( "{:02}", value.minute ),
Box::new( move |s: &str| -> Option<Time>
{
let m = parse_clamped_digits( s, 59 )?;
Some( Time::with_seconds( value.hour, m, value.second ) )
} ),
);
let minute_col = stepper( minute_field, minute_up, minute_dn );
let mut units = row::<Msg>().spacing( theme::SPACING )
.push( hour_col )
.push( text( ":" ).size( theme::SEP_FS ).color( theme::text_muted() ) )
.push( minute_col );
if self.seconds
{
let second_step = self.second_step as i32;
let second_up = snap_step_delta( value.second as i32, second_step, 1 );
let second_dn = snap_step_delta( value.second as i32, second_step, -1 );
let second_field = digit_field(
format!( "{:02}", value.second ),
Box::new( move |s: &str| -> Option<Time>
{
let sec = parse_clamped_digits( s, 59 )?;
Some( Time::with_seconds( value.hour, value.minute, sec ) )
} ),
);
let second_col = stepper( second_field, second_up, second_dn );
units = units
.push( text( ":" ).size( theme::SEP_FS ).color( theme::text_muted() ) )
.push( second_col );
}
if twelve
{
let is_pm = value.hour >= 12;
let label = if is_pm { "PM" } else { "AM" };
let mut ampm = button::<Msg>( label ).variant( ButtonVariant::Secondary );
if let Some( ref cb ) = on_chg
{
// Toggle PM / AM = ±12 hours, modulo 24.
let toggled = Time::from_seconds( value.as_seconds() + 12 * 3600 );
ampm = ampm.on_press( cb( toggled ) );
}
// Fixed-width gap before AM/PM so the toggle sits next to
// the digits as a single cluster instead of being pushed
// to the right edge — Row's auto-centring then balances
// the whole hh:mm AM block inside the container. A flex
// spacer here would defeat the auto-centre and pin AM/PM
// to the right.
units = units.push( spacer().width( theme::SPACING * 2.0 ) ).push( ampm );
}
// `Row::layout` centres a cluster horizontally when there are
// no flex spacers inside (see `layout/row.rs` — `start_x =
// rect.x + (rect.width - fixed_w - gaps) / 2.0`). All the
// `units` rows we build here only contain fixed-width
// children (steppers, separator text, fixed-width spacer for
// AM/PM), so they centre automatically inside the container.
container::<Msg>( units )
.background( theme::surface_alt() )
.padding( theme::PADDING )
.radius( theme::RADIUS )
.into()
}
}
impl<Msg: Clone + 'static> From<TimePicker<Msg>> for Element<Msg>
{
fn from( t: TimePicker<Msg> ) -> Self { t.build() }
}
/// Create a [`TimePicker`] with the given current time.
///
/// ```rust,no_run
/// # use ltk::{ time_picker, Time, TimePicker };
/// # #[ derive( Clone ) ] enum Msg { TimeChanged( Time ) }
/// # struct App { time: Time }
/// # impl App { fn _ex( &self ) -> TimePicker<Msg> {
/// time_picker( self.time )
/// .minute_step( 5 )
/// .on_change( Msg::TimeChanged )
/// # }}
/// ```
pub fn time_picker<Msg: Clone + 'static>( value: Time ) -> TimePicker<Msg>
{
TimePicker::new( value )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
// ── Time arithmetic ───────────────────────────────────────────────────────
#[ test ]
fn time_round_trips_through_seconds()
{
let t = Time::with_seconds( 13, 24, 56 );
assert_eq!( Time::from_seconds( t.as_seconds() ), t );
}
#[ test ]
fn time_from_seconds_wraps_modulo_24h()
{
// One second past midnight → 00:00:01.
assert_eq!( Time::from_seconds( 24 * 3600 + 1 ), Time::with_seconds( 0, 0, 1 ) );
// One second before midnight (negative) → 23:59:59.
assert_eq!( Time::from_seconds( -1 ), Time::with_seconds( 23, 59, 59 ) );
}
#[ test ]
fn time_validity()
{
assert!( Time::with_seconds( 23, 59, 59 ).is_valid() );
assert!( !Time::with_seconds( 24, 0, 0 ).is_valid() );
assert!( !Time::with_seconds( 0, 60, 0 ).is_valid() );
}
// ── Builders ──────────────────────────────────────────────────────────────
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Pick( Time ) }
#[ test ]
fn defaults()
{
let p: TimePicker<Msg> = time_picker( Time::new( 9, 30 ) );
assert_eq!( p.value, Time::new( 9, 30 ) );
assert_eq!( p.minute_step, 1 );
assert_eq!( p.second_step, 1 );
assert!( !p.seconds );
assert!( !p.twelve_hour );
assert!( p.on_change.is_none() );
}
#[ test ]
fn minute_step_clamps_to_thirty()
{
let p: TimePicker<Msg> = time_picker( Time::new( 0, 0 ) ).minute_step( 99 );
assert_eq!( p.minute_step, 30 );
let p: TimePicker<Msg> = time_picker( Time::new( 0, 0 ) ).minute_step( 0 );
assert_eq!( p.minute_step, 1 );
}
#[ test ]
fn on_change_callback_invokes_with_time()
{
let p: TimePicker<Msg> = time_picker( Time::new( 9, 30 ) ).on_change( Msg::Pick );
let cb = p.on_change.as_ref().expect( "set" );
assert_eq!( cb( Time::new( 10, 0 ) ), Msg::Pick( Time::new( 10, 0 ) ) );
}
#[ test ]
fn build_does_not_panic_minimal()
{
let _: Element<Msg> = time_picker::<Msg>( Time::new( 9, 30 ) ).build();
}
#[ test ]
fn build_does_not_panic_with_twelve_hour_and_seconds()
{
let _: Element<Msg> = time_picker::<Msg>( Time::with_seconds( 13, 24, 56 ) )
.twelve_hour( true )
.seconds( true )
.minute_step( 15 )
.on_change( Msg::Pick )
.build();
}
// ── Step snapping ────────────────────────────────────────────────────────
#[ test ]
fn snap_step_delta_advances_to_next_multiple_when_off_step()
{
// Starting at :32 with step 5: up should land on :35 (+3),
// down should land on :30 (2).
assert_eq!( snap_step_delta( 32, 5, 1 ), 3 );
assert_eq!( snap_step_delta( 32, 5, -1 ), -2 );
}
#[ test ]
fn snap_step_delta_full_step_when_already_aligned()
{
// Starting on a multiple of step takes a full step in the
// requested direction.
assert_eq!( snap_step_delta( 30, 5, 1 ), 5 );
assert_eq!( snap_step_delta( 30, 5, -1 ), -5 );
}
#[ test ]
fn snap_step_delta_clamps_step_to_at_least_one()
{
// A step of 0 is meaningless — clamp to 1 so the picker
// always makes some progress on a click.
assert_eq!( snap_step_delta( 0, 0, 1 ), 1 );
}
// ── Typed-input parser ────────────────────────────────────────────────────
#[ test ]
fn parse_clamped_digits_strips_non_numeric()
{
assert_eq!( parse_clamped_digits( "0a3", 99 ), Some( 3 ) );
assert_eq!( parse_clamped_digits( " 12 ", 59 ), Some( 12 ) );
}
#[ test ]
fn parse_clamped_digits_clamps_to_max()
{
// A 12-hour field caps at 12; typing "23" reads as 23 then
// clamps to 12.
assert_eq!( parse_clamped_digits( "23", 12 ), Some( 12 ) );
// A 24-hour field caps at 23.
assert_eq!( parse_clamped_digits( "099", 23 ), Some( 23 ) );
}
#[ test ]
fn parse_clamped_digits_empty_returns_none()
{
// `None` lets the caller distinguish "user erased the
// field" from "user committed a zero", so the picker can
// hold the previous value instead of jumping to midnight.
assert!( parse_clamped_digits( "", 59 ).is_none() );
assert!( parse_clamped_digits( "abc", 59 ).is_none() );
}
}

305
src/widget/toast.rs Normal file
View File

@@ -0,0 +1,305 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Toast / Snackbar — short-lived bottom-anchored message overlay.
//!
//! [`Toast`] is **not** an [`Element`]; it is a builder that produces
//! an [`OverlaySpec`] for the application to return from
//! [`App::overlays`](crate::App::overlays). The widget itself is
//! stateless: the application owns the visibility / timer state and
//! returns the overlay only while a toast is pending.
//!
//! Auto-dismissal is the application's responsibility. A typical
//! pattern is to spawn a `calloop` timer (or use the channel sender
//! from [`App::set_channel_sender`](crate::App::set_channel_sender))
//! that fires a "toast expired" message after [`Toast::duration`]
//! elapses.
//!
//! ```rust,no_run
//! # use ltk::{ toast, OverlaySpec, WidgetId };
//! # #[ derive( Clone ) ] enum Msg {}
//! # struct ToastState { message: String }
//! # struct App { toast: Option<ToastState> }
//! # impl App {
//! fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
//! {
//! match &self.toast
//! {
//! Some( t ) => vec![ toast( &t.message ).id( WidgetId( "toast/main" ) ).overlay() ],
//! None => vec![],
//! }
//! }
//! # }
//! ```
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use std::time::Duration;
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::{ Color, WidgetId };
use super::Element;
mod theme
{
use crate::types::Color;
/// Toast pill background — the theme's elevated surface token so
/// the toast reads as a "card" against the page below in both
/// light and dark modes.
pub fn bg() -> Color { crate::theme::palette().surface_alt }
/// Body text colour — primary text token from the theme.
pub fn text() -> Color { crate::theme::palette().text_primary }
pub const HEIGHT: u32 = 56;
pub const BOTTOM_MARGIN: i32 = 32;
pub const PAD_H: f32 = 24.0;
pub const FONT_SIZE: f32 = 16.0;
pub const RADIUS: f32 = 28.0;
}
/// Builder for a transient bottom-anchored notification overlay.
///
/// `Toast` does not own its own timer — the application is responsible
/// for clearing the toast (typically by storing its `message` in an
/// `Option` and resetting it via a delayed message).
pub struct Toast<Msg: Clone>
{
/// Stable id for the overlay. Used to derive the [`OverlayId`] so
/// the same toast persists across frames when the message stays the
/// same.
pub id: WidgetId,
/// Body text rendered inside the pill.
pub message: String,
/// Display duration. The runtime does **not** consume this — it is
/// returned through [`Toast::duration_value`] so the app's timer
/// scheduler can read it back.
pub duration: Duration,
/// Optional message fired when the user taps anywhere outside the
/// pill. Convenient for "tap anywhere to dismiss" behaviour.
pub on_dismiss: Option<Msg>,
/// Optional override for the pill background colour.
pub bg: Option<Color>,
/// Optional override for the body text colour.
pub fg: Option<Color>,
}
impl<Msg: Clone + 'static> Toast<Msg>
{
/// Create a toast with the given message text. The default id is
/// `"toast/default"`; override with [`Self::id`] when more than one
/// toast variant might coexist.
pub fn new( message: impl Into<String> ) -> Self
{
Self
{
id: WidgetId( "toast/default" ),
message: message.into(),
duration: Duration::from_secs( 2 ),
on_dismiss: None,
bg: None,
fg: None,
}
}
/// Override the stable id used to derive the [`OverlayId`].
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = id;
self
}
/// Set the display duration. The toast itself does not auto-hide;
/// this value is stored so the application's timer scheduler can
/// read it back via [`Self::duration_value`].
pub fn duration( mut self, secs: f32 ) -> Self
{
self.duration = Duration::from_secs_f32( secs.max( 0.0 ) );
self
}
/// Read back the configured duration. Lets the app delegate timer
/// setup to a helper that does not need to know how the toast was
/// configured.
pub fn duration_value( &self ) -> Duration
{
self.duration
}
/// Set the dismiss message fired on a tap outside the pill.
pub fn on_dismiss( mut self, msg: Msg ) -> Self
{
self.on_dismiss = Some( msg );
self
}
/// Override the pill background colour.
pub fn background( mut self, c: Color ) -> Self
{
self.bg = Some( c );
self
}
/// Override the body text colour.
pub fn color( mut self, c: Color ) -> Self
{
self.fg = Some( c );
self
}
/// Build an [`Element`] that paints the toast pill bottom-anchored
/// inside its parent rect. The intended pattern is to push it on
/// top of the main view via a [`Stack`](crate::stack):
///
/// ```text
/// fn view( &self ) -> Element<Msg>
/// {
/// let body = column().push( … );
/// let mut s = stack().push( body );
/// if self.toast_until.is_some()
/// {
/// s = s.push( toast( "Saved" ).view() );
/// }
/// s.into()
/// }
/// ```
///
/// Works on every compositor — no `wlr-layer-shell` needed —
/// because the pill is just a regular widget tree drawn on top of
/// the existing canvas. Use [`Self::overlay`] only when you
/// specifically need the toast to render *above other windows*
/// (notification-style), which requires `wlr-layer-shell`.
pub fn view( self ) -> Element<Msg>
{
use super::{ container, text };
use crate::layout::stack::stack;
use crate::layout::stack::{ HAlign, VAlign };
let bg = self.bg.unwrap_or_else( theme::bg );
let fg = self.fg.unwrap_or_else( theme::text );
let pill: Element<Msg> = container(
text( self.message.clone() )
.size( theme::FONT_SIZE )
.color( fg )
)
.background( bg )
.padding_h( theme::PAD_H )
.padding_v( 12.0 )
.radius( theme::RADIUS )
.into();
stack::<Msg>()
.push_aligned_margin( pill, HAlign::Center, VAlign::Bottom, theme::BOTTOM_MARGIN as f32 )
.into()
}
/// Build the [`OverlaySpec`] the application returns from
/// [`App::overlays`](crate::App::overlays). The overlay is anchored
/// to the bottom edge of the screen with a small breathing margin.
///
/// Requires the compositor to advertise `wlr-layer-shell`. For a
/// portable variant that works everywhere — at the cost of being
/// confined to the application's surface — use [`Self::view`].
pub fn overlay( self ) -> OverlaySpec<Msg>
{
use super::{ container, text };
use crate::layout::stack::stack;
let mut hasher = DefaultHasher::new();
self.id.0.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 );
let bg = self.bg.unwrap_or_else( theme::bg );
let fg = self.fg.unwrap_or_else( theme::text );
let pill: Element<Msg> = container(
text( self.message.clone() )
.size( theme::FONT_SIZE )
.color( fg )
)
.background( bg )
.padding_h( theme::PAD_H )
.padding_v( 12.0 )
.radius( theme::RADIUS )
.into();
// Centre horizontally, anchor to the bottom with a small margin.
let body: Element<Msg> = stack()
.push_aligned_margin(
pill,
crate::layout::stack::HAlign::Center,
crate::layout::stack::VAlign::Bottom,
theme::BOTTOM_MARGIN as f32,
)
.into();
OverlaySpec
{
id: overlay_id,
layer: Layer::Overlay,
anchor: Anchor::BOTTOM,
size: ( 0, theme::HEIGHT + theme::BOTTOM_MARGIN as u32 + 24 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: body,
on_dismiss: self.on_dismiss,
anchor_widget_id: None,
}
}
}
/// Create a [`Toast`] with the given message.
pub fn toast<Msg: Clone + 'static>( message: impl Into<String> ) -> Toast<Msg>
{
Toast::new( message )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg { Dismiss }
#[ test ]
fn defaults()
{
let t: Toast<Msg> = toast( "Saved" );
assert_eq!( t.message, "Saved" );
assert_eq!( t.duration, Duration::from_secs( 2 ) );
assert!( t.on_dismiss.is_none() );
}
#[ test ]
fn duration_builder_clamps_negative()
{
let t: Toast<Msg> = toast( "x" ).duration( -1.0 );
assert_eq!( t.duration_value(), Duration::ZERO );
}
#[ test ]
fn on_dismiss_stored()
{
let t = toast( "x" ).on_dismiss( Msg::Dismiss );
assert_eq!( t.on_dismiss, Some( Msg::Dismiss ) );
}
#[ test ]
fn overlay_uses_stable_id()
{
let a: OverlaySpec<Msg> = toast( "x" ).id( WidgetId( "toast/a" ) ).overlay();
let b: OverlaySpec<Msg> = toast( "y" ).id( WidgetId( "toast/a" ) ).overlay();
assert_eq!( a.id, b.id, "same WidgetId must yield same OverlayId" );
let c: OverlaySpec<Msg> = toast( "x" ).id( WidgetId( "toast/b" ) ).overlay();
assert_ne!( a.id, c.id, "different WidgetIds must yield different OverlayIds" );
}
#[ test ]
fn overlay_is_layer_shell_not_xdg_popup()
{
let o: OverlaySpec<Msg> = toast( "x" ).overlay();
assert!( o.anchor_widget_id.is_none() );
assert_eq!( o.layer, Layer::Overlay );
}
}

244
src/widget/toggle.rs Normal file
View File

@@ -0,0 +1,244 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
mod theme
{
use crate::types::Color;
pub fn track_off() -> Color { crate::theme::palette().divider }
pub fn track_on() -> Color { crate::theme::palette().accent }
/// Thumb — uses the page-background colour so it reads as the
/// inverse of the track fill regardless of mode.
pub fn thumb() -> Color { crate::theme::palette().bg }
pub fn thumb_border() -> Color { crate::theme::palette().text_primary }
pub fn focus_color() -> Color { crate::theme::palette().accent }
pub fn label_color() -> Color { crate::theme::palette().text_primary }
pub const TRACK_W: f32 = 52.0;
pub const TRACK_H: f32 = 30.0;
pub const THUMB_SIZE: f32 = 24.0;
pub const HEIGHT: f32 = 48.0;
pub const GAP: f32 = 12.0;
pub const FOCUS_W: f32 = 3.0;
pub const THUMB_BORDER_W: f32 = 1.0;
pub const FONT_SIZE: f32 = 16.0;
}
/// A two-state on / off switch.
///
/// Renders as a horizontal pill with a circular thumb that slides between the
/// off (left, divider colour) and on (right, accent colour) positions. The
/// widget is stateless: the application owns `value` and rebuilds the toggle
/// from its current state on every frame. Tapping or pressing Enter / Space
/// while focused emits the message configured with [`Self::on_toggle`] — the
/// app's `update` is then expected to flip the bool and re-render.
///
/// ```rust,no_run
/// # use ltk::{ toggle, Toggle };
/// # #[ derive( Clone ) ] enum Msg { ToggleWifi }
/// # struct App { wifi_enabled: bool }
/// # impl App { fn _ex( &self ) -> Toggle<Msg> {
/// // In view():
/// toggle( self.wifi_enabled )
/// .label( "Wi-Fi" )
/// .on_toggle( Msg::ToggleWifi )
/// # }}
/// ```
///
/// See also [`Checkbox`](super::checkbox::Checkbox) for binary opt-in
/// controls (terms acceptance, multi-select form fields) where the
/// pill-style affordance is too prominent.
pub struct Toggle<Msg: Clone>
{
/// Current on / off state. Drawn from this field every frame; the
/// runtime never mutates it.
pub value: bool,
/// Message emitted on activation. `None` leaves the toggle inert (it
/// still renders and takes focus, but does nothing on press).
pub on_toggle: Option<Msg>,
/// Optional label drawn to the left of the track.
pub label: Option<String>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
}
impl<Msg: Clone> Toggle<Msg>
{
/// Create a toggle in the given state, with no label and no callback.
///
/// Wire activation through [`Self::on_toggle`] before adding it to a
/// widget tree, otherwise the toggle is decorative.
pub fn new( value: bool ) -> Self
{
Self { value, on_toggle: None, label: None, id: None }
}
/// Set the message emitted when the toggle is activated (tap, Enter or
/// Space while focused). The application's `update` is responsible for
/// flipping `value` in response.
pub fn on_toggle( mut self, msg: Msg ) -> Self
{
self.on_toggle = Some( msg );
self
}
/// Set a text label rendered to the left of the track. The toggle's
/// preferred width grows to fit `label_width + gap + track_width`,
/// capped at the parent-supplied `max_width`.
pub fn label( mut self, label: impl Into<String> ) -> Self
{
self.label = Some( label.into() );
self
}
/// Assign a stable identifier so the application can target this
/// toggle through [`crate::App::take_focus_request`].
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
/// Return the preferred `(width, height)` given available `max_width`.
///
/// Width is `track_width` for an unlabelled toggle, or
/// `label_width + gap + track_width` (clamped to `max_width`) when a
/// label is set. Height is the theme-defined row height.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let w = if let Some( ref label ) = self.label
{
let text_w = canvas.measure_text( label, theme::FONT_SIZE );
( text_w + theme::GAP + theme::TRACK_W ).min( max_width )
} else {
theme::TRACK_W.min( max_width )
};
( w, theme::HEIGHT )
}
/// Bounding box of everything painted at `rect` across all states. The
/// focus ring is drawn as `track_rect.expand( FOCUS_W + 2 )` with a stroke
/// of width `FOCUS_W`, so it extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px`
/// beyond the track. Since the track sits at the right edge of `rect`,
/// the ring can extend that far outside the widget's layout rect.
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W + 2.0 + theme::FOCUS_W * 0.5 + 1.0 )
}
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
{
let track_x = if let Some( ref label ) = self.label
{
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0;
canvas.draw_text( label, rect.x, text_y, theme::FONT_SIZE, theme::label_color() );
rect.x + rect.width - theme::TRACK_W
} else {
rect.x + ( rect.width - theme::TRACK_W ) / 2.0
};
let track_y = rect.y + ( rect.height - theme::TRACK_H ) / 2.0;
let track_r = theme::TRACK_H / 2.0;
let track_rect = Rect
{
x: track_x,
y: track_y,
width: theme::TRACK_W,
height: theme::TRACK_H,
};
let track_color = if self.value { theme::track_on() } else { theme::track_off() };
canvas.fill_rect( track_rect, track_color, track_r );
let thumb_pad = ( theme::TRACK_H - theme::THUMB_SIZE ) / 2.0;
let thumb_cx = if self.value
{
track_x + theme::TRACK_W - thumb_pad - theme::THUMB_SIZE / 2.0
} else {
track_x + thumb_pad + theme::THUMB_SIZE / 2.0
};
let thumb_cy = track_y + theme::TRACK_H / 2.0;
let thumb_r = theme::THUMB_SIZE / 2.0;
let thumb_rect = Rect
{
x: thumb_cx - thumb_r,
y: thumb_cy - thumb_r,
width: theme::THUMB_SIZE,
height: theme::THUMB_SIZE,
};
canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r );
canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r );
if focused
{
let ring = track_rect.expand( theme::FOCUS_W + 2.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, track_r + theme::FOCUS_W + 2.0 );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Toggle<U>
where
U: Clone + 'static,
Msg: 'static,
{
Toggle
{
value: self.value,
on_toggle: self.on_toggle.map( |m| ( *f )( m ) ),
label: self.label,
id: self.id,
}
}
}
/// Create a [`Toggle`] in the given state.
///
/// Shorthand for [`Toggle::new`]. Wire activation with [`Toggle::on_toggle`]
/// and an optional label with [`Toggle::label`]:
///
/// ```rust,no_run
/// # use ltk::{ toggle, Toggle };
/// # #[ derive( Clone ) ] enum Msg { ToggleWifi }
/// # struct App { wifi_enabled: bool }
/// # impl App { fn _ex( &self ) -> Toggle<Msg> {
/// toggle( self.wifi_enabled )
/// .label( "Wi-Fi" )
/// .on_toggle( Msg::ToggleWifi )
/// # }}
/// ```
pub fn toggle<Msg: Clone>( value: bool ) -> Toggle<Msg>
{
Toggle::new( value )
}
impl<Msg: Clone + 'static> From<Toggle<Msg>> for Element<Msg>
{
fn from( t: Toggle<Msg> ) -> Self
{
Element::Toggle( t )
}
}
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn toggle_default_state()
{
let t = toggle::<()>( true );
assert!( t.value );
assert!( t.on_toggle.is_none() );
assert!( t.label.is_none() );
}
#[test]
fn toggle_off_state()
{
let t = toggle::<()>( false );
assert!( !t.value );
}
}

247
src/widget/tooltip.rs Normal file
View File

@@ -0,0 +1,247 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Tooltip — short hint anchored below a widget.
//!
//! Like [`Toast`](super::toast::Toast), `Tooltip` is **not** an
//! [`Element`]; it is a builder that produces an [`OverlaySpec`] for
//! the application to return from [`App::overlays`](crate::App::overlays).
//! The overlay is rendered as an `xdg-popup` anchored to the widget
//! tagged with [`Tooltip::anchor_id`] in the previous frame's layout,
//! so the hint appears flush below the trigger and follows it across
//! resizes and re-layouts.
//!
//! Hover detection and the show / hide delay are the **application's
//! responsibility**. ltk does not currently expose pointer-hover
//! callbacks at the widget level, so the typical pattern is:
//!
//! 1. Track which widget the pointer is over via `App::on_tap` or
//! a custom hover bookkeeping you maintain in `update`.
//! 2. Start a timer (via [`App::set_channel_sender`](crate::App::set_channel_sender))
//! that fires a "show tooltip" message after the desired delay.
//! 3. Return the [`Tooltip::overlay`] from `overlays()` only while the
//! tooltip should be visible.
//!
//! ```rust,no_run
//! # use ltk::{ tooltip, OverlaySpec, WidgetId };
//! # struct App { tooltip_for: Option<WidgetId> }
//! # impl App {
//! fn overlays( &self ) -> Vec<OverlaySpec<()>>
//! {
//! match self.tooltip_for
//! {
//! Some( id ) => vec![ tooltip( "Save the document", id ).overlay() ],
//! None => vec![],
//! }
//! }
//! # }
//! ```
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::{ Color, WidgetId };
use super::Element;
mod theme
{
use crate::types::Color;
/// Tooltip background — uses the theme's primary text colour as
/// a high-contrast hint pill (dark in light mode, light in dark
/// mode). Tooltips traditionally invert against the surface to
/// stand out.
pub fn bg() -> Color
{
let p = crate::theme::palette().text_primary;
Color::rgba( p.r, p.g, p.b, 0.95 )
}
/// Tooltip text — `bg-page` so the lettering reads as the
/// inverse of the bg.
pub fn text() -> Color { crate::theme::palette().bg }
pub const PAD_H: f32 = 12.0;
pub const PAD_V: f32 = 6.0;
pub const FONT_SIZE: f32 = 13.0;
pub const RADIUS: f32 = 8.0;
pub const MAX_W: u32 = 320;
pub const HEIGHT: u32 = 28;
}
/// Builder for an `xdg-popup`-backed hint anchored to a widget.
pub struct Tooltip<Msg: Clone>
{
/// Body text rendered inside the tooltip.
pub message: String,
/// Stable identifier of the widget the popup anchors to. The widget
/// must have been built with `.id( anchor_id )` so the runtime can
/// look its rect up in the previous frame's layout snapshot.
pub anchor_id: WidgetId,
/// Maximum width (logical pixels). The tooltip is single-line; long
/// strings are clipped at the surface boundary.
pub max_width: u32,
/// Optional override for the tooltip background colour.
pub bg: Option<Color>,
/// Optional override for the body text colour.
pub fg: Option<Color>,
/// Optional message fired when the user taps outside the popup.
pub on_dismiss: Option<Msg>,
}
impl<Msg: Clone + 'static> Tooltip<Msg>
{
/// Create a tooltip with the given message anchored to `anchor_id`.
pub fn new( message: impl Into<String>, anchor_id: WidgetId ) -> Self
{
Self
{
message: message.into(),
anchor_id,
max_width: theme::MAX_W,
bg: None,
fg: None,
on_dismiss: None,
}
}
/// Override the maximum tooltip width. Defaults to 320 logical px.
pub fn max_width( mut self, w: u32 ) -> Self
{
self.max_width = w;
self
}
/// Override the background colour.
pub fn background( mut self, c: Color ) -> Self
{
self.bg = Some( c );
self
}
/// Override the body text colour.
pub fn color( mut self, c: Color ) -> Self
{
self.fg = Some( c );
self
}
/// Set the dismiss message fired on a tap outside the popup.
pub fn on_dismiss( mut self, msg: Msg ) -> Self
{
self.on_dismiss = Some( msg );
self
}
/// Build the [`OverlaySpec`] the application returns from
/// [`App::overlays`](crate::App::overlays).
pub fn overlay( self ) -> OverlaySpec<Msg>
{
use super::{ container, text };
let mut hasher = DefaultHasher::new();
"tooltip".hash( &mut hasher );
self.anchor_id.0.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 );
let bg = self.bg.unwrap_or_else( theme::bg );
let fg = self.fg.unwrap_or_else( theme::text );
let body: Element<Msg> = container(
text( self.message.clone() )
.size( theme::FONT_SIZE )
.color( fg )
)
.background( bg )
.padding_h( theme::PAD_H )
.padding_v( theme::PAD_V )
.radius( theme::RADIUS )
.into();
OverlaySpec
{
id: overlay_id,
layer: Layer::Overlay,
anchor: Anchor::TOP,
size: ( self.max_width, theme::HEIGHT ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: body,
on_dismiss: self.on_dismiss,
anchor_widget_id: Some( self.anchor_id ),
}
}
}
/// Create a [`Tooltip`] anchored to the widget tagged with `anchor_id`.
///
/// ```rust,no_run
/// # use ltk::{ tooltip, OverlaySpec, WidgetId };
/// # fn _ex() -> OverlaySpec<()> {
/// tooltip( "Click to save", WidgetId( "btn/save" ) )
/// .max_width( 240 )
/// .overlay()
/// # }
/// ```
pub fn tooltip<Msg: Clone + 'static>(
message: impl Into<String>,
anchor_id: WidgetId,
) -> Tooltip<Msg>
{
Tooltip::new( message, anchor_id )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn defaults()
{
let t: Tooltip<()> = tooltip( "hi", WidgetId( "x" ) );
assert_eq!( t.message, "hi" );
assert_eq!( t.anchor_id.0, "x" );
assert_eq!( t.max_width, theme::MAX_W );
assert!( t.on_dismiss.is_none() );
}
#[ test ]
fn overlay_uses_anchor_widget_id()
{
let id = WidgetId( "btn/save" );
let o: OverlaySpec<()> = tooltip( "Save", id ).overlay();
assert_eq!( o.anchor_widget_id, Some( id ) );
}
#[ test ]
fn overlay_id_includes_tooltip_namespace()
{
// Same WidgetId used for a tooltip and (hypothetically) some
// other overlay must yield distinct OverlayIds. The tooltip's
// hash includes the literal "tooltip" prefix to namespace it
// against e.g. combo overlays that hash only the WidgetId.
let id = WidgetId( "foo" );
let tooltip_overlay_id = {
let mut h = DefaultHasher::new();
"tooltip".hash( &mut h );
id.0.hash( &mut h );
OverlayId( h.finish() as u32 )
};
let combo_style_id = {
let mut h = DefaultHasher::new();
id.0.hash( &mut h );
OverlayId( h.finish() as u32 )
};
assert_ne!( tooltip_overlay_id, combo_style_id );
let o: OverlaySpec<()> = tooltip( "x", id ).overlay();
assert_eq!( o.id, tooltip_overlay_id );
}
#[ test ]
fn max_width_builder_applies()
{
let t: Tooltip<()> = tooltip( "x", WidgetId( "a" ) ).max_width( 120 );
assert_eq!( t.max_width, 120 );
}
}

183
src/widget/viewport.rs Normal file
View File

@@ -0,0 +1,183 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::widget::Element;
/// A non-scrollable clipped viewport for revealing only part of a child tree.
///
/// Unlike `Scroll`, this widget does not own
/// any gesture state. It simply renders its child into an off-screen canvas
/// and blits the result into the allocated rect, clipping anything outside.
pub struct Viewport<Msg: Clone>
{
/// The child element to render inside the viewport.
pub child: Box<Element<Msg>>,
/// Optional fixed width in logical pixels. When omitted the
/// viewport reports `max_width` as its preferred width — i.e. it
/// stretches to fill whatever horizontal slice the parent layout
/// gives it. Set explicitly when the viewport has to coexist with
/// `flex` siblings (the flex would otherwise lose every pixel to
/// the viewport's `max_width` claim) or whenever the inner content
/// has its own intrinsic width and the viewport is just a vertical
/// clip.
pub width: Option<f32>,
/// Optional fixed height in logical pixels. When omitted the viewport
/// reports the child's natural height.
pub height: Option<f32>,
/// Logical pixels at the bottom edge that fade to transparent during the
/// blit. Zero leaves the bottom hard-edged. Useful for slide-in panels so
/// the leading edge of the animation does not knife-cut against the layer
/// below it.
pub fade_bottom: f32,
}
impl<Msg: Clone> Viewport<Msg>
{
pub fn new( child: impl Into<Element<Msg>> ) -> Self
{
Self { child: Box::new( child.into() ), width: None, height: None, fade_bottom: 0.0 }
}
/// Set a fixed viewport width in logical pixels. Mirrors
/// [`Self::height`]: with an explicit value the viewport
/// reports `w` as its preferred width and the child is laid
/// out against `w` rather than the parent-supplied `max_width`.
pub fn width( mut self, w: f32 ) -> Self
{
self.width = Some( w.max( 0.0 ) );
self
}
/// Set a fixed viewport height in logical pixels.
pub fn height( mut self, h: f32 ) -> Self
{
self.height = Some( h.max( 0.0 ) );
self
}
/// Feather the bottom `px` rows of the viewport so its lower edge dissolves
/// to transparent. Drawn by the GLES blit shader as a linear alpha ramp
/// over the bottom band; the software backend currently renders a hard
/// edge regardless.
pub fn fade_bottom( mut self, px: f32 ) -> Self
{
self.fade_bottom = px.max( 0.0 );
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let inner_w = self.width.unwrap_or( max_width );
let child_h = self.child.preferred_size( inner_w, canvas ).1;
( self.width.unwrap_or( max_width ), self.height.unwrap_or( child_h ) )
}
/// No-op — rendering is handled by `layout_and_draw` in `draw.rs`.
pub fn draw( &self ) {}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Viewport<U>
where
U: Clone + 'static,
Msg: 'static,
{
Viewport
{
child: Box::new( self.child.map_arc( f ) ),
width: self.width,
height: self.height,
fade_bottom: self.fade_bottom,
}
}
}
impl<Msg: Clone + 'static> From<Viewport<Msg>> for Element<Msg>
{
fn from( v: Viewport<Msg> ) -> Self
{
Element::Viewport( v )
}
}
/// Create a clipped viewport wrapping `child`.
pub fn viewport<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Viewport<Msg>
{
Viewport::new( child )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn new_defaults_are_unset_dimensions_and_no_fade()
{
let v = Viewport::<()>::new( spacer() );
assert!( v.width.is_none() );
assert!( v.height.is_none() );
assert_eq!( v.fade_bottom, 0.0 );
}
#[ test ]
fn width_builder_clamps_negative_to_zero()
{
let v = Viewport::<()>::new( spacer() ).width( -50.0 );
assert_eq!( v.width, Some( 0.0 ) );
}
#[ test ]
fn height_builder_clamps_negative_to_zero()
{
let v = Viewport::<()>::new( spacer() ).height( -10.0 );
assert_eq!( v.height, Some( 0.0 ) );
}
#[ test ]
fn fade_bottom_clamps_negative_to_zero()
{
let v = Viewport::<()>::new( spacer() ).fade_bottom( -1.0 );
assert_eq!( v.fade_bottom, 0.0 );
}
#[ test ]
fn fade_bottom_accepts_positive_pixel_count()
{
let v = Viewport::<()>::new( spacer() ).fade_bottom( 16.0 );
assert_eq!( v.fade_bottom, 16.0 );
}
#[ test ]
fn explicit_dimensions_override_child_intrinsic_size()
{
let v = Viewport::<()>::new( spacer().width( 99.0 ).height( 99.0 ) )
.width( 200.0 )
.height( 80.0 );
let canvas = make_canvas();
let ( w, h ) = v.preferred_size( 600.0, &canvas );
assert_eq!( w, 200.0 );
assert_eq!( h, 80.0 );
}
#[ test ]
fn unset_width_falls_through_to_max_width()
{
let v = Viewport::<()>::new( spacer().height( 40.0 ) );
let canvas = make_canvas();
let ( w, _ ) = v.preferred_size( 400.0, &canvas );
assert_eq!( w, 400.0 );
}
#[ test ]
fn unset_height_falls_through_to_child_intrinsic_height()
{
let v = Viewport::<()>::new( spacer().height( 75.0 ) );
let canvas = make_canvas();
let ( _, h ) = v.preferred_size( 400.0, &canvas );
assert_eq!( h, 75.0 );
}
}

486
src/widget/vslider.rs Normal file
View File

@@ -0,0 +1,486 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::Rect;
use crate::render::Canvas;
use super::Element;
use super::slider::intersect_clip;
mod theme
{
use crate::types::Color;
/// Slot ids for the Glass surfaces that back the VSlider track and
/// fill. The default theme ships them; downstream themes either
/// override them or let the widget fall through to a flat-colour
/// fallback painted from the [`track_bg`] / [`track_fill`] palette
/// tokens below.
pub const SURFACE_TRACK: &str = "surface-slider-track";
pub const SURFACE_FILL: &str = "surface-slider-fill";
/// Flat-colour fallback for the unfilled track — reuses the translucent
/// raised-surface token so the pill reads on both light and dark
/// wallpapers without hardcoding.
pub fn track_bg() -> Color { crate::theme::palette().surface_alt }
/// Flat-colour fallback for the filled portion — brand accent,
/// matching horizontal [`Slider`].
pub fn track_fill() -> Color { crate::theme::palette().accent }
/// Default pill width in pixels.
pub const WIDTH: f32 = 56.0;
/// Default pill height in pixels.
pub const HEIGHT: f32 = 160.0;
}
/// Compute the slider value `[0.0, 1.0]` from a tap/drag y position within a
/// slider's layout rect. `rect.top` maps to `1.0` and `rect.bottom` to `0.0`
/// — so the fill rises from the bottom as the user drags upward. Pure — no
/// theme / canvas dependency. Lifted out of [`VSlider`] so input handlers
/// can call it directly from [`crate::widget::LaidOutWidget`] without
/// needing the [`Element`] tree.
pub fn value_from_y_in_rect( rect: Rect, y: f32 ) -> f32
{
let track_h = rect.height.max( 1.0 );
( 1.0 - ( y - rect.y ) / track_h ).clamp( 0.0, 1.0 )
}
/// A vertical slider — a rounded pill that fills from bottom to top to
/// indicate its value.
///
/// Unlike [`Slider`](crate::Slider), which is horizontal and designed to
/// stretch across whatever width its parent allocates, a [`VSlider`] has
/// fixed pill dimensions (56 × 160 px by default) configurable via
/// [`VSlider::size`]. The widget reports those
/// dimensions as its preferred size and ignores the `max_width` the parent
/// offers — it is intrinsically sized, not filler.
///
/// The widget renders a rounded track in `palette.surface_alt` and, on top,
/// a rising pill in `palette.accent` whose height is proportional to
/// [`VSlider::value`]. No separate thumb is drawn; the top edge of the fill
/// itself acts as the value indicator.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # #[ derive( Clone ) ] enum Msg { SetVolume( f32 ) }
/// # struct App { volume: f32 }
/// # impl App { fn _ex( &self, speaker_rgba: Arc<Vec<u8>>, speaker_w: u32, speaker_h: u32 ) -> ltk::Element<Msg> {
/// use ltk::{ stack, vslider, img_widget, HAlign, VAlign };
///
/// // Plain vertical slider.
/// let _: ltk::VSlider<Msg> = vslider( self.volume ).on_change( Msg::SetVolume );
///
/// // With a speaker icon overlaid at the top. Stacked image children are
/// // non-interactive, so drag events still reach the slider underneath.
/// stack::<Msg>()
/// .push( vslider( self.volume ).on_change( Msg::SetVolume ) )
/// .push_aligned(
/// img_widget( speaker_rgba, speaker_w, speaker_h ),
/// HAlign::Center, VAlign::Top,
/// )
/// .into()
/// # }}
/// ```
pub struct VSlider<Msg: Clone>
{
/// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the
/// whole pill.
pub value: f32,
/// Fixed width of the pill in pixels. Defaults to 56.
pub width: f32,
/// Fixed height of the pill in pixels. Defaults to 160.
pub height: f32,
/// Callback invoked with the new value when the slider is tapped or
/// dragged. `Arc` (not `Box`) so the layout pass can clone it into the
/// per-leaf handler snapshot for O(1) dispatch on input events.
pub on_change: Option<Arc<dyn Fn(f32) -> Msg>>,
/// Theme slot id for the unfilled track. Defaults to
/// `surface-slider-track`. Override with [`VSlider::track_surface`]
/// when the slider lives inside a panel that already provides its
/// own backdrop blur — point the slot at a `*-flat` variant
/// (no `backdrop` field) so the pipeline does not run a redundant
/// backdrop snapshot per slider per frame.
pub track_surface: &'static str,
/// Theme slot id for the filled portion. Same role as
/// [`Self::track_surface`] but for the rising fill.
pub fill_surface: &'static str,
}
impl<Msg: Clone> VSlider<Msg>
{
/// Create a vertical slider at the given value (clamped to `[0.0, 1.0]`).
pub fn new( value: f32 ) -> Self
{
Self
{
value: value.clamp( 0.0, 1.0 ),
width: theme::WIDTH,
height: theme::HEIGHT,
on_change: None,
track_surface: theme::SURFACE_TRACK,
fill_surface: theme::SURFACE_FILL,
}
}
/// Override the theme slot id used for the unfilled track. See
/// [`Self::track_surface`] for the use case.
pub fn track_surface( mut self, id: &'static str ) -> Self
{
self.track_surface = id;
self
}
/// Override the theme slot id used for the rising fill. See
/// [`Self::track_surface`] for the use case.
pub fn fill_surface( mut self, id: &'static str ) -> Self
{
self.fill_surface = id;
self
}
/// Override the fixed pill `(width, height)` in pixels. Both are clamped
/// to a minimum of `2.0` so a rounded pill can always be drawn.
pub fn size( mut self, width: f32, height: f32 ) -> Self
{
self.width = width.max( 2.0 );
self.height = height.max( 2.0 );
self
}
/// Set the callback invoked when the slider value changes.
pub fn on_change( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
/// Return the preferred `(width, height)`. `max_width` is ignored — see
/// the type-level docs on intrinsic sizing.
pub fn preferred_size( &self, _max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
( self.width, self.height )
}
/// Compute the value `[0.0, 1.0]` from a tap/drag y position within `rect`.
pub fn value_from_y( &self, rect: Rect, y: f32 ) -> f32
{
value_from_y_in_rect( rect, y )
}
/// VSlider paints strictly inside its layout rect — no hover halo, no
/// thumb overshoot. The partial-redraw path gets a tight bound.
pub fn paint_bounds( &self, rect: Rect ) -> Rect { rect }
/// Draw the slider into `canvas` at `rect`. The track fills `rect` as a
/// rounded pill; the value rises from the bottom edge.
///
/// The track and fill both resolve to Glass surfaces when the active
/// theme ships the `surface-slider-track` / `surface-slider-fill`
/// slots (the default does). When the slots are absent we fall back
/// to a flat pill in `palette.surface_alt` / `palette.accent` — this
/// is how a bare-bones third-party theme still paints a usable
/// slider without having to replicate the full inset-shadow stack.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
{
let radius_bg = ( rect.width.min( rect.height ) ) / 2.0;
// Track outer shadow only — BG fill and insets are deferred to
// above the water line so the fill's silhouette AA doesn't pick
// up the track's translucent white as a 1-px rim.
if let Some( ( _surf, outer ) ) = crate::theme::resolve_surface( self.track_surface )
{
for shadow in &outer
{
canvas.fill_shadow_outer( rect, shadow, radius_bg );
}
}
else
{
canvas.fill_rect( rect, theme::track_bg(), radius_bg );
}
// Fill rises from the bottom as a "liquid level". Sub-pixel
// heights are skipped so we don't draw a hairline at value=0.
//
// The fill is rendered with the TRACK's full geometry (same
// rect, same radius) and scissor-clipped to the visible band
// at the bottom. The visible silhouette is the intersection of
// the track pill with the band, so:
//
// * sides and bottom of the fill follow the track's pill
// curve at all values — no "sticking out" at low fills;
// * top of the fill is a flat horizontal line — the water
// level — at all values, not a droplet cap;
// * inset shadows / backdrop of the fill's Glass surface are
// anchored to the track rect, not to a shrinking fill rect,
// so the rim / highlight geometry stays stable as the user
// drags. Only the clip band changes with value.
//
// The scissor is save/restored via `canvas.clip_bounds()` so
// the tighter clip does not stomp on any outer partial-redraw
// scissor.
let fill_h = ( rect.height * self.value ).clamp( 0.0, rect.height );
if fill_h > 0.5
{
let visible = Rect
{
x: rect.x,
y: rect.y + rect.height - fill_h,
width: rect.width,
height: fill_h,
};
let saved_clip = canvas.clip_bounds();
let band = intersect_clip( &saved_clip, visible );
if !band.is_empty()
{
canvas.set_clip_rects( &band );
// Fill paint + the fill surface's bottom-biased insets.
//
// Any inset with a negative Y offset (the top-left
// Glass highlight, `offset = [-3.6, -3.6]` in the
// default theme) lives near the TOP rim of the full
// track pill. With the surface anchored to the track
// rect and clipped to the water band, that highlight
// would be sliced by the scissor exactly at the water
// line, painting a visibly rectangular bright/dark
// edge across the liquid. Bottom-biased insets
// (offset.y >= 0) live near the track's bottom curve,
// always inside the visible band regardless of level,
// so their rim is continuous.
//
// Outer shadows / backdrop are dropped too: outer
// shadows would only be visible outside the fill
// silhouette (and the scissor kills them anyway);
// re-running the backdrop blur on the track rect
// every time the value changes is expensive and
// produces the same visible result as letting the
// track's own Glass backdrop show through.
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface )
{
canvas.fill_paint_rect( rect, &surf.fill, radius_bg );
for inset in surf.inset_shadows.iter().filter( |s| s.offset[1] >= 0.0 )
{
canvas.fill_shadow_inset( rect, inset, radius_bg );
}
}
else
{
canvas.fill_rect( rect, theme::track_fill(), radius_bg );
}
canvas.set_clip_rects( &saved_clip );
}
}
// Track BG + insets, clipped above the water line. Floor the
// height so the scissor doesn't overlap the fill scissor by
// 1 px when `fill_h` is fractional.
let above_h = ( rect.height - fill_h ).floor();
if above_h > 0.5
{
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.track_surface )
{
let above = Rect
{
x: rect.x,
y: rect.y,
width: rect.width,
height: above_h,
};
let saved_clip = canvas.clip_bounds();
let band = intersect_clip( &saved_clip, above );
if !band.is_empty()
{
canvas.set_clip_rects( &band );
canvas.fill_paint_rect( rect, &surf.fill, radius_bg );
for inset in &surf.inset_shadows
{
canvas.fill_shadow_inset( rect, inset, radius_bg );
}
canvas.set_clip_rects( &saved_clip );
}
}
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> VSlider<U>
where
U: Clone + 'static,
Msg: 'static,
{
let on_change = self.on_change.map( |old| -> Arc<dyn Fn( f32 ) -> U>
{
let mapper = Arc::clone( f );
Arc::new( move |v| ( *mapper )( ( *old )( v ) ) )
} );
VSlider
{
value: self.value,
width: self.width,
height: self.height,
on_change,
track_surface: self.track_surface,
fill_surface: self.fill_surface,
}
}
}
/// Create a [`VSlider`] at the given value (clamped to `[0.0, 1.0]`).
pub fn vslider<Msg: Clone>( value: f32 ) -> VSlider<Msg>
{
VSlider::new( value )
}
impl<Msg: Clone + 'static> From<VSlider<Msg>> for Element<Msg>
{
fn from( s: VSlider<Msg> ) -> Self { Element::VSlider( s ) }
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
use crate::types::Rect;
fn make_canvas() -> Canvas { Canvas::new( 400, 400 ) }
#[ test ]
fn value_clamped_on_creation()
{
let s: VSlider<()> = vslider( 1.5 );
assert_eq!( s.value, 1.0 );
let s: VSlider<()> = vslider( -0.5 );
assert_eq!( s.value, 0.0 );
}
#[ test ]
fn value_from_y_top_is_one()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, 0.0 ), 1.0 );
}
#[ test ]
fn value_from_y_bottom_is_zero()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, 160.0 ), 0.0 );
}
#[ test ]
fn value_from_y_center_is_half()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
let v = value_from_y_in_rect( rect, 80.0 );
assert!( ( v - 0.5 ).abs() < 1e-6 );
}
#[ test ]
fn value_from_y_above_rect_clamps_to_one()
{
let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, -50.0 ), 1.0 );
}
#[ test ]
fn value_from_y_below_rect_clamps_to_zero()
{
let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, 500.0 ), 0.0 );
}
#[ test ]
fn value_from_y_respects_rect_offset()
{
// A rect starting at y=100 with height=100: y=100 → 1.0, y=200 → 0.0.
let rect = Rect { x: 0.0, y: 100.0, width: 56.0, height: 100.0 };
assert_eq!( value_from_y_in_rect( rect, 100.0 ), 1.0 );
assert_eq!( value_from_y_in_rect( rect, 200.0 ), 0.0 );
let v = value_from_y_in_rect( rect, 150.0 );
assert!( ( v - 0.5 ).abs() < 1e-6 );
}
#[ test ]
fn size_overrides_defaults()
{
let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 ).size( 40.0, 200.0 );
let ( w, h ) = s.preferred_size( 500.0, &canvas );
assert_eq!( w, 40.0 );
assert_eq!( h, 200.0 );
}
#[ test ]
fn size_clamps_to_minimum()
{
let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 ).size( 0.0, 0.0 );
let ( w, h ) = s.preferred_size( 500.0, &canvas );
assert_eq!( w, 2.0 );
assert_eq!( h, 2.0 );
}
#[ test ]
fn preferred_size_ignores_max_width()
{
// A VSlider is intrinsically sized — the parent's max_width doesn't
// change what we return.
let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 );
let ( w_small, _ ) = s.preferred_size( 10.0, &canvas );
let ( w_big, _ ) = s.preferred_size( 9_999.0, &canvas );
assert_eq!( w_small, theme::WIDTH );
assert_eq!( w_big, theme::WIDTH );
}
#[ test ]
fn default_dimensions_are_the_theme_constants()
{
let s: VSlider<()> = vslider( 0.0 );
assert_eq!( s.width, theme::WIDTH );
assert_eq!( s.height, theme::HEIGHT );
}
#[ test ]
fn draw_at_value_zero_does_not_panic()
{
let mut canvas = make_canvas();
let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 };
let s: VSlider<()> = vslider( 0.0 );
s.draw( &mut canvas, rect, false );
}
#[ test ]
fn draw_at_value_one_does_not_panic()
{
let mut canvas = make_canvas();
let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 };
let s: VSlider<()> = vslider( 1.0 );
s.draw( &mut canvas, rect, true );
}
#[ test ]
fn on_change_is_stored()
{
let s: VSlider<u32> = vslider( 0.5 ).on_change( |v| ( v * 100.0 ) as u32 );
let cb = s.on_change.expect( "on_change was set" );
assert_eq!( cb( 0.25 ), 25 );
}
#[ test ]
fn element_from_vslider()
{
let s: VSlider<()> = vslider( 0.5 );
let el: Element<()> = s.into();
assert!( matches!( el, Element::VSlider( _ ) ) );
}
#[ test ]
fn paint_bounds_equals_layout_rect()
{
let rect = Rect { x: 4.0, y: 8.0, width: 56.0, height: 160.0 };
let s: VSlider<()> = vslider( 0.5 );
let pb = s.paint_bounds( rect );
assert_eq!( pb.x, rect.x );
assert_eq!( pb.y, rect.y );
assert_eq!( pb.width, rect.width );
assert_eq!( pb.height, rect.height );
}
}

383
src/widget/window_button.rs Normal file
View File

@@ -0,0 +1,383 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::Element;
use crate::layout::row::{ row, Row };
use crate::render::Canvas;
use crate::types::{ Color, Rect, WidgetId };
mod theme
{
use crate::types::Color;
pub fn icon() -> Color { crate::theme::window_controls().icon }
pub fn hover_bg() -> Color { crate::theme::window_controls().hover_bg }
pub fn pressed_bg() -> Color { crate::theme::window_controls().pressed_bg }
pub fn focus_color() -> Color { crate::theme::window_controls().focus_ring }
pub fn close_hover() -> Color { crate::theme::window_controls().close_hover_bg }
pub fn close_icon() -> Color { crate::theme::window_controls().close_icon }
pub const SIZE: f32 = 36.0;
pub const RADIUS: f32 = 10.0;
pub const FOCUS_W: f32 = 2.0;
pub const STROKE_W: f32 = 2.0;
}
/// Semantic role for a window-decoration button.
///
/// Drives both the rendered glyph (a horizontal bar for minimize, a square
/// outline for maximize, etc.) and the close button's special hover
/// treatment (red surface tint instead of the neutral hover wash). Maps
/// 1:1 to the four standard title-bar controls on Windows / GNOME / macOS.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum WindowButtonKind
{
/// Hide the window to the dock / taskbar.
Minimize,
/// Maximize the window to fill the available output.
Maximize,
/// Restore a previously-maximized window to its original size.
Restore,
/// Close the window. Renders with a destructive (red) hover surface.
Close,
}
/// Button styled for compositor / window decorations.
///
/// The widget is intentionally policy-free: it paints a standard control and
/// emits the message supplied by the caller. Forge remains responsible for
/// deciding what that message does to the window.
pub struct WindowButton<Msg: Clone>
{
/// Which decoration role this button paints.
pub kind: WindowButtonKind,
/// Message emitted on activation. `None` greys the button and skips
/// the hover / pressed surface — useful for "maximize disabled" on
/// fixed-size windows.
pub on_press: Option<Msg>,
/// Square hit-target size in logical pixels. Clamped to a 20 px floor
/// by [`Self::size`] so the button stays touchable.
pub size: f32,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
/// Whether this button takes part in the Tab / Shift+Tab cycle. Defaults
/// to `false` to match desktop convention — title-bar chrome on macOS,
/// GNOME and Windows is click/touch-only and never steals keyboard focus
/// from window content. Opt in with [`Self::focusable`] for shells where
/// keyboard reachability of decorations matters (accessibility, no-mouse
/// kiosks). Pointer / touch hit testing is unaffected by this flag.
pub focusable: bool,
}
impl<Msg: Clone> WindowButton<Msg>
{
/// Create a window-decoration button of the given kind. The button is
/// inert (no callback) until [`Self::on_press`] is configured.
pub fn new( kind: WindowButtonKind ) -> Self
{
Self
{
kind,
on_press: None,
size: theme::SIZE,
id: None,
focusable: false,
}
}
/// Set the message emitted when the button is activated.
pub fn on_press( mut self, msg: Msg ) -> Self
{
self.on_press = Some( msg );
self
}
/// Like [`Self::on_press`] but keeps the disabled state when `None`
/// is passed — useful when the message depends on a runtime
/// condition (e.g. maximize is disabled for fixed-size windows).
pub fn on_press_maybe( mut self, msg: Option<Msg> ) -> Self
{
self.on_press = msg;
self
}
/// Override the square hit-target size in logical pixels. Clamped to
/// a 20 px floor so the button remains touchable.
pub fn size( mut self, size: f32 ) -> Self
{
self.size = size.max( 20.0 );
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
/// Opt into keyboard focus traversal. Defaults to `false` so the
/// button does not steal Tab focus from window content.
pub fn focusable( mut self, yes: bool ) -> Self
{
self.focusable = yes;
self
}
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> ( f32, f32 )
{
let s = self.size.min( max_width );
( s, s )
}
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_W * 1.5 + 2.0 )
}
pub fn draw(
&self,
canvas: &mut Canvas,
rect: Rect,
focused: bool,
hovered: bool,
pressed: bool,
)
{
let disabled = self.on_press.is_none();
let bg = if disabled
{
Color::TRANSPARENT
}
else if pressed
{
theme::pressed_bg()
}
else if hovered && self.kind == WindowButtonKind::Close
{
theme::close_hover()
}
else if hovered
{
theme::hover_bg()
}
else
{
Color::TRANSPARENT
};
if bg.a > 0.0
{
canvas.fill_rect( rect, bg, theme::RADIUS );
}
if focused
{
canvas.stroke_rect(
rect.expand( theme::FOCUS_W + 1.0 ),
theme::focus_color(),
theme::FOCUS_W,
theme::RADIUS + theme::FOCUS_W + 1.0,
);
}
let icon_color = if hovered && self.kind == WindowButtonKind::Close && !disabled
{
theme::close_icon()
}
else
{
let base = theme::icon();
if disabled
{
Color::rgba( base.r, base.g, base.b, base.a * 0.35 )
}
else
{
base
}
};
draw_glyph( canvas, self.kind, rect, icon_color );
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> WindowButton<U>
where
U: Clone + 'static,
Msg: 'static,
{
WindowButton
{
kind: self.kind,
on_press: self.on_press.map( |m| ( *f )( m ) ),
size: self.size,
id: self.id,
focusable: self.focusable,
}
}
}
/// Catalogue path for the symbolic SVG that paints `kind`. Each entry
/// resolves through the theme's `icons/catalogue/filled/window/<kind>.svg`
/// (or the `line/` fallback the catalogue lookup applies automatically).
fn icon_name( kind: WindowButtonKind ) -> &'static str
{
match kind
{
WindowButtonKind::Minimize => "window/minimize",
WindowButtonKind::Maximize => "window/maximize",
WindowButtonKind::Restore => "window/restore",
WindowButtonKind::Close => "window/close",
}
}
/// Render the glyph for `kind` centered inside `rect`, tinted with
/// `color`. Tries the theme catalogue first via [`crate::theme::icon_rgba`]
/// + [`crate::theme::tint_symbolic`]; falls back to the programmatic
/// line / rect drawing in [`draw_symbol`] when the active theme has no
/// catalogue entry for `window/<kind>`.
fn draw_glyph( canvas: &mut Canvas, kind: WindowButtonKind, rect: Rect, color: Color )
{
let s = rect.width.min( rect.height );
let icon_px = ( s * 0.44 ).round().max( 8.0 ) as u32;
if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name( kind ), icon_px )
{
let tinted = crate::theme::tint_symbolic( &rgba, color );
// Centre the rasterised glyph inside the button rect. Round to
// integer offsets so the bilinear sampler hits texel centres
// and the glyph stays crisp.
let cx = rect.x + rect.width / 2.0;
let cy = rect.y + rect.height / 2.0;
let dest = Rect
{
x: ( cx - iw as f32 / 2.0 ).round(),
y: ( cy - ih as f32 / 2.0 ).round(),
width: iw as f32,
height: ih as f32,
};
canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 );
}
else
{
// No catalogue entry → fall back to the programmatic glyph so
// chrome stays usable on themes that don't ship
// `icons/catalogue/filled/window/`.
draw_symbol( canvas, kind, rect, color );
}
}
fn draw_symbol( canvas: &mut Canvas, kind: WindowButtonKind, rect: Rect, color: Color )
{
let cx = rect.x + rect.width / 2.0;
let cy = rect.y + rect.height / 2.0;
let s = rect.width.min( rect.height );
let a = s * 0.22;
let w = theme::STROKE_W;
match kind
{
WindowButtonKind::Minimize =>
{
let y = cy + a * 0.65;
canvas.draw_line( cx - a, y, cx + a, y, color, w );
}
WindowButtonKind::Maximize =>
{
let r = Rect { x: cx - a, y: cy - a, width: a * 2.0, height: a * 2.0 };
canvas.stroke_rect( r, color, w, 2.0 );
}
WindowButtonKind::Restore =>
{
let back = Rect { x: cx - a * 0.45, y: cy - a, width: a * 1.55, height: a * 1.55 };
let front = Rect { x: cx - a, y: cy - a * 0.45, width: a * 1.55, height: a * 1.55 };
canvas.stroke_rect( back, color, w, 2.0 );
canvas.stroke_rect( front, color, w, 2.0 );
}
WindowButtonKind::Close =>
{
canvas.draw_line( cx - a, cy - a, cx + a, cy + a, color, w );
canvas.draw_line( cx + a, cy - a, cx - a, cy + a, color, w );
}
}
}
impl<Msg: Clone + 'static> From<WindowButton<Msg>> for Element<Msg>
{
fn from( b: WindowButton<Msg> ) -> Self
{
Element::WindowButton( b )
}
}
/// Create a window-decoration button.
pub fn window_button<Msg: Clone>( kind: WindowButtonKind ) -> WindowButton<Msg>
{
WindowButton::new( kind )
}
/// Create the standard minimize / maximize-or-restore / close control group.
pub fn window_controls<Msg: Clone + 'static>(
minimize: Option<Msg>,
maximize_kind: WindowButtonKind,
maximize: Option<Msg>,
close: Option<Msg>,
) -> Row<Msg>
{
row::<Msg>()
.spacing( 4.0 )
.push( window_button( WindowButtonKind::Minimize ).on_press_maybe( minimize ) )
.push( window_button( maximize_kind ).on_press_maybe( maximize ) )
.push( window_button( WindowButtonKind::Close ).on_press_maybe( close ) )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
#[ test ]
fn default_size_is_decoration_sized()
{
let canvas = Canvas::new( 100, 100 );
let b = window_button::<()>( WindowButtonKind::Close );
assert_eq!( b.preferred_size( 100.0, &canvas ), ( theme::SIZE, theme::SIZE ) );
}
#[ test ]
fn controls_has_three_children()
{
let controls = window_controls::<()>(
Some( () ), WindowButtonKind::Maximize, Some( () ), Some( () ),
);
assert_eq!( controls.children.len(), 3 );
}
#[ test ]
fn icon_names_resolve_each_kind_to_the_window_catalogue()
{
// Lock the catalogue paths so a future rename of the on-disk
// SVG (e.g. close.svg → x.svg) breaks this test instead of
// silently falling back to the programmatic glyph at runtime.
assert_eq!( icon_name( WindowButtonKind::Minimize ), "window/minimize" );
assert_eq!( icon_name( WindowButtonKind::Maximize ), "window/maximize" );
assert_eq!( icon_name( WindowButtonKind::Restore ), "window/restore" );
assert_eq!( icon_name( WindowButtonKind::Close ), "window/close" );
}
#[ test ]
fn paint_bounds_unchanged_after_glyph_refactor()
{
// The SVG glyph paints inside the button rect just like the
// programmatic one did, so paint_bounds must stay equal to
// the previous value (rect expanded by the focus-ring slack).
let b = window_button::<()>( WindowButtonKind::Close );
let rect = Rect { x: 10.0, y: 10.0, width: 36.0, height: 36.0 };
let bounds = b.paint_bounds( rect );
let slack = theme::FOCUS_W * 1.5 + 2.0;
assert!( ( bounds.x - ( rect.x - slack ) ).abs() < 0.01 );
assert!( ( bounds.y - ( rect.y - slack ) ).abs() < 0.01 );
assert!( ( bounds.width - ( rect.width + slack * 2.0 ) ).abs() < 0.01 );
assert!( ( bounds.height - ( rect.height + slack * 2.0 ) ).abs() < 0.01 );
}
}