diff --git a/README.md b/README.md index de5b404..b37f0eb 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ A simple focus timer that runs in your terminal. It follows the "Pomodoro" technique: work for **25 minutes**, take a **5 minute break**, -repeat. While it runs you'll see a countdown and a progress bar, and your +repeat. While it runs you'll see a big countdown and a progress bar, and your terminal beeps when each focus session or break is over. +The countdown is shown in large digits, colored **green** while you focus and +**blue** during breaks. If your terminal window is too small (or doesn't +support colors), it falls back to a simple one-line countdown. + It's a single Python file with nothing extra to install. ## What you need diff --git a/pomodoro.py b/pomodoro.py index 331e868..adfe9e1 100644 --- a/pomodoro.py +++ b/pomodoro.py @@ -9,6 +9,7 @@ Uses only the Python standard library. """ import argparse +import os import shutil import sys import time @@ -28,6 +29,62 @@ def pick_bar_chars(): FILL_CHAR, EMPTY_CHAR = pick_bar_chars() +# Colors for the countdown: green while focusing, blue during breaks. +RESET = "\033[0m" +COLORS = { + "Focus": "\033[1;92m", # bright green + "Break": "\033[1;94m", # bright blue +} + +# A chunky 5-line-tall font for the big countdown digits. +BIG_FONT = { + "0": ["#####", "# #", "# #", "# #", "#####"], + "1": [" # ", " ## ", " # ", " # ", " ### "], + "2": ["#####", " #", "#####", "# ", "#####"], + "3": ["#####", " #", " ####", " #", "#####"], + "4": ["# #", "# #", "#####", " #", " #"], + "5": ["#####", "# ", "#####", " #", "#####"], + "6": ["#####", "# ", "#####", "# #", "#####"], + "7": ["#####", " #", " # ", " # ", " # "], + "8": ["#####", "# #", "#####", "# #", "#####"], + "9": ["#####", "# #", "#####", " #", "#####"], + ":": [" ", " # ", " ", " # ", " "], +} +BIG_HEIGHT = 5 +INDENT = " " + + +def enable_ansi(): + """Return True if the terminal can show colors and move the cursor.""" + if not sys.stdout.isatty() or os.environ.get("TERM") == "dumb": + return False + if os.name == "nt": + # Windows 10+ supports colors, but they have to be switched on. + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) # standard output + mode = ctypes.c_uint32() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return False + # 0x0004 = ENABLE_VIRTUAL_TERMINAL_PROCESSING + if not kernel32.SetConsoleMode(handle, mode.value | 0x0004): + return False + except Exception: + return False + return True + + +ANSI = enable_ansi() +USE_COLOR = ANSI and "NO_COLOR" not in os.environ + + +def colorize(text, label): + if not USE_COLOR or label not in COLORS: + return text + return COLORS[label] + text + RESET + def format_time(seconds): seconds = max(0, int(round(seconds))) @@ -38,8 +95,17 @@ def format_time(seconds): return f"{minutes:02d}:{secs:02d}" +def progress(remaining, total): + return 1.0 if total <= 0 else min(1.0, max(0.0, 1 - remaining / total)) + + +def make_bar(fraction, width): + filled = int(round(fraction * width)) + return FILL_CHAR * filled + EMPTY_CHAR * (width - filled) + + def render_line(label, remaining, total): - fraction = 1.0 if total <= 0 else min(1.0, max(0.0, 1 - remaining / total)) + fraction = progress(remaining, total) percent = f"{int(fraction * 100):3d}%" left = f"{label} {format_time(remaining)} " right = f" {percent}" @@ -47,23 +113,62 @@ def render_line(label, remaining, total): columns = shutil.get_terminal_size((80, 20)).columns # Leave one column free so the line never wraps on narrow terminals. bar_width = max(10, min(40, columns - len(left) - len(right) - 3)) - filled = int(round(fraction * bar_width)) - bar = FILL_CHAR * filled + EMPTY_CHAR * (bar_width - filled) + bar = make_bar(fraction, bar_width) return f"{left}[{bar}]{right}" +def big_digits(text): + """Turn a time like "24:59" into 5 lines of large block characters.""" + rows = [] + for row in range(BIG_HEIGHT): + parts = [BIG_FONT[ch][row] for ch in text] + rows.append(" ".join(parts).replace("#", FILL_CHAR).rstrip()) + return rows + + +def render_big(label, remaining, total): + """Return the big countdown as a list of lines, or None if it won't fit.""" + size = shutil.get_terminal_size((80, 20)) + digits = big_digits(format_time(remaining)) + width = len(INDENT) + max(len(row) for row in digits) + # Needs room for the title, the digits and the bar, without wrapping. + if width >= size.columns or BIG_HEIGHT + 2 >= size.lines: + return None + + fraction = progress(remaining, total) + percent = f" {int(fraction * 100):3d}%" + bar_width = max(10, min(40, size.columns - len(INDENT) - len(percent) - 3)) + bar = f"[{make_bar(fraction, bar_width)}]{percent}" + + lines = [colorize(INDENT + label, label)] + lines += [colorize(INDENT + row, label) for row in digits] + lines.append(colorize(INDENT + bar, label)) + return lines + + def run_session(label, minutes): """Count down for the given number of minutes, redrawing once a second.""" total = minutes * 60 end = time.monotonic() + total last_len = 0 + prev_lines = 0 while True: remaining = end - time.monotonic() - line = render_line(label, max(0.0, remaining), total) - # Pad with spaces to wipe out any leftover characters from before. - sys.stdout.write("\r" + line + " " * max(0, last_len - len(line))) + if ANSI: + lines = render_big(label, max(0.0, remaining), total) + if lines is None: + # Terminal too small for big digits: fall back to one line. + lines = [colorize(render_line(label, max(0.0, remaining), total), label)] + # Jump back to the top of the previous drawing and clear it. + move_up = f"\033[{prev_lines - 1}A" if prev_lines > 1 else "" + sys.stdout.write("\r" + move_up + "\033[J" + "\n".join(lines)) + prev_lines = len(lines) + else: + line = render_line(label, max(0.0, remaining), total) + # Pad with spaces to wipe out any leftover characters from before. + sys.stdout.write("\r" + line + " " * max(0, last_len - len(line))) + last_len = len(line) sys.stdout.flush() - last_len = len(line) if remaining <= 0: break # Sleep until the next whole second ticks over (keeps it from drifting).