Skip to main content

clock

A terminal clock: one analog face per time zone, its name and digital readout underneath, ordered by the time they read and spread over the window. Two independent implementations — Python and Go — that render byte-for-byte identical output.

clock 10001,PT,Jakarta,UTC      # the Go build
pyclock 10001,PT,Jakarta,UTC    # the Python one, to the same byte

Four clocks, in a 2x2 grid: PDT, EDT, UTC and WIB

On a terminal the hands are coloured apart — hour yellow, minute cyan, second red.

Installation

The two ports install separately, and the command each one leaves you with has a different name:

how you install it the command
a downloaded binary, or go install github.com/choey/clock@latest clock
pipx install terminal-clock, or pip install terminal-clock pyclock

They are named apart on purpose. go install produces a binary called clock — that name is the last element of the Go module path, not ours to choose — so the Python side takes pyclock, and the two can sit on one PATH without shadowing each other. That is also the only way to check this project's central claim without a checkout:

clock ET,PT,UTC | diff - <(pyclock ET,PT,UTC) && echo "byte for byte"

Neither implementation has a third-party dependency. The Go side is standard library only; the Python side needs nothing beyond zoneinfo, which has shipped in the standard library since 3.9. The one thing either reaches outside itself for is ziptz, the ZIP-to-zone library — also standard library only, and also written in both languages. Zones, countries and their names are read from the tz database already on the machine, out of its own zone.tab and iso3166.tab; nothing is installed for them and nothing is copied in here.

Go

No toolchain needed: every release carries a binary for each platform below.

curl -Lo clock https://github.com/choey/clock/releases/latest/download/clock_darwin_arm64
chmod +x clock && ./clock ET,PT,UTC

Swap the name for clock_darwin_amd64, clock_linux_amd64, clock_linux_arm64 or clock_freebsd_amd64. SHA256SUMS in the same release covers all five, so shasum -a 256 -c SHA256SUMS --ignore-missing checks what you got. The binaries are built from the tag with CGO_ENABLED=0 — the clock is pure Go with no cgo — which is also why the Linux ones run on a distro older than whatever built them.

With a toolchain:

go install github.com/choey/clock@latest

puts a clock on your PATH in $GOPATH/bin (or $GOBIN), fetches ziptz itself, and needs no checkout. From a clone instead:

go build -o clock . && mv clock /usr/local/bin/    # wherever you like
go install .                                       # or $GOPATH/bin

Both read ziptz from go.mod and fetch it through the module proxy on the first build, which is the one thing a fresh clone needs a network for. Build the package, not a file — go build ., never go build clock.go — for the build-tag reason below.

Go has no notion of an optional dependency, so every Go build resolves ZIP codes. On the Python side that is a choice — see below.

Python

pipx is the better verb for a program rather than a library: it gets its own environment and puts the command on your PATH regardless of what else is installed.

pipx install terminal-clock     # then: pyclock ET,PT,UTC

pip install terminal-clock does the same inside whatever environment you run it in. Either way the command is pyclock, and ziptz-us comes with it, so ZIP codes work out of the box exactly as they do from a go install.

Not pip install clock. That name on PyPI belongs to an unrelated datetime library from 2014, and installing it will quietly get you that instead of this. ziptz has the same problem and the same answer: it is ziptz-us on PyPI, where the bare name is an old empty registration pip cannot install, and it still imports as ziptz.

From a clone:

pip install .        # a pyclock entry point, plus ziptz-us
pip install -e .     # the same, re-reading this checkout on every run
make setup           # a .venv here, which every make target then uses

make setup is the shorter path for running the test suite, and the one that works on a system python that refuses pip install outright.

pyclock.py also runs as it stands — it carries a #!/usr/bin/env python3 shebang and needs no build step — so copying the one file is a first-class path:

cp pyclock.py /usr/local/bin/pyclock          # and, for ZIP codes:
curl -sO https://raw.githubusercontent.com/choey/ziptz/v0.1.2/ziptz.py
mv ziptz.py /usr/local/bin/

That second half is optional, and it is why pyclock.py guards its import of ziptz rather than requiring it outright: without the library every zone name, abbreviation and country code still works, and a ZIP code says what to install instead of the clock refusing to start. ziptz is a hard requirement of the package, not an extra — it is 23 KB with no dependencies of its own, so requiring it costs less than explaining when you would want it left out.

That guard is also the one thing a checkout no longer does for free. ziptz used to sit in this repository, so python3 pyclock.py 94110 worked out of a clone with nothing installed; it is its own module now, so the Python clock wants it installed — pip install ziptz-us, or a copy of ziptz.py beside pyclock.py. Every other kind of zone works without it, and make test says so plainly rather than reporting a Go clock that resolves ZIPs and a Python one that cannot as hundreds of differences.

Versions

However it got there, clock --version says which release you have, and both implementations print the same line. They also print the same help and answer to the same name in their own usage text; only the file, the module and the command differ. ziptz is released on its own cycle and answers separately — ziptz.Version in Go, ziptz.__version__ in Python — so a ZIP that resolves to the wrong zone is a question about that version rather than this one.

Running

The Go build is clock and the Python one is pyclock — two names for one program, because go install does not let its binary be called anything but the last element of the module path. Either takes the same arguments:

clock 10001,PT,Jakarta,UTC
pyclock 10001,PT,Jakarta,UTC

Press space to hold the frame still, h or ? for the key list, and q (or Ctrl+C) to quit.

From a clone, with neither installed:

go run .             # or: make run-go
python3 pyclock.py   # or: make run-py

Go needs go run ., not go run clock.go: the three termios ioctl requests are the one thing that differs between the BSDs and Linux, so they live in build-tagged term_*.go files, and naming a single file skips them.

Usage

clock [-n N | --per-row N] [--color[=WHEN]] [--day[=WHEN]] [-q | --quiet]
      [--halign WHERE] [--valign WHERE] [--hpad SPACE] [--vpad SPACE]
      [--cell-ratio N] [--scale N] [ZONES]

