terrahour 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. terrahour-1.0.0/LICENSE +30 -0
  2. terrahour-1.0.0/PKG-INFO +213 -0
  3. terrahour-1.0.0/README.md +186 -0
  4. terrahour-1.0.0/pyproject.toml +50 -0
  5. terrahour-1.0.0/setup.cfg +4 -0
  6. terrahour-1.0.0/terrahour/__init__.py +6 -0
  7. terrahour-1.0.0/terrahour/__main__.py +4 -0
  8. terrahour-1.0.0/terrahour/alerts.py +110 -0
  9. terrahour-1.0.0/terrahour/ambient.py +151 -0
  10. terrahour-1.0.0/terrahour/app.py +71 -0
  11. terrahour-1.0.0/terrahour/astro.py +40 -0
  12. terrahour-1.0.0/terrahour/canvas.py +52 -0
  13. terrahour-1.0.0/terrahour/cli.py +88 -0
  14. terrahour-1.0.0/terrahour/clock.py +265 -0
  15. terrahour-1.0.0/terrahour/compose.py +209 -0
  16. terrahour-1.0.0/terrahour/data/README.md +15 -0
  17. terrahour-1.0.0/terrahour/data/cities.txt.gz +0 -0
  18. terrahour-1.0.0/terrahour/data/curated.txt +88 -0
  19. terrahour-1.0.0/terrahour/data/land_mask.bin.gz +0 -0
  20. terrahour-1.0.0/terrahour/data/markets.txt +11 -0
  21. terrahour-1.0.0/terrahour/draw.py +386 -0
  22. terrahour-1.0.0/terrahour/geocode.py +77 -0
  23. terrahour-1.0.0/terrahour/keys.py +315 -0
  24. terrahour-1.0.0/terrahour/output.py +96 -0
  25. terrahour-1.0.0/terrahour/overlays.py +250 -0
  26. terrahour-1.0.0/terrahour/places.py +282 -0
  27. terrahour-1.0.0/terrahour/state.py +137 -0
  28. terrahour-1.0.0/terrahour/themes.py +84 -0
  29. terrahour-1.0.0/terrahour/weather.py +111 -0
  30. terrahour-1.0.0/terrahour/worldmap.py +133 -0
  31. terrahour-1.0.0/terrahour.egg-info/PKG-INFO +213 -0
  32. terrahour-1.0.0/terrahour.egg-info/SOURCES.txt +44 -0
  33. terrahour-1.0.0/terrahour.egg-info/dependency_links.txt +1 -0
  34. terrahour-1.0.0/terrahour.egg-info/entry_points.txt +2 -0
  35. terrahour-1.0.0/terrahour.egg-info/requires.txt +4 -0
  36. terrahour-1.0.0/terrahour.egg-info/top_level.txt +1 -0
  37. terrahour-1.0.0/tests/test_alerts.py +76 -0
  38. terrahour-1.0.0/tests/test_astro.py +40 -0
  39. terrahour-1.0.0/tests/test_cli.py +82 -0
  40. terrahour-1.0.0/tests/test_clock.py +187 -0
  41. terrahour-1.0.0/tests/test_keys.py +263 -0
  42. terrahour-1.0.0/tests/test_output.py +53 -0
  43. terrahour-1.0.0/tests/test_places.py +127 -0
  44. terrahour-1.0.0/tests/test_render.py +168 -0
  45. terrahour-1.0.0/tests/test_state.py +97 -0
  46. terrahour-1.0.0/tests/test_worldmap.py +71 -0
