refactor: split every monolithic module into focused submodules

Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves.
image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
2026-05-15 23:46:56 +02:00
parent 3d237039c6
commit 4aa3480b64
155 changed files with 13832 additions and 13035 deletions

View File

@@ -24,6 +24,9 @@ use crate::types::{ Rect, WidgetId };
use super::Element;
#[ cfg( test ) ]
mod tests;
/// 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>
@@ -96,29 +99,3 @@ impl<Msg: Clone + 'static> From<AnchoredOverlay<Msg>> for Element<Msg>
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 );
}
}

View File

@@ -0,0 +1,24 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -9,54 +9,10 @@ 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;
mod theme;
/// 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;
}
#[ cfg( test ) ]
mod tests;
/// Visual style of a text button.
#[ derive( Clone, Default ) ]
@@ -498,106 +454,3 @@ impl<Msg: Clone> Button<Msg>
}
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn text_button_focusable_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.focusable );
}
#[ test ]
fn icon_button_focusable_by_default()
{
let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 );
assert!( b.focusable );
}
#[ test ]
fn focusable_builder_disables_focus()
{
let b = Button::<()>::new( "ok".into() ).focusable( false );
assert!( !b.focusable );
}
#[ test ]
fn focusable_builder_re_enables_focus()
{
let b = Button::<()>::new( "ok".into() ).focusable( false ).focusable( true );
assert!( b.focusable );
}
#[ test ]
fn on_press_none_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.on_press.is_none() );
}
#[ test ]
fn tooltip_none_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.tooltip.is_none() );
let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 );
assert!( b.tooltip.is_none() );
}
#[ test ]
fn tooltip_builder_sets_text()
{
let b = Button::<()>::new( "ok".into() ).tooltip( "Save document" );
assert_eq!( b.tooltip.as_deref(), Some( "Save document" ) );
}
#[ test ]
fn tooltip_survives_map_msg()
{
let b = Button::<()>::new( "ok".into() ).tooltip( "Hint" );
let f: super::super::MapFn<(), u32> = std::sync::Arc::new( |()| 0u32 );
let mapped = b.map_msg( &f );
assert_eq!( mapped.tooltip.as_deref(), Some( "Hint" ) );
}
#[ test ]
fn element_tooltip_returns_button_tooltip()
{
let e: super::super::Element<()> = Button::<()>::new( "ok".into() ).tooltip( "Hi" ).into_element();
assert_eq!( e.tooltip(), Some( "Hi" ) );
}
#[ test ]
fn element_tooltip_none_for_non_button_widgets()
{
let t: super::super::Element<()> = super::super::text( "label" ).into();
assert!( t.tooltip().is_none() );
}
#[ test ]
fn on_press_maybe_none_leaves_disabled()
{
let b = Button::<()>::new( "ok".into() ).on_press_maybe( None );
assert!( b.on_press.is_none() );
}
#[ test ]
fn repeating_off_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( !b.repeating );
}
#[ test ]
fn repeating_builder_sets_flag()
{
let b = Button::<()>::new( "ok".into() ).repeating( true );
assert!( b.repeating );
let b = b.repeating( false );
assert!( !b.repeating );
}
}

101
src/widget/button/tests.rs Normal file
View File

@@ -0,0 +1,101 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::*;
#[ test ]
fn text_button_focusable_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.focusable );
}
#[ test ]
fn icon_button_focusable_by_default()
{
let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 );
assert!( b.focusable );
}
#[ test ]
fn focusable_builder_disables_focus()
{
let b = Button::<()>::new( "ok".into() ).focusable( false );
assert!( !b.focusable );
}
#[ test ]
fn focusable_builder_re_enables_focus()
{
let b = Button::<()>::new( "ok".into() ).focusable( false ).focusable( true );
assert!( b.focusable );
}
#[ test ]
fn on_press_none_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.on_press.is_none() );
}
#[ test ]
fn tooltip_none_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( b.tooltip.is_none() );
let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 );
assert!( b.tooltip.is_none() );
}
#[ test ]
fn tooltip_builder_sets_text()
{
let b = Button::<()>::new( "ok".into() ).tooltip( "Save document" );
assert_eq!( b.tooltip.as_deref(), Some( "Save document" ) );
}
#[ test ]
fn tooltip_survives_map_msg()
{
let b = Button::<()>::new( "ok".into() ).tooltip( "Hint" );
let f: super::super::MapFn<(), u32> = std::sync::Arc::new( |()| 0u32 );
let mapped = b.map_msg( &f );
assert_eq!( mapped.tooltip.as_deref(), Some( "Hint" ) );
}
#[ test ]
fn element_tooltip_returns_button_tooltip()
{
let e: super::super::Element<()> = Button::<()>::new( "ok".into() ).tooltip( "Hi" ).into_element();
assert_eq!( e.tooltip(), Some( "Hi" ) );
}
#[ test ]
fn element_tooltip_none_for_non_button_widgets()
{
let t: super::super::Element<()> = super::super::text( "label" ).into();
assert!( t.tooltip().is_none() );
}
#[ test ]
fn on_press_maybe_none_leaves_disabled()
{
let b = Button::<()>::new( "ok".into() ).on_press_maybe( None );
assert!( b.on_press.is_none() );
}
#[ test ]
fn repeating_off_by_default()
{
let b = Button::<()>::new( "ok".into() );
assert!( !b.repeating );
}
#[ test ]
fn repeating_builder_sets_flag()
{
let b = Button::<()>::new( "ok".into() ).repeating( true );
assert!( b.repeating );
let b = b.repeating( false );
assert!( !b.repeating );
}

View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -5,25 +5,10 @@ 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;
}
mod theme;
#[cfg(test)]
mod tests;
/// A two-state opt-in control with a square box and a check glyph.
///
@@ -194,24 +179,3 @@ impl<Msg: Clone + 'static> From<Checkbox<Msg>> for Element<Msg>
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 );
}
}

View File

@@ -0,0 +1,19 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -0,0 +1,19 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -32,18 +32,9 @@ 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;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// Format a [`Color`] as `#RRGGBB` (or `#RRGGBBAA` when `with_alpha`
/// is true and the colour is not fully opaque). Bytes are clamped to
@@ -382,164 +373,3 @@ 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})" );
}
}
}

View File

@@ -0,0 +1,159 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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})" );
}
}

View File

@@ -0,0 +1,12 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -104,6 +104,9 @@ use crate::types::WidgetId;
use super::Element;
#[ cfg( test ) ]
mod tests;
/// 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
@@ -805,173 +808,3 @@ 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() );
}
}

168
src/widget/combo/tests.rs Normal file
View File

@@ -0,0 +1,168 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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() );
}

View File

@@ -6,6 +6,9 @@ use crate::types::{ Color, Corners };
use crate::render::Canvas;
use super::Element;
#[ cfg( test ) ]
mod tests;
/// A transparent wrapper that adds a background color or a themed
/// surface and padding around any child [`Element`].
///
@@ -291,120 +294,3 @@ 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" ) );
}
#[ test ]
fn max_width_caps_preferred_width()
{
use crate::render::Canvas;
let canvas = Canvas::new( 800, 600 );
let c = container::<()>( spacer() ).max_width( 200.0 );
let ( w, _ ) = c.preferred_size( 800.0, &canvas );
assert!( w <= 200.0 );
}
}

View File

@@ -0,0 +1,114 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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" ) );
}
#[ test ]
fn max_width_caps_preferred_width()
{
use crate::render::Canvas;
let canvas = Canvas::new( 800, 600 );
let c = container::<()>( spacer() ).max_width( 200.0 );
let ( w, _ ) = c.preferred_size( 800.0, &canvas );
assert!( w <= 200.0 );
}

View File

@@ -43,22 +43,9 @@ 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;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// A calendar date in the proleptic Gregorian calendar. No time
/// component, no timezone. `month` is 112, `day` is 131 (further
@@ -493,152 +480,3 @@ 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();
}
}

View File

@@ -0,0 +1,147 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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();
}

View File

@@ -0,0 +1,16 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -75,6 +75,9 @@ use super::pressable::pressable;
use super::text;
use super::Element;
#[ cfg( test ) ]
mod tests;
/// Default scrim opacity over the underlying surface.
pub const SCRIM_ALPHA: f32 = 0.45;
/// Default card max-width (logical pixels). Override with
@@ -327,125 +330,3 @@ 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();
}
}

119
src/widget/dialog/tests.rs Normal file
View File

@@ -0,0 +1,119 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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();
}

456
src/widget/element.rs Normal file
View File

@@ -0,0 +1,456 @@
// 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::{
anchored_overlay, button, checkbox, container, external, flex, image,
list_item, pressable, progress_bar, radio, scroll, separator, slider,
spinner, text, text_edit, toggle, viewport, vslider, window_button,
};
use super::handlers::WidgetHandlers;
use super::MapFn;
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 [`super::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
/// [`super::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,
}
}
/// Add an arm here to opt a widget kind into the auto-tooltip flow.
pub fn tooltip( &self ) -> Option<&str>
{
match self
{
Element::Button( b ) => b.tooltip.as_deref(),
_ => None,
}
}
/// 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
/// [`super::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,
}
}
}
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 )
}
}

View File

@@ -145,38 +145,4 @@ impl External
}
#[ 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 );
}
}
mod tests;

35
src/widget/external/tests.rs vendored Normal file
View File

@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

52
src/widget/factory.rs Normal file
View File

@@ -0,0 +1,52 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use super::button::Button;
use super::container::Container;
use super::element::Element;
use super::external::{ External, ExternalSource };
use super::image::Image;
use super::text::Text;
use super::text_edit::TextEdit;
pub fn button<Msg: Clone>( label: impl Into<String> ) -> Button<Msg>
{
Button::new( label.into() )
}
pub fn icon_button<Msg: Clone>( rgba: Arc<Vec<u8>>, img_w: u32, img_h: u32 ) -> Button<Msg>
{
Button::new_icon( rgba, img_w, img_h )
}
pub fn text_edit<Msg: Clone>(
placeholder: impl Into<String>,
value: impl Into<String>,
) -> TextEdit<Msg>
{
TextEdit::new( placeholder.into(), value.into() )
}
pub fn image( rgba: Arc<Vec<u8>>, width: u32, height: u32 ) -> Image
{
Image::new( rgba, width, height )
}
pub fn text( content: impl Into<String> ) -> Text
{
Text::new( content )
}
pub fn container<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Container<Msg>
{
Container::new( child )
}
/// Build an [`External`] widget that hosts content rendered by
/// a caller-managed GL texture producer.
pub fn external( width: f32, height: f32, source: ExternalSource ) -> External
{
External::new( width, height, source )
}

View File