With no arguments you get one clock, in your local zone. Otherwise ZONES is a comma-separated list, and -n caps how many sit side by side before the grid wraps to a new row. By default -n is auto too, alongside --scale: rather than a fixed cap it picks whichever count grows the clocks the most, which for a wide, short window can be more than the plain default (3) and for a tall, narrow one can be fewer. Give it a number to cap it the old way instead.

Flags may go before or after the zone list — all four of these are the same command:

clock ET,PT,UTC -n 2
clock -n 2 ET,PT,UTC
clock ET,PT,UTC --per-row 2
clock --per-row 2 ET,PT,UTC

Layout

The clocks are centred in the window, across and down, and the space around and between them is shared out evenly — so they sit in from the edges rather than against them, and the grid re-settles as the window changes. With the default --scale auto this usually has little to work with, since the clocks have already grown to use most of it; see Tuning for that. It still decides the axis auto-scale didn't need to fill, and everything below still applies in full at a fixed --scale.

flag takes default
--halign left, center, right center
--valign top, center, bottom center
--hpad even, or a share of the width, 10% even
--vpad even, or a share of the height, 5% even

An even fill counts the margins as gaps: three clocks make four spaces, two of them against the edges, and each gets an equal share of what the faces leave over. Sharing between the clocks alone would give every spare column to the gutters and press the outer clocks flat against the borders — which is the one arrangement nobody wants, and which no amount of guessing at a padding should be needed to escape.

The share stops at the width of a face. Past that the clocks read as scattered rather than as a group, so on a wide window the extra goes to the margins and the clocks stay a cluster in the middle: at 300 columns two clocks sit 23 apart with 115 either side of them, not 254 apart in opposite corners. It never goes below the packed 3 columns either, so a window just big enough for the grid gets the packed layout rather than a squeeze.

Give a padding instead and the gap is that and nothing else, with everything over going to the margins, so the alignment decides where the grid sits:

clock --hpad 5% ET,PT,UTC               # 5% of the window between clocks
clock --hpad 5% --halign left ET,PT,UTC # that grid, against the left edge
clock --vpad 0 --valign top ET,PT,UTC   # rows stacked at the top, touching

A percentage is a share of the whole window, not of what is left over: at 100 columns, --hpad 10% puts 10 columns between neighbours whatever else is on screen, and unlike the even fill it is neither floored at 3 nor capped at a face. The flags take their value either way round — --hpad 5% or --hpad=5%.

An odd column cannot be halved into two margins — but a gutter can swallow it. Where the even fill leaves an odd column over, one gutter is widened by one instead, and the margins come out exactly equal: at 200 columns three clocks sit 23 and 24 apart with 42 either side, dead centre. The widened gutter is always the last of a full row, so a short row's clocks still line up with the row above.

One clock has no gutter to put it in, so there the face itself sits a column off centre; the readout under it then leans the other way, and that row comes out level. A padding named with --hpad is left exactly as named — it is not nudged to make the arithmetic work — so an odd column there goes to the right margin, one column and no more. (A short last row is asymmetric on purpose: its faces keep the gutters and margin of a full row rather than re-centring themselves under it.)

Padding is not allowed to break the frame. The faces per row still drop to what the width holds, counted against the padding you asked for rather than the default 3 columns, and a --vpad that would push the grid past the last line is refused the same way a too-tall grid always was.

A window that cannot be measured — redirected to a file, say — has nothing to fill or centre, so it keeps the packed layout instead: three columns between clocks, one row between rows, no margins.

Order

Faces are laid out in the order their clocks read, earliest first — left to right, then top to bottom — whatever order you typed them in. clock JST,PT,UTC draws PDT, then UTC, then JST, and the grid reads chronologically: each face is later than the one to its left, and the last face of a row is earlier than the first of the next.

Sorting on the offset is the same thing. Every face renders one instant, so what a clock reads is that instant plus its offset, and the westernmost zone is the one furthest behind. Two faces on the same offset — UTC and GMT are two faces, since they are labelled differently — keep the order you typed them in. Like the merging, the sort is redone once a second as the clock runs, so a zone entering daylight saving slides a place along as it happens.

Different days

When the faces on screen do not all fall on the same date, every readout picks up a weekday:

          PDT                       EDT                       UTC
   Tue 22:02:41.901          Wed 01:02:41.901          Wed 05:02:41.901

The weekday is absolute rather than a -1d counted from some reference face: it is true on its own terms, and it does not change meaning when you reorder the list. It appears under every face or none, so the columns stay lined up, and only when there is a disagreement to point out — a row of clocks all reading Wednesday says nothing worth three characters.

Nothing off screen is consulted. The clock compares the faces it draws against each other and never asserts a date for a zone you did not ask for, so clock JST on its own stays bare; name local alongside it to bring your own day into the comparison.

Up to three dates can be on screen at once — UTC−12 to UTC+14 spans 26 hours, which crosses two midnights — one reason the readout names the day outright instead of counting days from somewhere.

--day overrides the rule: --day (or --day=always) puts a weekday under every face whatever they read, --no-day (or --day=never) keeps it off entirely, and --day=auto is the rule above, which is the default.

A pinned clock defaults to always instead. CLOCK_FREEZE makes a still of one instant, usually to photograph, and an undated photograph records half of it — so a pinned frame carries its date even when every face agrees. --day=auto asks for the live rule back.

A face has to be 16 columns wide to hold Wed 05:02:41.901, which it is at any CLOCK_CELL_RATIO from about 1.5 up. Below that the weekday is dropped rather than the alignment, whatever --day says.

Keys

key
space hold the frame still, and again to carry on
h or ? show or hide the key list
r resize and align the clocks, while they run
S save those sizes and the zones on screen, for next time
q quit, as does Ctrl+C

Ctrl+Z suspends it, like any other job. The clock hands the terminal back for the length of the stop and takes it again on fg; see Terminal requirements.

h or ? opens the key list in a bordered box centred over the clocks, the same way a dialog sits over a window — not tucked under the grid, so it works whatever the grid's size or alignment. In a window too small to hold the box, the key press does nothing rather than clip or wrap it.

