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:
@@ -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 );
|
||||
}
|
||||
}
|
||||
24
src/widget/anchored_overlay/tests.rs
Normal file
24
src/widget/anchored_overlay/tests.rs
Normal 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 );
|
||||
}
|
||||
@@ -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
101
src/widget/button/tests.rs
Normal 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 );
|
||||
}
|
||||
48
src/widget/button/theme.rs
Normal file
48
src/widget/button/theme.rs
Normal 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;
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
19
src/widget/checkbox/tests.rs
Normal file
19
src/widget/checkbox/tests.rs
Normal 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 );
|
||||
}
|
||||
19
src/widget/checkbox/theme.rs
Normal file
19
src/widget/checkbox/theme.rs
Normal 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;
|
||||
@@ -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})" );
|
||||
}
|
||||
}
|
||||
}
|
||||
159
src/widget/color_picker/tests.rs
Normal file
159
src/widget/color_picker/tests.rs
Normal 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})" );
|
||||
}
|
||||
}
|
||||
12
src/widget/color_picker/theme.rs
Normal file
12
src/widget/color_picker/theme.rs
Normal 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;
|
||||
@@ -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
168
src/widget/combo/tests.rs
Normal 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() );
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
114
src/widget/container/tests.rs
Normal file
114
src/widget/container/tests.rs
Normal 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 );
|
||||
}
|
||||
@@ -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 1–12, `day` is 1–31 (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();
|
||||
}
|
||||
}
|
||||
147
src/widget/date_picker/tests.rs
Normal file
147
src/widget/date_picker/tests.rs
Normal 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();
|
||||
}
|
||||
16
src/widget/date_picker/theme.rs
Normal file
16
src/widget/date_picker/theme.rs
Normal 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;
|
||||
@@ -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
119
src/widget/dialog/tests.rs
Normal 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
456
src/widget/element.rs
Normal 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 )
|
||||
}
|
||||
}
|
||||
@@ -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
35
src/widget/external/tests.rs
vendored
Normal 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
52
src/widget/factory.rs
Normal 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 )
|
||||
}
|
||||
@@ -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
40
src/widget/flex/tests.rs
Normal 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
309
src/widget/handlers.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
107
src/widget/image/mod.rs
Normal 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
111
src/widget/image/tests.rs
Normal 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
55
src/widget/laid_out.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
14
src/widget/list_item/tests.rs
Normal file
14
src/widget/list_item/tests.rs
Normal 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() );
|
||||
}
|
||||
33
src/widget/list_item/theme.rs
Normal file
33
src/widget/list_item/theme.rs
Normal 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;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
68
src/widget/notebook/tests.rs
Normal file
68
src/widget/notebook/tests.rs
Normal 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();
|
||||
}
|
||||
4
src/widget/notebook/theme.rs
Normal file
4
src/widget/notebook/theme.rs
Normal 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;
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
98
src/widget/pressable/tests.rs
Normal file
98
src/widget/pressable/tests.rs
Normal 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 ) );
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
21
src/widget/progress_bar/tests.rs
Normal file
21
src/widget/progress_bar/tests.rs
Normal 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 );
|
||||
}
|
||||
8
src/widget/progress_bar/theme.rs
Normal file
8
src/widget/progress_bar/theme.rs
Normal 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;
|
||||
@@ -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
19
src/widget/radio/tests.rs
Normal 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
18
src/widget/radio/theme.rs
Normal 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;
|
||||
@@ -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.
|
||||
50
src/widget/scroll/tests.rs
Normal file
50
src/widget/scroll/tests.rs
Normal 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 );
|
||||
}
|
||||
@@ -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;
|
||||
19
src/widget/separator/tests.rs
Normal file
19
src/widget/separator/tests.rs
Normal 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 );
|
||||
}
|
||||
7
src/widget/separator/theme.rs
Normal file
7
src/widget/separator/theme.rs
Normal 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;
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
91
src/widget/slider/tests.rs
Normal file
91
src/widget/slider/tests.rs
Normal 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() );
|
||||
}
|
||||
28
src/widget/slider/theme.rs
Normal file
28
src/widget/slider/theme.rs
Normal 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";
|
||||
@@ -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;
|
||||
38
src/widget/spinner/tests.rs
Normal file
38
src/widget/spinner/tests.rs
Normal 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 );
|
||||
}
|
||||
20
src/widget/spinner/theme.rs
Normal file
20
src/widget/spinner/theme.rs
Normal 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;
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
38
src/widget/tab_bar/tests.rs
Normal file
38
src/widget/tab_bar/tests.rs
Normal 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() );
|
||||
}
|
||||
6
src/widget/tab_bar/theme.rs
Normal file
6
src/widget/tab_bar/theme.rs
Normal 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;
|
||||
@@ -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
96
src/widget/text/tests.rs
Normal 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
108
src/widget/text_edit/cursor.rs
Normal file
108
src/widget/text_edit/cursor.rs
Normal 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() )
|
||||
}
|
||||
397
src/widget/text_edit/draw.rs
Normal file
397
src/widget/text_edit/draw.rs
Normal 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/widget/text_edit/hit_test.rs
Normal file
203
src/widget/text_edit/hit_test.rs
Normal 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
513
src/widget/text_edit/mod.rs
Normal 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
702
src/widget/text_edit/tests.rs
Normal file
702
src/widget/text_edit/tests.rs
Normal 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 );
|
||||
}
|
||||
43
src/widget/text_edit/theme.rs
Normal file
43
src/widget/text_edit/theme.rs
Normal 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;
|
||||
115
src/widget/text_edit/wrapping.rs
Normal file
115
src/widget/text_edit/wrapping.rs
Normal 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()
|
||||
}
|
||||
@@ -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 0–23 (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() );
|
||||
}
|
||||
}
|
||||
138
src/widget/time_picker/tests.rs
Normal file
138
src/widget/time_picker/tests.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use 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() );
|
||||
}
|
||||
12
src/widget/time_picker/theme.rs
Normal file
12
src/widget/time_picker/theme.rs
Normal 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;
|
||||
@@ -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
48
src/widget/toast/tests.rs
Normal 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
15
src/widget/toast/theme.rs
Normal 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;
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
20
src/widget/toggle/tests.rs
Normal file
20
src/widget/toggle/tests.rs
Normal 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 );
|
||||
}
|
||||
20
src/widget/toggle/theme.rs
Normal file
20
src/widget/toggle/theme.rs
Normal 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;
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
54
src/widget/tooltip/tests.rs
Normal file
54
src/widget/tooltip/tests.rs
Normal 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 );
|
||||
}
|
||||
22
src/widget/tooltip/theme.rs
Normal file
22
src/widget/tooltip/theme.rs
Normal 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;
|
||||
@@ -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;
|
||||
75
src/widget/viewport/tests.rs
Normal file
75
src/widget/viewport/tests.rs
Normal 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 );
|
||||
}
|
||||
@@ -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
151
src/widget/vslider/tests.rs
Normal 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 );
|
||||
}
|
||||
22
src/widget/vslider/theme.rs
Normal file
22
src/widget/vslider/theme.rs
Normal 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;
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
50
src/widget/window_button/tests.rs
Normal file
50
src/widget/window_button/tests.rs
Normal 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 );
|
||||
}
|
||||
16
src/widget/window_button/theme.rs
Normal file
16
src/widget/window_button/theme.rs
Normal 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;
|
||||
Reference in New Issue
Block a user