@@ -96,43 +96,4 @@ impl<Msg: Clone + 'static> From<Flex<Msg>> for Element<Msg>
}
#[ 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 );
}
}
mod tests;

40
src/widget/flex/tests.rs Normal file
View File

@@ -0,0 +1,40 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

309
src/widget/handlers.rs Normal file
View File

@@ -0,0 +1,309 @@
// 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 super::{ slider, text };
/// Per-leaf interaction snapshot captured during layout. One variant per
/// interactive widget kind; the layout pass clones the relevant callbacks /
/// values from the [`Element`](super::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 [`super::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`](super::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,
}
}
}

View File

@@ -1,217 +0,0 @@
// 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 );
}
}

107
src/widget/image/mod.rs Normal file
View File

@@ -0,0 +1,107 @@
// 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;

111
src/widget/image/tests.rs Normal file
View File

@@ -0,0 +1,111 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

55
src/widget/laid_out.rs Normal file
View File

@@ -0,0 +1,55 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use super::handlers::WidgetHandlers;
/// 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 [`super::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 [`super::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,
pub tooltip: Option<String>,
}
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,
tooltip: self.tooltip.clone(),
}
}
}

View File

@@ -5,39 +5,10 @@ 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;
}
mod theme;
#[cfg(test)]
mod tests;
/// A row inside a list with a primary label and optional subtitle / trailing
/// text.
@@ -250,19 +221,3 @@ impl<Msg: Clone + 'static> From<ListItem<Msg>> for Element<Msg>
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() );
}
}

View File

@@ -0,0 +1,14 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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() );
}

View File

@@ -0,0 +1,33 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -87,849 +87,20 @@ 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,
},
}
pub mod element;
pub mod handlers;
pub mod laid_out;
pub mod factory;
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,
pub tooltip: Option<String>,
}
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,
tooltip: self.tooltip.clone(),
}
}
}
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,
}
}
/// Add an arm here to opt a widget kind into the auto-tooltip flow.
pub fn tooltip( &self ) -> Option<&str>
{
match self
{
Element::Button( b ) => b.tooltip.as_deref(),
_ => None,
}
}
/// 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,
}
}
}
pub use element::Element;
pub use handlers::WidgetHandlers;
pub use laid_out::LaidOutWidget;
pub use factory::{ button, icon_button, text_edit, image, text, container, external };
/// 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 )
}
pub( crate ) type MapFn<Msg, U> = std::sync::Arc<dyn Fn( Msg ) -> U>;

View File

@@ -40,10 +40,10 @@ use crate::layout::spacer::spacer;
use super::Element;
mod theme
{
pub const SPACING: f32 = 12.0;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// One page of a [`Notebook`] — a label for the tab strip and the
/// element to show when this page is active.
@@ -177,73 +177,3 @@ 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();
}
}

View File

@@ -0,0 +1,68 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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();
}

View File

@@ -0,0 +1,4 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub const SPACING: f32 = 12.0;

View File

@@ -5,6 +5,9 @@ use crate::render::Canvas;
use crate::types::WidgetId;
use super::Element;
#[ cfg( test ) ]
mod tests;
/// 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
@@ -194,103 +197,3 @@ impl<Msg: Clone + 'static> From<Pressable<Msg>> for Element<Msg>
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 ) );
}
}

View File

@@ -0,0 +1,98 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 ) );
}

View File

@@ -5,14 +5,10 @@ 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;
}
mod theme;
#[cfg(test)]
mod tests;
/// A linear progress indicator for determinate operations.
///
@@ -118,26 +114,3 @@ impl<Msg: Clone> From<ProgressBar> for Element<Msg>
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 );
}
}

View File

@@ -0,0 +1,21 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -0,0 +1,8 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -5,24 +5,10 @@ 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;
}
mod theme;
#[cfg(test)]
mod tests;
/// One option inside a mutually-exclusive group.
///
@@ -189,24 +175,3 @@ impl<Msg: Clone + 'static> From<Radio<Msg>> for Element<Msg>
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 );
}
}

19
src/widget/radio/tests.rs Normal file
View File

@@ -0,0 +1,19 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

18
src/widget/radio/theme.rs Normal file
View File

@@ -0,0 +1,18 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -5,6 +5,9 @@ use crate::render::Canvas;
use crate::types::WidgetId;
use crate::widget::Element;
#[cfg(test)]
mod tests;
/// 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)
@@ -85,58 +88,6 @@ pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f3
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.

View File

@@ -0,0 +1,50 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -5,13 +5,7 @@ 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;
}
mod theme;
/// A horizontal divider line.
///
@@ -116,22 +110,4 @@ impl<Msg: Clone> From<Separator> for Element<Msg>
}
#[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 );
}
}
mod tests;

View File