For the first three seconds a clock says Press q or Ctrl+C to quit, in that same boxed style, then drops it — a clock that has taken the whole screen owes you the way back out, but only until you have read it. -q/--quiet skips this hint from the start, for a launch that doesn't blink; the key list is still there on h or ? regardless. Pressing h/? before the three seconds are up shows the key list in its place, since the list already says everything the hint does.

Tuning the layout as it runs

r opens a box holding the seven knobs that shape the grid — --halign, --valign, --hpad, --vpad, --per-row, --scale and --cell-ratio — and lets you work them against the clocks themselves rather than against a guess:

  halign      left [center] right
  valign      top [center] bottom
> hpad        4% [5%] 6%
  vpad        [even] 0%
  per-row     [auto] 3
  scale       [auto] 1   (11 rows)
  cell-ratio  2 [2.1] 2.2

even, or a share like 10%
up down pick   left right or space change
or type a value and enter   esc done

Up and down pick a knob. Each one shows what it holds in brackets, with what is either side of it: the words a --halign takes, or the steps a number moves in — a whole percent for the pads, a whole face for --per-row, a tenth for --scale and --cell-ratio.

Left and right move a value, and the clocks redraw with it on the next frame, a nineteenth of a second later. Space does the same and wraps round the end of a list, so a knob with three words can be cycled with one key. Left from the smallest number reaches the word below it — even for a pad, auto for the scale and the per-row count — and stepping stops where the flag's own limits are: --scale will not step past the face the window can hold.

Or type a value and press Return, which is the way to reach one no number of steps would get to. A value the flag would not have is refused here in the same words, and the old one stays — so --scale cannot be typed up past what the window holds without a way back.

The box sits over the clocks, the way the key list does, and unlike the key list it stays up in a window too small for the grid — that being exactly the window a scale typed too large leaves behind, and no place to hide the only way out of it.

Escape closes the box — once to abandon a half-typed value, again to leave — and the clock then says what it would have taken to start this way:

--hpad 5% --scale 1.5 ET,PT,UTC

That line is shown in the box's place for a moment, and printed again on the way out, where the shell keeps it: the alternate screen goes back to what it held before, and takes anything left on it. clock (or pyclock) in front of it is the whole command. Nothing is printed if nothing was changed — and S, below, saves it instead of printing it.

--cell-ratio is the one worth tuning this way. It depends on your font and line spacing, and the difference between right and wrong is a face that reads as round or as an egg; see Round faces.

Saving what you tuned

S writes the clock on screen — the layout knobs that differ from the defaults, the per-row count among them, and the zones themselves — to ~/.config/clock/config, and says where it put it. Every clock started afterwards reads that file, so the tuning survives the terminal it was done in:

# clock: written by S, read at startup. One argument per line.
# Delete this file to forget it; CLOCK_CONFIG= ignores it.
--hpad
5%
ET,PT,UTC

The file is an argument list, one token per line — a token to a line so that a zone list with a space in it needs no quoting rules. Blank lines and lines starting with # are skipped. It is read by the same parser the command line goes through, before the command line, which gives the whole of the rule for how the two fit together:

the file is the front of your command line. Anything you type beats the same thing saved, because it is read second. clock UTC on a file holding ET,PT,UTC draws UTC alone; clock --scale 1 on a file holding --scale 2 gets 1. Everything in the file you did not override still applies. A setting the file may not hold is one that stops the clock rather than configuring it: -h, --help and --version are refused, by name.

Edit it, or delete it to forget the whole thing — a missing file is not an error, it is a clock that has never been asked to save. S writes it through a temporary file in the same directory, so a save that fails part way leaves the old one rather than half of a new one, and says what went wrong instead of taking the clock down with it.

where
$CLOCK_CONFIG this file, wherever you point it
$XDG_CONFIG_HOME/clock/config if that variable is set
~/.config/clock/config otherwise

CLOCK_CONFIG= — set, but empty — means no preferences file at all, for a script that wants the defaults whatever the machine has saved. It is what every harness in tools/ runs with, for that reason.

Holding a frame

Space stops the clock where it stands; space again lets it go on. Nothing else changes — the frame stays on screen, the window can still be resized, and q still quits — so a screenshot taken while it is held catches a still face and a readout that agrees with it, down to the millisecond. Left to run, the second hand moves within a single screenshot's exposure and the milliseconds are a blur.

Holding is the only way to keep a frame you can see: quitting restores the screen underneath, exactly as less does, so the clocks are gone before you can photograph them, and redirecting to a file keeps a frame but draws no clocks.

For a frame you can reproduce exactly — the ones in this README, for instance — pin the instant instead:

CLOCK_FREEZE=2026-07-15T05:02:41.901000Z clock UTC,10001,PT

The hands never move, space has nothing to hold back, and h and q work as usual. Every readout carries its weekday, for the same reason a photograph wants a date on it. Redirect that same command to a file and it draws the one frame and exits, instead of staying up.

The fraction is optional, and so is its length — 2026-07-15T05:02:41Z and 2026-07-15T05:02:41.9Z both pin the same second. For a time without a date in mind, a bare UTC clock time works too — CLOCK_FREEZE="15:30 UTC" (minutes and seconds are each optional) pins whichever of yesterday, today or tomorrow, by the wall clock right now, lands closest to that reading. A date ahead of it fills in what it leaves out: CLOCK_FREEZE="2026-07-22 15:30 UTC", CLOCK_FREEZE="2026/07/22 15:30 UTC" or CLOCK_FREEZE="7/22/2026 15:30 UTC" pin that exact day, CLOCK_FREEZE="7/22/26 15:30 UTC" the same day with the year written short, while CLOCK_FREEZE="7/22 15:30 UTC" — no year — pins whichever July 22, again by the wall clock right now, lands closest.

The zone does not have to be UTC either — it can be anything a --zones argument accepts, an IANA name, alias or fixed-offset abbreviation included: CLOCK_FREEZE="8/22 09:53 PT" pins whichever August 22 in Pacific time is closest to now, daylight saving and all. A state or city works as well, with underscores for the spaces in its name, since a space is what separates the date, the clock and the zone: CLOCK_FREEZE="10 New_Mexico".

CLOCK_FREEZE pins the time, not the size: with the default --scale auto, the same command still comes out a different size in a different window. Add a fixed --scale too — --scale 1 for these README frames — for a screenshot that reproduces byte for byte regardless of what window it's taken in.

Colour

The three hands are coloured apart — hour yellow, minute cyan, second red, in the terminal's own palette rather than fixed RGB, so they follow whatever theme it is wearing. The rim, the ticks and the numerals stay plain.

Colour is on for a terminal and off for a pipe or a file, so a redirected frame stays plain text. --color=always keeps it when redirecting, and any of --no-color, --color=never, --color=off or NO_COLOR in the environment drops it everywhere — though an explicit --color=always outranks NO_COLOR, since that is what asking for always means.

Bare --color means --color=always and never eats the following argument, the same rule ls and git use: clock --color ET is a coloured Eastern clock, while clock --color always ET is two zone lists and an error. The value only ever follows an =.

Where two hands cross, the shorter one is on top — hour over minute over second. A longer hand covers a shorter one along its whole length, while the short one can only ever hide a slice of it, so drawn the other way round the hour hand disappears under the minute hand for minutes at a time. The hour hand is the one you most want to find at a glance, and it is the one with nowhere to hide.

Because a braille cell carries eight dots and one colour, a crossing tints whole cells: where the minute hand passes under the hour hand, the shared cells go yellow, taking a few of the minute hand's dots with them. Numerals win outright — a cell holding one drops its dots and its colour together.

Zone names

Resolved in this order, first match winning:

You type You get
local your system zone TZ decides it; see below
94110 941 the zone that ZIP is in, labelled PDT (94110) US only
ET CT MT PT America/New_York and friends follows daylight saving, so it reads EST in winter and EDT in summer
AKT HT BST UK IST JST KST SGT HKT AET ACT AWT NZT the obvious place same
Europe/Berlin UTC EST MST HST GMT CET Etc/GMT+5 itself any name the tz database knows
PST PDT EDT CST CDT MDT AKST AKDT HDT that exact offset a fixed clock that never shifts
JP GB DE that country's zone, labelled JST (JP) 2-letter ISO code, via zone.tab
Berlin Jakarta New York Indiana/Indianapolis the zone that ends in it, labelled CEST (Berlin) the city alone, where only one zone ends that way
Arizona New Mexico Washington DC American Samoa the zone its capital keeps, labelled MST (Arizona) a US state, DC, or a territory
US-AZ US-NY the state that code names ISO 3166-2, labelled with the full name
Seattle Salt Lake City Mumbai Munich that city's zone, labelled PDT (Seattle) a common city the tz database has no zone for
Germany France Korea (South) that country's zone, labelled CEST (Germany) the country by name, spelt as iso3166.tab spells it

Your system zone

With no zone list, and for the local token, the clock draws whatever TZ says — or /etc/localtime where TZ is unset, as everything else does.

TZ has a second form, though, which this clock refuses: the POSIX rule, PST8PDT,M3.2.0,M11.1.0 or <+07>-7 or GMT+5, spelling out the offsets and the transition dates rather than naming a zone. The C library reads those and Go's standard library does not, so the two implementations here would disagree by whole hours about what time it is, each of them certain. Rather than have one of them quietly wrong, both stop:

clock: TZ="PST8PDT,M3.2.0,M11.1.0" is not a zone name, and a POSIX TZ rule
is not something both clocks read alike; name a zone as an argument instead

Which is the fix: clock PT draws Pacific whatever TZ says. A TZ naming a zone the database does not have — a typo, usually — is refused the same way, where before it silently drew UTC. Nothing is checked when it is not needed: a zone list that names its zones outright never asks TZ anything.

The city alone

An IANA name ends in the city, and the city on its own will do when only one zone ends that way — Berlin for Europe/Berlin, Jakarta for Asia/Jakarta. Case does not matter, and any whole tail of the name works, so Indianapolis and Indiana/Indianapolis both reach America/Indiana/Indianapolis.

A space will do for an underscore — "New York" is New_York — and the face names the city as well as the zone, spelt the way the tz database spells it: clock berlin reads CEST (Berlin). What was typed was a place, and the label is the one place that says which clock it landed on.

Whole segments only: York is not New_York, and Berl is not Berlin. Names come from zone.tab, the same file the country codes are read from, which lists the canonical zones and leaves out the backward-compatibility links — so Eastern is not a name here, while US/Eastern still resolves the ordinary way, in full.

This is looked up after everything else the tz database answers to, and before the states and cities this clock carries itself, so neither a city nor anything written here can shadow a name the database answers to.

Ambiguity is refused rather than guessed at:

$ clock Berlin
clock: Berlin names 2 zones; name one in full: Europe/Berlin, America/Berlin

Every city in the tz database is unique today — all 418 zones in zone.tab end differently — but nothing promises it stays that way, and two clocks an ocean apart is not a choice to make on your behalf.

The abbreviations only fill gaps the tz database leaves, which is why EST and ET are different clocks and both are right: EST is the fixed −05:00 zone that never shifts, while ET is the eastern US, which does. Same for MST vs MT and HST vs HT. GMT and CET are likewise left alone — aliasing GMT to Europe/London would make it read BST every July.

PST, PDT, EDT and the rest name an offset rather than a place — nowhere is on PDT in January — so the tz database has nothing to look up. They become fixed-offset clocks instead, which is exactly what the names mean. Put a pair side by side in July and the difference shows:

$ clock PST,PT,PDT
          PST                     PDT/PT
     12:00:00.000              13:00:00.000

PST stays on −08:00 while PT has moved to PDT — and PT and PDT, being the same clock in July, have merged into one face. In January they separate again, with PT back on PST.

