First commit. Version 0.1.0
This commit is contained in:
124
src/widget/anchored_overlay.rs
Normal file
124
src/widget/anchored_overlay.rs
Normal 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
553
src/widget/button.rs
Normal 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
217
src/widget/checkbox.rs
Normal 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
545
src/widget/color_picker.rs
Normal 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
977
src/widget/combo.rs
Normal 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
374
src/widget/container.rs
Normal 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
617
src/widget/date_picker.rs
Normal 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 1–12, `day` is 1–31 (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
451
src/widget/dialog.rs
Normal 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
182
src/widget/external.rs
Normal 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
138
src/widget/flex.rs
Normal 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
217
src/widget/image.rs
Normal 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
268
src/widget/list_item.rs
Normal 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
923
src/widget/mod.rs
Executable 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
249
src/widget/notebook.rs
Normal 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
296
src/widget/pressable.rs
Normal 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
143
src/widget/progress_bar.rs
Normal 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
212
src/widget/radio.rs
Normal 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
156
src/widget/scroll.rs
Normal 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
137
src/widget/separator.rs
Normal 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
541
src/widget/slider.rs
Normal 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
223
src/widget/spinner.rs
Normal 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
208
src/widget/tab_bar.rs
Normal 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
384
src/widget/text.rs
Normal 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
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
615
src/widget/time_picker.rs
Normal 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 0–23 (24-hour
|
||||
/// representation regardless of [`TimePicker::twelve_hour`] display
|
||||
/// mode), `minute` and `second` are 0–59.
|
||||
#[ 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 0–23. 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; 1–11 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
305
src/widget/toast.rs
Normal 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
244
src/widget/toggle.rs
Normal 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
247
src/widget/tooltip.rs
Normal 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
183
src/widget/viewport.rs
Normal 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
486
src/widget/vslider.rs
Normal 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
383
src/widget/window_button.rs
Normal 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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user