@@ -0,0 +1,19 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -0,0 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -6,6 +6,10 @@ use crate::types::{ Point, Rect };
use crate::render::Canvas;
use super::Element;
mod theme;
#[ cfg( test ) ]
mod tests;
/// 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).
@@ -32,35 +36,6 @@ pub fn value_from_pos_in_rect( rect: Rect, pos: Point, axis: SliderAxis ) -> f32
}
}
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`].
///
@@ -446,96 +421,3 @@ impl<Msg: Clone + 'static> From<Slider<Msg>> for Element<Msg>
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() );
}
}

View File

@@ -0,0 +1,91 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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() );
}

View File

@@ -0,0 +1,28 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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";

View File

@@ -27,26 +27,7 @@ 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;
}
mod theme;
/// An indeterminate progress spinner.
///
@@ -183,41 +164,4 @@ impl<Msg: Clone> From<Spinner> for Element<Msg>
}
#[ 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 );
}
}
mod tests;

View File

@@ -0,0 +1,38 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -0,0 +1,20 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -23,12 +23,10 @@
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;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// Segmented horizontal tab selector. One row of pressable cells with
/// the active cell painted as a filled pill and inactive cells as plain
@@ -166,43 +164,3 @@ where
{
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() );
}
}

View File

@@ -0,0 +1,38 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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() );
}

View File

@@ -0,0 +1,6 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub const RADIUS: f32 = 100.0;
pub const PADDING: f32 = 4.0;
pub const SPACING: f32 = 4.0;

View File

@@ -10,6 +10,9 @@ use crate::types::{ Color, Rect };
use crate::render::Canvas;
use super::Element;
#[ cfg( test ) ]
mod tests;
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum TextAlign
{
@@ -297,101 +300,3 @@ impl<Msg: Clone + 'static> From<Text> for Element<Msg>
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 );
}
}

96
src/widget/text/tests.rs Normal file
View File

@@ -0,0 +1,96 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Rect;
use super::hit_test::byte_offset_in_line;
use super::theme;
use super::wrapping::{ compute_visual_lines, VisualLine };
/// Locate the visual line containing `cursor` in the wrap layout for
/// `value` inside `rect`. When the cursor sits exactly on a soft-wrap
/// boundary (so it satisfies both the previous line's `end` and the
/// next line's `start`), prefer the *previous* line — that matches the
/// rendering convention in `draw_multiline`, which paints the caret at
/// the trailing edge of the prior visual row rather than the leading
/// edge of the new one.
fn current_visual_line(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> Option<( Vec<VisualLine>, usize )>
{
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE );
let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE );
if visual_lines.is_empty() { return None; }
let safe_cursor = cursor.min( value.len() );
let idx = visual_lines.iter()
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
.unwrap_or( visual_lines.len() - 1 );
Some( ( visual_lines, idx ) )
}
/// Move the text cursor one visual row up. Returns the new byte offset
/// or `None` when the cursor is already on the first visual row, so
/// the caller can fall through to sibling-widget keyboard navigation.
///
/// Visual X is preserved across the move: the new cursor lands as close
/// as possible (with snap-to-nearest-glyph) to the same horizontal
/// position the cursor had on its origin line. No "preferred column"
/// state is kept across multiple consecutive Up presses, so a long
/// short → long sequence will drift toward the end of every short
/// line — same simplification GTK / Cocoa make for plain controls.
pub( crate ) fn cursor_visual_up(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> Option<usize>
{
let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?;
if idx == 0 { return None; }
let cur = lines[ idx ];
let safe = cursor.min( value.len() );
let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE );
let prev = lines[ idx - 1 ];
Some( byte_offset_in_line( canvas, value, prev.start, prev.end, target_x, false, theme::FONT_SIZE ) )
}
/// Mirror of [`cursor_visual_up`] for the Down arrow.
pub( crate ) fn cursor_visual_down(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> Option<usize>
{
let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?;
if idx + 1 >= lines.len() { return None; }
let cur = lines[ idx ];
let safe = cursor.min( value.len() );
let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE );
let next = lines[ idx + 1 ];
Some( byte_offset_in_line( canvas, value, next.start, next.end, target_x, false, theme::FONT_SIZE ) )
}
/// Byte offset of the start of the cursor's current visual line.
/// Falls back to `0` when the wrap layout cannot be computed (e.g.
/// degenerate rect), so the Home key always has a sensible target.
pub( crate ) fn cursor_visual_home(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> usize
{
current_visual_line( canvas, rect, value, cursor )
.map( |( lines, idx )| lines[ idx ].start )
.unwrap_or( 0 )
}
/// Byte offset of the end of the cursor's current visual line. For a
/// hard-`\n`-terminated row this is the byte just before the newline;
/// for a soft-wrapped row it is the wrap point (which is also the
/// start of the next visual row, but the caret renders at the trailing
/// edge of *this* row by convention).
pub( crate ) fn cursor_visual_end(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
) -> usize
{
current_visual_line( canvas, rect, value, cursor )
.map( |( lines, idx )| lines[ idx ].end )
.unwrap_or( value.len() )
}

View File

@@ -0,0 +1,397 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Rect;
use super::TextEdit;
use super::hit_test::{ single_line_align_offset, single_line_scroll_x };
use super::theme;
use super::wrapping::compute_visual_lines;
/// Axis-aligned intersection of two rects. Returns `None` when the rects
/// do not overlap (zero-area / negative-extent results count as
/// "no overlap" so the caller can `filter_map` straight into a clip
/// list).
fn rect_intersect( a: Rect, b: Rect ) -> Option<Rect>
{
let x0 = a.x.max( b.x );
let y0 = a.y.max( b.y );
let x1 = ( a.x + a.width ).min( b.x + b.width );
let y1 = ( a.y + a.height ).min( b.y + b.height );
if x1 > x0 && y1 > y0
{
Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } )
} else {
None
}
}
/// Hit rect for the eye icon at the right edge of a `TextEdit`
/// configured with [`TextEdit::password_toggle`]. Returned in the
/// same coordinate space as the field's `rect`. Pointer / touch
/// dispatch consult this before falling through to cursor placement
/// — a tap inside the zone fires the toggle message instead of
/// moving the caret.
pub fn password_toggle_hit_zone( rect: Rect ) -> Rect
{
let zone_w = ( theme::PASSWORD_TOGGLE_SIZE
+ theme::PASSWORD_TOGGLE_SLOP * 2.0
+ theme::PAD_H * 0.5 ).min( rect.width );
Rect
{
x: rect.x + rect.width - zone_w,
y: rect.y,
width: zone_w,
height: rect.height,
}
}
impl<Msg: Clone> TextEdit<Msg>
{
/// Draw the field into `canvas` at `rect`.
///
/// `cursor_pos` is the byte offset of the text cursor — supplied by the runtime
/// from its persistent cursor state rather than from the widget itself.
/// `selection_anchor` is the other end of the selection range; when
/// `selection_anchor == cursor_pos` no highlight is painted.
pub fn draw(
&self,
canvas: &mut Canvas,
rect: Rect,
focused: bool,
cursor_pos: usize,
selection_anchor: usize,
)
{
if self.is_multiline()
{
self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor );
return;
}
// Borderless fields skip the surface fill + border stroke
// entirely. They still get the inner clip + scroll + alignment
// treatment below, so the field behaves like a regular
// text input — only the chrome is suppressed.
if !self.borderless
{
let border_c = if focused { theme::focus_border() } else { theme::border() };
let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W };
canvas.fill_rect( rect, theme::bg(), theme::RADIUS );
canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS );
}
let font_size = self.font_size;
let text_y = rect.y + (rect.height + font_size) / 2.0 - 2.0;
let text = self.display_text();
let secure = self.effective_secure();
// When `password_toggle` is set we reserve a column on the
// right edge for the eye icon: scroll / align / clip all
// behave as if the field were narrower so cursor + text
// never slide under the icon.
let toggle_reserve = if self.password_toggle.is_some()
{
theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0
}
else
{
0.0
};
let text_rect = Rect
{
width: ( rect.width - toggle_reserve ).max( theme::PAD_H * 2.0 ),
..rect
};
let scroll_x = single_line_scroll_x( canvas, text_rect, &self.value, cursor_pos, secure, font_size );
let align_x = single_line_align_offset( canvas, text_rect, &self.value, secure, self.align, font_size );
// Clip text / cursor / selection to the inner band so the
// scrolled-off portion does not bleed past the pill stroke.
// Save the parent clip first, intersect with our inner rect,
// and restore on exit so a `text_edit` wrapped in scroll(...)
// keeps the parent's clip honoured.
let outer_clip = canvas.clip_bounds();
let inner_rect = Rect
{
x: rect.x + theme::PAD_H * 0.5,
y: rect.y,
width: ( rect.width - theme::PAD_H - toggle_reserve ).max( 0.0 ),
height: rect.height,
};
let composed_clip: Vec<Rect> = if outer_clip.is_empty()
{
vec![ inner_rect ]
} else {
outer_clip.iter()
.filter_map( |o| rect_intersect( *o, inner_rect ) )
.collect()
};
canvas.set_clip_rects( &composed_clip );
// Selection highlight (single-line). Painted before the text
// so the glyphs sit on top of the tinted band.
if focused && selection_anchor != cursor_pos
{
let ( s, e ) = (
cursor_pos.min( selection_anchor ).min( self.value.len() ),
cursor_pos.max( selection_anchor ).min( self.value.len() ),
);
let prefix_text = if secure
{
"\u{2022}".repeat( self.value[..s].chars().count() )
} else {
self.value[..s].to_string()
};
let span_text = if secure
{
"\u{2022}".repeat( self.value[s..e].chars().count() )
} else {
self.value[s..e].to_string()
};
let x0 = rect.x + theme::PAD_H + align_x - scroll_x
+ canvas.measure_text( &prefix_text, font_size );
let w = canvas.measure_text( &span_text, font_size );
let sel_rect = Rect
{
x: x0, y: rect.y + 6.0,
width: w, height: rect.height - 12.0,
};
canvas.fill_rect( sel_rect, theme::selection(), 2.0 );
}
if text.is_empty()
{
// Placeholder follows the same alignment as the value
// would, so an empty centred / right-aligned field still
// reads with the placeholder where the digits will land.
let placeholder_align_x = single_line_align_offset(
canvas, rect, &self.placeholder, false, self.align, font_size,
);
canvas.draw_text(
&self.placeholder,
rect.x + theme::PAD_H + placeholder_align_x,
text_y,
font_size,
theme::placeholder(),
);
} else {
canvas.draw_text(
&text,
rect.x + theme::PAD_H + align_x - scroll_x,
text_y,
font_size,
theme::text(),
);
}
if focused
{
let safe_cursor = cursor_pos.min( self.value.len() );
let cursor_text = if secure
{
"\u{2022}".repeat( self.value[..safe_cursor].chars().count() )
} else {
self.value[..safe_cursor].to_string()
};
let cursor_x = rect.x + theme::PAD_H + align_x - scroll_x
+ canvas.measure_text( &cursor_text, font_size );
let cursor_rect = Rect
{
x: cursor_x,
y: rect.y + 8.0,
width: 2.0,
height: rect.height - 16.0,
};
canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 );
}
// Restore whatever clip was active on entry.
if outer_clip.is_empty()
{
canvas.clear_clip();
} else {
canvas.set_clip_rects( &outer_clip );
}
// Eye icon for `password_toggle`. Drawn outside the inner
// clip so it never gets occluded by overflowing text — the
// preceding clip-restore is the reason this lives here and
// not earlier in the function. Tinted with the placeholder
// colour so the icon reads as ambient affordance rather
// than a primary control.
if let Some( ( visible, _ ) ) = self.password_toggle.as_ref()
{
let icon_name = if *visible { "actions/invisible" } else { "actions/visible" };
let icon_px = theme::PASSWORD_TOGGLE_SIZE.round() as u32;
if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name, icon_px )
{
let tinted = crate::theme::tint_symbolic( &rgba, theme::placeholder() );
let zone = password_toggle_hit_zone( rect );
let dest = Rect
{
x: ( zone.x + ( zone.width - iw as f32 ) / 2.0 ).round(),
y: ( rect.y + ( rect.height - ih as f32 ) / 2.0 ).round(),
width: iw as f32,
height: ih as f32,
};
canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 );
}
}
}
fn draw_multiline(
&self,
canvas: &mut Canvas,
rect: Rect,
focused: bool,
cursor_pos: usize,
selection_anchor: usize,
)
{
let border_c = if focused { theme::focus_border() } else { theme::border() };
let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W };
canvas.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI );
canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI );
let line_h = theme::FONT_SIZE * theme::LINE_H_MULT;
let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h );
let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize;
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE );
// Compute the visual-line layout once per draw. Iterators
// later go through this list; long logical lines are
// soft-wrapped at the last whitespace before the row would
// overflow without mutating the buffer.
let visual_lines = compute_visual_lines( canvas, &self.value, inner_width, theme::FONT_SIZE );
// Vertical auto-scroll: keep the cursor's visual line on
// screen. The cursor sits in the *first* visual line whose
// `end >= cursor` — at a wrap boundary that places it at the
// trailing edge of the previous line rather than the start of
// the next, matching the convention of every standard text
// editor.
let safe_cursor = cursor_pos.min( self.value.len() );
let cursor_visual_idx = visual_lines.iter()
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
let total_lines = visual_lines.len();
let max_first = total_lines.saturating_sub( visible_lines );
let first_line = if cursor_visual_idx + 1 > visible_lines
{
( cursor_visual_idx + 1 - visible_lines ).min( max_first )
} else { 0 };
let baseline_0 = rect.y + theme::PAD_V_MULTI + theme::FONT_SIZE;
// Clip text to the inner rect so partial lines at the bottom
// stay inside the box and do not bleed onto sibling widgets
// or the border stroke. Save the parent clip first, then
// intersect it with our inner rect, so a `scroll(...)`-wrapped
// multiline edit keeps the parent's clip honoured.
let outer_clip = canvas.clip_bounds();
let inner_rect = Rect
{
x: rect.x + theme::PAD_H * 0.5,
y: rect.y + theme::PAD_V_MULTI * 0.5,
width: ( rect.width - theme::PAD_H ).max( 0.0 ),
height: ( rect.height - theme::PAD_V_MULTI ).max( 0.0 ),
};
let composed_clip: Vec<Rect> = if outer_clip.is_empty()
{
vec![ inner_rect ]
} else {
outer_clip.iter()
.filter_map( |o| rect_intersect( *o, inner_rect ) )
.collect()
};
canvas.set_clip_rects( &composed_clip );
// Selection highlight (multiline). Painted before the text so
// the glyphs sit on top of the tinted band(s). Iterates
// visual lines, so a soft-wrapped logical line gets one
// highlight rect per visual row automatically.
if focused && selection_anchor != cursor_pos
{
let s = cursor_pos.min( selection_anchor ).min( self.value.len() );
let e = cursor_pos.max( selection_anchor ).min( self.value.len() );
for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line )
{
let visual_idx = i - first_line;
let y = rect.y + theme::PAD_V_MULTI + visual_idx as f32 * line_h;
if y > rect.y + rect.height - theme::PAD_V_MULTI { break; }
// Intersection of [s, e] with this visual line's
// byte range. Empty / non-overlapping → skip.
let span_start = s.max( vl.start );
let span_end = e.min( vl.end );
if span_start >= span_end { continue; }
let prefix_text = &self.value[ vl.start..span_start ];
let span_text = &self.value[ span_start..span_end ];
let x0 = rect.x + theme::PAD_H + canvas.measure_text( prefix_text, theme::FONT_SIZE );
let w = canvas.measure_text( span_text, theme::FONT_SIZE ).max( 4.0 );
let sel_rect = Rect
{
x: x0, y,
width: w, height: line_h,
};
canvas.fill_rect( sel_rect, theme::selection(), 2.0 );
}
}
if self.value.is_empty()
{
canvas.draw_text(
&self.placeholder,
rect.x + theme::PAD_H,
baseline_0,
theme::FONT_SIZE,
theme::placeholder(),
);
} else {
for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line )
{
let visual_idx = i - first_line;
let y = baseline_0 + visual_idx as f32 * line_h;
if y > rect.y + rect.height - theme::PAD_V_MULTI + line_h
{
break;
}
let line = &self.value[ vl.start..vl.end ];
canvas.draw_text(
line,
rect.x + theme::PAD_H,
y,
theme::FONT_SIZE,
theme::text(),
);
}
}
if focused
{
let vl = visual_lines[ cursor_visual_idx ];
let line_prefix = &self.value[ vl.start..safe_cursor ];
let cursor_x = rect.x + theme::PAD_H
+ canvas.measure_text( line_prefix, theme::FONT_SIZE );
let visual_y_idx = cursor_visual_idx.saturating_sub( first_line );
let cursor_y = rect.y + theme::PAD_V_MULTI + visual_y_idx as f32 * line_h;
let cursor_rect = Rect
{
x: cursor_x,
y: cursor_y + 2.0,
width: 2.0,
height: theme::FONT_SIZE + 4.0,
};
canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 );
}
// Restore whatever clip was active on entry — a single empty
// `Vec` from `clip_bounds` means "no clip", which we model by
// calling `clear_clip` instead of pushing an empty slice.
if outer_clip.is_empty()
{
canvas.clear_clip();
} else {
canvas.set_clip_rects( &outer_clip );
}
}
}

View File

@@ -0,0 +1,203 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::{ Point, Rect };
use super::theme;
use super::wrapping::compute_visual_lines;
/// Convert a pointer position inside the widget rect to the byte offset
/// in `value` the cursor should land on. Free function so the runtime
/// can call it with the value snapshot it already holds in
/// [`crate::widget::WidgetHandlers::TextEdit`] without re-walking the
/// element tree to find the original [`super::TextEdit`] struct.
///
/// `multiline` and `secure` mirror the matching fields on the source
/// widget; `cursor_pos` is the current cursor — needed by the
/// multiline branch to reproduce the same vertical scroll the most
/// recent [`super::TextEdit::draw`] call computed.
pub( crate ) fn byte_offset_at(
canvas: &Canvas,
rect: Rect,
pos: Point,
value: &str,
multiline: bool,
secure: bool,
cursor_pos: usize,
align: crate::widget::text::TextAlign,
font_size: f32,
) -> usize
{
if value.is_empty() { return 0; }
if multiline && !secure
{
let line_h = theme::FONT_SIZE * theme::LINE_H_MULT;
let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h );
let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize;
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE );
// Recompute the visual layout from current state. Same call
// as `draw_multiline` makes, so the click maps to the line /
// column the user actually sees.
let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE );
let safe_cursor = cursor_pos.min( value.len() );
let cursor_visual_idx = visual_lines.iter()
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
let total_lines = visual_lines.len();
let max_first = total_lines.saturating_sub( visible_lines );
let first_line = if cursor_visual_idx + 1 > visible_lines
{
( cursor_visual_idx + 1 - visible_lines ).min( max_first )
} else { 0 };
let visual_idx = ( ( pos.y - rect.y - theme::PAD_V_MULTI ) / line_h )
.max( 0.0 ) as usize;
let target_idx = ( first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) );
let vl = visual_lines[ target_idx ];
byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - theme::PAD_H, false, theme::FONT_SIZE )
} else {
// Mirror the horizontal scroll *and* alignment the renderer
// applies so a click lands on the glyph the user actually
// sees, not on the byte offset that would correspond to an
// unscrolled, left-aligned layout.
let scroll_x = single_line_scroll_x( canvas, rect, value, cursor_pos, secure, font_size );
let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size );
byte_offset_in_line(
canvas, value, 0, value.len(),
pos.x - rect.x - theme::PAD_H - align_x + scroll_x,
secure,
font_size,
)
}
}
/// Horizontal scroll offset, in pixels, applied to a single-line
/// `text_edit` so the cursor stays visible inside the inner rect when
/// the value is wider than the box. Stateless — derived purely from
/// the current cursor position. Three regimes:
///
/// * cursor in the **left half** of the visible band → no scroll, the
/// start of the text reads naturally;
/// * cursor in the **right half from the end** → anchor to end so the
/// tail of the value is in view (this is the "typing past the right
/// edge" case the user sees the most);
/// * cursor anywhere else → centre the cursor in the visible band so
/// navigation with the arrow keys keeps trailing context on both
/// sides instead of jumping the text on every keystroke.
///
/// `secure` toggles bullet substitution before measuring so a
/// password field scrolls based on the displayed bullets, not the
/// underlying value's glyph widths.
pub( crate ) fn single_line_scroll_x(
canvas: &Canvas,
rect: Rect,
value: &str,
cursor: usize,
secure: bool,
font_size: f32,
) -> f32
{
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size );
let safe_cursor = cursor.min( value.len() );
let display_value: String = if secure
{
"\u{2022}".repeat( value.chars().count() )
} else {
value.to_string()
};
let prefix_text: String = if secure
{
"\u{2022}".repeat( value[..safe_cursor].chars().count() )
} else {
value[..safe_cursor].to_string()
};
let total_w = canvas.measure_text( &display_value, font_size );
if total_w <= inner_width { return 0.0; }
let prefix_w = canvas.measure_text( &prefix_text, font_size );
let suffix_w = ( total_w - prefix_w ).max( 0.0 );
const MARGIN: f32 = 4.0;
if prefix_w <= inner_width / 2.0
{
0.0
} else if suffix_w <= inner_width / 2.0 {
( total_w - inner_width + MARGIN ).max( 0.0 )
} else {
prefix_w - inner_width / 2.0
}
}
/// Horizontal offset, in pixels, applied on top of `single_line_scroll_x`
/// to honour the field's [`crate::widget::text::TextAlign`]. Only meaningful
/// while the value still fits inside `inner_width`; once the text
/// overflows, scrolling owns the layout and the alignment offset
/// collapses to `0` so anchoring the cursor / end of text reads
/// naturally without fighting the alignment.
pub( crate ) fn single_line_align_offset(
canvas: &Canvas,
rect: Rect,
value: &str,
secure: bool,
align: crate::widget::text::TextAlign,
font_size: f32,
) -> f32
{
use crate::widget::text::TextAlign;
if matches!( align, TextAlign::Left ) { return 0.0; }
let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size );
let display_value: String = if secure
{
"\u{2022}".repeat( value.chars().count() )
} else {
value.to_string()
};
let total_w = canvas.measure_text( &display_value, font_size );
if total_w >= inner_width { return 0.0; }
match align
{
TextAlign::Left => 0.0,
TextAlign::Center => ( inner_width - total_w ) * 0.5,
TextAlign::Right => inner_width - total_w,
}
}
/// Walk the slice `value[line_start..line_end]` char by char,
/// accumulating widths, and return the byte offset (within `value`)
/// where the click `target_x` falls. The "snap to nearest boundary"
/// rule is the standard `text input is text input` behaviour: if the
/// click is past the half-glyph mark, the cursor lands *after* the
/// glyph. `secure` toggles bullet substitution at measurement time so
/// the same logic works for password fields.
pub( super ) fn byte_offset_in_line(
canvas: &Canvas,
value: &str,
line_start: usize,
line_end: usize,
target_x: f32,
secure: bool,
font_size: f32,
) -> usize
{
let line = &value[line_start..line_end];
if target_x <= 0.0 { return line_start; }
let mut acc_w = 0.0f32;
let mut last_byte = line_start;
for ( ofs, ch ) in line.char_indices()
{
let glyph_str = if secure { "\u{2022}".to_string() } else { ch.to_string() };
let w = canvas.measure_text( &glyph_str, font_size );
if target_x < acc_w + w * 0.5
{
return line_start + ofs;
}
acc_w += w;
last_byte = line_start + ofs + ch.len_utf8();
}
last_byte.min( line_end )
}

513
src/widget/text_edit/mod.rs Normal file
View File

@@ -0,0 +1,513 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text input field — single-line or multiline. The widget itself
//! owns layout / draw; the runtime side of text editing
//! (insert, delete, cursor movement, selection, clipboard)
//! lives in [`crate::event_loop::text_editing`].
use std::sync::Arc;
use crate::render::Canvas;
use crate::secure_mem::secure_zero;
use crate::types::{ Rect, WidgetId };
use super::Element;
pub( crate ) mod theme;
pub( crate ) mod wrapping;
pub( crate ) mod hit_test;
pub( crate ) mod cursor;
mod draw;
#[ cfg( test ) ]
mod tests;
pub use draw::password_toggle_hit_zone;
pub( crate ) use hit_test::byte_offset_at;
pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_up };
/// A text input field.
///
/// Single-line by default; switches to a multi-row text-area via
/// [`Self::multiline`]. Single-line mode honours the optional inline
/// builders for picker-style fields:
///
/// * [`Self::align`] — horizontal alignment of the displayed text;
/// * [`Self::borderless`] — drop the surrounding pill / border so the
/// field can sit inside a parent that already paints its own
/// surface;
/// * [`Self::fixed_width`] — pin the preferred width to a specific
/// number of pixels instead of claiming `max_width`;
/// * [`Self::font_size`] — override the text font size;
/// * [`Self::select_on_focus`] — auto-select the value on focus so
/// the next keystroke replaces it (numeric pickers, short-form
/// inputs).
/// * [`Self::password_toggle`] — pin a built-in show / hide-password
/// eye icon to the right edge of the field; the bullet
/// substitution flips with the externally-owned `visible` state
/// on each tap.
///
/// ```rust,no_run
/// # use ltk::{ text_edit, Element };
/// # #[ derive( Clone ) ] enum Msg { UsernameChanged( String ), Submit }
/// # struct App { username: String }
/// # impl App { fn _ex( &self ) -> Element<Msg> {
/// text_edit( "Username", &self.username )
/// .on_change( |s| Msg::UsernameChanged( s ) )
/// .on_submit( Msg::Submit )
/// .into()
/// # }}
/// ```
///
/// ## Password field with show / hide toggle
///
/// ```rust,no_run
/// # use ltk::{ text_edit, Element };
/// # #[ derive( Clone ) ] enum Msg { PasswordChanged( String ), TogglePassword }
/// # struct App { password: String, password_visible: bool }
/// # impl App { fn _ex( &self ) -> Element<Msg> {
/// text_edit( "Password", &self.password )
/// .on_change( |s| Msg::PasswordChanged( s ) )
/// .password_toggle( self.password_visible, Msg::TogglePassword )
/// .into()
/// # }}
/// ```
///
/// `password_toggle` overrides [`Self::secure`] when both are set —
/// the toggle's `visible` parameter drives the bullet substitution
/// from then on. The widget still wipes the buffer on drop and
/// skips the IME registration (the same hardening
/// [`Self::secure`] gives) regardless of the current visibility,
/// so flipping the eye does not weaken the field's threat model
/// at runtime.
pub struct TextEdit<Msg: Clone>
{
/// Placeholder text shown when the field is empty.
pub placeholder: String,
/// Current field value.
pub value: String,
/// Callback invoked with the new value on every keystroke.
/// `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(String) -> Msg>>,
/// Message emitted when the user presses Enter.
pub on_submit: Option<Msg>,
/// When `true`, the value is rendered as bullet characters (password mode).
pub secure: bool,
/// When `true`, the widget renders as a multi-row text area: the
/// box grows to [`Self::rows`] visible rows, line breaks in the
/// value are honoured at draw time, and pressing Enter inserts a
/// `\n` rather than firing [`Self::on_submit`]. Ignored when
/// [`Self::secure`] is set — passwords are always single-line.
pub multiline: bool,
/// Visible row count when `multiline` is `true`. Drives
/// `preferred_size`'s height calculation so a multiline field
/// claims a sensible vertical slot in the parent layout. Ignored
/// when `multiline` is `false`.
pub rows: u32,
/// Byte offset of the text cursor within `value` (used by insert_str/backspace).
pub cursor_pos: usize,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
/// Override the pointer cursor shape on hover. `None` falls back
/// to the I-beam default that matches every desktop convention.
pub cursor: Option<crate::types::CursorShape>,
/// Horizontal alignment of the displayed text inside the inner
/// content rect. Only takes effect on the single-line path when
/// the value fits inside the inner width — once the value
/// overflows, the internal `single_line_scroll_x` helper takes
/// over and the alignment offset collapses to `0` so scrolling
/// reads naturally. Default `TextAlign::Left`.
pub align: super::text::TextAlign,
/// Skip the field's background fill and border stroke. Useful
/// when the [`TextEdit`] is dropped inside another container
/// that paints its own surface (e.g. the digit cells inside
/// [`crate::widget::time_picker::TimePicker`]) and a second pill
/// would only add visual noise.
pub borderless: bool,
/// Override the preferred width reported to the parent layout.
/// Without this the single-line `TextEdit` claims `max_width` and
/// fills whatever rect the parent allocates — which is the right
/// default for forms but wrong when the field needs to be sized
/// to fit a fixed number of glyphs (date / time pickers, inline
/// numeric inputs).
pub fixed_width: Option<f32>,
/// Font size in pixels for the single-line draw path. Defaults to
/// the theme's `FONT_SIZE` constant. Multiline mode ignores this
/// for now and always uses the default — multiline soft-wrap
/// layout depends on the constant in several places that are not
/// yet parameterised.
pub font_size: f32,
/// When `true`, focusing the field selects the whole value so the
/// next keystroke replaces it. Standard behaviour for numeric
/// pickers and short-form fields where the user usually wants to
/// retype rather than edit. Default `false` — long-form fields
/// keep the cursor at the end on focus.
pub select_on_focus: bool,
/// Self-managed "show / hide password" eye affordance — when
/// `Some( ( visible, on_toggle ) )` the field renders an
/// `actions/visible` ↔ `actions/invisible` icon at its right
/// edge, taps on that icon dispatch `on_toggle` instead of
/// placing the cursor, and the bullet substitution flips with
/// `visible` (overriding `secure`). Set on a field that already
/// has `secure( true )` and the explicit flag becomes redundant
/// — the toggle controls the visibility from then on.
pub password_toggle: Option<( bool, Msg )>,
}
impl<Msg: Clone> TextEdit<Msg>
{
/// Create a text field with the given placeholder and initial value.
///
/// The cursor is placed at the end of the initial value.
pub fn new( placeholder: String, value: String ) -> Self
{
let cursor_pos = value.len();
Self
{
placeholder,
value,
on_change: None,
on_submit: None,
secure: false,
multiline: false,
rows: theme::ROWS_DEFAULT,
cursor_pos,
id: None,
cursor: None,
align: super::text::TextAlign::Left,
borderless: false,
fixed_width: None,
font_size: theme::FONT_SIZE,
select_on_focus: false,
password_toggle: None,
}
}
/// Add a "show / hide password" eye toggle pinned to the right
/// edge of the field. `visible` controls whether the value
/// renders as bullets (`false`) or plain text (`true`); a tap on
/// the icon emits `on_toggle` so the caller can flip its own
/// `bool` state and re-render. Works with or without an explicit
/// [`Self::secure`] — when this is set, the toggle's `visible`
/// drives the bullet substitution and the `secure` field is
/// ignored.
pub fn password_toggle( mut self, visible: bool, on_toggle: Msg ) -> Self
{
self.password_toggle = Some( ( visible, on_toggle ) );
self
}
/// Effective secure flag honoured by drawing / measurement /
/// hit-testing — [`Self::password_toggle`] takes precedence over
/// the manual [`Self::secure`] when both are set.
pub fn effective_secure( &self ) -> bool
{
match &self.password_toggle
{
Some( ( visible, _ ) ) => !visible,
None => self.secure,
}
}
/// Override the font size used by the single-line draw path.
/// Defaults to the theme's `FONT_SIZE` constant. Ignored in
/// multiline mode.
pub fn font_size( mut self, px: f32 ) -> Self
{
self.font_size = px.max( 1.0 );
self
}
/// Select the whole value when the field receives focus, so the
/// next keystroke replaces it. Default `false`.
pub fn select_on_focus( mut self, on: bool ) -> Self
{
self.select_on_focus = on;
self
}
/// Set the horizontal alignment of the displayed text. Default
/// [`TextAlign::Left`](super::text::TextAlign::Left).
pub fn align( mut self, a: super::text::TextAlign ) -> Self
{
self.align = a;
self
}
/// Skip the field's background fill and border stroke — useful
/// when the field is nested inside a container that already
/// paints its own surface.
pub fn borderless( mut self, on: bool ) -> Self
{
self.borderless = on;
self
}
/// Override the preferred width reported to the parent layout.
/// Pass `None` (default) to fall back to claiming `max_width`.
pub fn fixed_width( mut self, w: f32 ) -> Self
{
self.fixed_width = Some( w );
self
}
/// Override the pointer cursor shape shown on hover. Defaults to
/// [`CursorShape::Text`](crate::CursorShape::Text) (I-beam).
pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self
{
self.cursor = Some( shape );
self
}
/// Switch to multiline (text-area) mode. The box is laid out with
/// [`Self::rows`] visible rows of height, line breaks in the value
/// are rendered as separate rows, and Enter inserts a `\n` instead
/// of firing [`Self::on_submit`]. Ignored when [`Self::secure`] is
/// `true`.
pub fn multiline( mut self, m: bool ) -> Self
{
self.multiline = m;
self
}
/// Configure the number of visible rows in multiline mode. Defaults
/// to 5; ignored when [`Self::multiline`] is `false`.
pub fn rows( mut self, n: u32 ) -> Self
{
self.rows = n.max( 1 );
self
}
/// Set the callback invoked on every keystroke with the updated value.
pub fn on_change( mut self, f: impl Fn(String) -> Msg + 'static ) -> Self
{
self.on_change = Some( Arc::new( f ) );
self
}
/// Set the message emitted when Enter is pressed.
pub fn on_submit( mut self, msg: Msg ) -> Self
{
self.on_submit = Some( msg );
self
}
/// Enable or disable password mode.
///
/// When `true`, this widget:
///
/// 1. Renders the value as bullet characters (`•`) instead of the
/// raw glyphs.
/// 2. Forces single-line mode (multiline + secure is mutually
/// exclusive — passwords don't have line breaks).
/// 3. Wipes the underlying byte buffer with zero before the
/// `String` allocation is returned to the allocator. The wipe
/// runs in `Drop` for both the `TextEdit` itself and for the
/// per-frame [`crate::widget::WidgetHandlers::TextEdit`] snapshot
/// the runtime keeps for input dispatch — so the in-tree copies
/// that ltk owns never linger as plain text in freed memory.
///
/// # Threat model — what `secure` covers
///
/// Inside the widget tree the runtime keeps two copies of the
/// value for the lifetime of one frame: the `TextEdit` itself and
/// the `WidgetHandlers` snapshot. Both run the `secure_zero` wipe
/// on `Drop`, so when the next frame replaces them (the typical
/// case — `view()` rebuilds every frame) the freed allocations are
/// overwritten before being released back to the allocator. The
/// wipe uses volatile writes + a `compiler_fence` so the optimiser
/// cannot elide it as dead code (the implementation is in the
/// crate-private `secure_mem` module).
///
/// # What `secure` does **not** cover
///
/// * **The application's own state.** The `String` you pass in
/// through `text_edit( placeholder, &self.password )` lives on
/// **your** struct, not on the widget. Wiping it is
/// your job — typically a `Drop` impl on the credential
/// container, or an explicit
/// `secure_mem::secure_zero( password.as_bytes_mut() )` after
/// the auth handshake completes.
/// * **Callback-allocated copies.** Every keystroke passes through
/// `on_change( |s: String| ... )`, which receives a fresh
/// `String` clone. If your closure stores or forwards that
/// `String` (e.g. clone it into a worker thread for PAM), each
/// stored copy is the consumer's responsibility to wipe. ltk
/// only owns the buffers it allocated itself.
/// * **OS-level disclosure surfaces.** Swap-out, hibernation
/// images, and core dumps are outside any user-space wipe's
/// reach. For threat models that require resistance to these,
/// compile against an `mlock`-aware allocator, disable swap on
/// the credential mount, and restrict core-dump capability with
/// `prctl( PR_SET_DUMPABLE, 0 )` on the process.
/// * **Compositor-side records.** Wayland text-input protocols can
/// surface preedit / commit strings to the compositor's IME
/// stack; `secure` skips text-input-v3 registration so this path
/// stays closed. Verify your compositor honours that — most do,
/// but the protocol does not strictly require it.
///
/// See the in-repo `SECURITY.md` for the full threat-model write-up
/// (the *Hardening features* section enumerates each guarantee and
/// its boundary).
pub fn secure( mut self, s: bool ) -> Self
{
self.secure = s;
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
/// Return the preferred `(width, height)` given available `max_width`.
///
/// Single-line: theme-defined `HEIGHT`.
/// Multiline: enough room for [`Self::rows`] lines plus padding.
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
if self.multiline && !self.effective_secure()
{
let line_h = theme::FONT_SIZE * theme::LINE_H_MULT;
let h = self.rows as f32 * line_h + theme::PAD_V_MULTI * 2.0;
( max_width, h )
} else {
let w = self.fixed_width
.map( |fw| fw.min( max_width ) )
.unwrap_or( max_width );
( w, theme::HEIGHT )
}
}
/// `true` when the widget is laid out as a multi-row text area —
/// i.e. [`Self::multiline`] was set and [`Self::effective_secure`]
/// is `false`. A `password_toggle` field collapses to single-line
/// like an explicit `secure( true )` does.
pub fn is_multiline( &self ) -> bool
{
self.multiline && !self.effective_secure()
}
/// Translate a pointer position inside `rect` to the byte offset
/// in [`Self::value`] that the cursor should land on. Thin
/// wrapper around `byte_offset_at` using this widget's value /
/// flags.
pub fn byte_offset_at_self(
&self,
canvas: &Canvas,
rect: Rect,
pos: crate::types::Point,
cursor_pos: usize,
) -> usize
{
byte_offset_at(
canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, self.align, self.font_size,
)
}
/// Border stroke is centered on `rect`, so half the stroke width plus ~1 px
/// of antialiasing bleed sits outside. The widest stroke is the focused
/// border, so use that as the envelope.
pub fn paint_bounds( &self, rect: Rect ) -> Rect
{
rect.expand( theme::FOCUS_BORDER_W * 0.5 + 1.0 )
}
/// Return the display string — bullet characters in secure mode, plain value otherwise.
pub fn display_text( &self ) -> String
{
if self.effective_secure()
{
"\u{2022}".repeat( self.value.chars().count() )
} else {
self.value.clone()
}
}
/// Wrap this widget in an [`Element`].
pub fn into_element( self ) -> Element<Msg>
{
Element::TextEdit( self )
}
/// Insert a string at the current cursor position, advance the cursor, and
/// return the `on_change` message if one is set.
pub fn insert_str( &mut self, s: &str ) -> Option<Msg>
{
self.value.insert_str( self.cursor_pos.min( self.value.len() ), s );
self.cursor_pos = (self.cursor_pos + s.len()).min( self.value.len() );
self.on_change.as_ref().map( |f| f( self.value.clone() ) )
}
/// Delete the character before the cursor and return the `on_change` message
/// if one is set. Does nothing if the cursor is already at position 0.
pub fn backspace( &mut self ) -> Option<Msg>
{
if self.cursor_pos == 0 { return None; }
let chars: Vec<char> = self.value.chars().collect();
let char_pos = self.value[..self.cursor_pos].chars().count();
if char_pos == 0 { return None; }
let mut chars = chars;
let removed = chars.remove( char_pos - 1 );
self.cursor_pos -= removed.len_utf8();
self.value = chars.iter().collect();
self.on_change.as_ref().map( |f| f( self.value.clone() ) )
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> TextEdit<U>
where
U: Clone + 'static,
Msg: 'static,
{
// Wrap on_change the same way the slider does. on_submit is a
// plain `Option<Msg>`, so it goes through the user mapper once.
let on_change = self.on_change.clone().map( |old| -> Arc<dyn Fn( String ) -> U>
{
let mapper = Arc::clone( f );
Arc::new( move |s| ( *mapper )( ( *old )( s ) ) )
} );
TextEdit
{
placeholder: self.placeholder.clone(),
value: self.value.clone(),
on_change,
on_submit: self.on_submit.clone().map( |m| ( *f )( m ) ),
secure: self.secure,
multiline: self.multiline,
rows: self.rows,
cursor_pos: self.cursor_pos,
id: self.id,
cursor: self.cursor,
align: self.align,
borderless: self.borderless,
fixed_width: self.fixed_width,
font_size: self.font_size,
select_on_focus: self.select_on_focus,
password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ),
}
}
}
impl<Msg: Clone> Drop for TextEdit<Msg>
{
/// When `secure( true )` is set, scrub the value bytes before the
/// underlying `String` allocation is returned to the allocator. The
/// non-secure path is a no-op so the cost is paid only by widgets
/// that opted into credential handling.
fn drop( &mut self )
{
if self.secure || self.password_toggle.is_some()
{
// SAFETY: as_mut_vec exposes the underlying byte buffer of the
// String. We only overwrite each byte with zero, which is valid
// UTF-8 (a sequence of NUL codepoints), so the String invariant
// is preserved through to the Vec drop that runs immediately
// after this fn returns.
let bytes = unsafe { self.value.as_mut_vec() };
secure_zero( bytes );
}
}
}

View File

@@ -0,0 +1,702 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::TextEdit;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Changed( String ),
Submitted,
}
fn new_te( value: &str ) -> TextEdit<Msg>
{
TextEdit::new( "placeholder".into(), value.into() )
}
// ── Construction ──────────────────────────────────────────────────────────
#[ test ]
fn new_places_cursor_at_end_of_initial_value()
{
let t = new_te( "hello" );
assert_eq!( t.cursor_pos, 5 );
assert_eq!( t.value, "hello" );
}
#[ test ]
fn new_with_empty_value_starts_with_zero_cursor()
{
let t = new_te( "" );
assert_eq!( t.cursor_pos, 0 );
assert!( t.value.is_empty() );
}
#[ test ]
fn new_with_multibyte_value_uses_byte_length_for_cursor()
{
// "café" is 5 bytes (c-a-f-é where é is 2 bytes in UTF-8).
let t = new_te( "café" );
assert_eq!( t.cursor_pos, 5 );
assert_eq!( t.value.len(), 5 );
}
// ── insert_str ────────────────────────────────────────────────────────────
#[ test ]
fn insert_str_at_end_appends_and_advances_cursor()
{
let mut t = new_te( "ab" );
assert_eq!( t.cursor_pos, 2 );
let _ = t.insert_str( "c" );
assert_eq!( t.value, "abc" );
assert_eq!( t.cursor_pos, 3 );
}
#[ test ]
fn insert_str_at_middle_inserts_and_advances_cursor_by_byte_len()
{
let mut t = new_te( "ab" );
t.cursor_pos = 1;
let _ = t.insert_str( "X" );
assert_eq!( t.value, "aXb" );
assert_eq!( t.cursor_pos, 2 );
}
#[ test ]
fn insert_str_multibyte_advances_cursor_by_utf8_byte_count()
{
let mut t = new_te( "" );
let _ = t.insert_str( "é" ); // 2 bytes
assert_eq!( t.value, "é" );
assert_eq!( t.cursor_pos, 2 );
let _ = t.insert_str( "🦀" ); // 4 bytes
assert_eq!( t.cursor_pos, 6 );
}
#[ test ]
fn insert_str_clamps_runaway_cursor()
{
// If cursor_pos is somehow past value.len() (defensive), insert should
// behave as "insert at end" rather than panic.
let mut t = new_te( "ab" );
t.cursor_pos = 99;
let _ = t.insert_str( "c" );
assert_eq!( t.value, "abc" );
// Cursor is clamped to new value length.
assert_eq!( t.cursor_pos, 3 );
}
#[ test ]
fn insert_str_returns_on_change_with_new_value()
{
let mut t = new_te( "ab" )
.on_change( |s| Msg::Changed( s ) );
let msg = t.insert_str( "c" );
assert_eq!( msg, Some( Msg::Changed( "abc".into() ) ) );
}
#[ test ]
fn insert_str_without_on_change_returns_none()
{
let mut t = new_te( "ab" );
assert_eq!( t.insert_str( "c" ), None );
}
// ── backspace ─────────────────────────────────────────────────────────────
#[ test ]
fn backspace_at_zero_cursor_is_noop()
{
let mut t = new_te( "abc" );
t.cursor_pos = 0;
assert_eq!( t.backspace(), None );
assert_eq!( t.value, "abc" );
assert_eq!( t.cursor_pos, 0 );
}
#[ test ]
fn backspace_at_end_removes_last_char()
{
let mut t = new_te( "abc" );
let _ = t.backspace();
assert_eq!( t.value, "ab" );
assert_eq!( t.cursor_pos, 2 );
}
#[ test ]
fn backspace_in_middle_removes_char_before_cursor()
{
let mut t = new_te( "abc" );
t.cursor_pos = 2;
let _ = t.backspace();
assert_eq!( t.value, "ac" );
assert_eq!( t.cursor_pos, 1 );
}
#[ test ]
fn backspace_handles_multibyte_acute()
{
// "é" is two bytes; backspacing must remove the whole codepoint.
let mut t = new_te( "café" );
let _ = t.backspace();
assert_eq!( t.value, "caf" );
assert_eq!( t.cursor_pos, 3 );
}
#[ test ]
fn backspace_handles_emoji_codepoint()
{
// Crab emoji is 4 bytes in UTF-8; cursor must back up by 4.
let mut t = new_te( "x🦀" );
assert_eq!( t.cursor_pos, 5 );
let _ = t.backspace();
assert_eq!( t.value, "x" );
assert_eq!( t.cursor_pos, 1 );
}
#[ test ]
fn backspace_returns_on_change_with_new_value()
{
let mut t = new_te( "ab" )
.on_change( |s| Msg::Changed( s ) );
let msg = t.backspace();
assert_eq!( msg, Some( Msg::Changed( "a".into() ) ) );
}
// ── display_text + secure ─────────────────────────────────────────────────
#[ test ]
fn display_text_plain_returns_value_verbatim()
{
let t = new_te( "hello" );
assert_eq!( t.display_text(), "hello" );
}
#[ test ]
fn display_text_secure_returns_bullets_one_per_codepoint()
{
// One bullet per codepoint, NOT per byte. "café" has 4 codepoints,
// so the masked text must be 4 bullets — not 5 (the byte length).
let t = new_te( "café" ).secure( true );
let masked = t.display_text();
assert_eq!( masked.chars().count(), 4 );
assert!( masked.chars().all( |c| c == '\u{2022}' ) );
}
#[ test ]
fn display_text_secure_with_emoji_one_bullet_per_codepoint()
{
// Crab emoji is one codepoint — exactly one bullet, even though it
// occupies four bytes in UTF-8.
let t = new_te( "🦀" ).secure( true );
assert_eq!( t.display_text().chars().count(), 1 );
}
#[ test ]
fn secure_mode_does_not_touch_underlying_value()
{
let t = new_te( "secret" ).secure( true );
assert_eq!( t.value, "secret" );
}
#[ test ]
fn secure_toggles_via_builder()
{
let t = new_te( "x" ).secure( true ).secure( false );
assert_eq!( t.display_text(), "x" );
}
// ── secure-mode credential zeroize ────────────────────────────────────────
#[ test ]
fn secure_flag_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
// The runtime takes a `WidgetHandlers` snapshot of every interactive
// widget once per frame. The `secure` flag must travel with the
// snapshot so the handler's own `Drop` (which mirrors `TextEdit`'s
// wipe-on-drop) knows whether to scrub on release.
let widget: Element<()> = TextEdit::new( "p".into(), "secret".into() )
.secure( true )
.into_element();
let h = widget.handlers();
match h
{
WidgetHandlers::TextEdit { secure, .. } => assert!( secure, "secure flag must propagate" ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn multiline_flag_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<()> = TextEdit::new( "p".into(), "x".into() )
.multiline( true )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { multiline, .. } => assert!( multiline, "multiline flag must propagate" ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn secure_overrides_multiline_in_widget_handlers()
{
use crate::widget::{ Element, WidgetHandlers };
// `is_multiline()` requires `!secure` — a secure password field
// must remain single-line even if the caller also asked for
// multiline. The handler snapshot reads `is_multiline()`, so
// `multiline` here must be `false`.
let widget: Element<()> = TextEdit::new( "p".into(), "x".into() )
.multiline( true )
.secure( true )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { multiline, secure, .. } =>
{
assert!( secure, "secure must propagate" );
assert!( !multiline, "secure must override multiline" );
}
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn non_secure_flag_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<()> = TextEdit::new( "p".into(), "x".into() )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { secure, .. } => assert!( !secure, "default is non-secure" ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn secure_drop_zeros_underlying_string_buffer()
{
// Reach into the value's byte buffer right before the drop, run the
// drop logic by replacing the binding, and confirm the bytes that
// had been the credential are now zero. We cannot inspect the
// allocation after the actual drop (UB), so this test exercises the
// same path that `Drop::drop` runs internally.
let mut t: TextEdit<()> = TextEdit::new( "p".into(), "secret".into() ).secure( true );
assert_eq!( t.value, "secret" );
// Manually run the wipe the way Drop will; the next assertion checks
// the buffer is zeroed before the auto-drop releases it.
// SAFETY: same as the production wipe path above — overwriting
// every byte with zero leaves the String holding a sequence of NUL
// codepoints, which is valid UTF-8.
let bytes = unsafe { t.value.as_mut_vec() };
crate::secure_mem::secure_zero( bytes );
assert!( bytes.iter().all( |&b| b == 0 ) );
assert_eq!( bytes.len(), 6 );
}
#[ test ]
fn non_secure_text_edit_drop_is_a_noop_for_value()
{
// A non-secure widget must not pay the wipe cost. We cannot observe
// the absence of writes directly, but we can confirm that the
// underlying bytes still match the original content right up to
// the moment of drop.
let t: TextEdit<()> = TextEdit::new( "p".into(), "value".into() );
assert_eq!( t.value.as_bytes(), b"value" );
// Drop runs at end of scope without touching the bytes.
}
// ── on_submit / on_change wiring ──────────────────────────────────────────
#[ test ]
fn on_submit_stores_message_unchanged()
{
let t = new_te( "" ).on_submit( Msg::Submitted );
assert_eq!( t.on_submit, Some( Msg::Submitted ) );
}
// ── full edit roundtrip ───────────────────────────────────────────────────
#[ test ]
fn type_word_then_clear_with_backspace()
{
let mut t = new_te( "" );
for ch in "hola".chars()
{
let _ = t.insert_str( &ch.to_string() );
}
assert_eq!( t.value, "hola" );
assert_eq!( t.cursor_pos, 4 );
while t.cursor_pos > 0
{
let _ = t.backspace();
}
assert!( t.value.is_empty() );
assert_eq!( t.cursor_pos, 0 );
}
// ── multiline mode ───────────────────────────────────────────────────────
#[ test ]
fn multiline_default_off()
{
let t = new_te( "" );
assert!( !t.multiline );
assert!( !t.is_multiline() );
}
#[ test ]
fn multiline_builder_enables()
{
let t = new_te( "" ).multiline( true );
assert!( t.multiline );
assert!( t.is_multiline() );
}
#[ test ]
fn multiline_grows_preferred_height_with_rows()
{
use crate::render::Canvas;
let canvas = Canvas::new( 800, 600 );
let single = new_te( "" );
let ( _, h_single ) = single.preferred_size( 200.0, &canvas );
assert_eq!( h_single, super::theme::HEIGHT );
let multi = new_te( "" ).multiline( true ).rows( 5 );
let ( _, h_multi ) = multi.preferred_size( 200.0, &canvas );
assert!( h_multi > h_single, "multiline must claim more vertical space" );
let bigger = new_te( "" ).multiline( true ).rows( 10 );
let ( _, h_bigger ) = bigger.preferred_size( 200.0, &canvas );
assert!( h_bigger > h_multi, "more rows → taller box" );
}
#[ test ]
fn rows_clamps_zero_to_one()
{
let t = new_te( "" ).multiline( true ).rows( 0 );
assert_eq!( t.rows, 1 );
}
#[ test ]
fn secure_overrides_multiline_in_is_multiline_query()
{
// A `multiline + secure` configuration must remain single-line:
// passwords carrying a literal `\n` would defeat the password
// mode rendering and turn the credential boundary fuzzy.
let t: TextEdit<()> = TextEdit::new( "p".into(), "x".into() )
.multiline( true )
.secure( true );
assert!( t.multiline ); // raw flag preserved
assert!( !t.is_multiline() ); // effective mode is single-line
}
#[ test ]
fn insert_str_can_carry_newline_in_multiline_mode()
{
// The widget itself does not gate `\n` — that decision lives at
// dispatch time. We just confirm `\n` round-trips through
// insert_str + the value buffer untouched.
let mut t = new_te( "ab" ).multiline( true );
let _ = t.insert_str( "\n" );
assert_eq!( t.value, "ab\n" );
assert_eq!( t.cursor_pos, 3 );
let _ = t.insert_str( "c" );
assert_eq!( t.value, "ab\nc" );
}
// ── builders for the inline / picker variants ────────────────────────────
#[ test ]
fn defaults_for_new_inline_builders()
{
let t: TextEdit<()> = TextEdit::new( "p".into(), "v".into() );
assert_eq!( t.align, super::super::text::TextAlign::Left );
assert!( !t.borderless );
assert!( t.fixed_width.is_none() );
assert_eq!( t.font_size, super::theme::FONT_SIZE );
assert!( !t.select_on_focus );
}
#[ test ]
fn align_builder_sets_alignment()
{
use super::super::text::TextAlign;
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).align( TextAlign::Center );
assert_eq!( t.align, TextAlign::Center );
let t = t.align( TextAlign::Right );
assert_eq!( t.align, TextAlign::Right );
}
#[ test ]
fn borderless_builder_toggles_flag()
{
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).borderless( true );
assert!( t.borderless );
}
#[ test ]
fn fixed_width_builder_stores_value()
{
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 72.0 );
assert_eq!( t.fixed_width, Some( 72.0 ) );
}
#[ test ]
fn font_size_builder_clamps_to_at_least_one_pixel()
{
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( 28.0 );
assert_eq!( t.font_size, 28.0 );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( -3.0 );
assert_eq!( t.font_size, 1.0 );
}
#[ test ]
fn select_on_focus_builder_toggles_flag()
{
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).select_on_focus( true );
assert!( t.select_on_focus );
}
#[ test ]
fn fixed_width_overrides_preferred_size_width()
{
let canvas = crate::render::Canvas::new( 1, 1 );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 64.0 );
let ( w, _h ) = t.preferred_size( 1000.0, &canvas );
assert_eq!( w, 64.0 );
}
#[ test ]
fn fixed_width_capped_by_max_width()
{
let canvas = crate::render::Canvas::new( 1, 1 );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 500.0 );
let ( w, _h ) = t.preferred_size( 200.0, &canvas );
// fixed_width is capped by the parent's available width to
// avoid overflowing tight rows.
assert_eq!( w, 200.0 );
}
#[ test ]
fn no_fixed_width_falls_back_to_max_width()
{
let canvas = crate::render::Canvas::new( 1, 1 );
let t: TextEdit<()> = TextEdit::new( "".into(), "".into() );
let ( w, _h ) = t.preferred_size( 320.0, &canvas );
assert_eq!( w, 320.0 );
}
#[ test ]
fn align_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
use super::super::text::TextAlign;
let widget: Element<()> = TextEdit::new( "".into(), "".into() )
.align( TextAlign::Center )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { align, .. } => assert_eq!( align, TextAlign::Center ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn font_size_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<()> = TextEdit::new( "".into(), "".into() )
.font_size( 28.0 )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { font_size, .. } => assert!( ( font_size - 28.0 ).abs() < 1e-6 ),
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn select_on_focus_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<()> = TextEdit::new( "".into(), "".into() )
.select_on_focus( true )
.into_element();
match widget.handlers()
{
WidgetHandlers::TextEdit { select_on_focus, .. } => assert!( select_on_focus ),
_ => panic!( "expected TextEdit handler" ),
}
}
// ── password_toggle ───────────────────────────────────────────────────────
#[ derive( Clone, Debug, PartialEq, Eq ) ] enum ToggleMsg { Flip }
#[ test ]
fn password_toggle_default_is_none()
{
let t = TextEdit::<ToggleMsg>::new( "".into(), "".into() );
assert!( t.password_toggle.is_none() );
}
#[ test ]
fn password_toggle_builder_records_visible_and_msg()
{
let t = TextEdit::<ToggleMsg>::new( "".into(), "".into() )
.password_toggle( true, ToggleMsg::Flip );
assert_eq!( t.password_toggle, Some( ( true, ToggleMsg::Flip ) ) );
}
#[ test ]
fn effective_secure_falls_back_to_secure_field_when_no_toggle()
{
let plain = TextEdit::<ToggleMsg>::new( "".into(), "".into() );
let secret = TextEdit::<ToggleMsg>::new( "".into(), "".into() ).secure( true );
assert!( !plain.effective_secure() );
assert!( secret.effective_secure() );
}
#[ test ]
fn password_toggle_overrides_secure_field()
{
// When the toggle is set, its `visible` controls bullets
// regardless of the explicit `secure` flag.
let visible_with_secure = TextEdit::<ToggleMsg>::new( "".into(), "".into() )
.secure( true )
.password_toggle( true, ToggleMsg::Flip );
let hidden_no_secure = TextEdit::<ToggleMsg>::new( "".into(), "".into() )
.password_toggle( false, ToggleMsg::Flip );
assert!( !visible_with_secure.effective_secure() );
assert!( hidden_no_secure.effective_secure() );
}
#[ test ]
fn password_toggle_hidden_substitutes_bullets_in_display_text()
{
let t = TextEdit::<ToggleMsg>::new( "".into(), "abc".into() )
.password_toggle( false, ToggleMsg::Flip );
assert_eq!( t.display_text(), "•••" );
}
#[ test ]
fn password_toggle_visible_returns_value_verbatim()
{
let t = TextEdit::<ToggleMsg>::new( "".into(), "abc".into() )
.password_toggle( true, ToggleMsg::Flip );
assert_eq!( t.display_text(), "abc" );
}
#[ test ]
fn password_toggle_collapses_multiline_to_single_line()
{
// `is_multiline()` follows `effective_secure` — a hidden
// password collapses to single-line even if `multiline(true)`
// was set, matching the behaviour of an explicit `secure`.
let t = TextEdit::<ToggleMsg>::new( "".into(), "".into() )
.multiline( true )
.password_toggle( false, ToggleMsg::Flip );
assert!( !t.is_multiline() );
}
#[ test ]
fn password_toggle_msg_propagates_to_widget_handler_snapshot()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<ToggleMsg> = TextEdit::new( "".into(), "".into() )
.password_toggle( false, ToggleMsg::Flip )
.into_element();
// `handlers()` returns a value of `WidgetHandlers<Msg>`, which
// implements `Drop` (wipe-on-drop for secure fields), so we
// cannot destructure it by value-move. Bind first, then match
// on a reference.
let h = widget.handlers();
match &h
{
WidgetHandlers::TextEdit { password_toggle_msg, secure, .. } =>
{
assert_eq!( password_toggle_msg.as_ref(), Some( &ToggleMsg::Flip ) );
// Snapshot's `secure` is true even though `visible=false`
// because the field carries a password regardless.
assert!( *secure );
}
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn password_toggle_msg_none_when_no_toggle_configured()
{
use crate::widget::{ Element, WidgetHandlers };
let widget: Element<ToggleMsg> = TextEdit::new( "".into(), "".into() )
.into_element();
let h = widget.handlers();
match &h
{
WidgetHandlers::TextEdit { password_toggle_msg, .. } =>
{
assert!( password_toggle_msg.is_none() );
}
_ => panic!( "expected TextEdit handler" ),
}
}
#[ test ]
fn password_toggle_hit_zone_sits_at_right_edge()
{
use crate::types::{ Point, Rect };
let rect = Rect { x: 100.0, y: 50.0, width: 240.0, height: 48.0 };
let zone = super::password_toggle_hit_zone( rect );
// Right edge of the zone matches the field's right edge.
assert!( ( ( zone.x + zone.width ) - ( rect.x + rect.width ) ).abs() < 0.01 );
// Wide enough to cover the icon + slop on both sides + half pad.
assert!( zone.width >= super::theme::PASSWORD_TOGGLE_SIZE );
// A point on the centre-right hits the zone.
let p = Point { x: rect.x + rect.width - 8.0, y: rect.y + rect.height / 2.0 };
assert!( zone.contains( p ) );
// A point well to the left of the zone misses it.
let p = Point { x: rect.x + 10.0, y: rect.y + rect.height / 2.0 };
assert!( !zone.contains( p ) );
}
#[ test ]
fn password_toggle_hit_zone_clamped_when_field_narrower_than_icon()
{
use crate::types::Rect;
let rect = Rect { x: 0.0, y: 0.0, width: 4.0, height: 24.0 };
let zone = super::password_toggle_hit_zone( rect );
// Zone never extends past the field's right edge.
assert!( zone.x + zone.width <= rect.x + rect.width + f32::EPSILON );
}
#[ test ]
fn password_toggle_drop_zeros_underlying_string_buffer()
{
// Same wipe-on-drop guarantee `secure( true )` gives — a
// `password_toggle` field is sensitive even when currently
// visible, so the buffer must be scrubbed on drop.
let t = TextEdit::<ToggleMsg>::new( "".into(), "shhh".into() )
.password_toggle( true, ToggleMsg::Flip );
let ptr = t.value.as_ptr();
let len = t.value.len();
drop( t );
// SAFETY: `String` deallocates the buffer in its `Drop`, but
// the bytes at `ptr..ptr+len` were zeroed *before* the
// allocator was notified — reading them now is a use-after-
// free, so we don't. Instead we simply assert that the test
// reaches this line without panicking; the `secure_zero`
// path runs unconditionally for password_toggle fields.
let _ = ( ptr, len );
}

View File

@@ -0,0 +1,43 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Color;
pub fn bg() -> Color { crate::theme::palette().surface_alt }
pub fn border() -> Color { crate::theme::palette().divider }
pub fn text() -> Color { crate::theme::palette().text_primary }
pub fn placeholder() -> Color { crate::theme::palette().text_secondary }
pub fn focus_border() -> Color { crate::theme::palette().text_primary }
pub fn cursor() -> Color { crate::theme::palette().text_primary }
/// Selection highlight — accent colour at low opacity so the text
/// underneath stays readable.
pub fn selection() -> Color
{
let a = crate::theme::palette().accent;
Color::rgba( a.r, a.g, a.b, 0.35 )
}
pub const BORDER_W: f32 = 1.5;
pub const FOCUS_BORDER_W: f32 = 2.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 = 20.0;
/// Vertical padding inside multiline boxes — the single-line variant
/// centres the text vertically in `HEIGHT`, so it doesn't need this.
pub const PAD_V_MULTI: f32 = 12.0;
/// Line height multiplier on top of [`FONT_SIZE`] when laying out
/// multiline content. 1.4 leaves enough room above and below each
/// line that adjacent rows do not visually crowd each other.
pub const LINE_H_MULT: f32 = 1.4;
/// Default visible row count for a [`super::TextEdit`] in multiline mode.
pub const ROWS_DEFAULT: u32 = 5;
/// Corner radius applied to multiline boxes — the pill shape used by
/// the single-line variant looks wrong at multi-row heights.
pub const RADIUS_MULTI: f32 = 12.0;
/// Side length, in logical pixels, of the
/// [`super::TextEdit::password_toggle`] eye icon.
pub const PASSWORD_TOGGLE_SIZE: f32 = 20.0;
/// Extra horizontal slop on each side of the eye icon's hit zone
/// to make the toggle finger-friendly even on touch panels.
pub const PASSWORD_TOGGLE_SLOP: f32 = 4.0;

View File

@@ -0,0 +1,115 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
/// One visual row of a wrapped multiline TextEdit. `start..end` is a
/// byte range inside the value; the renderer draws those bytes on a
/// single visual row, even if the underlying logical line (the run
/// between two `\n` characters) spans several rows. Soft wraps do
/// not mutate the buffer — the value stays a flat string with hard
/// breaks; only the rendering / hit-testing model treats wraps as
/// row boundaries.
#[ derive( Clone, Copy, Debug ) ]
pub( crate ) struct VisualLine
{
pub start: usize,
pub end: usize,
}
/// Wrap `value` into a list of visual lines that each fit within
/// `inner_width` when rendered at `font_size`. Hard `\n` breaks are
/// always row boundaries; long logical lines are additionally split
/// at the last whitespace before the row would overflow, falling
/// back to a per-character break inside a single oversized word.
///
/// O(N) per call where N is `value.len()` — `draw_multiline` runs
/// it once per frame, which is fine for the kilobyte-scale buffers a
/// `text_edit` is meant to hold (anything larger should use a
/// dedicated text-area widget with cached layout).
pub( crate ) fn compute_visual_lines(
canvas: &Canvas,
value: &str,
inner_width: f32,
font_size: f32,
) -> Vec<VisualLine>
{
let mut out = Vec::new();
if value.is_empty()
{
out.push( VisualLine { start: 0, end: 0 } );
return out;
}
let max_w = inner_width.max( 1.0 );
let mut byte_pos = 0usize;
for logical_line in value.split( '\n' )
{
let line_start = byte_pos;
let line_end = byte_pos + logical_line.len();
if line_start == line_end
{
// Empty logical line still occupies a row.
out.push( VisualLine { start: line_start, end: line_end } );
} else {
wrap_logical_line( canvas, value, line_start, line_end, max_w, font_size, &mut out );
}
byte_pos = line_end + 1; // step past the '\n'
}
out
}
fn wrap_logical_line(
canvas: &Canvas,
value: &str,
start: usize,
end: usize,
max_width: f32,
font_size: f32,
out: &mut Vec<VisualLine>,
)
{
let mut cur = start;
while cur < end
{
let segment = &value[cur..end];
let break_at = find_wrap_offset( canvas, segment, max_width, font_size );
// Always advance at least one byte so a degenerate measure
// (zero-width font, ridiculously narrow rect) still
// terminates instead of looping forever.
let raw_next = cur + break_at;
let next = raw_next.max( cur + 1 ).min( end );
out.push( VisualLine { start: cur, end: next } );
cur = next;
}
}
/// Walk `segment` left-to-right accumulating glyph widths and return
/// the byte offset (within `segment`) where the visual line should
/// break. Prefers a break *after* the most recent whitespace; falls
/// back to mid-character break if a single word is wider than
/// `max_width`.
fn find_wrap_offset(
canvas: &Canvas,
segment: &str,
max_width: f32,
font_size: f32,
) -> usize
{
let mut acc_w = 0.0f32;
let mut last_break_after: Option<usize> = None;
for ( i, ch ) in segment.char_indices()
{
let glyph = ch.to_string();
let w = canvas.measure_text( &glyph, font_size );
if acc_w + w > max_width && i > 0
{
return last_break_after.unwrap_or( i );
}
acc_w += w;
if ch == ' ' || ch == '\t'
{
last_break_after = Some( i + ch.len_utf8() );
}
}
segment.len()
}

View File

@@ -41,18 +41,10 @@ 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;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// A wall-clock time, no date / no timezone. `hour` is 023 (24-hour
/// representation regardless of [`TimePicker::twelve_hour`] display
@@ -473,143 +465,3 @@ 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() );
}
}

