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, cell_width};
use crate::keymap::{KeysConfig, Meta, PanelKeymap};
use crate::panel::{KeyOutcome, Panel, RenderContext};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClocksAction {
Seconds,
Add,
Edit,
MoveUp,
MoveDown,
TwelveHour,
Remove,
Up,
Down,
ShowPath,
}
pub const ACTIONS: &[Meta<ClocksAction>] = &[
Meta {
action: ClocksAction::Seconds,
name: "seconds",
defaults: &[(KeyCode::Char('s'), KeyModifiers::NONE)],
label: "seconds",
primary: true,
joins: false,
about: "show or hide seconds",
},
Meta {
action: ClocksAction::Add,
name: "add",
defaults: &[(KeyCode::Char('a'), KeyModifiers::NONE)],
label: "add",
primary: true,
joins: false,
about: "add a clock",
},
Meta {
action: ClocksAction::Edit,
name: "edit",
defaults: &[(KeyCode::Char('e'), KeyModifiers::NONE)],
label: "edit",
primary: true,
joins: false,
about: "edit the selected clock",
},
Meta {
action: ClocksAction::MoveUp,
name: "move_up",
defaults: &[
(KeyCode::Up, KeyModifiers::SHIFT),
(KeyCode::Char('K'), KeyModifiers::NONE),
],
label: "move",
primary: true,
joins: false,
about: "move the selected clock up",
},
Meta {
action: ClocksAction::MoveDown,
name: "move_down",
defaults: &[
(KeyCode::Down, KeyModifiers::SHIFT),
(KeyCode::Char('J'), KeyModifiers::NONE),
],
label: "move",
primary: true,
joins: true,
about: "move the selected clock down",
},
Meta {
action: ClocksAction::TwelveHour,
name: "twelve_hour",
defaults: &[(KeyCode::Char('h'), KeyModifiers::NONE)],
label: "12/24h",
primary: true,
joins: false,
about: "switch between the 24- and 12-hour clock",
},
Meta {
action: ClocksAction::Remove,
name: "remove",
defaults: &[(KeyCode::Char('d'), KeyModifiers::NONE)],
label: "remove",
primary: true,
joins: false,
about: "remove the selected clock",
},
Meta {
action: ClocksAction::Up,
name: "up",
defaults: &[
(KeyCode::Up, KeyModifiers::NONE),
(KeyCode::Char('k'), KeyModifiers::NONE),
],
label: "select a clock",
primary: false,
joins: false,
about: "select the clock above",
},
Meta {
action: ClocksAction::Down,
name: "down",
defaults: &[
(KeyCode::Down, KeyModifiers::NONE),
(KeyCode::Char('j'), KeyModifiers::NONE),
],
label: "select a clock",
primary: false,
joins: true,
about: "select the clock below",
},
Meta {
action: ClocksAction::ShowPath,
name: "show_path",
defaults: &[(KeyCode::Char('o'), KeyModifiers::NONE)],
label: "show file path",
primary: false,
joins: false,
about: "show where the clocks are saved",
},
];
pub fn keymap(keys: &KeysConfig) -> Result<PanelKeymap<ClocksAction>, String> {
PanelKeymap::new("clocks", ACTIONS, keys)
}
fn default_keys() -> PanelKeymap<ClocksAction> {
PanelKeymap::defaults("clocks", ACTIONS)
}
const MAX_CLOCK_SCALE: u16 = 3;
const GLYPH_ROWS: u16 = 5;
const BIG_CLOCK_ROWS: u16 = GLYPH_ROWS * MAX_CLOCK_SCALE;
pub(crate) const COLUMNS: &[Column] = &[
Column::flex("zone", 1),
Column::fixed("time", 12),
Column::fixed("vs local", 9).right().drops_below(30),
];
pub(crate) const COLUMNS_MERIDIEM: &[Column] = &[
Column::flex("zone", 1),
Column::fixed("time", 15),
Column::fixed("vs local", 9).right().drops_below(33),
];
#[derive(Debug)]
struct Clock {
label: String,
zone: Result<TimeZone, String>,
}
#[derive(Debug)]
pub struct ClocksPanel {
keys: PanelKeymap<ClocksAction>,
config: ClocksConfig,
primary: Clock,
secondary: Vec<Clock>,
show_seconds: bool,
twelve_hour: bool,
zones: crate::zones::Zones,
selected: usize,
zone_offset: 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 mut zones = crate::zones::Zones::load(path, &config.zones)?;
zones.save_reporting();
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;
let twelve_hour = config.twelve_hour;
Ok(Self {
keys: default_keys(),
config,
primary,
secondary: clocks,
show_seconds,
twelve_hour,
zones,
selected: 1,
zone_offset: 0,
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),
};
match applied {
Ok(()) => {
self.asking = None;
self.editing = None;
self.reload();
}
Err(refused) => {
if let Some(prompt) = self.asking.as_mut() {
prompt.reject(refused.why());
}
}
}
}
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 let Err(refused) = applied {
prompt.reject(refused.why());
return;
}
self.asking = None;
self.editing = None;
self.reload();
}
}
}
}
fn without_seconds(format: &str) -> String {
let mut out = String::with_capacity(format.len());
let mut copied = 0;
for (range, _, conversion) in specifiers(format) {
let (replacement, separators): (&str, &[char]) = match conversion {
'S' => ("", &[':', '.', '-']),
'f' | 'N' => ("", &['.', ',']),
'T' | 'X' => ("%H:%M", &[]),
'r' => ("%-I:%M %p", &[]),
_ => continue,
};
let mut text = &format[copied..range.start];
if let Some(before) = text.chars().next_back()
&& separators.contains(&before)
{
text = &text[..text.len() - before.len_utf8()];
}
out.push_str(text);
out.push_str(replacement);
copied = range.end;
}
out.push_str(&format[copied..]);
out
}
fn specifiers(format: &str) -> impl Iterator<Item = (std::ops::Range<usize>, &str, char)> {
let mut from = 0;
std::iter::from_fn(move || {
let at = from + format[from..].find('%')?;
let spec = &format[at + 1..];
let flags = spec
.find(|c: char| !matches!(c, '-' | '_' | '0' | '^' | '#'))
.unwrap_or(spec.len());
let width = flags
+ spec[flags..]
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(spec.len() - flags);
let colons = width
+ spec[width..]
.find(|c: char| c != ':')
.unwrap_or(spec.len() - width);
let dotted = spec[colons..].strip_prefix('.').and_then(|rest| {
let digits = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
rest[digits..]
.starts_with('f')
.then_some(colons + 1 + digits)
});
let (between, conversion) = match dotted {
Some(between) => (between, Some('f')),
None => (colons, spec[colons..].chars().next()),
};
let end = at + 1 + between + conversion.map_or(0, char::len_utf8);
from = end;
Some((at..end, &spec[..between], conversion.unwrap_or('%')))
})
}
fn has_meridiem(format: &str) -> bool {
specifiers(format).any(|(_, _, conversion)| matches!(conversion, 'p' | 'P' | 'r'))
}
fn twelve_hour_format(format: &str) -> String {
let mut out = String::with_capacity(format.len() + 3);
let mut converted = false;
let mut after_time = None;
let mut copied = 0;
for (range, flags, conversion) in specifiers(format) {
out.push_str(&format[copied..range.start]);
copied = range.end;
let twin = match conversion {
'H' => "I",
'k' => "l",
'T' => "I:%M:%S",
'R' => "I:%M",
_ => "",
};
if twin.is_empty() {
out.push_str(&format[range]);
} else {
out.push('%');
out.push_str(flags);
out.push_str(twin);
}
converted |= matches!(conversion, 'H' | 'k' | 'T' | 'R');
if matches!(conversion, 'H' | 'k' | 'T' | 'R' | 'I' | 'l' | 'M' | 'S') {
after_time = Some(out.len());
}
}
out.push_str(&format[copied..]);
if converted
&& !has_meridiem(format)
&& let Some(at) = after_time
{
out.insert_str(at, " %p");
}
out
}
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}`"))
}
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}")
}
}
fn choose_face(
show_seconds: bool,
full: &str,
short: &str,
seconds: &str,
meridiem: Option<&str>,
width: u16,
rows: u16,
) -> (String, Option<String>, Option<u16>) {
let fits =
|text: &str, columns: u16| glyphs::fitting_scale(text, columns, rows, MAX_CLOCK_SCALE);
let meridiem_cells = meridiem.map_or(0, |m| cell_width(m).saturating_add(1));
if !show_seconds {
return (
short.to_string(),
None,
fits(short, width.saturating_sub(meridiem_cells)),
);
}
if let Some(scale) = fits(full, width.saturating_sub(meridiem_cells)) {
return (full.to_string(), None, Some(scale));
}
let suffix_cells = cell_width(seconds).max(meridiem.map_or(0, cell_width)) + 1;
if let Some(scale) = fits(short, width.saturating_sub(suffix_cells)) {
return (short.to_string(), Some(seconds.to_string()), Some(scale));
}
(full.to_string(), None, None)
}
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) -> &[Binding] {
self.keys.bindings()
}
fn set_keys(&mut self, config: &crate::config::Config) {
self.keys = PanelKeymap::or_defaults("clocks", ACTIONS, &config.clocks.keys);
}
fn max_width(&self) -> Option<u16> {
let meridiem = if self.twelve_hour { 3 } else { 0 };
Some(glyphs::width_of("00:00:00", MAX_CLOCK_SCALE) + meridiem + 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();
let Some(action) = self.keys.action(key) else {
return KeyOutcome::Ignored;
};
match action {
ClocksAction::Seconds => self.show_seconds = !self.show_seconds,
ClocksAction::TwelveHour => {
self.twelve_hour = !self.twelve_hour;
self.last_shown = None;
}
ClocksAction::Add => {
self.editing = None;
self.asking = Some(crate::prompt::Prompt::new(
"ADD A CLOCK",
"↑↓ choose · Enter adds · Esc cancels",
"",
crate::prompt::Completion::Places(crate::zones::PLACES),
));
}
ClocksAction::Edit => {
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),
));
}
}
ClocksAction::ShowPath => {
self.status = Some(self.zones.path().display().to_string());
}
ClocksAction::MoveUp => self.move_selected_up(),
ClocksAction::MoveDown => self.move_selected_down(),
ClocksAction::Remove => {
if self.zones.remove(self.selected) {
self.reload();
} else {
self.status = Some("the big clock stays".into());
}
}
ClocksAction::Down => {
self.selected = self.selected.saturating_add(1).min(last.max(1));
}
ClocksAction::Up => {
self.selected = self.selected.saturating_sub(1).max(1);
}
}
KeyOutcome::Consumed
}
fn remember(&self, state: &mut crate::state::UiState) {
state.clocks_show_seconds = Some(self.show_seconds);
state.clocks_twelve_hour = Some(self.twelve_hour);
}
#[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 hour = if self.twelve_hour { "%l" } else { "%H" };
let full = local.strftime(&format!("{hour}:%M:%S")).to_string();
let short = local.strftime(&format!("{hour}:%M")).to_string();
let seconds = local.strftime("%S").to_string();
let meridiem = self.twelve_hour.then(|| local.strftime("%p").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 face = |rows: u16| {
choose_face(
self.show_seconds,
&full,
&short,
&seconds,
meridiem.as_deref(),
area.width,
rows,
)
};
let (mut time_text, mut small_seconds, mut scale) = face(clock_budget);
let notice = self
.status
.as_ref()
.or(self.zones.last_error.as_ref())
.filter(|_| area.height >= 2)
.cloned();
if notice.is_some() {
let face_rows = scale.map_or(1, |scale| GLYPH_ROWS * scale);
let table_rows = zone_rows.saturating_sub(1);
if face_rows
.saturating_add(date_rows)
.saturating_add(table_rows)
>= area.height
{
(time_text, small_seconds, scale) = face(clock_budget.saturating_sub(1).max(1));
}
}
let bottom = area.y + area.height - u16::from(notice.is_some());
let mut cursor = area.y;
if let Some(scale) = scale {
let big = BigText::new(&time_text, scale);
let suffix = small_seconds
.iter()
.chain(&meridiem)
.map(|text| cell_width(text))
.max()
.map_or(0, |cells| cells.saturating_add(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 >= bottom {
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;
let room = (area.x + area.width).saturating_sub(sx);
if room >= suffix.saturating_sub(1) && y < bottom {
frame.render_widget(
Paragraph::new(Span::styled(
seconds.clone(),
Style::default().fg(theme.muted),
)),
Rect::new(sx, y, suffix.saturating_sub(1).min(room), 1),
);
}
}
if let Some(meridiem) = &meridiem {
let sx = x + big.width + 1;
let room = (area.x + area.width).saturating_sub(sx);
let cells = suffix.saturating_sub(1);
if room >= cells {
frame.render_widget(
Paragraph::new(Span::styled(
meridiem.clone(),
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::BOLD),
)),
Rect::new(sx, area.y, cells, 1),
);
}
}
cursor += big.height;
} else {
let time = if self.show_seconds { full } else { short };
let text = match &meridiem {
Some(meridiem) => format!("{} {meridiem}", time.trim_start()),
None => time,
};
let text = crate::grid::truncate(&text, usize::from(area.width));
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 < bottom && !self.config.date_format.is_empty() {
let date = glyphs::utility(&local.strftime(&self.config.date_format).to_string());
let date = crate::grid::truncate(&date, usize::from(area.width));
let width = u16::try_from(crate::grid::display_width(&date)).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 let Some(message) = ¬ice {
let row = Rect::new(area.x, bottom, area.width, 1);
frame.render_widget(ratatui::widgets::Clear, row);
frame.render_widget(
Paragraph::new(Span::styled(
crate::grid::truncate(message, usize::from(area.width)),
Style::default().fg(theme.error),
)),
row,
);
}
if self.secondary.is_empty() || cursor >= bottom {
return;
}
let needed = u16::try_from(self.secondary.len()).unwrap_or(u16::MAX);
if bottom.saturating_sub(cursor) > needed.saturating_add(1) {
cursor += 1;
}
let remaining = bottom.saturating_sub(cursor);
if remaining < 2 {
return;
}
let rows = usize::from(remaining - 1);
let at = self.selected.saturating_sub(1);
if at < self.zone_offset {
self.zone_offset = at;
} else if at >= self.zone_offset + rows {
self.zone_offset = at + 1 - rows;
}
self.zone_offset = self
.zone_offset
.min(self.secondary.len().saturating_sub(rows));
let time_format = if self.twelve_hour {
twelve_hour_format(&self.config.time_format)
} else {
self.config.time_format.clone()
};
let time_format = if self.show_seconds {
time_format
} else {
without_seconds(&time_format)
};
let columns = if has_meridiem(&time_format) {
COLUMNS_MERIDIEM
} else {
COLUMNS
};
let grid = Grid::new(columns, area.width);
let mut lines = vec![grid.header(theme)];
for (index, clock) in self
.secondary
.iter()
.enumerate()
.skip(self.zone_offset)
.take(rows)
{
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),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::testing::TempDir;
#[test]
fn every_key_in_the_map_works_and_is_advertised() {
crate::keymap::assert_every_key_works(&default_keys(), |event| {
let (mut p, _g) = panel_from_named("keymap", ClocksConfig::default());
p.handle_key(event)
});
}
use crate::config::ClockZone;
use jiff::tz::Offset;
#[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 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;
let mut shrank = Vec::new();
for width in 20..104u16 {
for height in 6..24u16 {
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 hiding_the_seconds_reads_the_format_the_way_strftime_does() {
assert_eq!(without_seconds("%H:%M:%-S"), "%H:%M");
assert_eq!(without_seconds("%H:%M:%_S"), "%H:%M");
assert_eq!(without_seconds("%H:%M:%02S"), "%H:%M");
assert_eq!(without_seconds("%H:%M %%S"), "%H:%M %%S");
let at = jiff::civil::date(2026, 10, 8)
.at(9, 5, 7, 123_456_789)
.to_zoned(TimeZone::UTC)
.expect("a real instant");
let drawn = |format: &str| at.strftime(format).to_string();
assert_eq!(drawn("%H:%M:%:S"), "09:05:07");
assert_eq!(drawn("%H:%M:%S%.3f"), "09:05:07.123");
let one: Vec<_> = specifiers("%.3f").collect();
assert_eq!(one, [(0..4, ".3", 'f')], "{one:?}");
for (format, expected) in [
("%H:%M:%S", "09:05"),
("%H:%M:%-S", "09:05"),
("%H:%M:%:S", "09:05"),
("%-T", "09:05"),
("%X", "09:05"),
("%r", "9:05 AM"),
("%I:%M:%S %p", "09:05 AM"),
("%H:%M %%S", "09:05 %S"),
("%H:%M:%S%.3f", "09:05"),
("%H:%M:%S.%N", "09:05"),
("%H:%M:%S,%3f", "09:05"),
("%T%.f", "09:05"),
] {
assert_eq!(drawn(&without_seconds(format)), expected, "{format}");
}
assert!(has_meridiem("%r"));
assert!(has_meridiem(&without_seconds("%r")));
}
#[test]
fn a_flagged_seconds_specifier_leaves_the_zone_table_with_s() {
let config = ClocksConfig {
time_format: "%H:%M:%-S".into(),
..ClocksConfig::default()
};
let (mut panel, _guard) = panel_from_named("flagged-seconds", config);
assert!(!panel.secondary.is_empty(), "a zone table to look at");
let zone_rows_with_seconds = |panel: &mut ClocksPanel| -> Vec<String> {
let buffer = crate::widgets::testing::render_in(
panel,
60,
20,
&crate::theme::Theme::default(),
true,
);
crate::widgets::testing::rows(&buffer)
.into_iter()
.filter(|row| row.matches(':').count() >= 2 && !row.contains('\u{2588}'))
.collect()
};
panel.show_seconds = true;
assert!(
!zone_rows_with_seconds(&mut panel).is_empty(),
"with seconds on, the zone rows carry them"
);
panel.show_seconds = false;
let still = zone_rows_with_seconds(&mut panel);
assert!(still.is_empty(), "{still:#?}");
}
#[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");
};
let meridiem = COLUMNS_MERIDIEM
.iter()
.find(|column| column.label == "time")
.expect("there is a time column");
let crate::grid::Width::Fixed(meridiem_width) = meridiem.width else {
panic!("the time column is fixed width");
};
for (time, width) in [("00:00:00", width), ("00:00:00 AM", meridiem_width)] {
for marker in ["", " +1d", " -1d"] {
let widest = format!("{time}{marker}");
assert!(
crate::grid::display_width(&widest) <= usize::from(width),
"`{widest}` needs {} cells and the column is {width}",
crate::grid::display_width(&widest)
);
}
}
}
#[test]
fn the_date_line_says_when_it_has_been_cut() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
for format in [
"MIDWEEK LONG DATE LINE",
"\u{6c34}\u{66dc}\u{65e5} \u{4e5d}\u{6708}",
] {
let full = crate::glyphs::utility(format);
let mut ever_cut = false;
for width in 4..48u16 {
let (mut panel, _guard) = panel_from_named(
"date-cut",
ClocksConfig {
date_format: format.to_string(),
..ClocksConfig::default()
},
);
let mut terminal = Terminal::new(TestBackend::new(width, 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();
let rows: Vec<String> = (0..buffer.area.height)
.map(|y| {
let mut row = String::new();
let mut x = 0u16;
while x < buffer.area.width {
let Some(cell) = buffer.cell((x, y)) else {
break;
};
let symbol = cell.symbol();
row.push_str(symbol);
x += cell_width(symbol).max(1);
}
row
})
.collect();
let head: String = full.chars().take(1).collect();
let Some(row) = rows
.iter()
.find(|line| line.contains(&head) && !line.contains(':'))
else {
continue;
};
let drawn = row.trim();
assert!(
crate::grid::display_width(drawn) <= usize::from(width),
"the date overflowed {width} cells: {drawn:?}"
);
if drawn == full {
continue;
}
ever_cut = true;
assert!(
drawn.ends_with('\u{2026}'),
"the date was cut at {width} without saying so: {drawn:?}"
);
}
assert!(
ever_cut,
"no width in the sweep actually cut `{full}`, so this proves nothing"
);
}
}
#[test]
fn a_date_measured_wider_than_a_u16_is_not_drawn() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let format = format!("MIDWEEK{}", "\u{1}".repeat(70_000));
assert!(
crate::grid::display_width(&crate::grid::truncate(&format, 40)) > 65_535,
"the format no longer survives `truncate` too wide for a u16, so this proves nothing"
);
let (mut panel, _guard) = panel_from_named(
"date-u16",
ClocksConfig {
date_format: format,
..ClocksConfig::default()
},
);
let mut terminal = Terminal::new(TestBackend::new(40, 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();
let rows: Vec<String> = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.filter_map(|x| buffer.cell((x, y)).map(ratatui::buffer::Cell::symbol))
.collect()
})
.collect();
assert!(rows.iter().all(|row| !row.contains("MIDWEEK")), "{rows:#?}");
}
#[test]
fn the_small_seconds_are_drawn_whole_or_not_at_all() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let mut seen_small_seconds = false;
let (mut panel, _guard) = panel_from_named("small-secs", ClocksConfig::default());
panel.show_seconds = true;
for width in 8..=100u16 {
for height in 6..=14u16 {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let rows = crate::widgets::testing::rows(terminal.backend().buffer());
let Some(baseline) = rows.iter().rev().find(|r| r.contains('\u{2588}')) else {
continue;
};
let after = baseline.rsplit('\u{2588}').next().unwrap_or("").trim();
if after.is_empty() {
continue;
}
seen_small_seconds = true;
assert!(
after.len() == 2 && after.bytes().all(|b| b.is_ascii_digit()),
"at {width}x{height} the seconds beside the numerals read {after:?}: {baseline:?}"
);
}
}
assert!(
seen_small_seconds,
"no size in the sweep drew small seconds, so the property was never exercised"
);
}
#[test]
fn the_short_face_is_only_chosen_when_its_seconds_fit_beside_it() {
let (full, short, seconds) = ("15:44:26", "15:44", "26");
let rows = 6;
let short_width = glyphs::width_of(short, 1);
let suffix = 3;
let (text, small, scale) =
choose_face(true, full, short, seconds, None, short_width + suffix, rows);
assert_eq!(
(text.as_str(), small.as_deref(), scale),
(short, Some(seconds), Some(1))
);
let (text, small, scale) = choose_face(
true,
full,
short,
seconds,
None,
short_width + suffix - 1,
rows,
);
assert_eq!(
(text.as_str(), small.as_deref(), scale),
(full, None, None),
"numerals that would cut or drop the seconds must give way to plain text"
);
let (text, small, scale) =
choose_face(false, full, short, seconds, None, short_width, rows);
assert_eq!(
(text.as_str(), small.as_deref(), scale),
(short, None, Some(1))
);
let (text, small, scale) = choose_face(
true,
full,
short,
seconds,
Some("PM"),
short_width + suffix,
rows,
);
assert_eq!(
(text.as_str(), small.as_deref(), scale),
(short, Some(seconds), Some(1))
);
let (_, _, scale) = choose_face(false, full, short, seconds, Some("PM"), short_width, rows);
assert_eq!(
scale, None,
"the numerals fit only by leaving AM/PM no room"
);
let (_, _, scale) = choose_face(
false,
full,
short,
seconds,
Some("PM"),
short_width + 3,
rows,
);
assert_eq!(scale, Some(1));
}
#[test]
fn a_twelve_hour_clock_converts_the_table_format_it_is_given() {
assert_eq!(twelve_hour_format("%H:%M:%S"), "%I:%M:%S %p");
assert_eq!(twelve_hour_format("%k:%M"), "%l:%M %p");
assert_eq!(twelve_hour_format("%-H:%M"), "%-I:%M %p");
assert_eq!(twelve_hour_format("%T"), "%I:%M:%S %p");
assert_eq!(without_seconds(&twelve_hour_format("%T")), "%I:%M %p");
assert_eq!(twelve_hour_format("%R"), "%I:%M %p");
assert_eq!(twelve_hour_format("%H:%M (%Z)"), "%I:%M %p (%Z)");
assert_eq!(twelve_hour_format("%I:%M:%S %p"), "%I:%M:%S %p");
assert_eq!(twelve_hour_format("%-I:%M%P"), "%-I:%M%P");
assert_eq!(twelve_hour_format("%H:%M %p"), "%I:%M %p");
assert_eq!(twelve_hour_format("%M past"), "%M past");
assert_eq!(twelve_hour_format(""), "");
assert_eq!(twelve_hour_format("100%% %H"), "100%% %I %p");
assert_eq!(twelve_hour_format("%%H:%M"), "%%H:%M");
assert_eq!(twelve_hour_format("%H %"), "%I %p %");
}
#[test]
fn h_switches_the_clock_between_twelve_and_twenty_four_hours() {
let (mut panel, _guard) = panel_from_named("h-key", ClocksConfig::default());
assert!(!panel.twelve_hour);
assert_eq!(
panel.handle_key(KeyEvent::from(KeyCode::Char('h'))),
KeyOutcome::Consumed
);
assert!(panel.twelve_hour);
let mut state = crate::state::UiState::default();
panel.remember(&mut state);
assert_eq!(state.clocks_twelve_hour, Some(true));
press(&mut panel, KeyCode::Char('h'));
assert!(!panel.twelve_hour);
}
fn drawn(panel: &mut ClocksPanel, width: u16, height: u16) -> Vec<String> {
crate::widgets::testing::rows(&crate::widgets::testing::rendered(panel, width, height))
}
#[test]
fn the_meridiem_is_drawn_whole_or_not_at_all() {
let (mut seen_beside, mut seen_plain) = (false, false);
let (mut panel, _guard) = panel_from_named("meridiem", ClocksConfig::default());
panel.twelve_hour = true;
for show_seconds in [true, false] {
panel.show_seconds = show_seconds;
for width in 6..=100u16 {
for height in 6..=14u16 {
let rows = drawn(&mut panel, width, height);
let top = &rows[0];
if top.contains('\u{2588}') {
let after = top.rsplit('\u{2588}').next().unwrap_or("").trim();
assert!(
after == "AM" || after == "PM",
"at {width}x{height} the numerals' top row ends {after:?}: {top:?}"
);
seen_beside = true;
} else {
let text = top.trim();
assert!(
text.ends_with("AM")
|| text.ends_with("PM")
|| text.ends_with('\u{2026}'),
"at {width}x{height} the plain clock lost its meridiem silently: {text:?}"
);
seen_plain = true;
}
}
}
}
assert!(
seen_beside && seen_plain,
"the sweep must reach both faces, or it proves half of this"
);
}
#[test]
fn a_twelve_hour_zone_row_keeps_its_day_marker() {
let (mut panel, _guard) = panel_from_named(
"meridiem-marker",
ClocksConfig {
zones: vec![
zone("Here", "Etc/GMT+12"),
zone("Kiritimati", "Pacific/Kiritimati"),
],
twelve_hour: true,
..ClocksConfig::default()
},
);
let rows = drawn(&mut panel, 40, 20);
let row = rows
.iter()
.find(|row| row.contains("Kiritimati"))
.expect("the zone row is drawn");
assert!(
row.contains("M +1d"),
"a 12-hour row keeps AM/PM and its marker: {row:?}"
);
}
fn panel_from_named(name: &str, config: ClocksConfig) -> (ClocksPanel, TempDir) {
let dir = TempDir::new(&format!("zones-{name}"));
let panel = ClocksPanel::new(config, dir.join("zones.toml")).expect("builds from a seed");
(panel, 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");
let rows = drawn(&mut panel, 40, 12);
assert!(
rows.iter().any(|row| row.contains("the big clock stays")),
"on screen, too: {rows:#?}"
);
}
#[test]
fn a_first_run_writes_the_seeded_zone_file() {
let seed = vec![zone("Home", "UTC"), zone("Tokyo", "Asia/Tokyo")];
let (panel, _guard) = panel_from_named(
"first_run_writes",
ClocksConfig {
zones: seed.clone(),
..ClocksConfig::default()
},
);
assert!(
panel.zones.path().exists(),
"the seed is on disk to be edited"
);
assert_eq!(panel.zones.last_error, None);
let written = crate::zones::Zones::load(panel.zones.path(), &[]).expect("reads back");
let names = |zones: &[ClockZone]| -> Vec<(String, String)> {
zones
.iter()
.map(|z| (z.label.clone(), z.timezone.clone()))
.collect()
};
assert_eq!(names(written.zones()), names(&seed));
}
#[test]
fn an_empty_seed_writes_no_zone_file() {
let (panel, _guard) = panel_from_named(
"empty_seed_writes_nothing",
ClocksConfig {
zones: Vec::new(),
..ClocksConfig::default()
},
);
assert!(!panel.zones.path().exists());
}
#[test]
fn a_seed_that_cannot_be_written_is_reported_not_fatal() {
let (_, guard) = panel_from_named("unwritable_seed", ClocksConfig::default());
let blocker = guard.join("blocker");
std::fs::write(&blocker, "a file where a directory has to be").unwrap();
let panel = ClocksPanel::new(ClocksConfig::default(), blocker.join("zones.toml"))
.expect("a failed save does not stop the panel");
assert!(panel.zones.last_error.is_some(), "it is reported");
assert!(panel.alert().is_some(), "where the status bar sees it");
}
#[test]
fn the_selected_clock_is_always_on_screen() {
let names = [
"Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel",
];
let zones = [
"UTC",
"Asia/Tokyo",
"Europe/Paris",
"America/New_York",
"Australia/Sydney",
"Asia/Kolkata",
"America/Chicago",
"Africa/Cairo",
];
let mut seed = vec![zone("Home", "local")];
seed.extend(names.iter().zip(zones).map(|(n, z)| zone(n, z)));
let (mut panel, _guard) = panel_from_named(
"window",
ClocksConfig {
zones: seed,
..ClocksConfig::default()
},
);
let first = drawn(&mut panel, 40, 8);
assert!(
!first.iter().any(|row| row.contains("Hotel")),
"the table has to be too tall for the panel, or this proves nothing: {first:#?}"
);
let on_screen = |panel: &mut ClocksPanel, name: &str| {
assert_eq!(names[panel.selected - 1], name);
let rows = drawn(panel, 40, 8);
let shown = rows.iter().filter(|row| row.contains(name)).count();
assert_eq!(shown, 1, "{name} is selected: {rows:#?}");
};
let (last, rest) = names.split_last().expect("names");
for name in rest {
on_screen(&mut panel, name);
press(&mut panel, KeyCode::Down);
}
on_screen(&mut panel, last);
for name in rest.iter().rev() {
press(&mut panel, KeyCode::Up);
on_screen(&mut panel, name);
}
}
#[test]
fn a_notice_on_a_lone_clock_has_its_row_to_itself() {
for height in 2..=14u16 {
let (mut panel, _guard) = panel_from_named(
"lone-notice",
ClocksConfig {
zones: Vec::new(),
..ClocksConfig::default()
},
);
panel.status = Some("the big clock stays".into());
let rows = drawn(&mut panel, 40, height);
let last = rows.last().expect("a row").trim_end().to_string();
assert_eq!(last, "the big clock stays", "height {height}: {rows:#?}");
}
}
#[test]
fn a_notice_never_covers_a_clock() {
let (mut panel, _guard) = panel_from_named(
"notice_row",
ClocksConfig {
zones: vec![
zone("Home", "local"),
zone("Alpha", "UTC"),
zone("Bravo", "Asia/Tokyo"),
zone("Charlie", "Europe/Paris"),
],
..ClocksConfig::default()
},
);
press(&mut panel, KeyCode::Down);
press(&mut panel, KeyCode::Down);
let calm = drawn(&mut panel, 40, 7);
assert!(
calm[6].contains("Charlie"),
"the table ends on the panel's last row: {calm:#?}"
);
press(&mut panel, KeyCode::Char('o'));
let rows = drawn(&mut panel, 40, 7);
assert!(
rows.iter().any(|row| row.contains("Charlie")),
"the selected clock is still drawn: {rows:#?}"
);
let path = panel.zones.path().display().to_string();
let head: String = path.chars().take(10).collect();
assert!(rows[6].contains(&head), "and the path under it: {rows:#?}");
}
#[test]
fn a_notice_does_not_cost_the_numerals_a_scale_step() {
let numerals = |rows: &[String]| rows.iter().filter(|row| row.contains('\u{2588}')).count();
let notice = "the big clock stays";
let (mut table, _a) = panel_from_named("notice-table", ClocksConfig::default());
let (mut lone, _b) = panel_from_named(
"notice-lone",
ClocksConfig {
zones: vec![zone("Home", "local")],
..ClocksConfig::default()
},
);
let (mut undated, _c) = panel_from_named(
"notice-undated",
ClocksConfig {
zones: vec![zone("Home", "local")],
date_format: String::new(),
..ClocksConfig::default()
},
);
let width = table.max_width().expect("a widest") - FRAME_WIDTH;
let tallest = table.max_height().expect("a tallest") - FRAME_HEIGHT;
table.status = None;
let calm = drawn(&mut table, width, tallest);
assert_eq!(
numerals(&calm),
usize::from(BIG_CLOCK_ROWS),
"the numerals are at their largest, or this proves nothing: {calm:#?}"
);
table.status = Some(notice.into());
let noted = drawn(&mut table, width, tallest);
assert_eq!(numerals(¬ed), numerals(&calm), "{noted:#?}");
assert!(noted.last().is_some_and(|row| row.contains(notice)));
for name in ["UTC", "London", "Tokyo"] {
assert!(
noted.iter().any(|row| row.contains(name)),
"{name} is still drawn: {noted:#?}"
);
}
for (panel, has_table, dated) in [
(&mut table, true, true),
(&mut lone, false, true),
(&mut undated, false, false),
] {
for height in 2..=tallest + 3 {
panel.status = None;
let calm = drawn(panel, width, height);
panel.status = Some(notice.into());
let noted = drawn(panel, width, height);
assert!(
noted.last().is_some_and(|row| row.contains(notice)),
"height {height}: {noted:#?}"
);
assert_eq!(
numerals(¬ed) % usize::from(GLYPH_ROWS),
0,
"height {height}, numerals cut under the notice: {noted:#?}"
);
let face = numerals(&calm);
if dated && face > 0 {
let date = calm[face].trim();
assert!(!date.is_empty(), "the date is under the numerals");
assert!(
noted.iter().any(|row| row.trim() == date),
"height {height}, the date gave way: {noted:#?}"
);
}
let spare = has_table || calm.last().is_some_and(|row| row.trim().is_empty());
if spare {
assert_eq!(
numerals(¬ed),
numerals(&calm),
"height {height}, a row to spare: {calm:#?} {noted:#?}"
);
}
}
}
}
#[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_label_with_no_zone_is_refused_for_that_and_not_as_a_duplicate() {
let (mut panel, _guard) = panel_from_named(
"a_label_with_no_zone_is_refu",
ClocksConfig {
zones: vec![zone("Home", "local"), zone("UTC", "UTC")],
..ClocksConfig::default()
},
);
let complaint = |panel: &ClocksPanel| {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let theme = crate::config::Config::default().theme;
let prompt = panel.asking.as_ref().expect("the prompt stays open");
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
terminal
.draw(|frame| prompt.render(frame, frame.area(), &theme))
.unwrap();
let buffer = terminal.backend().buffer().clone();
buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
};
press(&mut panel, KeyCode::Char('a'));
for c in "Somewhere =".chars() {
press(&mut panel, KeyCode::Char(c));
}
let before = labels(&panel);
press(&mut panel, KeyCode::Enter);
let screen = complaint(&panel);
assert!(screen.contains("name a zone"), "add: {screen}");
assert!(!screen.contains("already"), "add: {screen}");
assert_eq!(labels(&panel), before, "and nothing was added");
press(&mut panel, KeyCode::Esc);
press(&mut panel, KeyCode::Char('e'));
for _ in 0..40 {
press(&mut panel, KeyCode::Backspace);
}
for c in "Elsewhere =".chars() {
press(&mut panel, KeyCode::Char(c));
}
press(&mut panel, KeyCode::Enter);
let screen = complaint(&panel);
assert!(screen.contains("name a zone"), "edit: {screen}");
assert!(!screen.contains("already"), "edit: {screen}");
}
#[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 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(default_keys().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 the_twelve_hour_key_reaches_the_border_once_there_is_room() {
let theme = crate::theme::Theme::default();
let drawn = |budget: u16| -> String {
crate::frame::hint_line(default_keys().bindings(), &theme, budget)
.expect("the clock has primaries")
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
let default = drawn(52 - 8);
assert!(
default.contains("Shift+↑↓") && !default.contains("12/24h"),
"at the default width the move key still fits and `h` drops: {default:?}"
);
let roomy = drawn(55);
assert!(
roomy.contains("h 12/24h"),
"a clock with room for `h` must show it: {roomy:?}"
);
assert!(
!roomy.contains("d remove"),
"`h` is offered before `d`, so at 55 cells it is `h` that fits: {roomy:?}"
);
let wide = drawn(200);
let (h, d) = (wide.find("h 12/24h"), wide.find("d remove"));
assert!(
matches!((h, d), (Some(h), Some(d)) if h < d),
"a wide clock shows `h` and then `d`: {wide:?}"
);
}
#[test]
fn move_is_offered_to_the_border_before_remove() {
let keys = default_keys();
let primaries: Vec<&str> = keys
.bindings()
.iter()
.filter(|b| b.primary)
.map(|b| b.action.as_ref())
.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:?}"
);
}
}