use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::undo::{CommandDescription, CommandId, UndoCommand, UndoStack};
use crate::widget::capability::access::time_to_string;
use crate::widget::capability::coercion::{expect_bool, expect_string, expect_time};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::dimensions;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TIME_EDIT_COMMAND_ID: AtomicU64 = AtomicU64::new(1);
struct TimeEditCommand {
id: CommandId,
target: Rc<RefCell<Time>>,
before: Time,
after: Time,
}
impl TimeEditCommand {
fn new(target: Rc<RefCell<Time>>, before: Time, after: Time) -> Self {
Self {
id: CommandId(NEXT_TIME_EDIT_COMMAND_ID.fetch_add(1, Ordering::Relaxed)),
target,
before,
after,
}
}
}
impl UndoCommand for TimeEditCommand {
fn id(&self) -> CommandId {
self.id
}
fn description(&self) -> CommandDescription {
CommandDescription {
text: "Edit time".to_string(),
timestamp_ms: 0,
command_type: "time_edit",
}
}
fn execute(&mut self) -> Result<(), String> {
*self.target.borrow_mut() = self.after;
Ok(())
}
fn undo(&mut self) -> Result<(), String> {
*self.target.borrow_mut() = self.before;
Ok(())
}
}
const CLOCK_DIAMETER: u32 = 160;
const CLOCK_GAP: i32 = 4;
const HOUR_HOLE: f32 = 0.22;
const MINUTE_RING_INNER: f32 = 0.62;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Time {
hour: u8, minute: u8, second: u8, msec: u16, }
impl Time {
pub fn new(hour: u8, minute: u8, second: u8, msec: u16) -> Self {
Self {
hour: hour.min(23),
minute: minute.min(59),
second: second.min(59),
msec: msec.min(999),
}
}
pub fn hour(&self) -> u8 {
self.hour
}
pub fn minute(&self) -> u8 {
self.minute
}
pub fn second(&self) -> u8 {
self.second
}
pub fn msec(&self) -> u16 {
self.msec
}
pub fn set_hour(&mut self, hour: u8) {
self.hour = hour.min(23);
}
pub fn set_minute(&mut self, minute: u8) {
self.minute = minute.min(59);
}
pub fn set_second(&mut self, second: u8) {
self.second = second.min(59);
}
pub fn set_msec(&mut self, msec: u16) {
self.msec = msec.min(999);
}
pub fn is_valid(&self) -> bool {
self.hour <= 23 && self.minute <= 59 && self.second <= 59 && self.msec <= 999
}
pub fn to_msecs_since_midnight(&self) -> u32 {
(self.hour as u32 * 3600 + self.minute as u32 * 60 + self.second as u32) * 1000
+ self.msec as u32
}
}
impl std::fmt::Display for Time {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
}
}
pub struct TimeEdit {
base: BaseWidget,
time: Time,
minimum: Time,
maximum: Time,
display_format: String,
clock_popup: bool,
hovered_hand: Option<ClockHand>,
pub popup_visibility_changed: Signal1<bool>,
pub time_changed: Signal1<Time>,
undo_stack: UndoStack,
history_target: Rc<RefCell<Time>>,
restoring_history: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClockHand {
Hour,
Minute,
}
impl TimeEdit {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::TimePicker, geometry, "TimeEdit"),
time: Time::new(0, 0, 0, 0),
minimum: Time::new(0, 0, 0, 0),
maximum: Time::new(23, 59, 59, 999),
display_format: "HH:mm:ss".to_string(),
clock_popup: false,
hovered_hand: None,
popup_visibility_changed: Signal1::new(),
time_changed: Signal1::new(),
undo_stack: UndoStack::new(),
history_target: Rc::new(RefCell::new(Time::new(0, 0, 0, 0))),
restoring_history: false,
}
}
pub fn time(&self) -> Time {
self.time
}
pub fn clock_popup(&self) -> bool {
self.clock_popup
}
pub fn set_clock_popup(&mut self, popup: bool) {
if self.clock_popup == popup {
return;
}
self.clock_popup = popup;
if !popup {
self.hovered_hand = None;
}
self.popup_visibility_changed.emit(popup);
self.base.request_redraw();
}
pub fn toggle_clock_popup(&mut self) {
self.set_clock_popup(!self.clock_popup);
}
fn clock_rect(&self) -> Rect {
let field = self.geometry();
let diameter = CLOCK_DIAMETER.min(field.width);
Rect::new(
field.x + (field.width.saturating_sub(diameter)) as i32 / 2,
field.y + field.height as i32 + CLOCK_GAP,
diameter,
diameter,
)
}
fn clock_centre(&self) -> Point {
let face = self.clock_rect();
Point::new(face.x + face.width as i32 / 2, face.y + face.height as i32 / 2)
}
fn hand_at_point(&self, point: Point) -> Option<ClockHand> {
if !self.clock_popup {
return None;
}
let face = self.clock_rect();
if !face.contains_point(point) {
return None;
}
let centre = self.clock_centre();
let dx = (point.x - centre.x) as f32;
let dy = (point.y - centre.y) as f32;
let distance = (dx * dx + dy * dy).sqrt();
let radius = face.width as f32 / 2.0;
if distance > radius || distance < radius * HOUR_HOLE {
return None;
}
if distance >= radius * MINUTE_RING_INNER {
Some(ClockHand::Hour)
} else if distance >= radius * HOUR_HOLE {
Some(ClockHand::Minute)
} else {
None
}
}
fn value_at_point(&self, point: Point, hand: ClockHand) -> Option<u8> {
let centre = self.clock_centre();
let dx = (point.x - centre.x) as f32;
let dy = (point.y - centre.y) as f32;
if dx == 0.0 && dy == 0.0 {
return None;
}
let clockwise_from_top = dy.atan2(dx);
let fraction = match hand {
ClockHand::Hour => {
let turns = (clockwise_from_top + std::f32::consts::FRAC_PI_2)
/ (2.0 * std::f32::consts::PI);
turns.rem_euclid(1.0)
}
ClockHand::Minute => {
let turns = (clockwise_from_top + std::f32::consts::FRAC_PI_2)
/ (2.0 * std::f32::consts::PI);
turns.rem_euclid(1.0)
}
};
let value = match hand {
ClockHand::Hour => {
let face_hour = (fraction * 12.0).round() as u8;
let is_pm = self.time.hour() >= 12;
match (face_hour % 12, is_pm) {
(0, false) => 0,
(0, true) => 12,
(h, false) => h,
(h, true) => h + 12,
}
}
ClockHand::Minute => {
let step = (fraction * 12.0).round() as u8;
(step % 12) * 5
}
};
Some(value)
}
fn pick_at(&mut self, point: Point) -> bool {
let Some(hand) = self.hand_at_point(point) else {
return false;
};
let Some(value) = self.value_at_point(point, hand) else {
return false;
};
let mut next = self.time;
match hand {
ClockHand::Hour => next.set_hour(value),
ClockHand::Minute => next.set_minute(value),
}
let before = self.time;
self.set_time(next);
self.time != before
}
pub fn minimum_time(&self) -> Time {
self.minimum
}
pub fn maximum_time(&self) -> Time {
self.maximum
}
pub fn display_format(&self) -> &str {
&self.display_format
}
pub fn set_time(&mut self, time: Time) {
if time.is_valid() && time >= self.minimum && time <= self.maximum && self.time != time {
let before = self.time;
self.time = time;
if !self.restoring_history {
*self.history_target.borrow_mut() = self.time;
self.undo_stack.push(Box::new(TimeEditCommand::new(
self.history_target.clone(),
before,
self.time,
)));
}
self.time_changed.emit(time);
self.base.request_redraw();
}
}
pub fn set_minimum_time(&mut self, time: Time) {
self.minimum = time;
self.clamp_to_range();
self.base.request_redraw();
}
pub fn set_maximum_time(&mut self, time: Time) {
self.maximum = time;
self.clamp_to_range();
self.base.request_redraw();
}
pub fn set_time_range(&mut self, min: Time, max: Time) {
self.minimum = min;
self.maximum = max;
self.clamp_to_range();
self.base.request_redraw();
}
fn clamp_to_range(&mut self) {
self.time = super::clamp_ordered_range(self.time, self.minimum, self.maximum);
}
pub fn set_display_format(&mut self, fmt: String) {
self.display_format = fmt;
self.base.request_redraw();
}
pub fn step_up(&mut self) {
let mut t = self.time;
let new_sec = t.second() + 1;
if new_sec >= 60 {
t.second = 0;
let new_min = t.minute() + 1;
if new_min >= 60 {
t.minute = 0;
if t.hour() < 23 {
t.hour = t.hour() + 1;
}
} else {
t.minute = new_min;
}
} else {
t.second = new_sec;
}
self.set_time(t);
}
pub fn step_down(&mut self) {
let mut t = self.time;
if t.second() > 0 {
t.set_second(t.second() - 1);
} else {
t.set_second(59);
if t.minute() > 0 {
t.set_minute(t.minute() - 1);
} else {
t.set_minute(59);
if t.hour() > 0 {
t.set_hour(t.hour() - 1);
}
}
}
self.set_time(t);
}
pub fn undo(&mut self) -> bool {
if self.undo_stack.undo().is_err() {
return false;
}
self.restore_history_time();
true
}
pub fn redo(&mut self) -> bool {
if self.undo_stack.redo().is_err() {
return false;
}
self.restore_history_time();
true
}
pub fn can_undo(&self) -> bool {
self.undo_stack.can_undo()
}
pub fn can_redo(&self) -> bool {
self.undo_stack.can_redo()
}
fn restore_history_time(&mut self) {
self.restoring_history = true;
self.time = *self.history_target.borrow();
self.restoring_history = false;
self.time_changed.emit(self.time);
self.base.request_redraw();
}
}
impl Widget for TimeEdit {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
let font = crate::core::Font::default();
let line_height = crate::widget::metrics::estimate_line_height(&font, 1.0);
let sample = super::date_edit::format_with_pattern(
&self.display_format,
super::date_edit::DateTimeComponents {
hour: Some(23),
minute: Some(59),
second: Some(59),
millisecond: Some(999),
..Default::default()
},
)
.unwrap_or_else(|| Time::new(23, 59, 59, 999).to_string());
let content = crate::widget::metrics::estimate_text_width(&sample, &font, 1.0);
let vertical_padding = dimensions::TEXT_FIELD_MIN_HEIGHT.saturating_sub(line_height) / 2;
let padding = crate::style::EdgeOffsets::symmetric(
vertical_padding,
dimensions::TEXT_FIELD_PADDING_H,
);
let floor = Size::new(
dimensions::TEXT_FIELD_PADDING_H * 2 + dimensions::BUTTON_ICON_SIZE,
dimensions::TEXT_FIELD_MIN_HEIGHT,
);
let hint = crate::widget::metrics::ControlMetrics::implicit_size(
Size::new(content, line_height),
padding,
floor,
);
Size::new(hint.width, hint.height.max(line_height))
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for TimeEdit {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"time" => Ok(CapabilityValue::String(time_to_string(self.time()))),
"minimum_time" => Ok(CapabilityValue::String(time_to_string(self.minimum_time()))),
"maximum_time" => Ok(CapabilityValue::String(time_to_string(self.maximum_time()))),
"display_format" => Ok(CapabilityValue::String(self.display_format().to_string())),
"clock_popup" => Ok(CapabilityValue::Bool(self.clock_popup())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"time" => {
self.set_time(expect_time(value)?);
Ok(())
}
"minimum_time" => {
self.set_minimum_time(expect_time(value)?);
Ok(())
}
"maximum_time" => {
self.set_maximum_time(expect_time(value)?);
Ok(())
}
"display_format" => {
self.set_display_format(expect_string(value)?);
Ok(())
}
"clock_popup" => {
self.set_clock_popup(expect_bool(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"time",
"minimum_time",
"maximum_time",
"display_format",
"clock_popup",
BASE_PROPERTY_NAMES
]
}
}
impl EventHandler for TimeEdit {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button } if *button == 1 => {
if self.clock_popup {
if self.pick_at(*pos) {
return;
}
if self.clock_rect().contains_point(*pos) {
return;
}
}
if self.geometry().contains_point(*pos) {
self.toggle_clock_popup();
} else if self.clock_popup {
self.set_clock_popup(false);
}
}
Event::MouseMove { pos } => {
if self.clock_popup {
let hovered = self.hand_at_point(*pos);
if hovered != self.hovered_hand {
self.hovered_hand = hovered;
self.base.request_redraw();
}
}
}
#[cfg(feature = "touch")]
Event::Tap { pos } => {
if self.clock_popup && self.pick_at(*pos) {
return;
}
if self.geometry().contains_point(*pos) {
self.toggle_clock_popup();
} else if self.clock_popup {
self.set_clock_popup(false);
}
}
Event::KeyPress { key, modifiers } => match *key {
90 if *modifiers == 2 => {
let _ = self.undo();
}
89 if *modifiers == 2 => {
let _ = self.redo();
}
13 | 32 if !self.clock_popup => self.set_clock_popup(true),
27 if self.clock_popup => self.set_clock_popup(false),
38 => self.step_up(),
40 => self.step_down(),
_ => { }
},
_ => { }
}
}
}
impl Draw for TimeEdit {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("time_edit");
let (window_fill, foreground) = {
let manager = crate::style::theme_manager();
match manager.current_theme() {
Some(active) => (active.colors.background, active.colors.foreground),
None => (Color::rgb(240, 240, 240), Color::BLACK),
}
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(foreground);
let field = window_fill.blend(&ink, 0.08);
let surface = match style.background_color {
Some(resolved) if resolved != window_fill => resolved,
_ => field,
};
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != surface)
.unwrap_or_else(|| surface.blend(&ink, 0.35));
context.face(
rect,
surface,
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
context.draw_rect(rect, border);
let text = super::date_edit::format_with_pattern(
&self.display_format,
super::date_edit::DateTimeComponents {
hour: Some(self.time.hour()),
minute: Some(self.time.minute()),
second: Some(self.time.second()),
millisecond: Some(self.time.msec()),
..Default::default()
},
)
.unwrap_or_else(|| self.time.to_string());
let font = Font::default();
let line = context.text_line(rect, &font);
context.draw_text_fitted(
Rect {
x: rect.x + 6,
y: line.y,
width: rect.width.saturating_sub(12),
height: line.height,
},
&text,
&font,
ink,
HorizontalAlignment::Left,
);
if self.clock_popup {
self.draw_clock_face(context, surface, border, ink);
}
}
}
impl TimeEdit {
fn draw_clock_face(
&self,
context: &mut RenderContext,
surface: Color,
border: Color,
ink: Color,
) {
let face = self.clock_rect();
if face.width < CLOCK_DIAMETER / 2 {
return;
}
let plate = surface.blend(&ink, 0.10);
context.fill_rect(face, plate);
context.draw_rect(face, border);
let centre = self.clock_centre();
let radius = face.width as f32 / 2.0;
let font = Font::default();
let muted = plate.blend(&ink, 0.45);
context.draw_circle(centre, (radius * MINUTE_RING_INNER) as u32, border);
context.draw_circle(centre, (radius * HOUR_HOLE) as u32, border);
for step in 0..12 {
let angle = step as f32 / 12.0 * 2.0 * std::f32::consts::PI;
let (sin, cos) = angle.sin_cos();
let hour = if step == 0 { 12 } else { step };
self.draw_clock_numerals(
context,
centre,
radius * 0.82,
sin,
cos,
&hour.to_string(),
if self.hovered_hand == Some(ClockHand::Hour) { ink } else { muted },
&font,
);
let minute = step * 5;
self.draw_clock_numerals(
context,
centre,
radius * 0.42,
sin,
cos,
&format!("{minute:02}"),
if self.hovered_hand == Some(ClockHand::Minute) { ink } else { muted },
&font,
);
}
let hour_angle = (self.time.hour() % 12) as f32 / 12.0 * 2.0 * std::f32::consts::PI;
let (hour_sin, hour_cos) = hour_angle.sin_cos();
context.draw_line(
centre,
Point::new(
centre.x + (hour_sin * radius * 0.55) as i32,
centre.y - (hour_cos * radius * 0.55) as i32,
),
ink,
);
let minute_angle = self.time.minute() as f32 / 60.0 * 2.0 * std::f32::consts::PI;
let (minute_sin, minute_cos) = minute_angle.sin_cos();
context.draw_line(
centre,
Point::new(
centre.x + (minute_sin * radius * 0.78) as i32,
centre.y - (minute_cos * radius * 0.78) as i32,
),
ink,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_clock_numerals(
&self,
context: &mut RenderContext,
centre: Point,
distance: f32,
sin: f32,
cos: f32,
label: &str,
color: Color,
font: &Font,
) {
let metrics = context.measure_text(label, font);
let x = centre.x + (sin * distance) as i32 - metrics.width as i32 / 2;
let y = centre.y - (cos * distance) as i32 - metrics.height as i32 / 2;
context.draw_text(Point::new(x, y), label, font, color, HorizontalAlignment::Left);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Point;
use crate::widget::svg::render_to_svg;
use std::sync::{Arc, Mutex};
#[test]
fn time_creation_and_accessors() {
let t = Time::new(10, 30, 45, 500);
assert_eq!(t.hour(), 10);
assert_eq!(t.minute(), 30);
assert_eq!(t.second(), 45);
assert_eq!(t.msec(), 500);
}
#[test]
fn time_clamps_invalid_values() {
let t = Time::new(25, 70, 99, 2000);
assert_eq!(t.hour(), 23);
assert_eq!(t.minute(), 59);
assert_eq!(t.second(), 59);
assert_eq!(t.msec(), 999);
}
#[test]
fn time_is_valid_true_for_all_public_api_constructs() {
assert!(Time::new(12, 0, 0, 0).is_valid());
assert!(Time::new(23, 59, 59, 999).is_valid());
assert!(Time::new(0, 0, 0, 0).is_valid());
}
#[test]
fn time_setters_clamp_values() {
let mut t = Time::new(0, 0, 0, 0);
t.set_hour(24);
assert_eq!(t.hour(), 23);
t.set_minute(60);
assert_eq!(t.minute(), 59);
t.set_second(60);
assert_eq!(t.second(), 59);
t.set_msec(1000);
assert_eq!(t.msec(), 999);
}
#[test]
fn time_to_msecs_since_midnight() {
let t = Time::new(1, 30, 15, 250);
let expected = (3600 + 30 * 60 + 15) * 1000 + 250;
assert_eq!(t.to_msecs_since_midnight(), expected);
}
#[test]
fn time_midnight_is_zero_msecs() {
let t = Time::new(0, 0, 0, 0);
assert_eq!(t.to_msecs_since_midnight(), 0);
}
#[test]
fn time_display_format() {
let t = Time::new(9, 5, 3, 0);
assert_eq!(t.to_string(), "09:05:03");
let t2 = Time::new(23, 59, 59, 999);
assert_eq!(t2.to_string(), "23:59:59");
}
#[test]
fn time_ordering() {
let early = Time::new(8, 0, 0, 0);
let late = Time::new(9, 0, 0, 0);
assert!(early < late);
assert!(late > early);
assert_eq!(early, Time::new(8, 0, 0, 0));
}
#[test]
fn time_edit_creation_defaults() {
let editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
assert_eq!(editor.time(), Time::new(0, 0, 0, 0));
assert_eq!(editor.display_format(), "HH:mm:ss");
assert_eq!(editor.minimum_time(), Time::new(0, 0, 0, 0));
assert_eq!(editor.maximum_time(), Time::new(23, 59, 59, 999));
assert!(editor.is_visible());
assert!(editor.is_enabled());
}
#[test]
fn time_edit_set_and_get_time() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
let t = Time::new(14, 30, 0, 0);
editor.set_time(t);
assert_eq!(editor.time(), t);
}
#[test]
fn time_edit_set_time_clamps_to_minimum() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_minimum_time(Time::new(10, 0, 0, 0));
assert_eq!(editor.time(), Time::new(10, 0, 0, 0), "setting the minimum clamps up to it");
editor.set_time(Time::new(5, 0, 0, 0));
assert_eq!(editor.time(), Time::new(10, 0, 0, 0), "5:00 < 10:00, so the write is refused");
editor.set_time(Time::new(11, 0, 0, 0));
assert_eq!(editor.time(), Time::new(11, 0, 0, 0));
}
#[test]
fn time_edit_set_time_clamps_to_maximum() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(12, 0, 0, 0));
assert_eq!(editor.time(), Time::new(12, 0, 0, 0));
editor.set_maximum_time(Time::new(9, 0, 0, 0));
assert_eq!(editor.time(), Time::new(9, 0, 0, 0), "lowering the maximum clamps down to it");
editor.set_time(Time::new(18, 0, 0, 0));
assert_eq!(editor.time(), Time::new(9, 0, 0, 0), "18:00 > 09:00, so the write is refused");
editor.set_time(Time::new(6, 0, 0, 0));
assert_eq!(editor.time(), Time::new(6, 0, 0, 0));
}
#[test]
fn time_edit_bound_setters_keep_the_value_in_range() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(12, 0, 0, 0));
editor.set_minimum_time(Time::new(15, 0, 0, 0));
assert_eq!(editor.time(), Time::new(15, 0, 0, 0), "raised minimum pulls the value up");
editor.set_maximum_time(Time::new(9, 0, 0, 0));
assert_eq!(
editor.time(),
Time::new(15, 0, 0, 0),
"with min > max the minimum wins, so the editor is never wedged"
);
}
#[test]
fn time_edit_set_time_range() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
let min = Time::new(6, 0, 0, 0);
let max = Time::new(22, 0, 0, 0);
editor.set_time_range(min, max);
assert_eq!(editor.minimum_time(), min);
assert_eq!(editor.maximum_time(), max);
}
#[test]
fn time_edit_set_display_format() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_display_format("HH:mm".to_string());
assert_eq!(editor.display_format(), "HH:mm");
}
#[test]
fn time_edit_step_up_increments_second() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 30, 15, 0));
editor.step_up();
assert_eq!(editor.time(), Time::new(10, 30, 16, 0));
}
#[test]
fn time_edit_step_up_rolls_minute() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 30, 59, 0));
editor.step_up();
assert_eq!(editor.time(), Time::new(10, 31, 0, 0));
}
#[test]
fn time_edit_step_up_rolls_hour() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 59, 59, 0));
editor.step_up();
assert_eq!(editor.time(), Time::new(11, 0, 0, 0));
}
#[test]
fn time_edit_step_up_from_23_59_59_wraps_minutes_and_seconds() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(23, 59, 59, 0));
editor.step_up();
assert_eq!(editor.time(), Time::new(23, 0, 0, 0));
}
#[test]
fn time_edit_step_down_decrements_second() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 30, 15, 0));
editor.step_down();
assert_eq!(editor.time(), Time::new(10, 30, 14, 0));
}
#[test]
fn time_edit_step_down_rolls_minute() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 30, 0, 0));
editor.step_down();
assert_eq!(editor.time(), Time::new(10, 29, 59, 0));
}
#[test]
fn time_edit_step_down_rolls_hour() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 0, 0, 0));
editor.step_down();
assert_eq!(editor.time(), Time::new(9, 59, 59, 0));
}
#[test]
fn time_edit_step_down_stops_at_0() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(0, 0, 0, 0));
editor.step_down();
assert_eq!(editor.time(), Time::new(0, 59, 59, 0));
}
#[test]
fn time_edit_undo_redo_restores_time() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 0, 0, 0));
editor.step_up();
assert!(editor.can_undo());
assert!(editor.undo());
assert_eq!(editor.time(), Time::new(10, 0, 0, 0));
assert!(editor.can_redo());
assert!(editor.redo());
assert_eq!(editor.time(), Time::new(10, 0, 1, 0));
}
#[test]
fn time_edit_keyboard_shortcuts_drive_history() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 0, 0, 0));
editor.set_time(Time::new(10, 0, 1, 0));
editor.handle_event(&Event::KeyPress { key: 90, modifiers: 2 });
assert_eq!(editor.time(), Time::new(10, 0, 0, 0));
editor.handle_event(&Event::KeyPress { key: 89, modifiers: 2 });
assert_eq!(editor.time(), Time::new(10, 0, 1, 0));
}
#[test]
fn time_edit_time_changed_signal_emits() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
let captured = Arc::new(Mutex::new(Time::new(0, 0, 0, 0)));
let sink = captured.clone();
editor.time_changed.connect(move |t| {
if let Ok(mut c) = sink.lock() {
*c = *t;
}
});
let expected = Time::new(18, 30, 0, 0);
editor.set_time(expected);
let got = *captured.lock().unwrap();
assert_eq!(got, expected);
}
#[test]
fn time_edit_time_changed_not_emitted_for_same_time() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
let hits = Arc::new(Mutex::new(0usize));
let hits_clone = hits.clone();
editor.time_changed.connect(move |_| {
if let Ok(mut h) = hits_clone.lock() {
*h += 1;
}
});
editor.set_time(Time::new(0, 0, 0, 0)); assert_eq!(*hits.lock().unwrap(), 0);
}
#[test]
fn time_edit_geometry_delegation() {
let rect = Rect::new(10, 20, 200, 30);
let editor = TimeEdit::new(rect);
assert_eq!(editor.geometry(), rect);
assert_eq!(editor.position(), Point::new(10, 20));
assert_eq!(editor.size(), crate::core::Size::new(200, 30));
}
#[test]
fn time_edit_widget_kind() {
let editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
assert_eq!(editor.kind(), WidgetKind::TimePicker);
}
#[test]
fn time_edit_ids_are_unique() {
let a = TimeEdit::new(Rect::new(0, 0, 200, 30));
let b = TimeEdit::new(Rect::new(0, 0, 200, 30));
assert_ne!(a.id(), b.id());
}
#[test]
fn time_edit_svg_output() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(12, 30, 45, 0));
let svg = render_to_svg(&mut editor);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("width=\"200\""));
assert!(svg.contains("height=\"30\""));
assert!(svg.contains("12:30:45") || svg.contains("fill="));
}
#[test]
fn time_edit_disabled_state_blocks_changes() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 0, 0, 0));
editor.set_enabled(false);
assert!(!editor.is_enabled());
editor.step_up();
editor.set_time(Time::new(10, 0, 0, 0)); editor.handle_event(&Event::KeyPress { key: 38, modifiers: 0 });
assert_eq!(editor.time(), Time::new(10, 0, 0, 0));
}
#[test]
fn time_edit_keyboard_increment_and_decrement() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 0, 0, 0));
editor.handle_event(&Event::KeyPress { key: 38, modifiers: 0 });
assert_eq!(editor.time(), Time::new(10, 0, 1, 0));
editor.handle_event(&Event::KeyPress { key: 40, modifiers: 0 });
assert_eq!(editor.time(), Time::new(10, 0, 0, 0));
}
#[test]
fn time_edit_mouse_wheel_does_not_change_time() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(10, 0, 0, 0));
editor.handle_event(&Event::Wheel { delta: Point::new(0, 120), modifiers: 0 });
assert_eq!(editor.time(), Time::new(10, 0, 0, 0));
editor.handle_event(&Event::Wheel { delta: Point::new(0, -120), modifiers: 0 });
assert_eq!(editor.time(), Time::new(10, 0, 0, 0));
}
#[test]
fn time_edit_visibility_toggle() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
assert!(editor.is_visible());
editor.set_visible(false);
assert!(!editor.is_visible());
editor.set_visible(true);
assert!(editor.is_visible());
}
#[test]
fn time_edit_set_time_respects_min_max_range() {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time_range(Time::new(9, 0, 0, 0), Time::new(17, 0, 0, 0));
let within = Time::new(12, 0, 0, 0);
editor.set_time(within);
assert_eq!(editor.time(), within);
editor.set_time(Time::new(8, 0, 0, 0));
assert_eq!(editor.time(), within);
editor.set_time(Time::new(18, 0, 0, 0));
assert_eq!(editor.time(), within); }
#[test]
fn the_clock_popup_is_actually_painted() {
use crate::widget::svg::{render_to_svg, text_subpath_count};
let _theme_guard = crate::style::theme_test_guard();
let make = |popup: bool| {
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_time(Time::new(14, 30, 0, 0));
editor.set_clock_popup(popup);
editor
};
let closed_svg = render_to_svg(&mut make(false));
let open_svg = render_to_svg(&mut make(true));
assert_ne!(closed_svg, open_svg, "opening the clock must change the picture");
let closed_ink = text_subpath_count(&closed_svg);
let open_ink = text_subpath_count(&open_svg);
assert!(
open_ink > closed_ink * 4,
"the clock must paint both rings of numerals: closed={closed_ink} open={open_ink}"
);
assert!(
open_svg.matches("<rect").count() > closed_svg.matches("<rect").count(),
"the clock paints its own plate"
);
let tiny_open = {
let mut t = TimeEdit::new(Rect::new(0, 0, 40, 30));
t.set_clock_popup(true);
render_to_svg(&mut t)
};
let tiny_closed = {
let mut t = TimeEdit::new(Rect::new(0, 0, 40, 30));
t.set_clock_popup(false);
render_to_svg(&mut t)
};
assert_eq!(
tiny_closed, tiny_open,
"a face below the readable floor draws nothing rather than a smear"
);
}
#[test]
fn clicking_a_ring_sets_the_hand_it_belongs_to() {
use crate::core::Point;
use crate::event::{Event, EventHandler};
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_clock_popup(true);
let centre = editor.clock_centre();
let radius = editor.clock_rect().width as f32 / 2.0;
editor.set_time(Time::new(9, 0, 0, 0));
let hour_point = Point::new(centre.x + (radius * 0.82) as i32, centre.y);
assert_eq!(
editor.hand_at_point(hour_point),
Some(ClockHand::Hour),
"the outer ring is the hour"
);
editor.handle_event(&Event::MousePress { pos: hour_point, button: 1 });
assert_eq!(editor.time().hour(), 3, "three o'clock on the hour ring");
assert_eq!(editor.time().minute(), 0, "and the minute is untouched");
let minute_point = Point::new(centre.x + (radius * 0.45) as i32, centre.y);
assert_eq!(
editor.hand_at_point(minute_point),
Some(ClockHand::Minute),
"the inner ring is the minute"
);
editor.handle_event(&Event::MousePress { pos: minute_point, button: 1 });
assert_eq!(editor.time().minute(), 15, "three o'clock on the minute ring is :15");
assert_eq!(editor.time().hour(), 3, "and the hour is untouched");
editor.set_time(Time::new(21, 30, 0, 0));
let nine_point = Point::new(centre.x + (radius * 0.82) as i32, centre.y);
editor.handle_event(&Event::MousePress { pos: nine_point, button: 1 });
assert_eq!(editor.time().hour(), 15, "the afternoon half is preserved");
assert_eq!(editor.hand_at_point(centre), None, "the hub is not a ring");
let outside = Point::new(centre.x + (radius * 2.0) as i32, centre.y);
assert_eq!(editor.hand_at_point(outside), None, "outside the rim is no ring");
let before = editor.time();
editor.handle_event(&Event::MousePress { pos: centre, button: 1 });
assert_eq!(editor.time(), before, "a click on the hub changes nothing");
}
#[test]
fn the_field_opens_the_clock_and_escape_closes_it() {
use crate::event::{Event, EventHandler};
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
assert!(!editor.clock_popup(), "a fresh field shows no face");
let field_point = crate::core::Point::new(80, editor.geometry().y + 10);
editor.handle_event(&Event::MousePress { pos: field_point, button: 1 });
assert!(editor.clock_popup(), "a press on the field opens the face");
editor.handle_event(&Event::KeyPress { key: 27, modifiers: 0 });
assert!(!editor.clock_popup(), "escape closes it");
editor.handle_event(&Event::KeyPress { key: 13, modifiers: 0 });
assert!(editor.clock_popup(), "enter opens the face");
editor.handle_event(&Event::MousePress {
pos: crate::core::Point::new(9000, 9000),
button: 1,
});
assert!(!editor.clock_popup(), "a press outside dismisses the face");
}
#[test]
fn the_clock_cannot_set_a_time_the_range_forbids() {
use crate::core::Point;
use crate::event::{Event, EventHandler};
let mut editor = TimeEdit::new(Rect::new(0, 0, 200, 30));
editor.set_clock_popup(true);
editor.set_time_range(Time::new(9, 0, 0, 0), Time::new(9, 10, 0, 0));
editor.set_time(Time::new(9, 5, 0, 0));
let centre = editor.clock_centre();
let radius = editor.clock_rect().width as f32 / 2.0;
let minute_radius = radius * (HOUR_HOLE + MINUTE_RING_INNER) / 2.0;
let half_past = Point::new(centre.x, centre.y + minute_radius as i32);
assert_eq!(
editor.hand_at_point(half_past),
Some(ClockHand::Minute),
"the fixture must aim at the minute ring, or the assertion below proves nothing"
);
editor.handle_event(&Event::MousePress { pos: half_past, button: 1 });
assert_eq!(
editor.time().minute(),
5,
"a ring click that would leave the range is refused, not clamped"
);
let minute_radius = radius * (HOUR_HOLE + MINUTE_RING_INNER) / 2.0;
let five_past = Point::new(
centre.x + (minute_radius * 0.5) as i32,
centre.y - (minute_radius * 0.866) as i32,
);
assert_eq!(
editor.hand_at_point(five_past),
Some(ClockHand::Minute),
"the fixture must aim at the minute ring"
);
editor.handle_event(&Event::MousePress { pos: five_past, button: 1 });
assert_eq!(
editor.time().minute(),
5,
"the :05 position is inside the range, so it is accepted"
);
let quarter = Point::new(centre.x + minute_radius as i32, centre.y);
editor.handle_event(&Event::MousePress { pos: quarter, button: 1 });
assert_eq!(
editor.time().minute(),
5,
"the :15 position is past the range's :10, so it is refused"
);
}
}