use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::{point_on_circle, RenderContext};
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_bool, expect_string, expect_u32};
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::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const HOUR_MARKS: u32 = 12;
const MINUTE_MARKS: u32 = 60;
const DIAL_NUMERALS: [(&str, f32); 4] = [("12", 0.0), ("3", 3.0), ("6", 6.0), ("9", 9.0)];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ClockTimeSource {
#[default]
Manual,
System,
Utc,
}
impl ClockTimeSource {
pub const ALL: [Self; 3] = [Self::Manual, Self::System, Self::Utc];
pub const fn token(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::System => "system",
Self::Utc => "utc",
}
}
pub fn from_token(token: &str) -> Option<Self> {
Self::ALL.into_iter().find(|source| source.token() == token)
}
}
fn system_time_of_day(source: ClockTimeSource) -> Option<(u32, u32, u32, u32)> {
use chrono::Timelike;
match source {
ClockTimeSource::System => {
let now = chrono::Local::now();
Some((now.hour(), now.minute(), now.second(), now.timestamp_subsec_millis()))
}
ClockTimeSource::Utc => {
let now = chrono::Utc::now();
Some((now.hour(), now.minute(), now.second(), now.timestamp_subsec_millis()))
}
ClockTimeSource::Manual => None,
}
}
pub struct Clock {
base: BaseWidget,
hour: u32,
minute: u32,
second: u32,
millis: u32,
show_seconds: bool,
show_numerals: bool,
hand_shading: bool,
numeral_cache: Option<NumeralCache>,
tick_cache: Option<TickCache>,
time_source: ClockTimeSource,
pending_ms: u32,
pub tick: Signal1<u32>,
}
struct TickCache {
x: i32,
y: i32,
width: u32,
height: u32,
marks: Vec<(Point, Point, u32)>,
}
struct NumeralCache {
size_bits: u32,
dpi_scale_bits: u32,
font: Font,
box_width: u32,
height: u32,
}
impl Clock {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Clock, geometry, "Clock"),
hour: 0,
minute: 0,
second: 0,
millis: 0,
show_seconds: false,
show_numerals: false,
hand_shading: false,
numeral_cache: None,
tick_cache: None,
time_source: ClockTimeSource::Manual,
pending_ms: 0,
tick: Signal1::new(),
}
}
pub fn hour(&self) -> u32 {
self.hour
}
pub fn minute(&self) -> u32 {
self.minute
}
pub fn second(&self) -> u32 {
self.second
}
pub fn millis(&self) -> u32 {
self.millis
}
pub fn sub_second(&self) -> f32 {
self.millis as f32 / 1000.0
}
pub fn show_seconds(&self) -> bool {
self.show_seconds
}
pub fn set_time(&mut self, hour: u32, minute: u32, second: u32) {
self.set_time_with_millis(hour, minute, second, 0);
}
pub fn set_time_with_millis(&mut self, hour: u32, minute: u32, second: u32, millis: u32) {
let total_ms = (hour as u64)
.saturating_mul(3_600_000)
.saturating_add((minute as u64).saturating_mul(60_000))
.saturating_add((second as u64).saturating_mul(1_000))
.saturating_add(millis as u64);
let day = 24u64 * 3_600_000;
let wrapped = total_ms % day;
let hour = (wrapped / 3_600_000) as u32;
let minute = ((wrapped % 3_600_000) / 60_000) as u32;
let second = ((wrapped % 60_000) / 1_000) as u32;
let millis = (wrapped % 1_000) as u32;
if (self.hour, self.minute, self.second, self.millis) == (hour, minute, second, millis) {
return;
}
self.hour = hour;
self.minute = minute;
self.second = second;
self.millis = millis;
self.base.request_redraw();
}
pub fn set_hour(&mut self, hour: u32) {
self.set_time(hour, self.minute, self.second);
}
pub fn set_minute(&mut self, minute: u32) {
self.set_time(self.hour, minute, self.second);
}
pub fn set_second(&mut self, second: u32) {
self.set_time(self.hour, self.minute, second);
}
pub fn set_show_seconds(&mut self, show_seconds: bool) {
if self.show_seconds == show_seconds {
return;
}
self.show_seconds = show_seconds;
self.base.request_redraw();
}
pub fn show_numerals(&self) -> bool {
self.show_numerals
}
pub fn set_show_numerals(&mut self, show: bool) {
if self.show_numerals == show {
return;
}
self.show_numerals = show;
self.base.request_redraw();
}
pub fn hand_shading(&self) -> bool {
self.hand_shading
}
pub fn set_hand_shading(&mut self, enabled: bool) {
if self.hand_shading == enabled {
return;
}
self.hand_shading = enabled;
self.base.request_redraw();
}
pub fn time_source(&self) -> ClockTimeSource {
self.time_source
}
pub fn set_time_source(&mut self, source: ClockTimeSource) {
if self.time_source == source {
return;
}
self.time_source = source;
self.pending_ms = 0;
self.base.request_redraw();
}
pub fn tick(&mut self, delta_ms: u32) -> bool {
let mut repainted = false;
if matches!(self.time_source, ClockTimeSource::System | ClockTimeSource::Utc) {
if let Some((hour, minute, second, millis)) = system_time_of_day(self.time_source) {
let before = (self.hour, self.minute, self.second, self.millis);
self.set_time_with_millis(hour, minute, second, millis);
repainted = before != (self.hour, self.minute, self.second, self.millis);
}
}
self.pending_ms = self.pending_ms.saturating_add(delta_ms);
let elapsed_seconds = self.pending_ms / 1_000;
if elapsed_seconds > 0 {
self.pending_ms -= elapsed_seconds * 1_000;
self.tick.emit(elapsed_seconds);
repainted = true;
}
repainted
}
fn ensure_tick_marks(&mut self, center: Point, radius: f32) -> bool {
let rect = self.geometry();
let fresh = self.tick_cache.as_ref().is_some_and(|cache| {
cache.x == rect.x
&& cache.y == rect.y
&& cache.width == rect.width
&& cache.height == rect.height
});
if fresh {
return false;
}
let mut marks = Vec::with_capacity((MINUTE_MARKS + HOUR_MARKS) as usize);
for index in 0..MINUTE_MARKS {
if index % 5 == 0 {
continue;
}
let angle = Clock::angle_of(index as f32, MINUTE_MARKS as f32);
marks.push((
point_on_circle(center, radius * 0.94, angle),
point_on_circle(center, radius * 0.88, angle),
1,
));
}
for index in 0..HOUR_MARKS {
let angle = Clock::angle_of(index as f32, HOUR_MARKS as f32);
marks.push((
point_on_circle(center, radius * 0.94, angle),
point_on_circle(center, radius * 0.82, angle),
2,
));
}
self.tick_cache =
Some(TickCache { x: rect.x, y: rect.y, width: rect.width, height: rect.height, marks });
true
}
fn face(&self) -> (Point, f32) {
let rect = self.geometry();
let center = Point::new(
rect.x.saturating_add((rect.width / 2) as i32),
rect.y.saturating_add((rect.height / 2) as i32),
);
let radius = ((rect.width.min(rect.height) as f32) / 2.0 - 1.0).max(1.0);
(center, radius)
}
fn hand_extents(&self) -> (f32, f32, f32) {
let (_, radius) = self.face();
(radius * 0.50, radius * 0.74, radius * 0.86)
}
fn angle_of(position: f32, per_turn: f32) -> f32 {
-core::f32::consts::FRAC_PI_2 + (position / per_turn) * core::f32::consts::TAU
}
fn draw_numerals(&mut self, ctx: &mut RenderContext, center: Point, radius: f32, ink: Color) {
let size = (radius * 0.14).clamp(8.0, 20.0);
let size_bits = size.to_bits();
let dpi_scale_bits = ctx.dpi_scale().to_bits();
let cache_valid = self.numeral_cache.as_ref().is_some_and(|cache| {
cache.size_bits == size_bits && cache.dpi_scale_bits == dpi_scale_bits
});
if !cache_valid {
let font = Font::simple("Arial", size);
let metrics = ctx.measure_text("12", &font);
self.numeral_cache = Some(NumeralCache {
size_bits,
dpi_scale_bits,
font,
box_width: metrics.width.max(1).saturating_add(4),
height: metrics.height.max(1),
});
}
let Some(cache) = self.numeral_cache.as_ref() else {
return;
};
let font = &cache.font;
let box_width = cache.box_width;
let height = cache.height;
let numeral_ink = ink.with_alpha_f32(0.85);
for (label, position) in DIAL_NUMERALS {
let anchor = point_on_circle(
center,
radius * 0.70,
Clock::angle_of(position, HOUR_MARKS as f32),
);
let band = Rect::new(
anchor.x.saturating_sub((box_width / 2) as i32),
anchor.y.saturating_sub((height / 2) as i32),
box_width,
height,
);
ctx.draw_text_line(band, label, font, numeral_ink, HorizontalAlignment::Center);
}
}
fn draw_hand(
&self,
ctx: &mut RenderContext,
center: Point,
angle: f32,
length: f32,
color: Color,
thickness: u32,
) {
let tip = point_on_circle(center, length, angle);
if !self.hand_shading {
ctx.draw_line_stroke(center, tip, color, thickness);
return;
}
ctx.draw_line_stroke(
Point::new(center.x.saturating_add(1), center.y.saturating_add(1)),
Point::new(tip.x.saturating_add(1), tip.y.saturating_add(1)),
Color::BLACK.with_alpha_f32(0.25),
thickness,
);
ctx.draw_line_stroke(center, tip, color, thickness);
let offset = (thickness / 2).max(1) as f32;
let dx = (-angle.sin() * offset).round() as i32;
let dy = (angle.cos() * offset).round() as i32;
ctx.draw_line_stroke(
Point::new(center.x.saturating_add(dx), center.y.saturating_add(dy)),
Point::new(tip.x.saturating_add(dx), tip.y.saturating_add(dy)),
color.blend(&Color::WHITE, 0.45),
1,
);
}
}
impl Widget for Clock {
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 {
Size::new(140, 140)
}
fn tick(&mut self, delta_ms: u32) -> bool {
Clock::tick(self, delta_ms)
}
fn is_animating(&self) -> bool {
matches!(self.time_source, ClockTimeSource::System | ClockTimeSource::Utc)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"tick" => Some(EventSignalRef::mapped("tick", &self.tick, |value| {
CapabilityValue::UInt(u64::from(*value))
})),
_ => None,
}
}
}
impl WidgetProperties for Clock {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"hour" => Ok(CapabilityValue::UInt(u64::from(self.hour))),
"minute" => Ok(CapabilityValue::UInt(u64::from(self.minute))),
"second" => Ok(CapabilityValue::UInt(u64::from(self.second))),
"show_seconds" => Ok(CapabilityValue::Bool(self.show_seconds)),
"time_source" => Ok(CapabilityValue::String(self.time_source().token().to_string())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"hour" => {
self.set_hour(expect_u32(value)?);
Ok(())
}
"minute" => {
self.set_minute(expect_u32(value)?);
Ok(())
}
"second" => {
self.set_second(expect_u32(value)?);
Ok(())
}
"show_seconds" => {
self.set_show_seconds(expect_bool(value)?);
Ok(())
}
"time_source" => {
let token = expect_string(value)?;
let source =
ClockTimeSource::from_token(&token).ok_or(CapabilityAccessError::OutOfRange)?;
self.set_time_source(source);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"hour",
"minute",
"second",
"show_seconds",
"time_source",
BASE_PROPERTY_NAMES
]
}
}
impl Draw for Clock {
fn draw(&mut self, ctx: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style();
let themed = crate::style::resolved_theme_style_shared("clock");
let (window_background, window_foreground, accent) = {
let manager = crate::style::theme_manager();
match manager.current_theme() {
Some(theme) => {
(theme.colors.background, theme.colors.foreground, theme.colors.primary)
}
None => (Color::WHITE, Color::BLACK, Color::PRIMARY),
}
};
let surface = style
.background_color
.or_else(|| themed.as_ref().and_then(|resolved| resolved.background_color))
.unwrap_or(window_background);
let ink = style
.text_color
.or_else(|| themed.as_ref().and_then(|resolved| resolved.text_color))
.unwrap_or(window_foreground);
let accent = themed.as_ref().and_then(|resolved| resolved.border_color).unwrap_or(accent);
let (center, radius) = self.face();
let radius_px = radius.round().max(1.0) as u32;
let face_fill = ink.blend(&surface, 0.94).legible_on(surface, 1.05);
ctx.fill_circle_aa(center, radius_px, face_fill);
ctx.draw_circle_stroke(center, radius_px, ink.with_alpha_f32(0.65), 1);
self.ensure_tick_marks(center, radius);
if let Some(cache) = self.tick_cache.as_ref() {
for (from, to, thickness) in &cache.marks {
let color = if *thickness == 2 {
ink.with_alpha_f32(0.75)
} else {
ink.with_alpha_f32(0.35)
};
ctx.draw_line_stroke(*from, *to, color, *thickness);
}
}
if self.show_numerals {
self.draw_numerals(ctx, center, radius, ink);
}
let (hour_len, minute_len, second_len) = self.hand_extents();
let hour_position = (self.hour % 12) as f32 + self.minute as f32 / 60.0;
let hour_angle = Clock::angle_of(hour_position, HOUR_MARKS as f32);
self.draw_hand(ctx, center, hour_angle, hour_len, ink, 3);
let minute_position = self.minute as f32 + self.second as f32 / 60.0;
let minute_angle = Clock::angle_of(minute_position, MINUTE_MARKS as f32);
self.draw_hand(ctx, center, minute_angle, minute_len, ink, 2);
if self.show_seconds {
let second_position = self.second as f32 + self.sub_second();
let second_angle = Clock::angle_of(second_position, MINUTE_MARKS as f32);
self.draw_hand(ctx, center, second_angle, second_len, accent, 1);
}
let pin = (radius * 0.06).round().max(1.0) as u32;
ctx.fill_circle(center, pin, accent);
}
}
impl EventHandler for Clock {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_new_clock_reads_midnight() {
let clock = Clock::new(Rect::new(0, 0, 140, 140));
assert_eq!((clock.hour(), clock.minute(), clock.second()), (0, 0, 0));
assert!(!clock.show_seconds());
assert_eq!(clock.kind(), WidgetKind::Clock);
}
#[test]
fn minutes_and_seconds_carry_into_the_larger_unit() {
let mut clock = Clock::new(Rect::new(0, 0, 140, 140));
clock.set_time(0, 90, 0);
assert_eq!((clock.hour(), clock.minute(), clock.second()), (1, 30, 0));
clock.set_time(0, 0, 3661);
assert_eq!((clock.hour(), clock.minute(), clock.second()), (1, 1, 1));
}
#[test]
fn hours_wrap_at_a_day() {
let mut clock = Clock::new(Rect::new(0, 0, 140, 140));
clock.set_time(24, 0, 0);
assert_eq!(clock.hour(), 0);
clock.set_time(49, 0, 0);
assert_eq!(clock.hour(), 1);
}
#[test]
fn the_hour_hand_advances_with_the_minutes() {
let mut clock = Clock::new(Rect::new(0, 0, 140, 140));
clock.set_time(3, 0, 0);
let on_the_hour = Clock::angle_of((clock.hour() % 12) as f32, HOUR_MARKS as f32);
clock.set_time(3, 30, 0);
let half_past = Clock::angle_of(
(clock.hour() % 12) as f32 + clock.minute() as f32 / 60.0,
HOUR_MARKS as f32,
);
assert!(
half_past > on_the_hour,
"the hour hand must move forward: {on_the_hour} -> {half_past}"
);
}
#[test]
fn twelve_oclock_points_straight_up() {
let angle = Clock::angle_of(0.0, 12.0);
let point = point_on_circle(Point::new(0, 0), 10.0, angle);
assert_eq!(point, Point::new(0, -10));
assert_eq!(
point_on_circle(Point::new(0, 0), 10.0, Clock::angle_of(3.0, 12.0)),
Point::new(10, 0)
);
assert_eq!(
point_on_circle(Point::new(0, 0), 10.0, Clock::angle_of(6.0, 12.0)),
Point::new(0, 10)
);
assert_eq!(
point_on_circle(Point::new(0, 0), 10.0, Clock::angle_of(9.0, 12.0)),
Point::new(-10, 0)
);
}
#[test]
fn every_hand_stays_inside_the_face() {
let clock = Clock::new(Rect::new(0, 0, 140, 140));
let (_, radius) = clock.face();
let (hour, minute, second) = clock.hand_extents();
assert!(hour > 0.0 && minute > hour && second > minute);
assert!(second < radius, "the longest hand must stay inside the face: {second} < {radius}");
}
#[test]
fn the_property_route_round_trips_every_field() {
let mut clock = Clock::new(Rect::new(0, 0, 140, 140));
clock.set("hour", CapabilityValue::UInt(14)).expect("writable");
clock.set("minute", CapabilityValue::UInt(5)).expect("writable");
clock.set("second", CapabilityValue::UInt(9)).expect("writable");
clock.set("show_seconds", CapabilityValue::Bool(true)).expect("writable");
assert_eq!(clock.get("hour"), Ok(CapabilityValue::UInt(14)));
assert_eq!(clock.get("minute"), Ok(CapabilityValue::UInt(5)));
assert_eq!(clock.get("second"), Ok(CapabilityValue::UInt(9)));
assert_eq!(clock.get("show_seconds"), Ok(CapabilityValue::Bool(true)));
}
#[test]
fn a_clock_paints_its_face() {
let mut clock = Clock::new(Rect::new(0, 0, 140, 140));
clock.set_time(10, 10, 30);
clock.set_show_seconds(true);
let svg = crate::widget::svg::render_to_svg(&mut clock);
assert!(svg.contains("circle"), "the face must be drawn: {svg}");
assert!(svg.contains("<line"), "the hands and marks must be drawn: {svg}");
}
#[test]
fn numerals_are_opt_in_and_sit_inside_the_face() {
let mut clock = Clock::new(Rect::new(0, 0, 140, 140));
assert!(!clock.show_numerals(), "the default face has no numerals");
let plain = crate::widget::svg::render_to_svg(&mut clock);
assert!(
crate::widget::svg::text_ink_boxes(&plain).is_empty(),
"a default clock paints no text: {plain}"
);
clock.set_show_numerals(true);
assert!(clock.show_numerals());
let annotated = crate::widget::svg::render_to_svg(&mut clock);
let boxes = crate::widget::svg::text_ink_boxes(&annotated);
assert_eq!(boxes.len(), DIAL_NUMERALS.len(), "one run per numeral: {annotated}");
let (center, radius) = clock.face();
for (left, top, right, bottom) in boxes {
let cx = (left + right) as f32 / 2.0;
let cy = (top + bottom) as f32 / 2.0;
let half_reach = ((right - left).max(bottom - top) as f32) / 2.0;
let dx = cx - center.x as f32;
let dy = cy - center.y as f32;
assert!(
(dx * dx + dy * dy).sqrt() + half_reach <= radius + 1.0,
"a numeral at ({cx}, {cy}) must stay inside the face of radius {radius}: {annotated}"
);
}
}
#[test]
fn the_second_hand_interpolates_by_milliseconds() {
let mut clock = Clock::new(Rect::new(0, 0, 140, 140));
clock.set_show_seconds(true);
clock.set_time_with_millis(10, 10, 30, 0);
assert_eq!(clock.millis(), 0);
let whole =
Clock::angle_of(clock.second() as f32 + clock.sub_second(), MINUTE_MARKS as f32);
clock.set_time_with_millis(10, 10, 30, 500);
assert_eq!(clock.millis(), 500);
assert!((clock.sub_second() - 0.5).abs() < 1e-6, "half a second is 0.5");
let half = Clock::angle_of(clock.second() as f32 + clock.sub_second(), MINUTE_MARKS as f32);
assert!(half > whole, "the hand must move forward within the second: {whole} -> {half}");
clock.set_time_with_millis(0, 0, 59, 1500);
assert_eq!((clock.hour(), clock.minute(), clock.second(), clock.millis()), (0, 1, 0, 500));
clock.set_time(10, 10, 30);
assert_eq!(clock.millis(), 0);
}
#[test]
fn hand_shading_adds_a_shadow_and_a_highlight() {
let mut clock = Clock::new(Rect::new(0, 0, 140, 140));
clock.set_time(10, 10, 30);
clock.set_show_seconds(true);
assert!(!clock.hand_shading(), "shading is off by default");
let flat = crate::widget::svg::render_to_svg(&mut clock);
let flat_strokes = flat.matches("<line").count();
clock.set_hand_shading(true);
assert!(clock.hand_shading());
let shaded = crate::widget::svg::render_to_svg(&mut clock);
let shaded_strokes = shaded.matches("<line").count();
assert_eq!(
shaded_strokes - flat_strokes,
6,
"each hand paints two extra strokes: {flat_strokes} -> {shaded_strokes}"
);
}
#[test]
fn the_system_source_reads_local_time_and_utc_reads_utc() {
use chrono::Timelike;
let (local_h, local_m, _, _) =
system_time_of_day(ClockTimeSource::System).expect("the local clock is readable");
let now = chrono::Local::now();
assert_eq!((local_h, local_m), (now.hour(), now.minute()));
let (utc_h, utc_m, _, _) =
system_time_of_day(ClockTimeSource::Utc).expect("the UTC clock is readable");
let now = chrono::Utc::now();
assert_eq!((utc_h, utc_m), (now.hour(), now.minute()));
assert_eq!(system_time_of_day(ClockTimeSource::Manual), None);
}
#[test]
fn every_time_source_round_trips_through_its_token() {
for source in ClockTimeSource::ALL {
assert_eq!(ClockTimeSource::from_token(source.token()), Some(source));
}
assert_eq!(ClockTimeSource::from_token("local"), None);
}
#[test]
fn the_tick_ring_is_cached_until_the_rectangle_changes() {
let mut clock = Clock::new(Rect::new(0, 0, 140, 140));
let (center, radius) = clock.face();
assert!(clock.ensure_tick_marks(center, radius), "the first call builds the ring");
assert!(
!clock.ensure_tick_marks(center, radius),
"a second call with the same rectangle must reuse the table"
);
let marks = clock.tick_cache.as_ref().expect("the cache exists").marks.len();
assert_eq!(marks, (MINUTE_MARKS - MINUTE_MARKS / 5 + HOUR_MARKS) as usize);
clock.set_geometry(Rect::new(0, 0, 200, 200));
let (center, radius) = clock.face();
assert!(clock.ensure_tick_marks(center, radius), "a resize rebuilds the ring once");
}
}