PatchHub

patchhub / pomodoro Public

A focus timer for your terminal

pomodoro / pomodoro.py 5230 bytes · raw
1#!/usr/bin/env python3
2"""A simple Pomodoro focus timer for the terminal.
3
4Alternates between focus sessions (25 minutes by default) and breaks
5(5 minutes by default), showing a live countdown with a progress bar and
6ringing the terminal bell when each session ends.
7
8Uses only the Python standard library.
9"""
10
11import argparse
12import shutil
13import sys
14import time
15
16BELL = "\a"
17
18
19def pick_bar_chars():
20 """Use nice block characters if the terminal can show them, else ASCII."""
21 encoding = getattr(sys.stdout, "encoding", None) or "ascii"
22 try:
23 "█░".encode(encoding)
24 return "█", "░"
25 except (UnicodeEncodeError, LookupError):
26 return "#", "-"
27
28
29FILL_CHAR, EMPTY_CHAR = pick_bar_chars()
30
31
32def format_time(seconds):
33 seconds = max(0, int(round(seconds)))
34 minutes, secs = divmod(seconds, 60)
35 hours, minutes = divmod(minutes, 60)
36 if hours:
37 return f"{hours}:{minutes:02d}:{secs:02d}"
38 return f"{minutes:02d}:{secs:02d}"
39
40
41def render_line(label, remaining, total):
42 fraction = 1.0 if total <= 0 else min(1.0, max(0.0, 1 - remaining / total))
43 percent = f"{int(fraction * 100):3d}%"
44 left = f"{label} {format_time(remaining)} "
45 right = f" {percent}"
46
47 columns = shutil.get_terminal_size((80, 20)).columns
48 # Leave one column free so the line never wraps on narrow terminals.
49 bar_width = max(10, min(40, columns - len(left) - len(right) - 3))
50 filled = int(round(fraction * bar_width))
51 bar = FILL_CHAR * filled + EMPTY_CHAR * (bar_width - filled)
52 return f"{left}[{bar}]{right}"
53
54
55def run_session(label, minutes):
56 """Count down for the given number of minutes, redrawing once a second."""
57 total = minutes * 60
58 end = time.monotonic() + total
59 last_len = 0
60 while True:
61 remaining = end - time.monotonic()
62 line = render_line(label, max(0.0, remaining), total)
63 # Pad with spaces to wipe out any leftover characters from before.
64 sys.stdout.write("\r" + line + " " * max(0, last_len - len(line)))
65 sys.stdout.flush()
66 last_len = len(line)
67 if remaining <= 0:
68 break
69 # Sleep until the next whole second ticks over (keeps it from drifting).
70 time.sleep(min(remaining, (remaining % 1) or 1))
71 sys.stdout.write("\n")
72 sys.stdout.flush()
73
74
75def ring(times):
76 for i in range(times):
77 sys.stdout.write(BELL)
78 sys.stdout.flush()
79 if i < times - 1:
80 time.sleep(0.4)
81
82
83def positive_number(text):
84 try:
85 value = float(text)
86 except ValueError:
87 raise argparse.ArgumentTypeError(f"'{text}' is not a number")
88 if value <= 0:
89 raise argparse.ArgumentTypeError("must be greater than 0")
90 return value
91
92
93def non_negative_int(text):
94 try:
95 value = int(text)
96 except ValueError:
97 raise argparse.ArgumentTypeError(f"'{text}' is not a whole number")
98 if value < 0:
99 raise argparse.ArgumentTypeError("must be 0 or more")
100 return value
101
102
103def parse_args(argv=None):
104 parser = argparse.ArgumentParser(
105 description="A simple Pomodoro focus timer for the terminal. "
106 "Press Ctrl+C at any time to stop."
107 )
108 parser.add_argument(
109 "-f", "--focus", type=positive_number, default=25, metavar="MINUTES",
110 help="length of each focus session in minutes (default: 25)",
111 )
112 parser.add_argument(
113 "-b", "--break", dest="break_minutes", type=positive_number, default=5, metavar="MINUTES",
114 help="length of each break in minutes (default: 5)",
115 )
116 parser.add_argument(
117 "-r", "--rounds", type=non_negative_int, default=0, metavar="N",
118 help="how many focus sessions to do; 0 means keep going until "
119 "you press Ctrl+C (default: 0)",
120 )
121 parser.add_argument(
122 "-q", "--quiet", action="store_true",
123 help="don't ring the terminal bell",
124 )
125 return parser.parse_args(argv)
126
127
128def main(argv=None):
129 args = parse_args(argv)
130 ring_times = 0 if args.quiet else 3
131
132 def plural(n):
133 return "minute" if n == 1 else "minutes"
134
135 focus_text = f"{args.focus:g} {plural(args.focus)}"
136 break_text = f"{args.break_minutes:g} {plural(args.break_minutes)}"
137 print(f"Pomodoro timer: {focus_text} focus, {break_text} break.")
138 print("Press Ctrl+C to stop.\n")
139
140 completed = 0
141 try:
142 round_number = 1
143 while args.rounds == 0 or round_number <= args.rounds:
144 suffix = f"/{args.rounds}" if args.rounds else ""
145 print(f"Round {round_number}{suffix}")
146 run_session("Focus", args.focus)
147 completed += 1
148 ring(ring_times)
149
150 is_last = args.rounds and round_number >= args.rounds
151 if is_last:
152 print("Focus session done!")
153 break
154
155 print("Focus session done! Time for a break.")
156 run_session("Break", args.break_minutes)
157 ring(ring_times)
158 print("Break over! Back to work.\n")
159 round_number += 1
160 except KeyboardInterrupt:
161 print("\n\nStopped.")
162
163 print(f"Focus sessions completed: {completed}. Nice work!")
164 return 0
165
166
167if __name__ == "__main__":
168 sys.exit(main())