// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! 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 { //! 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 crate::types::Length; 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 } } /// 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 { 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 ) } /// 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` 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 { pub( crate ) value: Time, pub( crate ) on_change: Option Msg>>, pub( crate ) minute_step: u8, pub( crate ) second_step: u8, pub( crate ) seconds: bool, pub( crate ) twelve_hour: bool, } impl TimePicker { /// 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 { 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 { 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::( tinted, w, h ).icon_size( ARROW_PX as f32 ) } None => button::( 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, up_secs: i32, down_secs: i32 | -> crate::layout::column::Column { let mut col = column::() .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 Option