Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,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 }}
Expand Down
179 changes: 165 additions & 14 deletions src/countdown/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -31,6 +38,71 @@
)

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().decode("utf-8", errors="ignore")
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)."""
return key in (" ", "p", "k", "\r", "\n")


def is_time_adjust_key(key):
"""Check if the given key is a time adjustment key (+, =, -)."""
return key in ("+", "=", "-")


def get_time_adjustment(key):
"""Return the time adjustment in seconds for the given key."""
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():
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):
Expand All @@ -48,7 +120,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)]
Expand All @@ -65,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.
Expand All @@ -77,23 +153,69 @@ 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.

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="")
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 or adjust time
if check_for_keypress():
key = read_key() # Consume the keypress

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)
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:
# 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
Expand All @@ -109,13 +231,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="")


Expand Down
2 changes: 1 addition & 1 deletion src/countdown/digits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading