egui_timepicker 0.1.0

A time picker widget for egui
Documentation
use std::fmt::Display;

use chrono::{NaiveTime, Timelike};
use egui::{
    Align2, Color32, DragValue, FontId, Id, Layout, Painter, Pos2, Response, RichText, Sense, Ui,
    Vec2,
};

use crate::button::TimePickerButtonState;

#[derive(Default, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
enum TimeFrame {
    #[default]
    Hour,
    Minute,
    Second,
}

#[derive(Default, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
enum AmPm {
    #[default]
    Am,
    Pm,
}

impl Display for AmPm {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AmPm::Am => write!(f, "AM"),
            AmPm::Pm => write!(f, "PM"),
        }
    }
}

#[derive(Default, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub(crate) struct TimePickerPopupState {
    hour: u32,
    minute: u32,
    second: u32,
    setup: bool,
    timeframe: TimeFrame,
    am_pm: AmPm,
}

pub(crate) struct TimePickerPopup<'a> {
    pub selection: &'a mut NaiveTime,
    pub button_id: Id,
    pub show_clockface: bool,
    pub use_12_hour_clock: bool,
    pub show_seconds: bool,
    pub use_dragvalue: bool,
}

impl TimePickerPopup<'_> {
    pub fn draw(&mut self, ui: &mut Ui) -> bool {
        let id = ui.make_persistent_id("time_picker");
        let mut popup_state = ui
            .data_mut(|data| data.get_persisted::<TimePickerPopupState>(id))
            .unwrap_or_default();
        if !popup_state.setup {
            popup_state.hour = self.selection.hour();
            popup_state.minute = self.selection.minute();
            popup_state.second = self.selection.second();
            popup_state.setup = true;
            ui.data_mut(|data| data.insert_persisted(id, popup_state.clone()));
        }

        let (mut close, mut saved) = (false, false);

        ui.horizontal(|ui| {
            if self.use_dragvalue {
                let range = if self.use_12_hour_clock {
                    0..=11
                } else {
                    0..=23
                };

                if ui
                    .add(DragValue::new(&mut popup_state.hour).range(range))
                    .clicked()
                {
                    popup_state.timeframe = TimeFrame::Hour;
                }
            } else {
                if ui
                    .button(RichText::new(popup_state.hour.to_string()).size(18.))
                    .clicked()
                {
                    popup_state.timeframe = TimeFrame::Hour;
                }
            }

            ui.label(RichText::new("h :").monospace());

            if self.use_dragvalue {
                if ui
                    .add(DragValue::new(&mut popup_state.minute).range(0..=59))
                    .clicked()
                {
                    popup_state.timeframe = TimeFrame::Minute;
                }
            } else {
                if ui
                    .button(RichText::new(popup_state.minute.to_string()).size(18.))
                    .clicked()
                {
                    popup_state.timeframe = TimeFrame::Minute;
                }
            }

            if self.show_seconds {
                ui.label(RichText::new("m :").monospace());

                if self.use_dragvalue {
                    if ui
                        .add(DragValue::new(&mut popup_state.second).range(0..=59))
                        .clicked()
                    {
                        popup_state.timeframe = TimeFrame::Second;
                    }
                } else {
                    if ui
                        .button(RichText::new(popup_state.second.to_string()).size(18.))
                        .clicked()
                    {
                        popup_state.timeframe = TimeFrame::Second;
                    }
                }

                ui.label(RichText::new("s").monospace());
            } else {
                ui.label(RichText::new("m").monospace());
            }

            if self.use_12_hour_clock {
                if ui
                    .button(RichText::new(popup_state.am_pm.to_string()).size(18.))
                    .clicked()
                {
                    popup_state.am_pm = match popup_state.am_pm {
                        AmPm::Am => AmPm::Pm,
                        AmPm::Pm => AmPm::Am,
                    };
                }
            }
        });

        let (rect, response) =
            ui.allocate_exact_size(Vec2::new(250., 250.), Sense::click_and_drag());
        let painter = ui.painter_at(rect);

        let center = rect.center();
        let radius = rect.width() / 2.0;
        let r_outer = radius * 0.8;
        let r_inner = radius * 0.55;

        let time = match popup_state.timeframe {
            TimeFrame::Hour => &mut popup_state.hour,
            TimeFrame::Minute => &mut popup_state.minute,
            TimeFrame::Second => &mut popup_state.second,
        };

        if self.show_clockface {
            draw_timepicker(
                r_outer,
                r_inner,
                center,
                &painter,
                &response,
                &popup_state.timeframe,
                time,
                self.use_12_hour_clock,
            );
        }

        ui.horizontal(|ui| {
            ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
                if ui.button("Save").clicked() {
                    let mut hour = popup_state.hour;
                    if self.use_12_hour_clock {
                        match popup_state.am_pm {
                            AmPm::Am => {}
                            AmPm::Pm => hour += 12,
                        }
                    }

                    *self.selection =
                        NaiveTime::from_hms_opt(hour, popup_state.minute, popup_state.second)
                            .expect("Could not create NaiveTime");
                    saved = true;
                    close = true;
                }

                if ui.button("Cancel").clicked() {
                    close = true;
                }
            });
        });

        ui.data_mut(|data| {
            data.insert_persisted(id, popup_state.clone());
        });

        if close {
            popup_state.setup = false;
            ui.data_mut(|data| {
                data.insert_persisted(id, popup_state);
                data.get_persisted_mut_or_default::<TimePickerButtonState>(self.button_id)
                    .picker_visible = false;
            });
        }

        saved && close
    }
}