@@ -0,0 +1,30 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alessio Coci
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ----------------------------------------------------------------------
24
+
25
+ The bundled data files under terrahour/data/ have their own licences:
26
+
27
+ * cities.txt.gz is derived from GeoNames (https://www.geonames.org/),
28
+ licensed under Creative Commons Attribution 4.0 (CC BY 4.0).
29
+ * land_mask.bin.gz is rasterised from Natural Earth (https://www.naturalearthdata.com/),
30
+ which is in the public domain.
@@ -0,0 +1,213 @@
1
+ Metadata-Version: 2.4
2
+ Name: terrahour
3
+ Version: 1.0.0
4
+ Summary: World clock, world map and 24-hour meeting planner for the terminal
5
+ Author: Alessio Coci
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ACoci86/terrahour
8
+ Project-URL: Issues, https://github.com/ACoci86/terrahour/issues
9
+ Keywords: world clock,time zones,tui,terminal,timezone,meeting planner,tmux
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest; extra == "dev"
25
+ Requires-Dist: pillow; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # terrahour
29
+
30
+ ![tests](https://github.com/ACoci86/terrahour/actions/workflows/tests.yml/badge.svg)
31
+
32
+ A world clock for the terminal, with a map that shows where it is daytime and a 24-hour
33
+ timeline that shows when your cities are at work.
34
+
35
+ | Main view: the day and night map above a timeline per city |
36
+ | --- |
37
+ | ![terrahour main view](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/main.png) |
38
+
39
+ | A quick tour: scrubbing time, exchanges, DST radar, ambient view |
40
+ | --- |
41
+ | ![demo](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/demo.gif) |
42
+
43
+ ## What it does
44
+
45
+ * **Map with day and night.** The world is drawn in braille dots, shaded by sunlight, and
46
+ coloured by time zone. Every city you track gets a marker. Zoom with `+` `-` or the mouse
47
+ wheel, drag to pan.
48
+ * **A timeline per city.** Each row is a 24-hour bar with the working hours lit up. The
49
+ overlap row at the bottom shows the window when everybody is at their desk.
50
+ * **Scrub through time.** Arrow keys move in 15-minute steps, `g` jumps to any time
51
+ ("15:30", "3pm", "+2h", "2026-10-05 09:00"), `r` snaps back to live.
52
+ * **DST radar.** `D` lists the next clock change for every city and, more usefully, whether
53
+ the gap between you and them is about to shift by an hour.
54
+ * **Stock exchanges.** `M` swaps the city list for the ten biggest exchanges with their real
55
+ trading sessions, lunch breaks included.
56
+ * **Alerts.** `A` sets a daily reminder in another city's time, a ping when an office or
57
+ exchange opens or closes, or a plain timer. They ring the bell and send a desktop
58
+ notification while the app is open.
59
+ * **Weather**, if you want it. `W` shows the current temperature next to each city
60
+ (Open-Meteo, needs network, off by default).
61
+ * **Ambient view.** `V` turns the whole terminal into a big clock over the map that cycles
62
+ through your cities. Nice on a spare monitor.
63
+ * **Fits anywhere.** Below about 76 columns it switches to a compact layout that works in a
64
+ tmux split. `--line` prints a one-liner for a status bar.
65
+ * **Six themes** (midnight, nord, dracula, light, mono, colorblind), 12 or 24 hour clock,
66
+ mouse support, and about 6,000 cities built in with online lookup for the rest.
67
+
68
+ | Exchanges | Compact layout for a tmux split |
69
+ | --- | --- |
70
+ | ![markets view](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/markets.png) | ![compact view](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/compact.png) |
71
+
72
+ | DST radar | Adding a city |
73
+ | --- | --- |
74
+ | ![DST radar](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/radar.png) | ![add city](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/add-city.png) |
75
+
76
+ | Ambient view | Light theme |
77
+ | --- | --- |
78
+ | ![ambient view](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/ambient.png) | ![light theme](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/theme-light.png) |
79
+
80
+ ## Install
81
+
82
+ You need Python 3.9 or newer, a terminal with truecolor support (almost all of them these
83
+ days) and a UTF-8 locale. Developed on Linux. macOS is covered by the automated tests, but I
84
+ have not tried it by hand. On Windows use WSL, the app relies on a Unix terminal.
85
+
86
+ ```sh
87
+ pipx install git+https://github.com/ACoci86/terrahour
88
+ ```
89
+
90
+ Then run `terrahour`. If your shell cannot find the command, run `pipx ensurepath` once and
91
+ open a new terminal.
92
+
93
+ or, without pipx, in a virtual environment (recent Debian and Ubuntu refuse a plain
94
+ `pip install` outside one):
95
+
96
+ ```sh
97
+ python3 -m venv .venv && . .venv/bin/activate
98
+ pip install git+https://github.com/ACoci86/terrahour
99
+ terrahour
100
+ ```
101
+
102
+ The command is available whenever that environment is active.
103
+
104
+ or just clone it and run it in place, there is nothing to build:
105
+
106
+ ```sh
107
+ git clone https://github.com/ACoci86/terrahour
108
+ cd terrahour
109
+ python -m terrahour
110
+ ```
111
+
112
+ ## Usage
113
+
114
+ ```sh
115
+ terrahour # your saved cities (a sensible default set the first time)
116
+ terrahour Europe/Berlin "Home=America/Chicago" Naples # a one-off set of zones or city names, not saved
117
+ terrahour --markets # start in the exchange view
118
+ terrahour --at 2026-10-05T09:00Z # start frozen at a given time
119
+ terrahour --theme nord --12h
120
+ terrahour --ambient # straight into the screensaver view
121
+ terrahour --reset # forget saved cities and settings
122
+ ```
123
+
124
+ Press `?` inside the app for the full list of keys. The ones you will use most:
125
+
126
+ | Key | Action |
127
+ | --- | --- |
128
+ | `←` `→` | scrub 15 minutes (Shift: 1 hour, PgUp/PgDn: 1 day) |
129
+ | `g` | go to a time, `r` back to live |
130
+ | `↑` `↓` `j` `k` | select a city, Shift moves it up or down the list |
131
+ | `a` `d` | add or remove a city |
132
+ | `*` | mark the selected city as home (offsets are then relative to it) |
133
+ | `Enter` | focus the selected city, dims the others |
134
+ | `M` `D` `A` `W` | exchanges, DST radar, alerts, weather |
135
+ | `+` `-` `[` `]` `0` | zoom the map, zoom the timeline, reset |
136
+ | `T` `t` `c` `V` | theme, 12/24h, compact layout, ambient view |
137
+ | `q` | quit |
138
+
139
+ Everything you change in the app (cities, theme, home, working hours, alerts) is saved to
140
+ `~/.config/terrahour/config.json`. Zones given on the command line are session-only and
141
+ leave that file alone.
142
+
143
+ ## Status bars and scripts
144
+
145
+ ```sh
146
+ $ terrahour --line
147
+ SF 07:30 · NY 10:30 · LON 14:30 · UTC 14:30 · DUB 18:30 · MUM 20:00 · SIN 22:30 · TOK 23:30 · SYD 01:30+1
148
+
149
+ $ terrahour --watch # the same line, updating in place (good in a small tmux pane)
150
+ $ terrahour --tmux # with tmux colour codes, for your status-right
151
+ $ terrahour --json --at 2026-03-18T14:30Z | jq '.cities[] | select(.open)'
152
+ ```
153
+
154
+ The JSON includes local time, UTC offset, whether the city is inside working hours, minutes
155
+ until that changes, and the next clock change. `--once --size 120x40` prints a single frame of
156
+ the full UI, which is how the screenshots above were made.
157
+
158
+ ## Network
159
+
160
+ Two features talk to the internet, and both are optional:
161
+
162
+ * While you type in the "add city" box, names that are not in the built-in list are looked up
163
+ through the Open-Meteo geocoding API.
164
+ * Weather, when you turn it on with `W`, comes from the Open-Meteo forecast API.
165
+
166
+ Set `TERRAHOUR_OFFLINE=1` to switch both off. Nothing else leaves your machine.
167
+
168
+ ## How it is put together
169
+
170
+ It is a single Python package with no third-party dependencies. The pieces:
171
+
172
+ | Module | What it does |
173
+ | --- | --- |
174
+ | `cli.py` | argument parsing and the non-interactive modes |
175
+ | `app.py` | the terminal loop: raw mode, input tokens, redraw only the lines that changed |
176
+ | `keys.py` | every key and mouse event ends up here |
177
+ | `compose.py`, `draw.py`, `overlays.py`, `ambient.py` | turn the state into a frame |
178
+ | `canvas.py` | a character grid with colours that renders to ANSI escapes |
179
+ | `clock.py` | offsets, formatting, open/closed status, DST transitions, overlap |
180
+ | `astro.py` | sun position, sunrise and sunset |
181
+ | `worldmap.py` | the land mask, braille rasterisation, zoom and pan |
182
+ | `places.py`, `geocode.py` | the city database, search, and the online lookup |
183
+ | `state.py` | the State object and the config file |
184
+ | `alerts.py`, `weather.py` | what the names say |
185
+ | `themes.py` | colours; every module reads them through the active theme object `C` |
186
+ | `data/` | the city list, the land mask and the exchange list, with [their own README](https://github.com/ACoci86/terrahour/blob/main/terrahour/data/README.md) |
187
+
188
+ The map is a 0.25 degree land mask folded into a summed-area table, so any zoom level can ask
189
+ "how much of this box is land?" in constant time, and the braille cells come straight out of
190
+ that. Sun position uses the usual NOAA approximation, good to a few minutes.
191
+
192
+ ## Development
193
+
194
+ ```sh
195
+ python -m venv .venv && . .venv/bin/activate
196
+ pip install -e ".[dev]"
197
+ pytest
198
+ ```
199
+
200
+ The tests do not need a terminal or the network: they compose frames in memory and drive the
201
+ app through the same key handler the terminal loop uses. `tools/screenshots.py` regenerates
202
+ everything in `docs/` (it needs Pillow and the DejaVu fonts).
203
+
204
+ ## Data and credits
205
+
206
+ * City list derived from [GeoNames](https://www.geonames.org/) (CC BY 4.0).
207
+ * Land mask rasterised from [Natural Earth](https://www.naturalearthdata.com/) (public domain).
208
+ * Geocoding and weather from [Open-Meteo](https://open-meteo.com/) (CC BY 4.0).
209
+ * Time zone rules come from your operating system's tz database through Python's `zoneinfo`.
210
+
211
+ ## License
212
+
213
+ MIT. See [LICENSE](https://github.com/ACoci86/terrahour/blob/main/LICENSE).
@@ -0,0 +1,186 @@
1
+ # terrahour
2
+
3
+ ![tests](https://github.com/ACoci86/terrahour/actions/workflows/tests.yml/badge.svg)
4
+
5
+ A world clock for the terminal, with a map that shows where it is daytime and a 24-hour
6
+ timeline that shows when your cities are at work.
7
+
8
+ | Main view: the day and night map above a timeline per city |
9
+ | --- |
10
+ | ![terrahour main view](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/main.png) |
11
+
12
+ | A quick tour: scrubbing time, exchanges, DST radar, ambient view |
13
+ | --- |
14
+ | ![demo](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/demo.gif) |
15
+
16
+ ## What it does
17
+
18
+ * **Map with day and night.** The world is drawn in braille dots, shaded by sunlight, and
19
+ coloured by time zone. Every city you track gets a marker. Zoom with `+` `-` or the mouse
20
+ wheel, drag to pan.
21
+ * **A timeline per city.** Each row is a 24-hour bar with the working hours lit up. The
22
+ overlap row at the bottom shows the window when everybody is at their desk.
23
+ * **Scrub through time.** Arrow keys move in 15-minute steps, `g` jumps to any time
24
+ ("15:30", "3pm", "+2h", "2026-10-05 09:00"), `r` snaps back to live.
25
+ * **DST radar.** `D` lists the next clock change for every city and, more usefully, whether
26
+ the gap between you and them is about to shift by an hour.
27
+ * **Stock exchanges.** `M` swaps the city list for the ten biggest exchanges with their real
28
+ trading sessions, lunch breaks included.
29
+ * **Alerts.** `A` sets a daily reminder in another city's time, a ping when an office or
30
+ exchange opens or closes, or a plain timer. They ring the bell and send a desktop
31
+ notification while the app is open.
32
+ * **Weather**, if you want it. `W` shows the current temperature next to each city
33
+ (Open-Meteo, needs network, off by default).
34
+ * **Ambient view.** `V` turns the whole terminal into a big clock over the map that cycles
35
+ through your cities. Nice on a spare monitor.
36
+ * **Fits anywhere.** Below about 76 columns it switches to a compact layout that works in a
37
+ tmux split. `--line` prints a one-liner for a status bar.
38
+ * **Six themes** (midnight, nord, dracula, light, mono, colorblind), 12 or 24 hour clock,
39
+ mouse support, and about 6,000 cities built in with online lookup for the rest.
40
+
41
+ | Exchanges | Compact layout for a tmux split |
42
+ | --- | --- |
43
+ | ![markets view](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/markets.png) | ![compact view](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/compact.png) |
44
+
45
+ | DST radar | Adding a city |
46
+ | --- | --- |
47
+ | ![DST radar](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/radar.png) | ![add city](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/add-city.png) |
48
+
49
+ | Ambient view | Light theme |
50
+ | --- | --- |
51
+ | ![ambient view](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/ambient.png) | ![light theme](https://raw.githubusercontent.com/ACoci86/terrahour/main/docs/theme-light.png) |
52
+
53
+ ## Install
54
+
55
+ You need Python 3.9 or newer, a terminal with truecolor support (almost all of them these
56
+ days) and a UTF-8 locale. Developed on Linux. macOS is covered by the automated tests, but I
57
+ have not tried it by hand. On Windows use WSL, the app relies on a Unix terminal.
58
+
59
+ ```sh
60
+ pipx install git+https://github.com/ACoci86/terrahour
61
+ ```
62
+
63
+ Then run `terrahour`. If your shell cannot find the command, run `pipx ensurepath` once and
64
+ open a new terminal.
65
+
66
+ or, without pipx, in a virtual environment (recent Debian and Ubuntu refuse a plain
67
+ `pip install` outside one):
68
+
69
+ ```sh
70
+ python3 -m venv .venv && . .venv/bin/activate
71
+ pip install git+https://github.com/ACoci86/terrahour
72
+ terrahour
73
+ ```
74
+
75
+ The command is available whenever that environment is active.
76
+
77
+ or just clone it and run it in place, there is nothing to build:
78
+
79
+ ```sh
80
+ git clone https://github.com/ACoci86/terrahour
81
+ cd terrahour
82
+ python -m terrahour
83
+ ```
84
+
85
+ ## Usage
86
+
87
+ ```sh
88
+ terrahour # your saved cities (a sensible default set the first time)
89
+ terrahour Europe/Berlin "Home=America/Chicago" Naples # a one-off set of zones or city names, not saved
90
+ terrahour --markets # start in the exchange view
91
+ terrahour --at 2026-10-05T09:00Z # start frozen at a given time
92
+ terrahour --theme nord --12h
93
+ terrahour --ambient # straight into the screensaver view
94
+ terrahour --reset # forget saved cities and settings
95
+ ```
96
+
97
+ Press `?` inside the app for the full list of keys. The ones you will use most:
98
+
99
+ | Key | Action |
100
+ | --- | --- |
101
+ | `←` `→` | scrub 15 minutes (Shift: 1 hour, PgUp/PgDn: 1 day) |
102
+ | `g` | go to a time, `r` back to live |
103
+ | `↑` `↓` `j` `k` | select a city, Shift moves it up or down the list |
104
+ | `a` `d` | add or remove a city |
105
+ | `*` | mark the selected city as home (offsets are then relative to it) |
106
+ | `Enter` | focus the selected city, dims the others |
107
+ | `M` `D` `A` `W` | exchanges, DST radar, alerts, weather |
108
+ | `+` `-` `[` `]` `0` | zoom the map, zoom the timeline, reset |
109
+ | `T` `t` `c` `V` | theme, 12/24h, compact layout, ambient view |
110
+ | `q` | quit |
111
+
112
+ Everything you change in the app (cities, theme, home, working hours, alerts) is saved to
113
+ `~/.config/terrahour/config.json`. Zones given on the command line are session-only and
114
+ leave that file alone.
115
+
116
+ ## Status bars and scripts
117
+
118
+ ```sh
119
+ $ terrahour --line
120
+ SF 07:30 · NY 10:30 · LON 14:30 · UTC 14:30 · DUB 18:30 · MUM 20:00 · SIN 22:30 · TOK 23:30 · SYD 01:30+1
121
+
122
+ $ terrahour --watch # the same line, updating in place (good in a small tmux pane)
123
+ $ terrahour --tmux # with tmux colour codes, for your status-right
124
+ $ terrahour --json --at 2026-03-18T14:30Z | jq '.cities[] | select(.open)'
125
+ ```
126
+
127
+ The JSON includes local time, UTC offset, whether the city is inside working hours, minutes
128
+ until that changes, and the next clock change. `--once --size 120x40` prints a single frame of
129
+ the full UI, which is how the screenshots above were made.
130
+
131
+ ## Network
132
+
133
+ Two features talk to the internet, and both are optional:
134
+
135
+ * While you type in the "add city" box, names that are not in the built-in list are looked up
136
+ through the Open-Meteo geocoding API.
137
+ * Weather, when you turn it on with `W`, comes from the Open-Meteo forecast API.
138
+
139
+ Set `TERRAHOUR_OFFLINE=1` to switch both off. Nothing else leaves your machine.
140
+
141
+ ## How it is put together
142
+
143
+ It is a single Python package with no third-party dependencies. The pieces:
144
+
145
+ | Module | What it does |
146
+ | --- | --- |
147
+ | `cli.py` | argument parsing and the non-interactive modes |
148
+ | `app.py` | the terminal loop: raw mode, input tokens, redraw only the lines that changed |
149
+ | `keys.py` | every key and mouse event ends up here |
150
+ | `compose.py`, `draw.py`, `overlays.py`, `ambient.py` | turn the state into a frame |
151
+ | `canvas.py` | a character grid with colours that renders to ANSI escapes |
152
+ | `clock.py` | offsets, formatting, open/closed status, DST transitions, overlap |
153
+ | `astro.py` | sun position, sunrise and sunset |
154
+ | `worldmap.py` | the land mask, braille rasterisation, zoom and pan |
155
+ | `places.py`, `geocode.py` | the city database, search, and the online lookup |
156
+ | `state.py` | the State object and the config file |
157
+ | `alerts.py`, `weather.py` | what the names say |
158
+ | `themes.py` | colours; every module reads them through the active theme object `C` |
159
+ | `data/` | the city list, the land mask and the exchange list, with [their own README](https://github.com/ACoci86/terrahour/blob/main/terrahour/data/README.md) |
160
+
161
+ The map is a 0.25 degree land mask folded into a summed-area table, so any zoom level can ask
162
+ "how much of this box is land?" in constant time, and the braille cells come straight out of
163
+ that. Sun position uses the usual NOAA approximation, good to a few minutes.
164
+
165
+ ## Development
166
+
167
+ ```sh
168
+ python -m venv .venv && . .venv/bin/activate
169
+ pip install -e ".[dev]"
170
+ pytest
171
+ ```
172
+
173
+ The tests do not need a terminal or the network: they compose frames in memory and drive the
174
+ app through the same key handler the terminal loop uses. `tools/screenshots.py` regenerates
175
+ everything in `docs/` (it needs Pillow and the DejaVu fonts).
176
+
177
+ ## Data and credits
178
+
179
+ * City list derived from [GeoNames](https://www.geonames.org/) (CC BY 4.0).
180
+ * Land mask rasterised from [Natural Earth](https://www.naturalearthdata.com/) (public domain).
181
+ * Geocoding and weather from [Open-Meteo](https://open-meteo.com/) (CC BY 4.0).
182
+ * Time zone rules come from your operating system's tz database through Python's `zoneinfo`.
183
+
184
+ ## License
185
+
186
+ MIT. See [LICENSE](https://github.com/ACoci86/terrahour/blob/main/LICENSE).
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "terrahour"
7
+ dynamic = ["version"]
8
+ description = "World clock, world map and 24-hour meeting planner for the terminal"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Alessio Coci" }]
13
+ keywords = ["world clock", "time zones", "tui", "terminal", "timezone", "meeting planner", "tmux"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: MacOS",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Topic :: Utilities",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest", "pillow"]
30
+
31
+ [project.scripts]
32
+ terrahour = "terrahour.cli:main"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/ACoci86/terrahour"
36
+ Issues = "https://github.com/ACoci86/terrahour/issues"
37
+
38
+ [tool.setuptools]
39
+ packages = ["terrahour"]
40
+
41
+ [tool.setuptools.dynamic]
42
+ version = { attr = "terrahour.__version__" }
43
+
44
+ [tool.setuptools.package-data]
45
+ terrahour = ["data/*"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ pythonpath = ["."]
50
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """terrahour: world clock, map and 24-hour planner for the terminal.
2
+
3
+ Pure standard library (Python 3.9+). Needs a truecolor terminal and a UTF-8 locale.
4
+ """
5
+
6
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ """Allow ``python -m terrahour``."""
2
+ from .cli import main
3
+
4
+ main()
@@ -0,0 +1,110 @@
1
+ """Alerts: parse what the user typed, check them every second, and notify the desktop."""
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from .clock import fmt_clock, is_open
10
+ from .places import City, markets, search_db
11
+
12
+
13
+ def resolve_place(st, name):
14
+ """Find a city/exchange by name: current rows, exchanges, then the database. -> (name, zone, market) or None."""
15
+ q = name.strip().lower()
16
+ if not q:
17
+ c = st.rows()[st.cursor()]
18
+ return c.name, c.zone, c.sessions is not None
19
+ for c in st.cities:
20
+ if q in c.name.lower():
21
+ return c.name, c.zone, False
22
+ for c in markets():
23
+ if q in c.name.lower():
24
+ return c.name, c.zone, True
25
+ res = search_db(name, 1)
26
+ if res:
27
+ return res[0]["name"], res[0]["zone"], False
28
+ return None
29
+
30
+
31
+ def parse_alert(text, st):
32
+ """-> (alert dict | None, error)."""
33
+ s = text.strip()
34
+ low = s.lower()
35
+ m = re.fullmatch(r"in\s+(?:(\d+)\s*h)?\s*(?:(\d+)\s*m?)?", low)
36
+ if m and (m.group(1) or m.group(2)):
37
+ secs = int(m.group(1) or 0) * 3600 + int(m.group(2) or 0) * 60
38
+ if secs <= 0:
39
+ return None, "Timer must be longer than zero."
40
+ return {"kind": "timer", "name": "timer", "at": time.time() + secs}, ""
41
+ m = re.fullmatch(r"(open|close)\s+(.+)", low)
42
+ if m:
43
+ p = resolve_place(st, m.group(2))
44
+ if not p:
45
+ return None, "Couldn't find %r." % m.group(2)
46
+ return {"kind": m.group(1), "name": p[0], "zone": p[1], "market": p[2]}, ""
47
+ m = re.fullmatch(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(.*)", low)
48
+ if m:
49
+ hh, mm = int(m.group(1)), int(m.group(2) or 0)
50
+ if m.group(3):
51
+ if not 1 <= hh <= 12:
52
+ return None, "Hour must be 1-12 with am/pm."
53
+ hh = hh % 12 + (12 if m.group(3) == "pm" else 0)
54
+ if hh > 23 or mm > 59:
55
+ return None, "That isn't a valid time."
56
+ p = resolve_place(st, m.group(4))
57
+ if not p:
58
+ return None, "Couldn't find %r." % m.group(4)
59
+ return {"kind": "time", "name": p[0], "zone": p[1], "m": hh * 60 + mm}, ""
60
+ return None, "Try: 9am tokyo, open new york, close NYSE, in 25m"
61
+
62
+
63
+ def alert_city(a):
64
+ if a.get("market"):
65
+ for c in markets():
66
+ if c.name == a["name"]:
67
+ return c
68
+ return City(a["name"], a["zone"], 0, 0, 0)
69
+
70
+
71
+ def notify(text):
72
+ try:
73
+ if sys.platform == "darwin" and shutil.which("osascript"):
74
+ subprocess.Popen(["osascript", "-e", 'display notification "%s" with title "terrahour"' % text.replace('"', "'")],
75
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
76
+ elif shutil.which("notify-send"):
77
+ subprocess.Popen(["notify-send", "terrahour", text], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
78
+ except Exception:
79
+ pass
80
+
81
+
82
+ def check_alerts(st, live):
83
+ """Returns messages for alerts that fired now. Uses real time, not scrubbed time."""
84
+ fired, keep = [], []
85
+ for a in st.alerts:
86
+ try:
87
+ if a["kind"] == "timer":
88
+ if time.time() >= a["at"]:
89
+ fired.append("Timer finished")
90
+ continue
91
+ elif a["kind"] == "time":
92
+ loc = live.astimezone(ZoneInfo(a["zone"]))
93
+ today = loc.date().isoformat()
94
+ if loc.hour * 60 + loc.minute == a["m"] and a.get("_last") != today:
95
+ a["_last"] = today
96
+ fired.append("%s in %s" % (fmt_clock(loc, st.h12), a["name"]))
97
+ else:
98
+ c = alert_city(a)
99
+ now_open = is_open(c, live.astimezone(c.tz), st.work)
100
+ prev = a.get("_state")
101
+ a["_state"] = now_open
102
+ if prev is not None and prev != now_open and now_open == (a["kind"] == "open"):
103
+ fired.append("%s %s" % (a["name"], "is open" if now_open else "has closed"))
104
+ except Exception:
105
+ pass
106
+ keep.append(a)
107
+ if len(keep) != len(st.alerts):
108
+ st.alerts = keep
109
+ st.aidx = min(st.aidx, max(0, len(keep) - 1))
110
+ return fired