Two of these carry a judgement call. CST is the US Central reading, −06:00, not China — for China use CN or Asia/Shanghai. And HDT is −09:00, the Aleutian daylight zone, since Hawaii itself never leaves HST.

A country is its 2-letter code or its name — DE or Germany, JP or Japan — and the face says which either way: CEST (DE), CEST (Germany). The names are the tz database's own, out of iso3166.tab beside zone.tab, so they are spelt the way it spells them: Korea (South), Britain (UK), Antigua & Barbuda — where an & may be written and. The four holding a character outside ASCII, Curaçao, Réunion, Côte d’Ivoire and the Åland Islands, have to be typed as it writes them, for the reason in Case folding. A name is read after the states and cities, which is the whole of why Georgia is the state and GE the country.

Where the file spells a name unusually, the ordinary spelling works too, and the face is labelled with whichever you wrote. An & may be written and, an St written Saint, and a trailing qualifier moved to the front — South Korea for its Korea (South), American Samoa for its Samoa (American). A short list covers what no rule reaches: United Kingdom and Great Britain for Britain (UK), USA and United States of America, Czechia, Ivory Coast, Myanmar, Burma, and the ASCII spellings of Cote d'Ivoire and the Aland Islands.

iso3166.tab is not on every system — a stripped-down container may ship zone files without the database's tables. Without it the names stop resolving and nothing else changes: DE still draws Germany, since the codes come from zone.tab, and a name the tz database answers to itself still resolves but loses its label. Without zone.tab the codes go too, and say so.

A country that genuinely spans zones asks you to pick, by whichever spelling you used:

$ clock "United States"
clock: United States spans 8 time zones; name one: America/New_York,
America/Chicago, America/Denver, America/Phoenix, America/Los_Angeles,
America/Anchorage, America/Adak, Pacific/Honolulu

Every zone it means is named, however many there are — Russia's eleven, Canada's ten — since a list you cannot see the end of is a list you cannot choose from. The count is of clocks rather than of zones: zone.tab holds 29 for the US and 23 for Canada, and the ones reading alike at that instant collapse first. Countries whose zones all agree — Germany lists both Europe/Berlin and the Europe/Busingen enclave — collapse to a single face and resolve without complaint.

States and cities

A US state works as a zone, and so do DC, the territories, and the common cities the tz database has no zone of its own for. Each is labelled with where it landed, which matters most when two places share a clock for only part of the year:

$ clock Arizona,Idaho       # in July
   MST (Arizona)      MDT (Idaho)
$ clock Arizona,Idaho       # in January
   MST (Arizona, Idaho)

Arizona keeps MST all year while Idaho moves, so in winter they are one face and in summer two.

A state means the zone its capital keeps. For most states that is the only zone there is. Where a state spans two, the capital's is also where most of it lives — but not all of it. Going by the populations GeoNames lists for their towns, about a quarter of South Dakota and of Tennessee keep another clock, and about a fifth of Indiana and of Idaho; Kentucky, North Dakota, Florida, Nebraska, Texas and Michigan have smaller parts on a second clock, and Kansas, Oregon and Arizona's Navajo Nation — which keeps daylight saving where the rest of Arizona does not — smaller still. Alaska's western Aleutians keep a clock of their own as well, on too few people for GeoNames' list of towns to show them. A corner of Alabama around Phenix City keeps Eastern time without the law saying so. The label is what tells anyone in one of those parts that they have been given the other clock:

$ clock "Texas,El Paso"
   MDT (El Paso)      CDT (Texas)

A ZIP code, or a city, is exact where a state cannot be.

Full names, with a space or an underscore between words — New Mexico, New_Mexico or "new mexico" — or the ISO 3166-2 code, which is labelled with the full name all the same:

$ clock US-CA,US-NY
   PDT (California)      EDT (New York)

The bare two letters are not taken, and cannot be. Of the 56 codes, 32 already mean something else to this clock, and 26 of those mean a different clock than the state does: CA is Canada, IN India, DE Germany, GA Gabon, VA the Vatican, and CT this clock's own Central. Six agree only by accident — MT is Montana and Mountain alike, and the five territories are their own ISO codes — and 24 are unclaimed today. But a short form that worked for TX and quietly drew Canada for CA would be worse than none at all.

Washington is the state, and the city is "Washington DC" or "District of Columbia". Georgia is the state, and the country is GE. The territories are "US Virgin Islands", "American Samoa" and "Northern Mariana Islands"; Guam and "Puerto Rico" are names the tz database itself answers to, and resolve there.

A city is one of about a hundred — Seattle, Dallas, Salt Lake City, Mumbai, Munich, Rio de Janeiro — that people want a clock for and no IANA zone is named after. Cities that do have a zone, like Los Angeles, Chicago or Hong Kong, are the city alone instead, and are labelled the same way.

Some names belong to more than one city, on more than one clock. The table keeps a shared name only when the city it means is at least three times the size of the largest namesake on another clock: Portland is Oregon, nearly ten times the size of Portland, Maine, and Manchester is England, five times Manchester, New Hampshire. Four common names fail that and are left out, as unknown zones, to be named in full or by ZIP instead:

left out because of instead
San Jose San José, Costa Rica, a third the size 95113, or America/Los_Angeles
St. Louis Saint-Louis, Senegal, nearly as large 63101, or America/Chicago
Barcelona Barcelona, Venezuela, half the size Europe/Madrid, or ES
Venice Venice, California, nearly as large as the Venice GeoNames counts in Italy Europe/Rome, or IT

Matching folds ASCII case and reads an underscore as a space, and forgives nothing else, for the reason in Case folding: two spaces in a row are not one. Both tables are looked up after the tz database, city tails included, so a future release that names a zone after one of these cities takes it over; tools/placecheck.py fails if any row is already answered by something the clock asks first.

Duplicates

Two zones showing the same wall clock are one face, whatever you called them: PDT,PDT, UK,BST and ET,America/New_York each draw once. The face is labelled with the abbreviation, and when more than one spelling collapsed onto it, with those spellings too — UK,BST reads BST/UK, while PDT,PDT was never ambiguous and stays plain PDT.

