use jiff::tz::TimeZone;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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, FRAME_HEIGHT, FRAME_WIDTH};
use crate::glyphs::{self, BigText};
use crate::grid::{Column, Grid};
use crate::panel::{KeyOutcome, Panel, RenderContext};
const BINDINGS: &[Binding] = &[
Binding::primary("s", "seconds"),
Binding::primary("a", "add"),
Binding::primary("e", "edit"),
Binding::primary("Shift+↑↓", "move"),
Binding::primary("d", "remove"),
Binding::extra("↑ / ↓", "select a clock"),
Binding::extra("j / k", "select a clock"),
Binding::extra("J / K", "move it"),
Binding::extra("o", "show file path"),
];
const MAX_CLOCK_SCALE: u16 = 3;
const BIG_CLOCK_ROWS: u16 = 5 * MAX_CLOCK_SCALE;
const COLUMNS: &[Column] = &[
Column::flex("zone", 1),
Column::fixed("time", 12),
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,
zones: crate::zones::Zones,
selected: usize,
asking: Option<crate::prompt::Prompt>,
editing: Option<usize>,
status: Option<String>,
last_shown: Option<i64>,
}
impl ClocksPanel {
pub fn new(config: ClocksConfig, path: std::path::PathBuf) -> anyhow::Result<Self> {
let zones = crate::zones::Zones::load(path, &config.zones)?;
let mut clocks: Vec<Clock> = zones
.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;
Ok(Self {
config,
primary,
secondary: clocks,
show_seconds,
zones,
selected: 1,
asking: None,
editing: None,
status: None,
last_shown: None,
})
}
fn reload(&mut self) {
let mut clocks: Vec<Clock> = self
.zones
.zones()
.iter()
.map(|zone| Clock {
label: if zone.label.is_empty() {
zone.timezone.clone()
} else {
zone.label.clone()
},
zone: resolve_zone(&zone.timezone),
})
.collect();
if !clocks.is_empty() {
self.primary = clocks.remove(0);
}
self.secondary = clocks;
self.selected = self.selected.clamp(1, self.secondary.len().max(1));
self.zones.save_reporting();
self.last_shown = None;
}
fn move_selected_up(&mut self) {
if self.zones.move_up(self.selected) {
self.selected -= 1;
self.reload();
} else {
self.status = Some("the big clock stays".into());
}
}
fn move_selected_down(&mut self) {
if self.zones.move_down(self.selected) {
self.selected += 1;
self.reload();
}
}
fn handle_prompt_key(&mut self, key: KeyEvent) {
let Some(prompt) = self.asking.as_mut() else {
return;
};
match prompt.handle_key(key) {
crate::prompt::Outcome::Editing => {}
crate::prompt::Outcome::Cancelled => {
self.asking = None;
self.editing = None;
}
crate::prompt::Outcome::Chose { label, value } => {
let applied = match self.editing {
Some(index) => self.zones.edit(index, label, value),
None => self.zones.add(label, value),
};
if applied {
self.asking = None;
self.editing = None;
self.reload();
} else if let Some(prompt) = self.asking.as_mut() {
prompt.reject("that clock is already on the panel");
}
}
crate::prompt::Outcome::Submitted(answer) => {
let (label, timezone) = match answer.split_once('=') {
Some((label, zone)) => (label.trim(), zone.trim()),
None => ("", answer.trim()),
};
if resolve_zone(timezone).is_err() {
prompt.reject(format!("unknown timezone `{timezone}`"));
return;
}
let applied = match self.editing {
Some(index) => self.zones.edit(index, label, timezone),
None => self.zones.add(label, timezone),
};
if !applied {
prompt.reject("that clock is already on the panel");
return;
}
self.asking = None;
self.editing = None;
self.reload();
}
}
}
}
fn without_seconds(format: &str) -> String {
for token in ["%:S", "%S", "%T"] {
if let Some(at) = format.find(token) {
let replacement = if token == "%T" { "%H:%M" } else { "" };
let mut start = at;
if replacement.is_empty()
&& let Some(before) = format[..at].chars().next_back()
&& matches!(before, ':' | '.' | '-')
{
start -= before.len_utf8();
}
return format!(
"{}{replacement}{}",
&format[..start],
&format[at + token.len()..]
);
}
}
format.to_string()
}
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 {
if self.show_seconds {
std::time::Duration::from_millis(250)
} else {
std::time::Duration::from_secs(1)
}
}
fn tick(&mut self) -> bool {
let second = jiff::Timestamp::now().as_second();
let unit = if self.show_seconds {
second
} else {
second / 60
};
let moved = self.last_shown != Some(unit);
self.last_shown = Some(unit);
moved
}
fn overlay(&self) -> Option<&crate::prompt::Prompt> {
self.asking.as_ref()
}
fn alert(&self) -> Option<crate::panel::Alert> {
self.zones.last_error.as_ref().map(|why| {
crate::panel::Alert::failing(format!("The world clocks could not be saved — {why}"))
})
}
fn captures_input(&self) -> bool {
self.asking.is_some()
}
fn handle_key(&mut self, key: KeyEvent) -> KeyOutcome {
if self.asking.is_some() {
self.handle_prompt_key(key);
return KeyOutcome::Consumed;
}
self.status = None;
let last = self.secondary.len();
match key.code {
KeyCode::Char('s') => self.show_seconds = !self.show_seconds,
KeyCode::Char('a') => {
self.editing = None;
self.asking = Some(crate::prompt::Prompt::new(
"ADD A CLOCK",
"Type to narrow · ↑↓ to choose · Enter adds · Esc cancels",
"",
crate::prompt::Completion::Places(crate::zones::PLACES),
));
}
KeyCode::Char('e') => {
if let Some(zone) = self.zones.zones().get(self.selected) {
self.editing = Some(self.selected);
self.asking = Some(crate::prompt::Prompt::new(
"EDIT THIS CLOCK",
"`Label = Zone` · Enter saves · Esc cancels",
&format!("{} = {}", zone.label, zone.timezone),
crate::prompt::Completion::Places(crate::zones::PLACES),
));
}
}
KeyCode::Char('o') => {
self.status = Some(self.zones.path().display().to_string());
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => self.move_selected_up(),
KeyCode::Char('K') => self.move_selected_up(),
KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
self.move_selected_down();
}
KeyCode::Char('J') => self.move_selected_down(),
KeyCode::Char('d') => {
if self.zones.remove(self.selected) {
self.reload();
} else {
self.status = Some("the big clock stays".into());
}
}
KeyCode::Down | KeyCode::Char('j') => {
self.selected = self.selected.saturating_add(1).min(last.max(1));
}
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1).max(1);
}
_ => return KeyOutcome::Ignored,
}
KeyOutcome::Consumed
}
fn remember(&self, state: &mut crate::state::UiState) {
state.clocks_show_seconds = Some(self.show_seconds);
}
#[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, clock_budget, MAX_CLOCK_SCALE);
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)];
let time_format = if self.show_seconds {
self.config.time_format.clone()
} else {
without_seconds(&self.config.time_format)
};
for (index, clock) in self.secondary.iter().enumerate() {
let here = ctx.focused && index + 1 == self.selected;
let label = if here {
Style::default()
.fg(theme.text)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(theme.text)
};
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(), label),
Span::styled(
format!("{}{day_marker}", zoned.strftime(&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(), label),
Span::styled(message.clone(), Style::default().fg(theme.error)),
])),
}
}
frame.render_widget(
Paragraph::new(lines),
Rect::new(area.x, cursor, area.width, remaining),
);
if let Some(message) = self.status.as_ref().or(self.zones.last_error.as_ref())
&& area.height > 0
{
frame.render_widget(
Paragraph::new(Span::styled(
crate::grid::truncate(message, usize::from(area.width)),
Style::default().fg(theme.error),
)),
Rect::new(area.x, area.y + area.height - 1, area.width, 1),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ClockZone;
use jiff::tz::Offset;
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn hiding_the_seconds_never_shrinks_the_clock() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let draws_block_numerals = |panel: &mut ClocksPanel, w: u16, h: u16| -> bool {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..h).any(|y| {
(0..w).any(|x| {
buffer
.cell((x, y))
.is_some_and(|cell| cell.symbol() == "\u{2588}")
})
})
};
let mut shrank = Vec::new();
for width in 20..104u16 {
for height in 6..24u16 {
let (mut with, _a) = panel_from_named("secs-on", ClocksConfig::default());
with.show_seconds = true;
let (mut without, _b) = panel_from_named("secs-off", ClocksConfig::default());
without.show_seconds = false;
if draws_block_numerals(&mut with, width, height)
&& !draws_block_numerals(&mut without, width, height)
{
shrank.push((width, height));
}
}
}
assert!(
shrank.is_empty(),
"at {} panel sizes, hiding the seconds dropped the clock to plain \
text; first few: {:?}",
shrank.len(),
&shrank[..shrank.len().min(6)]
);
}
#[test]
fn hiding_the_seconds_takes_them_out_of_the_zone_table_too() {
assert_eq!(without_seconds("%H:%M:%S"), "%H:%M");
assert_eq!(without_seconds("%I:%M:%S %p"), "%I:%M %p");
assert_eq!(without_seconds("%T"), "%H:%M");
assert_eq!(without_seconds("%H:%M"), "%H:%M");
assert_eq!(without_seconds(""), "");
assert!(!without_seconds("%H:%M:%S").ends_with(':'));
assert!(!without_seconds("%H.%M.%S").ends_with('.'));
}
#[test]
fn the_rendered_zone_table_loses_its_seconds_when_s_does() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let screen = |panel: &mut ClocksPanel| -> String {
let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..20)
.map(|y| {
(0..60)
.filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let (mut panel, _guard) = panel_from_named("seconds-wiring", ClocksConfig::default());
assert!(
!panel.secondary.is_empty(),
"the default config seeds secondary zones"
);
panel.show_seconds = true;
let with = screen(&mut panel);
let rows_with_two_colons = with
.lines()
.filter(|line| line.matches(':').count() >= 2)
.count();
assert!(
rows_with_two_colons > 0,
"with seconds on, some zone row shows HH:MM:SS\n{with}"
);
panel.show_seconds = false;
let without = screen(&mut panel);
let still_two = without
.lines()
.filter(|line| {
line.matches(':').count() >= 2 && !line.contains('\u{2588}')
})
.count();
assert_eq!(
still_two, 0,
"with seconds off, no zone row should still carry them\n{without}"
);
}
#[test]
fn the_day_marker_fits_beside_the_time_it_belongs_to() {
let time = COLUMNS
.iter()
.find(|column| column.label == "time")
.expect("there is a time column");
let crate::grid::Width::Fixed(width) = time.width else {
panic!("the time column is fixed width");
};
for marker in ["", " +1d", " -1d"] {
let widest = format!("00:00:00{marker}");
assert!(
crate::grid::display_width(&widest) <= usize::from(width),
"`{widest}` needs {} cells and the column is {width}",
crate::grid::display_width(&widest)
);
}
}
fn panel_from_named(name: &str, config: ClocksConfig) -> (ClocksPanel, TempDir) {
let dir = std::env::temp_dir().join(format!("mirador-zones-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("test directory");
let panel = ClocksPanel::new(config, dir.join("zones.toml")).expect("builds from a seed");
(panel, TempDir(dir))
}
fn zone(label: &str, tz: &str) -> ClockZone {
ClockZone {
label: label.into(),
timezone: tz.into(),
}
}
fn press(panel: &mut ClocksPanel, code: KeyCode) {
panel.handle_key(KeyEvent::from(code));
}
fn labels(panel: &ClocksPanel) -> Vec<String> {
panel.secondary.iter().map(|c| c.label.clone()).collect()
}
#[test]
fn the_cursor_stays_on_a_real_clock_after_a_removal() {
let (mut panel, _guard) = panel_from_named(
"the_cursor_stays_on_a_real_c",
ClocksConfig {
zones: vec![
zone("Home", "local"),
zone("UTC", "UTC"),
zone("Tokyo", "Asia/Tokyo"),
],
..ClocksConfig::default()
},
);
press(&mut panel, KeyCode::Down);
assert_eq!(panel.selected, 2);
press(&mut panel, KeyCode::Char('d'));
assert_eq!(labels(&panel), ["UTC"], "Tokyo is gone");
assert_eq!(panel.selected, 1);
press(&mut panel, KeyCode::Char('d'));
assert!(labels(&panel).is_empty(), "and UTC goes too");
}
#[test]
fn the_big_clock_survives_and_says_so() {
let (mut panel, _guard) = panel_from_named(
"the_big_clock_survives_and_s",
ClocksConfig {
zones: vec![zone("Home", "local")],
..ClocksConfig::default()
},
);
press(&mut panel, KeyCode::Char('d'));
assert_eq!(panel.primary.label, "Home", "still there");
assert!(panel.status.is_some(), "and the panel says why");
}
#[test]
fn a_zone_that_does_not_resolve_is_refused_with_the_prompt_left_open() {
let (mut panel, _guard) =
panel_from_named("a_zone_that_does_not_resolve", ClocksConfig::default());
press(&mut panel, KeyCode::Char('a'));
for c in "Mars/Olympus".chars() {
panel.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
let before = labels(&panel);
press(&mut panel, KeyCode::Enter);
assert!(panel.asking.is_some(), "the prompt stays open to be fixed");
assert_eq!(labels(&panel), before, "and nothing was added");
}
#[test]
fn a_clock_can_be_added_with_or_without_a_label() {
let (mut panel, _guard) = panel_from_named(
"a_clock_can_be_added_with_or",
ClocksConfig {
zones: vec![zone("Home", "local")],
..ClocksConfig::default()
},
);
press(&mut panel, KeyCode::Char('a'));
for c in "Bengaluru".chars() {
panel.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
press(&mut panel, KeyCode::Enter);
assert_eq!(
labels(&panel),
["Bengaluru"],
"the city you picked, not the one in the identifier"
);
press(&mut panel, KeyCode::Char('a'));
for c in "HQ = Europe/Berlin".chars() {
panel.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
press(&mut panel, KeyCode::Enter);
assert_eq!(labels(&panel), ["Bengaluru", "HQ"], "or named by you");
}
#[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, _guard) = panel_from_named(
"the_first_zone_becomes_the_l",
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, _guard) = panel_from_named(
"an_empty_zone_list_still_sho",
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, _guard) = panel_from_named(
"a_label_falls_back_to_the_zo",
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, _guard) =
panel_from_named("s_toggles_seconds_and_is_con", 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, _guard) =
panel_from_named("other_keys_fall_through_to_t", ClocksConfig::default());
let outcome = panel.handle_key(KeyEvent::new(
KeyCode::Tab,
ratatui::crossterm::event::KeyModifiers::NONE,
));
assert_eq!(outcome, KeyOutcome::Ignored);
}
#[test]
fn the_move_keys_reach_the_border_at_the_default_width() {
const BUDGET: u16 = 52 - 8;
let theme = crate::theme::Theme::default();
let hint =
crate::frame::hint_line(BINDINGS, &theme, BUDGET).expect("the clock has primaries");
let drawn: String = hint.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
drawn.contains("Shift+↑↓"),
"reordering is invisible on the border: {drawn:?}"
);
assert!(
drawn.contains("a add") && drawn.contains("e edit"),
"adding and editing must survive alongside it: {drawn:?}"
);
}
#[test]
fn move_is_offered_to_the_border_before_remove() {
let primaries: Vec<&str> = BINDINGS
.iter()
.filter(|b| b.primary)
.map(|b| b.action)
.collect();
let move_at = primaries
.iter()
.position(|a| *a == "move")
.expect("`move` must be a primary, or it never reaches the border");
let remove_at = primaries
.iter()
.position(|a| *a == "remove")
.expect("`remove` is still a primary");
assert!(
move_at < remove_at,
"move must come first; got {primaries:?}"
);
}
}