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