A place keeps its name through a merge, in parentheses after the rest: Boise,Denver reads MDT (Boise, Denver), and MT,Boise reads MDT/MT (Boise). A list too long for its cell is cut before the closing parenthesis rather than through it: MDT (Utah, Colorado, Ne...), in a window 60 columns wide.

Whether two zones agree is a property of the instant, not of the zones, so the grouping is redone as the clock runs rather than fixed at startup. PT and PDT are one face in July and two in January, and a clock left running across the boundary splits itself as it happens — within a frame of the transition, since the grouping is recomputed once a second, the coarsest interval that cannot skip one.

ZIP code accuracy

Give all five digits and the answer is exact. Three digits (the prefix alone) is usually right but not always: 233 ZIP codes, across 30 prefixes, sit on the losing side of a zone boundary their prefix rounds the wrong way.

$ clock 79835            # Canutillo, TX — El Paso County
   MDT (79835)           # right: five digits are always exact
$ clock 798              # the prefix alone
   CDT (798)             # wrong: most of 798 is CDT, but not this ZIP

Verified against the source: of 33,791 ZIP codes, 233 (0.69%) resolve wrongly from the prefix alone, and none resolve wrongly from all five digits.

The worst case a prefix gets wrong is 96799, American Samoa, an hour behind Honolulu; the mildest is 86502, which resolves to Phoenix where its prefix rounds to Denver — the same time all winter, an hour apart all summer, since Arizona skips daylight saving and the Navajo Nation around it does not.

One gap remains: PO-box and single-building ZIPs have no delivery-area data to place them precisely, so even given in full they fall back to their prefix's answer. See ARCHITECTURE.md for how the two lookup tables are built and encoded, and When to regenerate for when they need to be.

The tables and the two lookups over them are not part of the clock: they are ziptz, a library of its own, in Go and in Python, usable and installable without the clock.

Tuning

--cell-ratio is your font's cell height / width, and is the only knob that decides whether the face is round. Braille dots are square at exactly 2; most fonts sit near 2.1, which is the default. Raise it if the face looks squished, lower it if it bulges sideways.

clock --cell-ratio 2.6

Both it and --scale stop at a million, which no font or window is anywhere near: past that the face's width stops being the same number in the two implementations, Python's integers being unbounded where Go's are 64 bits.

CLOCK_CELL_RATIO sets the same thing, for a terminal you'd rather configure once than pass a flag to every time; --cell-ratio wins if both are set. A value it cannot use — including one over that million — leaves the default standing, the same as an unset one: an environment variable may be stale or meant for another program, which is not the same as a typo on the command line.

CLOCK_CELL_RATIO=2.6 clock       # or pyclock; both read it

Inside, ROWS/rowsN is the face height in terminal rows — what --scale resolves to, rather than a knob of its own — and the width follows from the cell ratio, as floor(ROWS * CELL_RATIO + 0.5): 23 columns at the defaults. A whole frame is then

width  = perRow * COLS + (perRow - 1) * GAP
height = rows * (ROWS + 2) + (rows - 1) * VGAP

so one clock is 23x13, three across is 75x13, four zones at -n 2 is 49x27, and five zones at -n 2 is 49x41. GAP and VGAP, 3 and 1, are the least space the layout will leave between clocks: that is the size a grid packs down to, and what decides how many faces fit.

By default --scale is auto: rather than a fixed size with the window's extra room spread out as padding, it picks the largest size that still fits using nothing more than --hpad/--vpad's own minimum gap — so growing the window grows the clocks themselves, not the space around them. It re-solves every frame, so resizing the window live resizes the clocks with it. Whichever axis isn't the tight one still has room left over, and that's exactly what Layout's halign/valign/hpad/vpad describe — a wide window with one short row of clocks, say, still centres them top-to-bottom.

It will give up a few rows of that maximum for a size where the 12/3/6/9 numerals sit flush against their tick marks rather than half a cell off — visible on some terminals, depending on how they draw braille next to text. A fixed --scale skips that check and gives exactly the size asked for.

-n's own default, auto, is what actually lets that maximum be found: against a wide, short window a narrow cap forces more rows of clocks than the window needs, and each of those rows steals height the face could have used instead, so -n auto searches per-row counts too rather than assuming the plain default (3) is the right shape for whatever window it finds. Cap -n to a number and --scale auto still maximises the face, just against whatever fixed shape that cap leaves it.

Give --scale a number instead for a fixed size, unrelated to the window: --scale 2 is twice the plain default (11 rows), --scale 0.5 is half. A fixed size is what leaves the window's leftover room as padding, the way Layout and the geometry above describe — and it is also where a fixed -n stops being just a cap on -n auto's search and starts deciding the grid's shape outright, exactly as it always did.

clock --scale 1.5
clock --scale auto -n auto   # the defaults; only worth naming to be explicit

Terminal requirements

macOS and Linux. Windows is not supported — it has no termios and no select on stdin — and both implementations say so and exit rather than failing obscurely; use WSL.

You need a font with braille coverage, and a window big enough for one face (23 columns at --scale 1, the base unit --scale/--cell-ratio work from). The clock measures the terminal every frame; by default that is what --scale auto/-n auto size and shape the grid against, and with a fixed --per-row it is quietly lowered to whatever fits instead, so a narrow window rewraps rather than garbling.

When not even one face fits, or the grid is taller than the window, the clocks give way to the reason:

2 rows of clocks need 27 lines and this terminal has 26;
raise --per-row, or name fewer zones

It stays there, folded to whatever width there is, until the window can hold the clocks again — dragging a corner back is the fix, and quitting to read an error on a screen that is about to be restored is no help to anyone. Note that a too-tall grid is fixed by raising --per-row, the opposite of a too-narrow one. q and Ctrl+C still work while it is up.

Redirected output has no window to drag, so there the same conditions are what they always were: the message goes to stderr and the clock exits 1. When it can't measure at all, e.g. piped to a file, it renders exactly what you asked for.

