From 2dc305ceda116b38d4802cee891668dc5b8f16c7 Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 7 Nov 2025 19:51:06 -0800 Subject: [PATCH 1/6] Require an extra blank line around larger digits --- src/countdown/__main__.py | 5 ++++- tests/test_main.py | 38 ++++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/countdown/__main__.py b/src/countdown/__main__.py index 0c1eedc..1a5988a 100644 --- a/src/countdown/__main__.py +++ b/src/countdown/__main__.py @@ -48,7 +48,10 @@ def get_chars_for_terminal(): for size in DIGIT_SIZES: chars = CHARS_BY_SIZE[size] required_width = get_required_width(chars) - if size <= height and required_width <= width: + # For size 3 (smallest multi-line), allow it without padding + # For larger sizes, require 1 line of padding on top and bottom (2 total) + padding_needed = 0 if size == 3 else 2 + if size + padding_needed <= height and required_width <= width: return chars # If terminal is too small, return the smallest available return CHARS_BY_SIZE[min(DIGIT_SIZES)] diff --git a/tests/test_main.py b/tests/test_main.py index 00ed46e..2938a4e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -85,8 +85,8 @@ def test_duration_2_minutes_and_8_seconds(): def test_get_number_lines_10_seconds(monkeypatch): - # Use 40x6 terminal to select size 5 digits (33w <= 40, 5h <= 6) - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) + # Use 40x7 terminal to select size 5 digits (33w <= 40, 5h+2 padding <= 7) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) assert join_lines(__main__.get_number_lines(10)) == dedent( """ ██████ ██████ ██ ██████ @@ -99,8 +99,8 @@ def test_get_number_lines_10_seconds(monkeypatch): def test_get_number_lines_60_seconds(monkeypatch): - # Use 40x6 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) + # Use 40x7 terminal to select size 5 digits + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) assert join_lines(__main__.get_number_lines(60)) == dedent( """ ██████ ██ ██████ ██████ @@ -113,8 +113,8 @@ def test_get_number_lines_60_seconds(monkeypatch): def test_get_number_lines_45_minutes(monkeypatch): - # Use 40x6 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) + # Use 40x7 terminal to select size 5 digits + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) assert join_lines(__main__.get_number_lines(2700)) == dedent( """ ██ ██ ██████ ██████ ██████ @@ -127,8 +127,8 @@ def test_get_number_lines_45_minutes(monkeypatch): def test_get_number_lines_17_minutes_and_four_seconds(monkeypatch): - # Use 40x6 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) + # Use 40x7 terminal to select size 5 digits + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) assert join_lines(__main__.get_number_lines(1024)) == ( " ██ ██████ ██████ ██ ██\n" " ███ ██ ██ ██ ██ ██ ██\n" @@ -139,8 +139,8 @@ def test_get_number_lines_17_minutes_and_four_seconds(monkeypatch): def test_get_number_lines_8_minutes_and_6_seconds(monkeypatch): - # Use 40x6 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) + # Use 40x7 terminal to select size 5 digits + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) assert join_lines(__main__.get_number_lines(486)) == dedent( """ ██████ ████ ██████ ██████ @@ -153,8 +153,8 @@ def test_get_number_lines_8_minutes_and_6_seconds(monkeypatch): def test_get_number_lines_9_minutes(monkeypatch): - # Use 40x6 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) + # Use 40x7 terminal to select size 5 digits + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) assert join_lines(__main__.get_number_lines(540)) == dedent( """ ██████ ██████ ██████ ██████ @@ -167,8 +167,8 @@ def test_get_number_lines_9_minutes(monkeypatch): def test_get_number_lines_3478(monkeypatch): - # Use 40x6 terminal to select size 5 digits - monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 6)) + # Use 40x7 terminal to select size 5 digits + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 7)) assert join_lines(__main__.get_number_lines(2118)) == dedent( """ ██████ ██████ ██ ████ @@ -257,7 +257,7 @@ def test_main_3_seconds_sleeps_4_times( runner, monkeypatch, ): - # Use 40x20 terminal to select size 5 digits (33w <= 40, 5h <= 20) + # Use 40x20 terminal to select size 5 digits (33w <= 40, 5h+2 <= 20) monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) fake_sleep = FakeSleep() monkeypatch.setattr("time.sleep", fake_sleep) @@ -289,14 +289,16 @@ def test_main_3_seconds_sleeps_4_times( " ██ ██ ██ ██ ██ ██ ██ ██ ██\n" " ██████ ██████ ██████ ██████ " ) - assert fake_sleep.slept == 4 # 3 seconds = 4 sleeps + # 3 seconds countdown = 4 iterations (3,2,1,0), each sleeps 1 second = 4 seconds total + # Sleeping in chunks of 0.05, so total is ~4 seconds (floating point precision) + assert fake_sleep.slept == pytest.approx(4.0, abs=0.01) def test_main_1_minute( runner, monkeypatch, ): - # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h <= 10) + # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10) monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10)) # Raise exception after 11 sleeps @@ -412,7 +414,7 @@ def test_main_early_exit_still_shows_cursor_at_end( runner, monkeypatch, ): - # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h <= 10) + # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10) monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10)) # Hit Ctrl+C after 4 seconds From 3cd156973a922eb23ce74a11d58362c3e41adf6d Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 7 Nov 2025 20:01:15 -0800 Subject: [PATCH 2/6] Allow pausing/resuming the timer --- src/countdown/__main__.py | 138 ++++++++++++++++-- tests/test_main.py | 286 +++++++++++++++++++++++++++++++++++++- 2 files changed, 409 insertions(+), 15 deletions(-) diff --git a/src/countdown/__main__.py b/src/countdown/__main__.py index 1a5988a..de0a51a 100644 --- a/src/countdown/__main__.py +++ b/src/countdown/__main__.py @@ -9,6 +9,13 @@ from .digits import CHARS_BY_SIZE, DIGIT_SIZES +if sys.platform == "win32": # pragma: no cover + import msvcrt +else: # pragma: no cover + import select + import termios + import tty + ENABLE_ALT_BUFFER = "\033[?1049h" DISABLE_ALT_BUFFER = "\033[?1049l" HIDE_CURSOR = "\033[?25l" @@ -31,6 +38,60 @@ ) CLEAR = "\033[H\033[J" +INTENSE_MAGENTA = "\x1b[95m" +RESET = "\033[0m" + + +def check_for_keypress(): # pragma: no cover + """Check if a key has been pressed (non-blocking).""" + if not sys.stdin.isatty(): + return False + if sys.platform == "win32": + return msvcrt.kbhit() + else: + return select.select([sys.stdin], [], [], 0)[0] + + +def read_key(): # pragma: no cover + """Read a single keypress.""" + if sys.platform == "win32": + return msvcrt.getch() + else: + return sys.stdin.read(1) + + +def drain_keypresses(): # pragma: no cover + """Consume all pending keypresses from the input buffer.""" + while check_for_keypress(): + read_key() + + +def is_pause_key(key): + """Check if the given key is a pause/resume key (Space, p, k, Enter).""" + # Handle both bytes (Windows) and strings (Unix) + if isinstance(key, bytes): + key = key.decode("utf-8", errors="ignore") + return key in (" ", "p", "k", "\r", "\n") + + +def setup_terminal(): # pragma: no cover + """Setup terminal for non-blocking input (Unix only).""" + if sys.platform != "win32" and sys.stdin.isatty(): + try: + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + tty.setcbreak(fd) + return old_settings + except (termios.error, OSError): + pass + return None + + +def restore_terminal(old_settings): # pragma: no cover + """Restore terminal settings (Unix only).""" + if sys.platform != "win32" and old_settings: + fd = sys.stdin.fileno() + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) def get_required_width(chars): @@ -80,23 +141,49 @@ def main(duration): - 5m (5 minutes) - 45s (45 seconds) - 2m30s (2 minutes and 30 seconds) + + Press Space, p, k, or Enter to pause/resume the countdown. """ # noqa: D301 enable_ansi_escape_codes() + old_settings = setup_terminal() print(ENABLE_ALT_BUFFER + HIDE_CURSOR, end="") try: - for n in range(duration, -1, -1): + paused = False + n = duration + while n >= 0: lines = get_number_lines(n) - print_full_screen(lines) - time.sleep(1) + print_full_screen(lines, paused=paused) + + # Check for keypress to toggle pause + if check_for_keypress(): + key = read_key() # Consume the keypress + if is_pause_key(key): + paused = not paused + drain_keypresses() # Ignore any additional rapid keypresses + lines = get_number_lines(n) + print_full_screen(lines, paused=paused) + + # Only sleep and decrement if not paused + if not paused: + # Sleep in small chunks to check for keypresses more frequently + for _ in range(20): # 20 x 0.05 = 1 second + time.sleep(0.05) + if check_for_keypress(): + break # Exit sleep early if key is pressed + n -= 1 + else: + # Short sleep when paused for responsive keypress checking + time.sleep(0.05) except KeyboardInterrupt: pass finally: + restore_terminal(old_settings) print(SHOW_CURSOR + DISABLE_ALT_BUFFER, end="") -def enable_ansi_escape_codes(): +def enable_ansi_escape_codes(): # pragma: no cover """If running on Windows, enable ANSI escape codes.""" - if sys.platform == "win32": # pragma: no cover + if sys.platform == "win32": from ctypes import windll k = windll.kernel32 @@ -112,13 +199,42 @@ def enable_ansi_escape_codes(): ) -def print_full_screen(lines): +def print_full_screen(lines, paused=False): """Print the given lines centered in the middle of the terminal window.""" - width, height = shutil.get_terminal_size() - width -= max(len(line) for line in lines) - height -= len(lines) - vertical_pad = "\n" * (height // 2) - padded_text = "\n".join(" " * (width // 2) + line for line in lines) + term_width, term_height = shutil.get_terminal_size() + + # Calculate total content height + content_height = len(lines) + show_pause_text = False + if paused and content_height + 2 <= term_height: + # Only show PAUSED text if there's room + content_height += 2 # Blank line + PAUSED text + show_pause_text = True + + # Calculate vertical padding (ensure it doesn't go negative) + vertical_padding = max(0, (term_height - content_height) // 2) + + # Calculate horizontal padding for timer + max_line_width = max(len(line) for line in lines) + horizontal_padding = max(0, (term_width - max_line_width) // 2) + + # Apply red color to timer if paused + if paused: + colored_lines = [INTENSE_MAGENTA + line + RESET for line in lines] + else: + colored_lines = lines + + # Build the output + vertical_pad = "\n" * vertical_padding + padded_text = "\n".join( + " " * horizontal_padding + line for line in colored_lines + ) + + if show_pause_text: + pause_text = "PAUSED - Press any key to resume" + pause_padding = " " * max(0, (term_width - len(pause_text)) // 2) + padded_text += "\n\n" + pause_padding + pause_text + print(CLEAR + vertical_pad + padded_text, flush=True, end="") diff --git a/tests/test_main.py b/tests/test_main.py index 2938a4e..c15395d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -19,8 +19,10 @@ def __init__(self, *, raises={}): # noqa: B006 def __call__(self, seconds): self.slept += seconds - if self.slept in self.raises: - raise self.raises[self.slept] + # Check for exception with floating point tolerance + for trigger_time, exception in self.raises.items(): + if abs(self.slept - trigger_time) < 0.001: + raise exception def fake_size( @@ -384,7 +386,9 @@ def test_main_10_minutes_has_over_600_clear_screens( fake_sleep = FakeSleep() monkeypatch.setattr("time.sleep", fake_sleep) result = runner.invoke(__main__.main, ["10m"]) - assert fake_sleep.slept == 601 # 10 minutes = 601 sleeps + # 10 minutes = 601 iterations, each sleeps 1 second (via 20×0.05 chunks) + # Floating point precision: 601 × 20 × 0.05 ≈ 601.0 + assert fake_sleep.slept == pytest.approx(601.0, abs=0.1) assert result.stdout.count("\033[H\033[J") == 601 @@ -417,11 +421,12 @@ def test_main_early_exit_still_shows_cursor_at_end( # Use 40x10 terminal to select size 5 digits (33w <= 40, 5h+2 <= 10) monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 10)) - # Hit Ctrl+C after 4 seconds + # Hit Ctrl+C after 4 seconds total sleep time (chunked sleep) fake_sleep = FakeSleep(raises={4: KeyboardInterrupt()}) monkeypatch.setattr("time.sleep", fake_sleep) result = runner.invoke(__main__.main, ["15m"]) + # After 4 seconds of sleep, we've completed 4 iterations, each prints lines assert len(result.stdout.splitlines()) == 25, "4 seconds of lines printed" assert result.stdout.endswith("\033[?25h\033[?1049l") @@ -546,3 +551,276 @@ def test_width_constraints_force_smaller_size(monkeypatch): assert height == 1, ( "19x5 terminal too narrow for size 3, should select size 1" ) + + +def test_is_pause_key_with_strings(): + """Test that is_pause_key recognizes pause keys as strings (Unix).""" + assert __main__.is_pause_key(' ') is True, "Space should be a pause key" + assert __main__.is_pause_key('p') is True, "p should be a pause key" + assert __main__.is_pause_key('k') is True, "k should be a pause key" + assert __main__.is_pause_key('\r') is True, "Carriage return should be a pause key" + assert __main__.is_pause_key('\n') is True, "Newline should be a pause key" + assert __main__.is_pause_key('a') is False, "a should not be a pause key" + assert __main__.is_pause_key('x') is False, "x should not be a pause key" + assert __main__.is_pause_key('q') is False, "q should not be a pause key" + + +def test_is_pause_key_with_bytes(): + """Test that is_pause_key recognizes pause keys as bytes (Windows).""" + assert __main__.is_pause_key(b' ') is True, "Space as bytes should be a pause key" + assert __main__.is_pause_key(b'p') is True, "p as bytes should be a pause key" + assert __main__.is_pause_key(b'k') is True, "k as bytes should be a pause key" + assert __main__.is_pause_key(b'\r') is True, "CR as bytes should be a pause key" + assert __main__.is_pause_key(b'\n') is True, "LF as bytes should be a pause key" + assert __main__.is_pause_key(b'a') is False, "a as bytes should not be a pause key" + assert __main__.is_pause_key(b'x') is False, "x as bytes should not be a pause key" + + +def test_print_full_screen_paused_shows_red_and_message( + capsys, + monkeypatch, +): + """Test that paused=True shows colored timer and PAUSED message.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) + lines = ["00:05"] + __main__.print_full_screen(lines, paused=True) + out, err = capsys.readouterr() + # Should contain intense magenta color code + assert "\x1b[95m" in out, "Should contain intense magenta color code when paused" + # Should contain reset code + assert "\033[0m" in out, "Should contain color reset code" + # Should contain PAUSED message + assert "PAUSED - Press any key to resume" in out + + +def test_print_full_screen_not_paused_no_red_or_message( + capsys, + monkeypatch, +): + """Test that paused=False shows normal timer without PAUSED message.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(80, 24)) + lines = ["00:05"] + __main__.print_full_screen(lines, paused=False) + out, err = capsys.readouterr() + # Should NOT contain PAUSED message + assert "PAUSED" not in out + # Red color may or may not be present depending on other features, but + # the important thing is the PAUSED message is not shown + + +def test_print_full_screen_paused_tiny_terminal_no_message( + capsys, + monkeypatch, +): + """Test that PAUSED message is hidden in tiny terminals with no room.""" + # Create a 3-line terminal with 3-line timer (no room for PAUSED text) + monkeypatch.setattr("shutil.get_terminal_size", fake_size(20, 3)) + lines = ["line1", "line2", "line3"] + __main__.print_full_screen(lines, paused=True) + out, err = capsys.readouterr() + # Should still show intense magenta color + assert "\x1b[95m" in out, "Should contain intense magenta color code when paused" + # Should NOT show PAUSED message (no room) + assert "PAUSED" not in out, "PAUSED message should not appear in tiny terminal" + + +def test_pause_key_triggers_pause( + runner, + monkeypatch, +): + """Test that pressing a pause key triggers the pause logic.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) + + # Exit after a short time + fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) + monkeypatch.setattr("time.sleep", fake_sleep) + + # Track whether pause key was detected + pause_key_detected = [False] + read_key_called = [False] + + def fake_check_for_keypress(): + # Return True once to simulate a keypress during first iteration + if not pause_key_detected[0]: + pause_key_detected[0] = True + return True + return False + + def fake_read_key(): + read_key_called[0] = True + return ' ' # Space bar (a pause key) + + def fake_drain(): + pass # No additional keys to drain + + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["5s"]) + + # The pause key should have been detected and read + assert pause_key_detected[0], "Pause key detection should have been called" + assert read_key_called[0], "read_key should have been called" + # Output should contain the paused color since we pressed a pause key + assert "\x1b[95m" in result.stdout, "Should show paused color when pause key pressed" + + +def test_non_pause_key_ignored( + runner, + monkeypatch, +): + """Test that non-pause keys are ignored during countdown.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) + + fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) + monkeypatch.setattr("time.sleep", fake_sleep) + + # Track keypresses + check_called = [False] + read_key_called = [False] + + def fake_check_for_keypress(): + if not check_called[0]: + check_called[0] = True + return True + return False + + def fake_read_key(): + read_key_called[0] = True + return 'x' # Not a pause key + + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + + result = runner.invoke(__main__.main, ["5s"]) + + # The key should have been read + assert read_key_called[0], "read_key should have been called" + # Output should NOT contain paused color since 'x' is not a pause key + assert "\x1b[95m" not in result.stdout, "Should not show paused color for non-pause key" + assert result.exit_code == 0 + + +def test_pause_key_detection( + monkeypatch, +): + """Test that check_for_keypress returns False when not a TTY.""" + import sys + + # Mock stdin.isatty() to return False + original_isatty = sys.stdin.isatty + sys.stdin.isatty = lambda: False + + try: + result = __main__.check_for_keypress() + assert result is False, "Should return False when not a TTY" + finally: + sys.stdin.isatty = original_isatty + + +def test_sleep_exits_early_on_keypress( + runner, + monkeypatch, +): + """Test that sleep loop exits early when a key is pressed mid-sleep.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) + + # Track sleep calls + sleep_calls = [] + + def fake_sleep(seconds): + sleep_calls.append(seconds) + # Exit after we've done a few sleep chunks + if len(sleep_calls) >= 5: + raise KeyboardInterrupt() + + monkeypatch.setattr("time.sleep", fake_sleep) + + # Simulate keypress after 3rd sleep call (during chunked 1-second sleep) + check_count = [0] + + def fake_check_for_keypress(): + check_count[0] += 1 + # Return True on the 3rd sleep chunk to simulate keypress mid-sleep + return len(sleep_calls) == 3 + + def fake_read_key(): + return ' ' # Pause key + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["10s"]) + + # Should have broken out of sleep loop early (not all 20 chunks) + # We expect: 3 chunks of first iteration, then breaks, then starts paused sleep + # The key point is we don't see all 20 chunks of 0.05 before breaking + assert len(sleep_calls) >= 3, "Should have at least 3 sleep calls" + # If it didn't exit early, we'd see many more 0.05 sleep calls + # The presence of the break means we don't complete all 20 chunks + first_iteration_sleeps = [s for s in sleep_calls[:3] if s == 0.05] + assert len(first_iteration_sleeps) == 3, "Should have 3 chunks of 0.05s before breaking" + + +def test_resume_from_pause_exits_early( + runner, + monkeypatch, +): + """Test that when paused, pressing a key to resume exits the 0.05s sleep loop.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) + + sleep_calls = [] + paused_state = [False] + + def fake_sleep(seconds): + sleep_calls.append((seconds, paused_state[0])) + if len(sleep_calls) >= 10: + raise KeyboardInterrupt() + + monkeypatch.setattr("time.sleep", fake_sleep) + + # Simulate: pause immediately, then resume after a few paused sleeps + keypress_count = [0] + + def fake_check_for_keypress(): + keypress_count[0] += 1 + # First keypress: pause immediately (keypress 1) + # Second keypress: resume after being paused (keypress 2) + return keypress_count[0] in [1, 5] + + keys_to_return = [' ', ' '] # Space to pause, space to resume + key_index = [0] + + def fake_read_key(): + key = keys_to_return[key_index[0]] + key_index[0] = min(key_index[0] + 1, len(keys_to_return) - 1) + return key + + def fake_drain(): + pass + + # Track pause state transitions + original_print = __main__.print_full_screen + + def tracking_print(lines, paused=False): + paused_state[0] = paused + return original_print(lines, paused=paused) + + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + monkeypatch.setattr(__main__, "print_full_screen", tracking_print) + + result = runner.invoke(__main__.main, ["10s"]) + + # Should have some paused sleeps (0.05) and some regular chunked sleeps (0.05) + paused_sleeps = [s for s, p in sleep_calls if p] + unpaused_sleeps = [s for s, p in sleep_calls if not p] + + assert len(paused_sleeps) > 0, "Should have some paused sleep periods" + assert len(unpaused_sleeps) > 0, "Should have some unpaused sleep periods" From 1c9d05090794f49dc392d21bf80bdb6f3b431a9b Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 7 Nov 2025 20:01:30 -0800 Subject: [PATCH 3/6] Add Windows testing to CI --- .github/workflows/tests.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9b220dd..21d9228 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,13 +21,14 @@ jobs: python-versions: ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} tests: - name: Tests on ${{ matrix.python-version }} + name: Tests on ${{ matrix.os }} - ${{ matrix.python-version }} needs: build-package - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: python-version: ${{ fromJson(needs.build-package.outputs.python-versions) }} + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 From d21b9fd2310c6b8585afd4dc918c6d0607f8657c Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 7 Nov 2025 20:07:52 -0800 Subject: [PATCH 4/6] Fix CI for Windows --- .github/workflows/tests.yml | 4 +++- src/countdown/digits.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 21d9228..2c15bf9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,7 +37,9 @@ jobs: with: name: Packages path: dist - - run: tar xf dist/*.tar.gz --strip-components=1 + - name: Extract source tarball + shell: bash + run: tar xf dist/*.tar.gz --strip-components=1 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/src/countdown/digits.py b/src/countdown/digits.py index f06ef5b..ef9f84f 100644 --- a/src/countdown/digits.py +++ b/src/countdown/digits.py @@ -31,7 +31,7 @@ def center(text, width): def populate_constants(): """Populate CHARS_BY_SIZE and DIGIT_SIZES to contain the numbers in numbers.txt.""" - lines = files("countdown").joinpath("numbers.txt").read_text().splitlines() + lines = files("countdown").joinpath("numbers.txt").read_text(encoding="utf-8").splitlines() number_types = list(paragraphs(lines)) for group in number_types: columns = transpose(group) From d61d9308da043e0a6c37bc6d84d068c836e329a7 Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 7 Nov 2025 20:12:04 -0800 Subject: [PATCH 5/6] Allow +/- to add/subtract time --- src/countdown/__main__.py | 30 +++++++- tests/test_main.py | 150 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/src/countdown/__main__.py b/src/countdown/__main__.py index de0a51a..3b130ea 100644 --- a/src/countdown/__main__.py +++ b/src/countdown/__main__.py @@ -74,6 +74,26 @@ def is_pause_key(key): return key in (" ", "p", "k", "\r", "\n") +def is_time_adjust_key(key): + """Check if the given key is a time adjustment key (+, =, -).""" + # Handle both bytes (Windows) and strings (Unix) + if isinstance(key, bytes): + key = key.decode("utf-8", errors="ignore") + return key in ("+", "=", "-") + + +def get_time_adjustment(key): + """Return the time adjustment in seconds for the given key.""" + # Handle both bytes (Windows) and strings (Unix) + if isinstance(key, bytes): + key = key.decode("utf-8", errors="ignore") + if key in ("+", "="): + return 30 # Add 30 seconds + elif key == "-": + return -30 # Subtract 30 seconds + return 0 + + def setup_terminal(): # pragma: no cover """Setup terminal for non-blocking input (Unix only).""" if sys.platform != "win32" and sys.stdin.isatty(): @@ -143,6 +163,7 @@ def main(duration): - 2m30s (2 minutes and 30 seconds) Press Space, p, k, or Enter to pause/resume the countdown. + Press +/= to add 30 seconds, - to subtract 30 seconds. """ # noqa: D301 enable_ansi_escape_codes() old_settings = setup_terminal() @@ -154,7 +175,7 @@ def main(duration): lines = get_number_lines(n) print_full_screen(lines, paused=paused) - # Check for keypress to toggle pause + # Check for keypress to toggle pause or adjust time if check_for_keypress(): key = read_key() # Consume the keypress if is_pause_key(key): @@ -162,6 +183,13 @@ def main(duration): drain_keypresses() # Ignore any additional rapid keypresses lines = get_number_lines(n) print_full_screen(lines, paused=paused) + elif is_time_adjust_key(key): + # Adjust the timer by +/- 30 seconds + adjustment = get_time_adjustment(key) + n = max(0, n + adjustment) # Don't go below 0 + drain_keypresses() # Ignore any additional rapid keypresses + lines = get_number_lines(n) + print_full_screen(lines, paused=paused) # Only sleep and decrement if not paused if not paused: diff --git a/tests/test_main.py b/tests/test_main.py index c15395d..f9d7214 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -824,3 +824,153 @@ def tracking_print(lines, paused=False): assert len(paused_sleeps) > 0, "Should have some paused sleep periods" assert len(unpaused_sleeps) > 0, "Should have some unpaused sleep periods" + + +def test_is_time_adjust_key_with_strings(): + """Test that is_time_adjust_key recognizes +, =, - as strings (Unix).""" + assert __main__.is_time_adjust_key('+') is True, "+ should be a time adjust key" + assert __main__.is_time_adjust_key('=') is True, "= should be a time adjust key" + assert __main__.is_time_adjust_key('-') is True, "- should be a time adjust key" + assert __main__.is_time_adjust_key('a') is False, "a should not be a time adjust key" + assert __main__.is_time_adjust_key(' ') is False, "space should not be a time adjust key" + + +def test_is_time_adjust_key_with_bytes(): + """Test that is_time_adjust_key recognizes +, =, - as bytes (Windows).""" + assert __main__.is_time_adjust_key(b'+') is True, "+ as bytes should be a time adjust key" + assert __main__.is_time_adjust_key(b'=') is True, "= as bytes should be a time adjust key" + assert __main__.is_time_adjust_key(b'-') is True, "- as bytes should be a time adjust key" + assert __main__.is_time_adjust_key(b'a') is False, "a as bytes should not be a time adjust key" + + +def test_get_time_adjustment(): + """Test that get_time_adjustment returns correct values.""" + assert __main__.get_time_adjustment('+') == 30, "+ should add 30 seconds" + assert __main__.get_time_adjustment('=') == 30, "= should add 30 seconds" + assert __main__.get_time_adjustment('-') == -30, "- should subtract 30 seconds" + assert __main__.get_time_adjustment('a') == 0, "non-adjust key should return 0" + # Test with bytes + assert __main__.get_time_adjustment(b'+') == 30, "+ as bytes should add 30 seconds" + assert __main__.get_time_adjustment(b'=') == 30, "= as bytes should add 30 seconds" + assert __main__.get_time_adjustment(b'-') == -30, "- as bytes should subtract 30 seconds" + + +def test_add_time_with_plus_key( + runner, + monkeypatch, +): + """Test that pressing + adds 30 seconds to the timer.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) + + fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) + monkeypatch.setattr("time.sleep", fake_sleep) + + # Track the displayed times + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def fake_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + def fake_check_for_keypress(): + # Return True once to simulate a keypress + return len(displayed_times) == 1 + + def fake_read_key(): + return '+' # Plus key to add time + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines) + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["1m"]) + + # Should have displayed 60s initially, then 90s after pressing + + assert 60 in displayed_times, "Should display initial time of 60s" + assert 90 in displayed_times, "Should display 90s after adding 30s" + + +def test_subtract_time_with_minus_key( + runner, + monkeypatch, +): + """Test that pressing - subtracts 30 seconds from the timer.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) + + fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) + monkeypatch.setattr("time.sleep", fake_sleep) + + # Track the displayed times + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def fake_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + def fake_check_for_keypress(): + # Return True once to simulate a keypress + return len(displayed_times) == 1 + + def fake_read_key(): + return '-' # Minus key to subtract time + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines) + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["1m"]) + + # Should have displayed 60s initially, then 30s after pressing - + assert 60 in displayed_times, "Should display initial time of 60s" + assert 30 in displayed_times, "Should display 30s after subtracting 30s" + + +def test_subtract_time_cannot_go_negative( + runner, + monkeypatch, +): + """Test that subtracting time stops at 0 (cannot go negative).""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) + + fake_sleep = FakeSleep(raises={1: KeyboardInterrupt()}) + monkeypatch.setattr("time.sleep", fake_sleep) + + # Track the displayed times + displayed_times = [] + original_get_number_lines = __main__.get_number_lines + + def fake_get_number_lines(seconds): + displayed_times.append(seconds) + return original_get_number_lines(seconds) + + def fake_check_for_keypress(): + # Return True once to simulate a keypress + return len(displayed_times) == 1 + + def fake_read_key(): + return '-' # Minus key to subtract time + + def fake_drain(): + pass + + monkeypatch.setattr(__main__, "get_number_lines", fake_get_number_lines) + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + monkeypatch.setattr(__main__, "drain_keypresses", fake_drain) + + result = runner.invoke(__main__.main, ["10s"]) + + # Should have displayed 10s initially, then 0s (not -20s) after pressing - + assert 10 in displayed_times, "Should display initial time of 10s" + assert 0 in displayed_times, "Should display 0s (not negative) after subtracting 30s" + assert all(t >= 0 for t in displayed_times), "All displayed times should be non-negative" From f31dc46789e3ef70948651069fd33b885f25ed43 Mon Sep 17 00:00:00 2001 From: Trey Hunner Date: Fri, 7 Nov 2025 20:22:42 -0800 Subject: [PATCH 6/6] Allow q to quit --- src/countdown/__main__.py | 30 ++++++++------- tests/test_main.py | 81 ++++++++++++++++++++++++--------------- 2 files changed, 67 insertions(+), 44 deletions(-) diff --git a/src/countdown/__main__.py b/src/countdown/__main__.py index 3b130ea..110b7c7 100644 --- a/src/countdown/__main__.py +++ b/src/countdown/__main__.py @@ -55,7 +55,7 @@ def check_for_keypress(): # pragma: no cover def read_key(): # pragma: no cover """Read a single keypress.""" if sys.platform == "win32": - return msvcrt.getch() + return msvcrt.getch().decode("utf-8", errors="ignore") else: return sys.stdin.read(1) @@ -68,25 +68,16 @@ def drain_keypresses(): # pragma: no cover def is_pause_key(key): """Check if the given key is a pause/resume key (Space, p, k, Enter).""" - # Handle both bytes (Windows) and strings (Unix) - if isinstance(key, bytes): - key = key.decode("utf-8", errors="ignore") return key in (" ", "p", "k", "\r", "\n") def is_time_adjust_key(key): """Check if the given key is a time adjustment key (+, =, -).""" - # Handle both bytes (Windows) and strings (Unix) - if isinstance(key, bytes): - key = key.decode("utf-8", errors="ignore") return key in ("+", "=", "-") def get_time_adjustment(key): """Return the time adjustment in seconds for the given key.""" - # Handle both bytes (Windows) and strings (Unix) - if isinstance(key, bytes): - key = key.decode("utf-8", errors="ignore") if key in ("+", "="): return 30 # Add 30 seconds elif key == "-": @@ -149,8 +140,9 @@ def duration(string): @click.command() @click.version_option(package_name="countdown-cli") -@click.argument("duration", type=duration) -def main(duration): +@click.argument("duration", type=duration, required=False) +@click.pass_context +def main(ctx, duration): """Countdown from the given duration to 0. DURATION should be a number followed by m or s for minutes or seconds. @@ -163,8 +155,16 @@ def main(duration): - 2m30s (2 minutes and 30 seconds) Press Space, p, k, or Enter to pause/resume the countdown. + Press +/= to add 30 seconds, - to subtract 30 seconds. + + Press q to quit. """ # noqa: D301 + # Show help if no duration provided + if duration is None: + click.echo(ctx.get_help()) + return + enable_ansi_escape_codes() old_settings = setup_terminal() print(ENABLE_ALT_BUFFER + HIDE_CURSOR, end="") @@ -178,7 +178,11 @@ def main(duration): # Check for keypress to toggle pause or adjust time if check_for_keypress(): key = read_key() # Consume the keypress - if is_pause_key(key): + + if key == "q": + # Quit the timer + break + elif is_pause_key(key): paused = not paused drain_keypresses() # Ignore any additional rapid keypresses lines = get_number_lines(n) diff --git a/tests/test_main.py b/tests/test_main.py index f9d7214..1566eac 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -235,17 +235,13 @@ def test_print_full_screen_multiline_text( def test_main_with_no_arguments(runner): - """It exits with a status code of zero.""" + """It shows help when run without arguments.""" result = runner.invoke(__main__.main) - assert result.output == dedent( - """\ - Usage: main [OPTIONS] DURATION - Try 'main --help' for help. - - Error: Missing argument 'DURATION'. - """ - ) - assert result.exit_code == 2 + # Should show help (not error) + assert result.exit_code == 0 + assert "Usage:" in result.output + assert "DURATION" in result.output + assert "5m" in result.output # Should show examples def test_version_works(runner): @@ -565,15 +561,6 @@ def test_is_pause_key_with_strings(): assert __main__.is_pause_key('q') is False, "q should not be a pause key" -def test_is_pause_key_with_bytes(): - """Test that is_pause_key recognizes pause keys as bytes (Windows).""" - assert __main__.is_pause_key(b' ') is True, "Space as bytes should be a pause key" - assert __main__.is_pause_key(b'p') is True, "p as bytes should be a pause key" - assert __main__.is_pause_key(b'k') is True, "k as bytes should be a pause key" - assert __main__.is_pause_key(b'\r') is True, "CR as bytes should be a pause key" - assert __main__.is_pause_key(b'\n') is True, "LF as bytes should be a pause key" - assert __main__.is_pause_key(b'a') is False, "a as bytes should not be a pause key" - assert __main__.is_pause_key(b'x') is False, "x as bytes should not be a pause key" def test_print_full_screen_paused_shows_red_and_message( @@ -835,24 +822,12 @@ def test_is_time_adjust_key_with_strings(): assert __main__.is_time_adjust_key(' ') is False, "space should not be a time adjust key" -def test_is_time_adjust_key_with_bytes(): - """Test that is_time_adjust_key recognizes +, =, - as bytes (Windows).""" - assert __main__.is_time_adjust_key(b'+') is True, "+ as bytes should be a time adjust key" - assert __main__.is_time_adjust_key(b'=') is True, "= as bytes should be a time adjust key" - assert __main__.is_time_adjust_key(b'-') is True, "- as bytes should be a time adjust key" - assert __main__.is_time_adjust_key(b'a') is False, "a as bytes should not be a time adjust key" - - def test_get_time_adjustment(): """Test that get_time_adjustment returns correct values.""" assert __main__.get_time_adjustment('+') == 30, "+ should add 30 seconds" assert __main__.get_time_adjustment('=') == 30, "= should add 30 seconds" assert __main__.get_time_adjustment('-') == -30, "- should subtract 30 seconds" assert __main__.get_time_adjustment('a') == 0, "non-adjust key should return 0" - # Test with bytes - assert __main__.get_time_adjustment(b'+') == 30, "+ as bytes should add 30 seconds" - assert __main__.get_time_adjustment(b'=') == 30, "= as bytes should add 30 seconds" - assert __main__.get_time_adjustment(b'-') == -30, "- as bytes should subtract 30 seconds" def test_add_time_with_plus_key( @@ -974,3 +949,47 @@ def fake_drain(): assert 10 in displayed_times, "Should display initial time of 10s" assert 0 in displayed_times, "Should display 0s (not negative) after subtracting 30s" assert all(t >= 0 for t in displayed_times), "All displayed times should be non-negative" + + +def test_q_key_quits_timer( + runner, + monkeypatch, +): + """Test that pressing 'q' exits the timer.""" + monkeypatch.setattr("shutil.get_terminal_size", fake_size(40, 20)) + + fake_sleep = FakeSleep() + monkeypatch.setattr("time.sleep", fake_sleep) + + keypress_count = [0] + + def fake_check_for_keypress(): + keypress_count[0] += 1 + # Return True on first check to simulate pressing q + return keypress_count[0] == 1 + + def fake_read_key(): + return 'q' # Press q to quit + + monkeypatch.setattr(__main__, "check_for_keypress", fake_check_for_keypress) + monkeypatch.setattr(__main__, "read_key", fake_read_key) + + result = runner.invoke(__main__.main, ["10m"]) + + # Should exit cleanly with code 0 + assert result.exit_code == 0 + # Should have shown cursor and disabled alt buffer on exit + assert result.stdout.endswith("\033[?25h\033[?1049l") + + +def test_no_arguments_shows_help(runner): + """Test that running without arguments shows help message.""" + result = runner.invoke(__main__.main, []) + + # Should exit with code 0 (not an error) + assert result.exit_code == 0 + # Should show usage information + assert "Usage:" in result.output + assert "DURATION" in result.output + # Should show examples + assert "5m" in result.output or "Examples" in result.output