diff --git a/README.md b/README.md index de5b404..25706a7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ 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 -terminal beeps when each focus session or break is over. +terminal beeps when each focus session or break is over. During each focus +session a motivational quote is shown under the timer (a different one each +round). It's a single Python file with nothing extra to install. @@ -38,6 +40,7 @@ You can change the times if you want: | `--break MINUTES` | Length of each break (default 5) | `--break 10` | | `--rounds N` | Stop after N focus sessions (default: keep going) | `--rounds 4` | | `--quiet` | Don't beep | `--quiet` | +| `--no-quotes` | Don't show motivational quotes | `--no-quotes` | For example, four rounds of 50 minutes work with 10 minute breaks: diff --git a/pomodoro.py b/pomodoro.py index 331e868..7fcda87 100644 --- a/pomodoro.py +++ b/pomodoro.py @@ -3,14 +3,18 @@ Alternates between focus sessions (25 minutes by default) and breaks (5 minutes by default), showing a live countdown with a progress bar and -ringing the terminal bell when each session ends. +ringing the terminal bell when each session ends. A motivational quote is +shown under the timer during each focus session. Uses only the Python standard library. """ import argparse +import os +import random import shutil import sys +import textwrap import time BELL = "\a" @@ -29,6 +33,84 @@ def pick_bar_chars(): FILL_CHAR, EMPTY_CHAR = pick_bar_chars() +QUOTES = [ + ("The secret of getting ahead is getting started.", "Mark Twain"), + ("It always seems impossible until it's done.", "Nelson Mandela"), + ("Focus on being productive instead of busy.", "Tim Ferriss"), + ("You don't have to see the whole staircase, just take the first step.", + "Martin Luther King Jr."), + ("Well done is better than well said.", "Benjamin Franklin"), + ("Action is the foundational key to all success.", "Pablo Picasso"), + ("It does not matter how slowly you go as long as you do not stop.", + "Confucius"), + ("The way to get started is to quit talking and begin doing.", + "Walt Disney"), + ("Quality is not an act, it is a habit.", "Aristotle"), + ("Small deeds done are better than great deeds planned.", "Peter Marshall"), + ("Concentrate all your thoughts upon the work at hand.", + "Alexander Graham Bell"), + ("Do what you can, with what you have, where you are.", + "Theodore Roosevelt"), + ("Success is the sum of small efforts, repeated day in and day out.", + "Robert Collier"), + ("A year from now you may wish you had started today.", "Karen Lamb"), + ("Done is better than perfect.", "Sheryl Sandberg"), + ("You will never always be motivated, so you must learn to be " + "disciplined.", "Unknown"), + ("Great things are done by a series of small things brought together.", + "Vincent van Gogh"), + ("Don't watch the clock; do what it does. Keep going.", "Sam Levenson"), + ("Energy and persistence conquer all things.", "Benjamin Franklin"), + ("The future depends on what you do today.", "Mahatma Gandhi"), +] + + +def pick_quote(previous=None): + """Pick a random quote, avoiding the one shown last time.""" + choices = [q for q in QUOTES if q != previous] or QUOTES + return random.choice(choices) + + +def format_quote(quote): + """Turn a (text, author) pair into lines that fit the terminal.""" + text, author = quote + encoding = getattr(sys.stdout, "encoding", None) or "ascii" + try: + "\u201c\u201d\u2014".encode(encoding) + open_q, close_q, dash = "\u201c", "\u201d", "\u2014" + except (UnicodeEncodeError, LookupError): + open_q, close_q, dash = '"', '"', "-" + full = f"{open_q}{text}{close_q} {dash} {author}" + columns = shutil.get_terminal_size((80, 20)).columns + # Leave one column free so lines never wrap on their own. + width = max(20, columns - 1) + return textwrap.wrap(full, width=width, initial_indent=" ", + subsequent_indent=" ") + + +def enable_cursor_moves(): + """Return True if the terminal lets us move the cursor up a line. + + That's needed to keep the quote sitting under the live countdown. On + Windows this switches on the console's support for it if possible. + """ + if not (hasattr(sys.stdout, "isatty") and sys.stdout.isatty()): + return False + if os.name != "nt": + return True + 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 + return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004)) + except Exception: + return False + + def format_time(seconds): seconds = max(0, int(round(seconds))) minutes, secs = divmod(seconds, 60) @@ -52,24 +134,42 @@ def render_line(label, remaining, total): return f"{left}[{bar}]{right}" -def run_session(label, minutes): - """Count down for the given number of minutes, redrawing once a second.""" +def run_session(label, minutes, quote_lines=None, can_move_cursor=False): + """Count down for the given number of minutes, redrawing once a second. + + If quote_lines is given, they are shown under the countdown (or above it + if the terminal can't move the cursor). + """ total = minutes * 60 end = time.monotonic() + total last_len = 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))) + below = 0 # how many quote lines sit under the countdown line + if quote_lines: + if can_move_cursor: + # Print the quote, then hop back up to the countdown line. + sys.stdout.write("\n" + "\n".join(quote_lines)) + below = len(quote_lines) + sys.stdout.write(f"\x1b[{below}A\r") + else: + sys.stdout.write("\n".join(quote_lines) + "\n") + sys.stdout.flush() + try: + 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))) + sys.stdout.flush() + last_len = len(line) + if remaining <= 0: + break + # Sleep until the next whole second ticks over (keeps it from + # drifting). + time.sleep(min(remaining, (remaining % 1) or 1)) + finally: + # Move past the countdown (and the quote, if it's underneath). + sys.stdout.write("\n" * (below + 1)) sys.stdout.flush() - last_len = len(line) - if remaining <= 0: - break - # Sleep until the next whole second ticks over (keeps it from drifting). - time.sleep(min(remaining, (remaining % 1) or 1)) - sys.stdout.write("\n") - sys.stdout.flush() def ring(times): @@ -122,6 +222,10 @@ def parse_args(argv=None): "-q", "--quiet", action="store_true", help="don't ring the terminal bell", ) + parser.add_argument( + "--no-quotes", action="store_true", + help="don't show a motivational quote during focus sessions", + ) return parser.parse_args(argv) @@ -137,13 +241,20 @@ def main(argv=None): print(f"Pomodoro timer: {focus_text} focus, {break_text} break.") print("Press Ctrl+C to stop.\n") + can_move_cursor = enable_cursor_moves() + quote = None + completed = 0 try: round_number = 1 while args.rounds == 0 or round_number <= args.rounds: suffix = f"/{args.rounds}" if args.rounds else "" print(f"Round {round_number}{suffix}") - run_session("Focus", args.focus) + quote_lines = None + if not args.no_quotes: + quote = pick_quote(quote) + quote_lines = format_quote(quote) + run_session("Focus", args.focus, quote_lines, can_move_cursor) completed += 1 ring(ring_times) @@ -158,7 +269,7 @@ def main(argv=None): print("Break over! Back to work.\n") round_number += 1 except KeyboardInterrupt: - print("\n\nStopped.") + print("\nStopped.") print(f"Focus sessions completed: {completed}. Nice work!") return 0