Because the clock runs on the alternate screen, quitting restores whatever was on screen before it and the last frame does not linger — the same as less or vim. Redirect to a file to keep a frame, or hold one with space and photograph it.

Ctrl+Z suspends it like any other job, and gets the same treatment: the clock gives the screen and the terminal back before it stops, so a stopped clock leaves a shell you can use rather than one with no echo and no cursor, and takes them again when fg brings it back. A clock stopped this way is stopped by SIGSTOP rather than SIGTSTP — the reason is in ARCHITECTURE.md, and the visible part of it is that the shell says Stopped(SIGSTOP) where it usually says Stopped.

Piping into something that stops reading — clock | head — is an ordinary way to end a clock and is treated as one: both implementations stop without a word, give the terminal back, and exit 141, the status a shell reports for a filter killed by SIGPIPE.

A kill -9 skips the terminal restore and leaves echo off and the alternate screen active; stty sane and printf '\033[?1049l' fix it.

Development

See ARCHITECTURE.md for how the renderer itself works — the braille canvas, the colour layering, the repaint strategy.

tools/difftest.sh proves the two implementations agree. It pins both to a fixed instant with CLOCK_FREEZE (an instant like 2026-07-15T09:53:07.123456Z, which redirected draws exactly one frame and exits, and on a terminal stays up) and to a fixed window with COLUMNS/LINES, then compares stdout, stderr and exit status across hundreds of argument lists, terminal sizes and cell ratios — including the --scale/-n auto searches, at window shapes chosen to land on both odd and even sizes. Colour is compared too, under --color=always — a redirected clock is plain otherwise — including the instants where the hands cross and the layering decides what shows. It also cross-compiles for Linux, macOS and Windows, and diffs the generated tables out of the two ziptz files.

tools/difftest.sh -v

What none of that can see is a change that alters the picture in both implementations — which is how every change is made, in one pass. So eighteen frames are kept as bytes in tools/golden/, one of each kind of picture the clock can draw, and compared after the two are compared with each other. Shortening the second hand to 0.70 of the radius, in both, passes every differential case and fails every one of the 18 goldens.

BLESS=1 tools/difftest.sh    # accept the new rendering, deliberately

The diff in the commit is the only review those get, so bless on purpose and read it. Blessing also writes tools/golden/+VERSION, the tz database the bytes hold the zone names of: rename an abbreviation upstream and the goldens fail exactly as a broken renderer would, so a failing one ends by saying whether this machine's tzdata is the release they were blessed under.

CLOCK_FRAMES makes a case a sequence rather than a frame: it draws that many, stepping the pinned instant by CLOCK_STEP milliseconds each time — one tick, 19ms, by default — and compares the whole run.

CLOCK_FREEZE=2026-11-01T05:59:59.900000Z CLOCK_FRAMES=21 clock ET,PT,EST,UTC

That covers what a single frame cannot show, and what nothing else in the harness reaches: the second hand sweeping between whole seconds, the rewind repainting over the frame before it, and the faces regrouping mid-run — the one above steps across the US fall-back, where ET lands on the fixed EST and the two faces become one. Since the grouping is cached on the instant's whole second, a one-frame case can never outlive that cache; a sequence can. Both variables need CLOCK_FREEZE, are refused if it is absent, and like it are dev hooks kept out of --help.

tools/keytest.py covers what difftest cannot reach at all: the keys, and one thing that is not a key -- whether stdout is a terminal at all. /dev/null is a character device, and a clock that reads that as a terminal runs forever instead of drawing its frame and exiting; difftest redirects to regular files and has no timeout, so it would hang rather than say so. Space, h, ? and q are read only from a terminal in cbreak mode, so nothing redirected ever presses one — difftest compares the table the key list is built from, not what pressing h does. Each implementation runs under a pty, both get the same keys at the same points, and what they paint is compared.

Two of the keys are not read by the clock at all. Ctrl+C and Ctrl+Z are turned into signals by the terminal driver, which only has somewhere to send them if the clock is a job: its own process group, in the foreground of a terminal some session owns. A pty opened by a test harness is none of that, so those two cases start the clock under a shim that arranges it the way a shell does, and the shim reports what becomes of the clock — since only a parent can wait for a stopped child. That is what makes it possible to ask the question a stopped clock exists to answer: what state did it leave the terminal in.

tools/keytest.py -v

A clock repaints every 19ms whether or not anything changed, so the streams are collapsed to their distinct frames first: how many repaints landed between two keystrokes is the machine's speed, not the clock's behaviour. Pinned with CLOCK_FREEZE, what is left is exactly the states the keys walked through — h q ends on a frame with the key list still up, an unknown key paints nothing new. Holding is checked on a live clock instead, where there is something to hold, and each phase is read on its own frames: what was painted between the first space and the second has to be one readout over and over, and what came after the second has to move again. Measuring the whole run at once could not see a clock that held and never let go, since the readouts it painted before the first keystroke were distinct enough to satisfy the count. The thresholds are shares rather than counts, because 0.6s buys thirty frames on a quiet machine and eight on a loaded CI runner, and a slow machine is not a broken clock.

difftest also keeps everything the Python clock writes to stderr and, at the end, checks that every message pyclock.py can raise turned up in it — tools/errcover.py. A message nothing ever prints is a message nothing tests, and it looks exactly like one nobody has broken yet. Three are exempt, with the reason written down: each needs a machine whose tz database is missing or lacks a zone the tables name, and Go will not give its up even then, falling back to the copy inside the binary.

That check is what turned up the clock's one Python-only behaviour going untested: without ziptz installed, a ZIP token says what to install while every zone name, abbreviation and country code still works. Two cases now run pyclock.py from a directory where the library is not there to import.

tools/fitfuzz.py throws window sizes at both implementations and checks what comes back fits in them: no line wider than the window, no more lines than it has. That is the same blind spot the goldens cover, generatively — a grid that overflows overflows in both implementations, agrees with itself, and matches no golden because no golden has that size. It is seeded, so a failure repeats.

