use jiff::tz::TimeZone;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use crate::config::ClocksConfig;
use crate::frame::Binding;
use crate::glyphs::{self, BigText};
use crate::grid::{Column, Grid};
use crate::panel::{KeyOutcome, Panel, RenderContext};
const BINDINGS: &[Binding] = &[Binding::primary("s", "seconds")];
const MAX_CLOCK_SCALE: u16 = 3;
const BIG_CLOCK_ROWS: u16 = 5 * MAX_CLOCK_SCALE;
const FRAME_HEIGHT: u16 = 2;
const FRAME_WIDTH: u16 = 4;
const COLUMNS: &[Column] = &[
Column::flex("zone", 1),
Column::fixed("time", 9),
Column::fixed("vs local", 9).right().drops_below(30),
];
#[derive(Debug)]
struct Clock {
label: String,
zone: Result<TimeZone, String>,
}
#[derive(Debug)]
pub struct ClocksPanel {
config: ClocksConfig,
primary: Clock,
secondary: Vec<Clock>,
show_seconds: bool,
}
impl ClocksPanel {
pub fn new(config: ClocksConfig) -> Self {
let mut clocks: Vec<Clock> = config
.zones
.iter()
.map(|zone| Clock {
label: if zone.label.is_empty() {
zone.timezone.clone()
} else {
zone.label.clone()
},
zone: resolve_zone(&zone.timezone),
})
.collect();
let primary = if clocks.is_empty() {
Clock {
label: "Local".into(),
zone: Ok(TimeZone::system()),
}
} else {
clocks.remove(0)
};
let show_seconds = config.show_seconds;
Self {
config,
primary,
secondary: clocks,
show_seconds,
}
}
}
fn resolve_zone(name: &str) -> Result<TimeZone, String> {
if name.eq_ignore_ascii_case("local") || name.is_empty() {
return Ok(TimeZone::system());
}
TimeZone::get(name).map_err(|_| format!("unknown timezone `{name}`"))
}
#[cfg_attr(not(test), allow(dead_code))]
fn format_offset(offset: jiff::tz::Offset) -> String {
let total = offset.seconds();
let sign = if total < 0 { '-' } else { '+' };
let abs = total.abs();
format!("{sign}{:02}:{:02}", abs / 3600, (abs % 3600) / 60)
}
fn relative_offset(primary: jiff::tz::Offset, other: jiff::tz::Offset) -> String {
let delta = i64::from(other.seconds()) - i64::from(primary.seconds());
if delta == 0 {
return "same".to_string();
}
let sign = if delta < 0 { '-' } else { '+' };
let abs = delta.abs();
let (hours, minutes) = (abs / 3600, (abs % 3600) / 60);
if minutes == 0 {
format!("{sign}{hours}h")
} else {
format!("{sign}{hours}h{minutes:02}")
}
}
impl Panel for ClocksPanel {
fn title(&self) -> String {
"Clock".to_string()
}
fn counter(&self) -> Option<String> {
let zone = self.primary.zone.as_ref().ok()?;
Some(
jiff::Timestamp::now()
.to_zoned(zone.clone())
.strftime("%Z")
.to_string(),
)
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn max_width(&self) -> Option<u16> {
Some(glyphs::width_of("00:00:00", MAX_CLOCK_SCALE) + FRAME_WIDTH)
}
fn max_height(&self) -> Option<u16> {
let date = u16::from(!self.config.date_format.is_empty());
let zones = if self.secondary.is_empty() {
0
} else {
u16::try_from(self.secondary.len()).unwrap_or(0) + 2
};
Some(BIG_CLOCK_ROWS + date + zones + FRAME_HEIGHT)
}
fn refresh_interval(&self) -> std::time::Duration {
std::time::Duration::from_millis(250)
}
fn handle_key(&mut self, key: KeyEvent) -> KeyOutcome {
if matches!(key.code, KeyCode::Char('s')) {
self.show_seconds = !self.show_seconds;
return KeyOutcome::Consumed;
}
KeyOutcome::Ignored
}
#[allow(clippy::too_many_lines)] fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
let theme = ctx.theme;
if area.width == 0 || area.height == 0 {
return;
}
let now = jiff::Timestamp::now();
let primary_zone = match &self.primary.zone {
Ok(zone) => zone.clone(),
Err(message) => {
frame.render_widget(
Paragraph::new(Span::styled(
message.clone(),
Style::default().fg(theme.error),
)),
area,
);
return;
}
};
let local = now.to_zoned(primary_zone);
let full = local.strftime("%H:%M:%S").to_string();
let short = local.strftime("%H:%M").to_string();
let seconds = local.strftime("%S").to_string();
let date_rows = u16::from(!self.config.date_format.is_empty());
let zone_rows = if self.secondary.is_empty() {
0
} else {
u16::try_from(self.secondary.len()).unwrap_or(0) + 2
};
let clock_budget = area.height.saturating_sub(date_rows + zone_rows).max(1);
let fits = |text: &str| {
glyphs::fitting_scale(text, area.width, MAX_CLOCK_SCALE)
.filter(|scale| BigText::new(text, *scale).height <= clock_budget)
};
let (time_text, small_seconds) = match (self.show_seconds, fits(&full)) {
(true, Some(_)) => (full.clone(), None),
(true, None) => (short.clone(), Some(seconds.clone())),
(false, _) => (short.clone(), None),
};
let scale = fits(&time_text);
let mut cursor = area.y;
if let Some(scale) = scale {
let big = BigText::new(&time_text, scale);
let suffix = small_seconds
.as_ref()
.map_or(0, |s| u16::try_from(s.chars().count()).unwrap_or(0) + 1);
let total = big.width + suffix;
let x = area.x + (area.width.saturating_sub(total)) / 2;
for (index, row) in big.rows.iter().enumerate() {
let y = area.y + u16::try_from(index).unwrap_or(0);
if y >= area.y + area.height {
break;
}
frame.render_widget(
Paragraph::new(Span::styled(row.clone(), Style::default().fg(theme.accent))),
Rect::new(x, y, big.width.min(area.width), 1),
);
}
if let Some(seconds) = &small_seconds {
let y = area.y + big.height.saturating_sub(1);
let sx = x + big.width + 1;
if sx < area.x + area.width && y < area.y + area.height {
frame.render_widget(
Paragraph::new(Span::styled(
seconds.clone(),
Style::default().fg(theme.muted),
)),
Rect::new(sx, y, suffix.min(area.width), 1),
);
}
}
cursor += big.height;
} else {
let text = if self.show_seconds { full } else { short };
frame.render_widget(
Paragraph::new(Span::styled(
text,
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Rect::new(area.x, cursor, area.width, 1),
);
cursor += 1;
}
if cursor < area.y + area.height && !self.config.date_format.is_empty() {
let date = glyphs::utility(&local.strftime(&self.config.date_format).to_string());
let width = u16::try_from(date.chars().count()).unwrap_or(0);
let x = area.x + (area.width.saturating_sub(width)) / 2;
frame.render_widget(
Paragraph::new(Span::styled(
date,
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
)),
Rect::new(x, cursor, width.min(area.width), 1),
);
cursor += 1;
}
if self.secondary.is_empty() || cursor >= area.y + area.height {
return;
}
let needed = u16::try_from(self.secondary.len()).unwrap_or(0) + 1;
if (area.y + area.height).saturating_sub(cursor) > needed {
cursor += 1;
}
let remaining = (area.y + area.height).saturating_sub(cursor);
if remaining == 0 {
return;
}
let grid = Grid::new(COLUMNS, area.width);
let mut lines = vec![grid.header(theme)];
for clock in &self.secondary {
match &clock.zone {
Ok(zone) => {
let zoned = now.to_zoned(zone.clone());
let day_marker = match zoned.date().cmp(&local.date()) {
std::cmp::Ordering::Greater => " +1d",
std::cmp::Ordering::Less => " -1d",
std::cmp::Ordering::Equal => "",
};
let offset = if self.config.show_offset {
relative_offset(local.offset(), zoned.offset())
} else {
String::new()
};
lines.push(grid.row(&[
Span::styled(clock.label.clone(), Style::default().fg(theme.text)),
Span::styled(
format!("{}{day_marker}", zoned.strftime(&self.config.time_format)),
Style::default().fg(if day_marker.is_empty() {
theme.text
} else {
theme.warning
}),
),
Span::styled(offset, Style::default().fg(theme.muted)),
]));
}
Err(message) => lines.push(grid.row(&[
Span::styled(clock.label.clone(), Style::default().fg(theme.muted)),
Span::styled(message.clone(), Style::default().fg(theme.error)),
])),
}
}
frame.render_widget(
Paragraph::new(lines),
Rect::new(area.x, cursor, area.width, remaining),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ClockZone;
use jiff::tz::Offset;
fn zone(label: &str, tz: &str) -> ClockZone {
ClockZone {
label: label.into(),
timezone: tz.into(),
}
}
#[test]
fn local_and_utc_always_resolve() {
assert!(resolve_zone("local").is_ok());
assert!(resolve_zone("LOCAL").is_ok());
assert!(resolve_zone("").is_ok());
assert!(resolve_zone("UTC").is_ok());
}
#[test]
fn unknown_zones_report_the_name_instead_of_panicking() {
let err = resolve_zone("Mars/Olympus").expect_err("must fail");
assert!(err.contains("Mars/Olympus"), "got: {err}");
}
#[test]
fn offsets_format_with_sign_and_padding() {
assert_eq!(format_offset(Offset::from_seconds(0).unwrap()), "+00:00");
assert_eq!(
format_offset(Offset::from_seconds(9 * 3600).unwrap()),
"+09:00"
);
assert_eq!(
format_offset(Offset::from_seconds(-5 * 3600).unwrap()),
"-05:00"
);
assert_eq!(
format_offset(Offset::from_seconds(5 * 3600 + 1800).unwrap()),
"+05:30"
);
assert_eq!(
format_offset(Offset::from_seconds(5 * 3600 + 2700).unwrap()),
"+05:45"
);
}
#[test]
fn relative_offsets_are_expressed_against_the_primary_clock() {
let utc = Offset::from_seconds(0).unwrap();
let tokyo = Offset::from_seconds(9 * 3600).unwrap();
let new_york = Offset::from_seconds(-4 * 3600).unwrap();
let kolkata = Offset::from_seconds(5 * 3600 + 1800).unwrap();
assert_eq!(relative_offset(utc, tokyo), "+9h");
assert_eq!(relative_offset(utc, new_york), "-4h");
assert_eq!(relative_offset(utc, utc), "same");
assert_eq!(relative_offset(utc, kolkata), "+5h30");
assert_eq!(relative_offset(new_york, tokyo), "+13h");
}
#[test]
fn the_first_zone_becomes_the_large_clock() {
let panel = ClocksPanel::new(ClocksConfig {
zones: vec![zone("Home", "UTC"), zone("Tokyo", "Asia/Tokyo")],
..Default::default()
});
assert_eq!(panel.primary.label, "Home");
assert_eq!(panel.secondary.len(), 1);
assert_eq!(panel.secondary[0].label, "Tokyo");
}
#[test]
fn an_empty_zone_list_still_shows_local_time() {
let panel = ClocksPanel::new(ClocksConfig {
zones: Vec::new(),
..Default::default()
});
assert!(panel.primary.zone.is_ok());
assert!(panel.secondary.is_empty());
}
#[test]
fn a_label_falls_back_to_the_zone_name() {
let panel = ClocksPanel::new(ClocksConfig {
zones: vec![zone("", "UTC"), zone("", "Asia/Tokyo")],
..Default::default()
});
assert_eq!(panel.primary.label, "UTC");
assert_eq!(panel.secondary[0].label, "Asia/Tokyo");
}
#[test]
fn s_toggles_seconds_and_is_consumed() {
let mut panel = ClocksPanel::new(ClocksConfig::default());
let before = panel.show_seconds;
let outcome = panel.handle_key(KeyEvent::new(
KeyCode::Char('s'),
ratatui::crossterm::event::KeyModifiers::NONE,
));
assert_eq!(outcome, KeyOutcome::Consumed);
assert_ne!(panel.show_seconds, before);
}
#[test]
fn other_keys_fall_through_to_the_application() {
let mut panel = ClocksPanel::new(ClocksConfig::default());
let outcome = panel.handle_key(KeyEvent::new(
KeyCode::Tab,
ratatui::crossterm::event::KeyModifiers::NONE,
));
assert_eq!(outcome, KeyOutcome::Ignored);
}
}