Coverage for src/countdown/__main__.py: 100%
64 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-06 17:54 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-06 17:54 -0800
1"""Command-line interface."""
3import re
4import shutil
5import sys
6import time
8import click
10from .digits import CHARS_BY_SIZE, DIGIT_SIZES
12ENABLE_ALT_BUFFER = "\033[?1049h"
13DISABLE_ALT_BUFFER = "\033[?1049l"
14HIDE_CURSOR = "\033[?25l"
15SHOW_CURSOR = "\033[?25h"
17DURATION_RE = re.compile(
18 r"""
19 ^
20 (?: # Optional minutes
21 ( \d{1,2} ) # D or DD
22 m # "m"
23 )?
24 (?: # Optional seconds
25 ( \d{1,2} ) # D or DD
26 s # "s"
27 )?
28 $
29""",
30 re.VERBOSE,
31)
33CLEAR = "\033[H\033[J"
36def get_required_width(chars):
37 """Calculate the minimum width required to display MM:SS format."""
38 # MM:SS format has 4 digits, 1 colon, and 1 space after each char
39 digit_width = max(len(line) for line in chars["0"].splitlines())
40 colon_width = max(len(line) for line in chars[":"].splitlines())
41 # Total: 4 digits + 1 colon + 5 spaces (after each character)
42 return digit_width * 4 + colon_width + 5
45def get_chars_for_terminal():
46 """Return the largest CHARS dictionary that fits in the current terminal."""
47 width, height = shutil.get_terminal_size()
48 for size in DIGIT_SIZES:
49 chars = CHARS_BY_SIZE[size]
50 required_width = get_required_width(chars)
51 if size <= height and required_width <= width:
52 return chars
53 # If terminal is too small, return the smallest available
54 return CHARS_BY_SIZE[min(DIGIT_SIZES)]
57def duration(string):
58 """Convert given XmXs string to seconds (as an integer)."""
59 match = DURATION_RE.search(string)
60 if not match:
61 raise ValueError(f"Invalid duration: {string}")
62 minutes, seconds = match.groups()
63 return int(minutes or 0) * 60 + int(seconds or 0)
66@click.command()
67@click.version_option(package_name="countdown-cli")
68@click.argument("duration", type=duration)
69def main(duration):
70 """Countdown from the given duration to 0.
72 DURATION should be a number followed by m or s for minutes or seconds.
74 Examples of DURATION:
76 \b
77 - 5m (5 minutes)
78 - 45s (30 seconds)
79 - 2m30s (2 minutes and 30 seconds)
80 """ # noqa: D301
81 enable_ansi_escape_codes()
82 print(ENABLE_ALT_BUFFER + HIDE_CURSOR, end="")
83 try:
84 for n in range(duration, -1, -1):
85 lines = get_number_lines(n)
86 print_full_screen(lines)
87 time.sleep(1)
88 except KeyboardInterrupt:
89 pass
90 finally:
91 print(SHOW_CURSOR + DISABLE_ALT_BUFFER, end="")
94def enable_ansi_escape_codes():
95 """If running on Windows, enable ANSI escape codes."""
96 if sys.platform == "win32": # pragma: no cover
97 from ctypes import windll
99 k = windll.kernel32
100 stdout = -11
101 enable_processed_output = 0x0001
102 enable_wrap_at_eol_output = 0x0002
103 enable_virtual_terminal_processing = 0x0004
104 k.SetConsoleMode(
105 k.GetStdHandle(stdout),
106 enable_processed_output
107 | enable_wrap_at_eol_output
108 | enable_virtual_terminal_processing,
109 )
112def print_full_screen(lines):
113 """Print the given lines centered in the middle of the terminal window."""
114 width, height = shutil.get_terminal_size()
115 width -= max(len(line) for line in lines)
116 height -= len(lines)
117 vertical_pad = "\n" * (height // 2)
118 padded_text = "\n".join(" " * (width // 2) + line for line in lines)
119 print(CLEAR + vertical_pad + padded_text, flush=True, end="")
122def get_number_lines(seconds):
123 """Return list of lines which make large MM:SS glyphs for given seconds."""
124 chars = get_chars_for_terminal()
125 digit_height = len(next(iter(chars.values())).splitlines())
126 lines = [""] * digit_height
127 minutes, seconds = divmod(seconds, 60)
128 time = f"{minutes:02d}:{seconds:02d}"
129 for char in time:
130 char_lines = chars[char].splitlines()
131 for i, line in enumerate(char_lines):
132 lines[i] += line + " "
133 return lines
136if __name__ == "__main__":
137 main(prog_name="countdown") # pragma: no cover