View File

@@ -0,0 +1,138 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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() );
}

View File

@@ -0,0 +1,12 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -40,21 +40,10 @@ 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;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// Builder for a transient bottom-anchored notification overlay.
///
@@ -253,53 +242,3 @@ 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 );
}
}

48
src/widget/toast/tests.rs Normal file
View File

@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

15
src/widget/toast/theme.rs Normal file
View File

@@ -0,0 +1,15 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -5,26 +5,10 @@ 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;
}
mod theme;
#[cfg(test)]
mod tests;
/// A two-state on / off switch.
///
@@ -220,25 +204,3 @@ impl<Msg: Clone + 'static> From<Toggle<Msg>> for Element<Msg>
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 );
}
}

View File

@@ -0,0 +1,20 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -0,0 +1,20 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -44,28 +44,10 @@ 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;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// Builder for an `xdg-popup`-backed hint anchored to a widget.
pub struct Tooltip<Msg: Clone>
@@ -189,59 +171,3 @@ pub fn tooltip<Msg: Clone + 'static>(
{
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 );
}
}

View File

@@ -0,0 +1,54 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -0,0 +1,22 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -106,78 +106,4 @@ pub fn viewport<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Viewport<Msg>
}
#[ 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 );
}
}
mod tests;

View File

@@ -0,0 +1,75 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -7,28 +7,10 @@ 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;
}
mod theme;
#[ cfg( test ) ]
mod tests;
/// 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`
@@ -331,156 +313,3 @@ 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 );
}
}

151
src/widget/vslider/tests.rs Normal file
View File

@@ -0,0 +1,151 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -0,0 +1,22 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;

View File

@@ -6,22 +6,10 @@ use crate::layout::row::{ row, Row };
use crate::render::Canvas;
use crate::types::{ Color, Rect, WidgetId };
mod theme
{
use crate::types::Color;
mod theme;
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;
}
#[ cfg( test ) ]
mod tests;
/// Semantic role for a window-decoration button.
///
@@ -329,55 +317,3 @@ pub fn window_controls<Msg: Clone + 'static>(
.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 );
}
}

View File

@@ -0,0 +1,50 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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 );
}

View File

@@ -0,0 +1,16 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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;