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 )
}