fn draw_timepicker(
    radius_outer: f32,
    radius_inner: f32,
    center: Pos2,
    painter: &Painter,
    response: &Response,
    timeframe: &TimeFrame,
    time: &mut u32,
    use_12_hour_format: bool,
) {
    for i in 0..12 {
        let angle = (-90. + 30. * i as f32).to_radians();
        let x_outer = center.x + radius_outer * angle.cos();
        let y_outer = center.y + radius_outer * angle.sin();

        let x_inner = center.x + radius_inner * angle.cos();
        let y_inner = center.y + radius_inner * angle.sin();

        match *timeframe {
            TimeFrame::Hour => {
                painter.text(
                    Pos2::new(x_outer, y_outer),
                    Align2::CENTER_CENTER,
                    i.to_string(),
                    FontId::monospace(12.0),
                    Color32::WHITE,
                );

                if !use_12_hour_format {
                    painter.text(
                        Pos2::new(x_inner, y_inner),
                        Align2::CENTER_CENTER,
                        (i + 12).to_string(),
                        FontId::monospace(12.0),
                        Color32::WHITE,
                    );
                }

                if *time == i {
                    painter.circle_filled(
                        Pos2::new(x_outer, y_outer),
                        15.,
                        Color32::from_rgba_unmultiplied(200, 200, 200, 50),
                    );
                }

                if *time == (i + 12) {
                    painter.circle_filled(
                        Pos2::new(x_inner, y_inner),
                        15.,
                        Color32::from_rgba_unmultiplied(200, 200, 200, 50),
                    );
                }
            }
            TimeFrame::Minute | TimeFrame::Second => {
                painter.text(
                    Pos2::new(x_outer, y_outer),
                    Align2::CENTER_CENTER,
                    (i * 5).to_string(),
                    FontId::monospace(12.0),
                    Color32::WHITE,
                );

                if *time % 5 == 0 && *time == i * 5 {
                    painter.circle_filled(
                        Pos2::new(x_outer, y_outer),
                        15.,
                        Color32::from_rgba_unmultiplied(200, 200, 200, 50),
                    );
                }
            }
        }
    }

    if let Some(pos) = response.interact_pointer_pos() {
        let angle = (pos - center).angle();
        let distance = (pos - center).length();

        match *timeframe {
            TimeFrame::Hour => {
                let mut h = (angle.to_degrees() + 90. + 15.).rem_euclid(360.) as u32 / 30. as u32;
                if distance < radius_outer - 15. && !use_12_hour_format {
                    h += 12;
                }
                *time = h;
            }
            TimeFrame::Minute | TimeFrame::Second => {
                let mut t = (angle.to_degrees() + 90. + 3.).rem_euclid(360.) as u32 / 6. as u32;
                if t == 60 {
                    t = 0;
                }
                *time = t;
            }
        }
    }

    match *timeframe {
        TimeFrame::Hour => {
            let angle = (*time as f32 * 30. - 90.).to_radians();
            if *time < 12 {
                let end = center + Vec2::angled(angle) * (radius_outer - 15.);
                painter.line_segment([center, end], (2., Color32::WHITE));
            } else {
                let end = center + Vec2::angled(angle) * (radius_inner - 15.);
                painter.line_segment([center, end], (2., Color32::WHITE));
            }
        }
        TimeFrame::Minute | TimeFrame::Second => {
            let angle = (*time as f32 * 6. - 90.).to_radians();

            let radius = if *time % 5 == 0 {
                radius_outer - 15.
            } else {
                radius_outer
            };

            let end = center + Vec2::angled(angle) * radius;
            painter.line_segment([center, end], (2., Color32::WHITE));

            if *time % 5 != 0 {
                painter.circle_filled(end, 4., Color32::WHITE);
            }
        }
    }

    painter.circle_filled(center, 4., Color32::WHITE);
}