ltk/widget/time_picker/mod.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 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 )
}