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

1"""Command-line interface.""" 

2 

3import re 

4import shutil 

5import sys 

6import time 

7 

8import click 

9 

10from .digits import CHARS_BY_SIZE, DIGIT_SIZES 

11 

12ENABLE_ALT_BUFFER = "\033[?1049h" 

13DISABLE_ALT_BUFFER = "\033[?1049l" 

14HIDE_CURSOR = "\033[?25l" 

15SHOW_CURSOR = "\033[?25h" 

16 

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) 

32 

33CLEAR = "\033[H\033[J" 

34 

35 

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 

43 

44 

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)] 

55 

56 

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) 

64 

65 

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. 

71 

72 DURATION should be a number followed by m or s for minutes or seconds. 

73 

74 Examples of DURATION: 

75 

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="") 

92 

93 

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 

98 

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 ) 

110 

111 

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="") 

120 

121 

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 

134 

135 

136if __name__ == "__main__": 

137 main(prog_name="countdown") # pragma: no cover