tools/fitfuzz.py 400

It checks where the grid sits, too, when it was told: --halign left leaves no margin on the left, right ends at the last column, center balances the two to within a column, and the same three vertically. Making pyclock.py ignore --valign fails it within a few dozen sizes.

It found one on its first run, in both implementations: the readout under a face is a fixed twelve characters, the layout measured only the face, and a window narrow enough to shrink the face below twelve wrote the readout past the right edge — where it wraps, and a wrapped line desynchronises the repaint that fit_per_row exists to protect. A face's column is now the wider of the face and its readout.

tools/docnums.py holds the prose to the tables. The documents quote figures that come out of the data — 233 ZIPs across 30 prefixes, 157 range records, and the 11 letters those fold onto — and regenerating the tables would leave those sentences quietly false, since they still read fine and nothing else reads prose. It recomputes each from the shipped tables and checks the file says it. Dropping one exception group makes four documents fail at once.

tools/placecheck.py holds the places to what they claim. It runs every state and city row through the real resolver, in four spellings, and fails on a zone that does not load, on a row something asked earlier already answers to — a city called Japan would be the tz database's, never the table's — and on a table out of order. It checks the ISO codes answer exactly as the names they stand for do, that the bare two letters never answer as a state, and that the countries the tz database supplies come back labelled as they should. What it cannot see offline is a zone that loads and is simply wrong: moving Seattle to Denver in both ports passes it. That, and any change to a row, is what --geonames is for, which checks every zone against a downloaded GeoNames city list.

tools/placecheck.py -v
tools/placecheck.py --geonames ~/geonames    # a directory holding cities1000.txt

.github/workflows/test.yml runs all of that on push and pull request, on the floor and the ceiling of what the project claims to support — Go 1.21 with Python 3.9, and current versions of both. The floor is the one that matters: pyproject.toml promises 3.9 and nothing but that job checks it. A third job runs the ceiling on macOS, the other platform Terminal requirements promises: term_bsd.go is compiled nowhere else, and termios, select and the pty are BSD's there rather than glibc's. It runs the ceiling only — the floor's Python has no build for the arm64 runners, and macOS is there for the platform, not the version.

It resizes the window too, which difftest cannot: that pins COLUMNS/LINES for a run and never changes them, so the re-measure the clock does every frame — the reason it does not trap SIGWINCH — went untested until now. The cases drag the window smaller and larger, and at a fixed --scale drag it smaller than the clocks can fit, which on a terminal complains and keeps measuring where redirected output would have exited. A clock that measured once at startup fails four of them.

tools/framecost.py is the odd one out in that directory: it measures what a frame costs against the 19ms the clock has to draw it in, and has nothing to pass or fail, since the answer belongs to the machine it runs on. make test leaves it alone. Run it when a change makes you wonder.

tools/framecost.py

tools/argfuzz.py is the same idea aimed at the arguments rather than the window. It mutates ordinary invocations into badly spelt ones — a digit swapped for the Arabic-Indic digit that means the same thing, a space in front of a number, a zone name in a case nobody types, a value handed to the flag next door — and requires both implementations to answer identically. That is where the two standard libraries part company, and where five bugs were found in an afternoon: --scale ١ drew a clock in Python and complained in Go, --cell-ratio 0x1p2 did the reverse, and İstanbul resolved under one and not the other. The seed is fixed, so a failure repeats.

tools/argfuzz.py -v --cases 2000 --seed 3

ziptz has tests of its own, and holds its two libraries to one shared list of cases in its own testdata/cases.json — the same idea as the difftest, a rung down. Where the clock can only sample its input space, that library can exhaust it, so it also sweeps every ZIP there is through both implementations: 101,000 answers, which either match or the build stops. make test runs those and the difftest together.

make test

The ZIP tables are ziptz's, not the clock's, and so is regenerating them: see Regenerating and When to regenerate there, and run it there. The short version is almost never, and not for daylight-saving changes — the tables store zone names, not offsets, so a rule change arrives with an OS update and needs nothing here.

The data derives from US Census ZCTA Gazetteer centroids (a US Government work, public domain) resolved through timezonefinder, whose boundaries come from timezone-boundary-builder (ODbL).

Licence

MIT; see LICENSE.

NOTICE is the other half, and matters because a clock is not only this repository's code. go build links ziptz into the binary and the documented Python install copies ziptz.py alongside pyclock.py, so distributing a clock distributes its ZIP tables — which carry an ODbL attribution from the boundary data they were produced against. NOTICE states it, and belongs with any copy you pass on. The zone names, offsets and abbreviations a clock reads at runtime are the system's tz database and are not copied into anything here.

Release files for terminal-clock 0.4.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for terminal-clock 0.4.3
File Size Uploaded
terminal_clock-0.4.3.tar.gz 116.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for terminal-clock 0.4.3
File Interpreter ABI Platform
terminal_clock-0.4.3-py3-none-any.whl Python 3 none any Details

Total release size: 189.6 kB

Release files / terminal_clock-0.4.3.tar.gz

Download URL terminal_clock-0.4.3.tar.gz
Size 116.3 kB
Tags Source
SHA-256 checksum
How to use checksums
2a753c814f99602f12d7efe8f11b5e7a154139d6bcb971a5f47158784c117380
BLAKE2b-256 checksum
How to use checksums
9343410ccaf5f83fc3ddc1299976053bd6fb75c021887a820bbbd337f289afd5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release files / terminal_clock-0.4.3-py3-none-any.whl

Download URL terminal_clock-0.4.3-py3-none-any.whl
Size 73.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ec0a725c83207a7389bf7f1de1311dc5f903bd66401386a312838409a336edf1
BLAKE2b-256 checksum
How to use checksums
09a28aefcc70a9bccfafc48ecf245a0113669902c24d8d63a4574bac5f943457
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release history Release notifications | RSS feed

This release

0.4.3 This release

2 release files

0.3.0

2 release files

0.2.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page