use crate::bar::Alignment; use crate::config::LabelPrefix; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; use crate::widgets::widget::BarWidget; use chrono::Local; use chrono::NaiveTime; use chrono_tz::Tz; use eframe::egui::text::LayoutJob; use eframe::egui::Align; use eframe::egui::Context; use eframe::egui::CornerRadius; use eframe::egui::Label; use eframe::egui::Sense; use eframe::egui::Stroke; use eframe::egui::TextFormat; use eframe::egui::Ui; use eframe::egui::Vec2; use eframe::epaint::StrokeKind; use lazy_static::lazy_static; use serde::Deserialize; use serde::Serialize; use std::time::Duration; use std::time::Instant; lazy_static! { static ref TIME_RANGES: Vec<(&'static str, NaiveTime)> = { vec![ ( egui_phosphor::regular::MOON, NaiveTime::from_hms_opt(0, 0, 0).expect("invalid"), ), ( egui_phosphor::regular::ALARM, NaiveTime::from_hms_opt(6, 0, 0).expect("invalid"), ), ( egui_phosphor::regular::BREAD, NaiveTime::from_hms_opt(6, 1, 0).expect("invalid"), ), ( egui_phosphor::regular::BARBELL, NaiveTime::from_hms_opt(6, 30, 0).expect("invalid"), ), ( egui_phosphor::regular::COFFEE, NaiveTime::from_hms_opt(8, 0, 0).expect("invalid"), ), ( egui_phosphor::regular::CLOCK, NaiveTime::from_hms_opt(8, 30, 0).expect("invalid"), ), ( egui_phosphor::regular::HAMBURGER, NaiveTime::from_hms_opt(12, 0, 0).expect("invalid"), ), ( egui_phosphor::regular::CLOCK_AFTERNOON, NaiveTime::from_hms_opt(12, 30, 0).expect("invalid"), ), ( egui_phosphor::regular::FORK_KNIFE, NaiveTime::from_hms_opt(18, 0, 0).expect("invalid"), ), ( egui_phosphor::regular::MOON_STARS, NaiveTime::from_hms_opt(18, 30, 0).expect("invalid"), ), ] }; } #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct TimeConfig { /// Enable the Time widget pub enable: bool, /// Set the Time format pub format: TimeFormat, /// Display label prefix pub label_prefix: Option, /// TimeZone (https://docs.rs/chrono-tz/latest/chrono_tz/enum.Tz.html) /// /// Use a custom format to display additional information, i.e.: /// ```json /// { /// "Time": { /// "enable": true, /// "format": { "Custom": "%T %Z (Tokyo)" }, /// "timezone": "Asia/Tokyo" /// } ///} /// ``` pub timezone: Option, /// Change the icon depending on the time. The default icon is used between 8:30 and 12:00. (default: false) pub changing_icon: Option, } impl From for Time { fn from(value: TimeConfig) -> Self { // using 1 second made the widget look "less accurate" and lagging (especially having multiple with seconds). // This is still better than getting an update every frame let data_refresh_interval = 500; Self { enable: value.enable, format: value.format, label_prefix: value.label_prefix.unwrap_or(LabelPrefix::Icon), timezone: value.timezone, changing_icon: value.changing_icon.unwrap_or_default(), data_refresh_interval_millis: data_refresh_interval, last_state: TimeOutput::new(), last_updated: Instant::now() .checked_sub(Duration::from_millis(data_refresh_interval)) .unwrap(), } } } #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub enum TimeFormat { /// Twelve-hour format (with seconds) TwelveHour, /// Twelve-hour format (without seconds) TwelveHourWithoutSeconds, /// Twenty-four-hour format (with seconds) TwentyFourHour, /// Twenty-four-hour format (without seconds) TwentyFourHourWithoutSeconds, /// Twenty-four-hour format displayed as a binary clock with circles (with seconds) (https://en.wikipedia.org/wiki/Binary_clock) BinaryCircle, /// Twenty-four-hour format displayed as a binary clock with rectangles (with seconds) (https://en.wikipedia.org/wiki/Binary_clock) BinaryRectangle, /// Custom format (https://docs.rs/chrono/latest/chrono/format/strftime/index.html) Custom(String), } impl TimeFormat { pub fn toggle(&mut self) { match self { TimeFormat::TwelveHour => *self = TimeFormat::TwelveHourWithoutSeconds, TimeFormat::TwelveHourWithoutSeconds => *self = TimeFormat::TwentyFourHour, TimeFormat::TwentyFourHour => *self = TimeFormat::TwentyFourHourWithoutSeconds, TimeFormat::TwentyFourHourWithoutSeconds => *self = TimeFormat::BinaryCircle, TimeFormat::BinaryCircle => *self = TimeFormat::BinaryRectangle, TimeFormat::BinaryRectangle => *self = TimeFormat::TwelveHour, _ => {} }; } fn fmt_string(&self) -> String { match self { TimeFormat::TwelveHour => String::from("%l:%M:%S %p"), TimeFormat::TwelveHourWithoutSeconds => String::from("%l:%M %p"), TimeFormat::TwentyFourHour => String::from("%T"), TimeFormat::TwentyFourHourWithoutSeconds => String::from("%H:%M"), TimeFormat::BinaryCircle => String::from("c%T"), TimeFormat::BinaryRectangle => String::from("r%T"), TimeFormat::Custom(format) => format.to_string(), } } } #[derive(Clone, Debug)] struct TimeOutput { label: String, icon: String, } impl TimeOutput { fn new() -> Self { Self { label: String::new(), icon: String::new(), } } } #[derive(Clone, Debug)] pub struct Time { pub enable: bool, pub format: TimeFormat, label_prefix: LabelPrefix, timezone: Option, changing_icon: bool, data_refresh_interval_millis: u64, last_state: TimeOutput, last_updated: Instant, } impl Time { fn output(&mut self) -> TimeOutput { let mut output = self.last_state.clone(); let now = Instant::now(); if now.duration_since(self.last_updated) > Duration::from_millis(self.data_refresh_interval_millis) { let (formatted, current_time) = match &self.timezone { Some(timezone) => match timezone.parse::() { Ok(tz) => { let dt = Local::now().with_timezone(&tz); ( dt.format(&self.format.fmt_string()) .to_string() .trim() .to_string(), Some(dt.time()), ) } Err(_) => (format!("Invalid timezone: {timezone:?}"), None), }, None => { let dt = Local::now(); ( dt.format(&self.format.fmt_string()) .to_string() .trim() .to_string(), Some(dt.time()), ) } }; if current_time.is_none() { return TimeOutput { label: formatted, icon: egui_phosphor::regular::WARNING_CIRCLE.to_string(), }; } let current_range = match &self.changing_icon { true => TIME_RANGES .iter() .rev() .find(|&(_, start)| current_time.unwrap() > *start) .cloned(), false => None, } .unwrap_or((egui_phosphor::regular::CLOCK, NaiveTime::default())); output = TimeOutput { label: formatted, icon: current_range.0.to_string(), }; self.last_state.clone_from(&output); self.last_updated = now; } output } fn paint_binary_circle( &mut self, size: f32, number: u32, max_power: usize, ctx: &Context, ui: &mut Ui, ) { let full_height = size; let height = full_height / 4.0; let width = height; let offset = height / 2.0 - height / 8.0; let (response, painter) = ui.allocate_painter(Vec2::new(width, full_height + offset * 2.0), Sense::hover()); let color = ctx.style().visuals.text_color(); let c = response.rect.center(); let r = height / 2.0 - 0.5; if number == 1 || number == 3 || number == 5 || number == 7 || number == 9 { painter.circle_filled(c + Vec2::new(0.0, height * 1.50 + offset), r, color); } else { painter.circle_filled(c + Vec2::new(0.0, height * 1.50 + offset), r / 2.5, color); } if number == 2 || number == 3 || number == 6 || number == 7 { painter.circle_filled(c + Vec2::new(0.0, height * 0.50 + offset), r, color); } else { painter.circle_filled(c + Vec2::new(0.0, height * 0.50 + offset), r / 2.5, color); } if number == 4 || number == 5 || number == 6 || number == 7 { painter.circle_filled(c + Vec2::new(0.0, -height * 0.50 + offset), r, color); } else if max_power > 2 { painter.circle_filled(c + Vec2::new(0.0, -height * 0.50 + offset), r / 2.5, color); } if number == 8 || number == 9 { painter.circle_filled(c + Vec2::new(0.0, -height * 1.50 + offset), r, color); } else if max_power > 3 { painter.circle_filled(c + Vec2::new(0.0, -height * 1.50 + offset), r / 2.5, color); } } fn paint_binary_rect( &mut self, size: f32, number: u32, max_power: usize, ctx: &Context, ui: &mut Ui, ) { let full_height = size; let height = full_height / 4.0; let width = height * 1.5; let offset = height / 2.0 - height / 8.0; let (response, painter) = ui.allocate_painter(Vec2::new(width, full_height + offset * 2.0), Sense::hover()); let color = ctx.style().visuals.text_color(); let stroke = Stroke::new(1.0, color); let round_all = CornerRadius::same((response.rect.width() * 0.1) as u8); let round_top = CornerRadius { nw: round_all.nw, ne: round_all.ne, ..Default::default() }; let round_none = CornerRadius::ZERO; let round_bottom = CornerRadius { sw: round_all.nw, se: round_all.ne, ..Default::default() }; if max_power == 2 { let mut rect = response .rect .shrink2(Vec2::new(stroke.width, stroke.width + offset)); rect.set_height(rect.height() - height * 2.0); rect = rect.translate(Vec2::new(0.0, height * 2.0 + offset)); painter.rect_stroke(rect, round_all, stroke, StrokeKind::Outside); } else if max_power == 3 { let mut rect = response .rect .shrink2(Vec2::new(stroke.width, stroke.width + offset)); rect.set_height(rect.height() - height); rect = rect.translate(Vec2::new(0.0, height + offset)); painter.rect_stroke(rect, round_all, stroke, StrokeKind::Outside); } else { let mut rect = response .rect .shrink2(Vec2::new(stroke.width, stroke.width + offset)); rect = rect.translate(Vec2::new(0.0, 0.0 + offset)); painter.rect_stroke(rect, round_all, stroke, StrokeKind::Outside); } let mut rect_bin = response.rect; rect_bin.set_width(width); if number == 1 || number == 5 || number == 9 { rect_bin.set_height(height); painter.rect_filled( rect_bin.translate(Vec2::new(stroke.width, height * 3.0 + offset * 2.0)), round_bottom, color, ); } if number == 2 { rect_bin.set_height(height); painter.rect_filled( rect_bin.translate(Vec2::new(stroke.width, height * 2.0 + offset * 2.0)), if max_power == 2 { round_top } else { round_none }, color, ); } if number == 3 { rect_bin.set_height(height * 2.0); painter.rect_filled( rect_bin.translate(Vec2::new(stroke.width, height * 2.0 + offset * 2.0)), round_bottom, color, ); } if number == 4 || number == 5 { rect_bin.set_height(height); painter.rect_filled( rect_bin.translate(Vec2::new(stroke.width, height * 1.0 + offset * 2.0)), if max_power == 3 { round_top } else { round_none }, color, ); } if number == 6 { rect_bin.set_height(height * 2.0); painter.rect_filled( rect_bin.translate(Vec2::new(stroke.width, height * 1.0 + offset * 2.0)), if max_power == 3 { round_top } else { round_none }, color, ); } if number == 7 { rect_bin.set_height(height * 3.0); painter.rect_filled( rect_bin.translate(Vec2::new(stroke.width, height + offset * 2.0)), if max_power == 3 { round_all } else { round_bottom }, color, ); } if number == 8 || number == 9 { rect_bin.set_height(height); painter.rect_filled( rect_bin.translate(Vec2::new(stroke.width, 0.0 + offset * 2.0)), round_top, color, ); } } } impl BarWidget for Time { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { if self.enable { let mut output = self.output(); if !output.label.is_empty() { let use_binary_circle = output.label.starts_with('c'); let use_binary_rectangle = output.label.starts_with('r'); let mut layout_job = LayoutJob::simple( match self.label_prefix { LabelPrefix::Icon | LabelPrefix::IconAndText => output.icon, LabelPrefix::None | LabelPrefix::Text => String::new(), }, config.icon_font_id.clone(), ctx.style().visuals.selection.stroke.color, 100.0, ); if let LabelPrefix::Text | LabelPrefix::IconAndText = self.label_prefix { output.label.insert_str(0, "TIME: "); } if !use_binary_circle && !use_binary_rectangle { layout_job.append( &output.label, 10.0, TextFormat { font_id: config.text_font_id.clone(), color: ctx.style().visuals.text_color(), valign: Align::Center, ..Default::default() }, ); } let font_id = config.icon_font_id.clone(); let is_reversed = matches!(config.alignment, Some(Alignment::Right)); config.apply_on_widget(false, ui, |ui| { if SelectableFrame::new(false) .show(ui, |ui| { if !is_reversed { ui.add(Label::new(layout_job.clone()).selectable(false)); } if use_binary_circle || use_binary_rectangle { let ordered_output = if is_reversed { output.label.chars().rev().collect() } else { output.label }; for (section_index, section) in ordered_output.split(':').enumerate() { ui.scope(|ui| { ui.spacing_mut().item_spacing = Vec2::splat(2.0); for (number_index, number_char) in section.chars().enumerate() { if let Some(number) = number_char.to_digit(10) { // the hour is the second char in the first section (in reverse, it's in the last section) let max_power = match ( is_reversed, section_index, number_index, ) { (true, 2, 1) | (false, 0, 1) => 2, (true, _, 1) | (false, _, 0) => 3, _ => 4, }; if use_binary_circle { self.paint_binary_circle( font_id.size, number, max_power, ctx, ui, ); } else if use_binary_rectangle { self.paint_binary_rect( font_id.size, number, max_power, ctx, ui, ); } } } }); } } if is_reversed { ui.add(Label::new(layout_job.clone()).selectable(false)); } }) .clicked() { self.format.toggle() } }); } } } }