pamplemousse 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ *.egg
8
+ .eggs/
9
+ *.so
10
+ .venv/
11
+ venv/
12
+ env/
13
+ .env
14
+ *.DS_Store
15
+ .mypy_cache/
16
+ .ruff_cache/
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: pamplemousse
3
+ Version: 0.1.0
4
+ Summary: A macOS menu bar Pomodoro timer
5
+ License-Expression: MIT
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Operating System :: MacOS
8
+ Classifier: Programming Language :: Python :: 3
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: pyobjc-framework-cocoa>=10.0
11
+ Requires-Dist: rich>=13.0
12
+ Requires-Dist: rumps>=0.4.0
@@ -0,0 +1,34 @@
1
+ # pamplemousse
2
+
3
+ A macOS menu bar Pomodoro timer.
4
+
5
+ ## Requirements
6
+
7
+ - macOS
8
+ - Python 3.10+
9
+
10
+ ## Install
11
+
12
+ ```
13
+ pip install pamplemousse
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```
19
+ pamplemousse
20
+ ```
21
+
22
+ Starts the menu bar app in the background, installs a launch agent so it runs on login, and returns to your terminal immediately.
23
+
24
+ Running it again while already running will prompt to stop.
25
+
26
+ ## Development
27
+
28
+ ```
29
+ pip install -r requirements-dev.txt
30
+ ```
31
+
32
+ ## License
33
+
34
+ MIT
@@ -0,0 +1,336 @@
1
+ # pamplemousse — macOS menu bar Pomodoro timer
2
+ # pip install rumps pyobjc-framework-Cocoa rich && python pamplemousse.py
3
+
4
+ import os
5
+ import shutil
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ from enum import Enum, auto
10
+ from pathlib import Path
11
+
12
+ import rumps
13
+ from AppKit import (
14
+ NSBackingStoreBuffered,
15
+ NSColor,
16
+ NSFloatingWindowLevel,
17
+ NSScreen,
18
+ NSView,
19
+ NSWindow,
20
+ NSWindowStyleMaskBorderless,
21
+ )
22
+ from Foundation import NSTimer as FoundationTimer
23
+
24
+ DEFAULT_WORK_MINS = 25
25
+ DEFAULT_BREAK_MINS = 5
26
+ WORK_DURATION_OPTIONS = [15, 20, 25, 30, 45, 60]
27
+ BREAK_DURATION_OPTIONS = [3, 5, 10, 15, 20]
28
+
29
+ FLASH_COLOR = (1.0, 0.0, 0.0, 0.35) # RGBA
30
+ DEFAULT_FLASH_SECS = 1.0
31
+ FLASH_DURATION_OPTIONS = [0.5, 1, 2, 3]
32
+ REMINDER_INTERVAL = 60 # seconds between reminder flashes
33
+
34
+ # NSWindowCollectionBehavior flags
35
+ CAN_JOIN_ALL_SPACES = 1 << 0
36
+ FULL_SCREEN_AUXILIARY = 1 << 4
37
+
38
+
39
+ class TimerState(Enum):
40
+ IDLE = auto()
41
+ RUNNING = auto()
42
+ PAUSED = auto()
43
+
44
+
45
+ class PomodoroApp(rumps.App):
46
+ def __init__(self) -> None:
47
+ super().__init__("Pomodoro", title="🍅")
48
+ self.work_mins = DEFAULT_WORK_MINS
49
+ self.break_mins = DEFAULT_BREAK_MINS
50
+ self.timer = rumps.Timer(self.tick, 1)
51
+ self.seconds_left = 0
52
+ self.on_break = False
53
+ self.state = TimerState.IDLE
54
+
55
+ self.flash_enabled = True
56
+ self.flash_secs = DEFAULT_FLASH_SECS
57
+ self.flash_reminder_2 = False
58
+ self.flash_reminder_3 = False
59
+
60
+ self.start_button = rumps.MenuItem("Start", callback=self.start)
61
+ # Stop is disabled until a session is actively running
62
+ self.stop_button = rumps.MenuItem("Stop")
63
+
64
+ self.work_menu = self._build_duration_menu(
65
+ "Work Duration", WORK_DURATION_OPTIONS, self.work_mins, self.set_work,
66
+ )
67
+ self.break_menu = self._build_duration_menu(
68
+ "Break Duration", BREAK_DURATION_OPTIONS, self.break_mins, self.set_break,
69
+ )
70
+ self.flash_menu = self._build_flash_menu()
71
+
72
+ self.settings_menu = rumps.MenuItem("Settings")
73
+ self.settings_menu[self.work_menu.title] = self.work_menu
74
+ self.settings_menu[self.break_menu.title] = self.break_menu
75
+ self.settings_menu[self.flash_menu.title] = self.flash_menu
76
+
77
+ self.menu = [
78
+ self.start_button,
79
+ self.stop_button,
80
+ None,
81
+ self.settings_menu,
82
+ ]
83
+
84
+ def _build_flash_menu(self) -> rumps.MenuItem:
85
+ menu = rumps.MenuItem("Screen Flash")
86
+
87
+ self._flash_toggle = rumps.MenuItem("Enabled", callback=self._toggle_flash)
88
+ self._flash_toggle.state = True
89
+
90
+ self._flash_dur_menu = rumps.MenuItem("Duration")
91
+ for secs in FLASH_DURATION_OPTIONS:
92
+ label = f"{secs:g} sec"
93
+ item = rumps.MenuItem(label, callback=self._set_flash_duration)
94
+ if secs == self.flash_secs:
95
+ item.state = True
96
+ self._flash_dur_menu.add(item)
97
+
98
+ self._reminder_2_toggle = rumps.MenuItem(
99
+ "Reminder at +1 min", callback=self._toggle_reminder_2,
100
+ )
101
+ self._reminder_3_toggle = rumps.MenuItem(
102
+ "Reminder at +2 min", callback=self._toggle_reminder_3,
103
+ )
104
+
105
+ menu[self._flash_toggle.title] = self._flash_toggle
106
+ menu[self._flash_dur_menu.title] = self._flash_dur_menu
107
+ menu[self._reminder_2_toggle.title] = self._reminder_2_toggle
108
+ menu[self._reminder_3_toggle.title] = self._reminder_3_toggle
109
+ return menu
110
+
111
+ @staticmethod
112
+ def _build_duration_menu(
113
+ title: str,
114
+ options: list[int],
115
+ default: int,
116
+ callback,
117
+ ) -> rumps.MenuItem:
118
+ menu = rumps.MenuItem(title)
119
+ for mins in options:
120
+ item = rumps.MenuItem(f"{mins} min", callback=callback)
121
+ if mins == default:
122
+ item.state = True
123
+ menu.add(item)
124
+ return menu
125
+
126
+ @staticmethod
127
+ def format_time(total_seconds: int) -> str:
128
+ m, s = divmod(max(total_seconds, 0), 60)
129
+ return f"{m:02d}:{s:02d}"
130
+
131
+ # -- Core pomodoro cycle --------------------------------------------------
132
+
133
+ def _start_next_session(self) -> None:
134
+ """Alternate between work and break sessions automatically."""
135
+ if self.on_break:
136
+ rumps.notification("Pomodoro", "Break over!", "Time to focus 🍅")
137
+ self.on_break = False
138
+ self.seconds_left = self.work_mins * 60
139
+ else:
140
+ rumps.notification("Pomodoro", "Work session done!", "Take a break ☕")
141
+ self.on_break = True
142
+ self.seconds_left = self.break_mins * 60
143
+ self.title = self.format_time(self.seconds_left)
144
+
145
+ def _fire_flash(self) -> None:
146
+ """Flash immediately, then schedule reminder flashes if enabled."""
147
+ flash_screen_red(self.flash_secs)
148
+ if self.flash_reminder_2:
149
+ FoundationTimer.scheduledTimerWithTimeInterval_repeats_block_(
150
+ REMINDER_INTERVAL, False, lambda _: flash_screen_red(self.flash_secs),
151
+ )
152
+ if self.flash_reminder_3:
153
+ FoundationTimer.scheduledTimerWithTimeInterval_repeats_block_(
154
+ REMINDER_INTERVAL * 2, False, lambda _: flash_screen_red(self.flash_secs),
155
+ )
156
+
157
+ def tick(self, _sender) -> None:
158
+ self.seconds_left -= 1
159
+ if self.seconds_left <= 0:
160
+ self.timer.stop()
161
+ if self.flash_enabled:
162
+ self._fire_flash()
163
+ self._start_next_session()
164
+ self.timer.start()
165
+ else:
166
+ prefix = "☕ " if self.on_break else ""
167
+ self.title = f"{prefix}{self.format_time(self.seconds_left)}"
168
+
169
+ # -- Start / Pause / Resume / Stop state machine --------------------------
170
+
171
+ def start(self, _sender) -> None:
172
+ if self.state == TimerState.IDLE:
173
+ self.on_break = False
174
+ self.seconds_left = self.work_mins * 60
175
+ self.title = self.format_time(self.seconds_left)
176
+ self.timer.start()
177
+ self.state = TimerState.RUNNING
178
+ self.start_button.title = "Pause"
179
+ self.stop_button.set_callback(self.stop)
180
+ elif self.state == TimerState.RUNNING:
181
+ self.timer.stop()
182
+ self.state = TimerState.PAUSED
183
+ self.start_button.title = "Resume"
184
+ else:
185
+ self.timer.start()
186
+ self.state = TimerState.RUNNING
187
+ self.start_button.title = "Pause"
188
+
189
+ def stop(self, _sender) -> None:
190
+ self.timer.stop()
191
+ self.title = "🍅"
192
+ self.on_break = False
193
+ self.state = TimerState.IDLE
194
+ self.start_button.title = "Start"
195
+ self.stop_button.set_callback(None)
196
+
197
+ # -- Settings callbacks ---------------------------------------------------
198
+
199
+ def _set_duration(self, sender, menu: rumps.MenuItem, attr: str) -> None:
200
+ mins = int(sender.title.split()[0])
201
+ setattr(self, attr, mins)
202
+ for item in menu.values():
203
+ item.state = item.title == sender.title
204
+
205
+ def set_work(self, sender) -> None:
206
+ self._set_duration(sender, self.work_menu, "work_mins")
207
+
208
+ def set_break(self, sender) -> None:
209
+ self._set_duration(sender, self.break_menu, "break_mins")
210
+
211
+ def _toggle_flash(self, sender) -> None:
212
+ self.flash_enabled = not self.flash_enabled
213
+ sender.state = self.flash_enabled
214
+
215
+ def _set_flash_duration(self, sender) -> None:
216
+ self.flash_secs = float(sender.title.split()[0])
217
+ for item in self._flash_dur_menu.values():
218
+ item.state = item.title == sender.title
219
+
220
+ def _toggle_reminder_2(self, sender) -> None:
221
+ self.flash_reminder_2 = not self.flash_reminder_2
222
+ sender.state = self.flash_reminder_2
223
+
224
+ def _toggle_reminder_3(self, sender) -> None:
225
+ self.flash_reminder_3 = not self.flash_reminder_3
226
+ sender.state = self.flash_reminder_3
227
+
228
+
229
+ # -- Screen flash overlay -----------------------------------------------------
230
+
231
+
232
+ def _create_overlay_window(frame) -> NSWindow:
233
+ """Create a translucent red overlay window covering the given screen frame."""
234
+ win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
235
+ frame, NSWindowStyleMaskBorderless, NSBackingStoreBuffered, False,
236
+ )
237
+ win.setLevel_(NSFloatingWindowLevel + 1)
238
+ win.setOpaque_(False)
239
+ win.setIgnoresMouseEvents_(True)
240
+ win.setBackgroundColor_(NSColor.colorWithRed_green_blue_alpha_(*FLASH_COLOR))
241
+ win.setCollectionBehavior_(CAN_JOIN_ALL_SPACES | FULL_SCREEN_AUXILIARY)
242
+ win.setContentView_(NSView.alloc().initWithFrame_(frame))
243
+ win.orderFrontRegardless()
244
+ return win
245
+
246
+
247
+ def flash_screen_red(duration: float = DEFAULT_FLASH_SECS) -> None:
248
+ """Flash a translucent red overlay on all screens, then auto-dismiss."""
249
+ windows = [_create_overlay_window(s.frame()) for s in NSScreen.screens()]
250
+
251
+ def dismiss():
252
+ for w in windows:
253
+ w.orderOut_(None)
254
+
255
+ FoundationTimer.scheduledTimerWithTimeInterval_repeats_block_(
256
+ duration, False, lambda _: dismiss(),
257
+ )
258
+
259
+
260
+ # -- Process management -------------------------------------------------------
261
+
262
+ PLIST_LABEL = "com.pamplemousse"
263
+ LAUNCH_AGENT = Path.home() / "Library" / "LaunchAgents" / f"{PLIST_LABEL}.plist"
264
+ PID_FILE = Path.home() / ".pamplemousse.pid"
265
+
266
+
267
+ def _install_launch_agent() -> None:
268
+ if LAUNCH_AGENT.exists():
269
+ return
270
+ import plistlib
271
+
272
+ exe = shutil.which("pamplemousse") or sys.executable
273
+ args = [exe, "--run"] if exe != sys.executable else [exe, __file__, "--run"]
274
+ plist = {"Label": PLIST_LABEL, "ProgramArguments": args, "RunAtLoad": True}
275
+ LAUNCH_AGENT.parent.mkdir(parents=True, exist_ok=True)
276
+ with open(LAUNCH_AGENT, "wb") as f:
277
+ plistlib.dump(plist, f)
278
+
279
+
280
+ def _get_running_pid() -> int | None:
281
+ if not PID_FILE.exists():
282
+ return None
283
+ try:
284
+ pid = int(PID_FILE.read_text().strip())
285
+ os.kill(pid, 0)
286
+ return pid
287
+ except (ValueError, ProcessLookupError, PermissionError):
288
+ PID_FILE.unlink(missing_ok=True)
289
+ return None
290
+
291
+
292
+ def _stop(pid: int) -> None:
293
+ os.kill(pid, signal.SIGTERM)
294
+ PID_FILE.unlink(missing_ok=True)
295
+
296
+
297
+ def _spawn() -> None:
298
+ exe = shutil.which("pamplemousse")
299
+ cmd = [exe, "--run"] if exe else [sys.executable, __file__, "--run"]
300
+ proc = subprocess.Popen(
301
+ cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
302
+ start_new_session=True,
303
+ )
304
+ PID_FILE.write_text(str(proc.pid))
305
+
306
+
307
+ def _run_app() -> None:
308
+ PID_FILE.write_text(str(os.getpid()))
309
+ try:
310
+ PomodoroApp().run()
311
+ finally:
312
+ PID_FILE.unlink(missing_ok=True)
313
+
314
+
315
+ def main():
316
+ if "--run" in sys.argv:
317
+ _run_app()
318
+ return
319
+
320
+ from rich import print as rprint
321
+
322
+ _install_launch_agent()
323
+ pid = _get_running_pid()
324
+ if pid:
325
+ answer = input("pamplemousse already running, stop it? [y/N] ")
326
+ if answer.lower() == "y":
327
+ _stop(pid)
328
+ rprint("[red]stopped[/red]")
329
+ return
330
+
331
+ _spawn()
332
+ rprint("[green]pamplemousse started[/green]")
333
+
334
+
335
+ if __name__ == "__main__":
336
+ PomodoroApp().run()
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pamplemousse"
7
+ version = "0.1.0"
8
+ description = "A macOS menu bar Pomodoro timer"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ dependencies = [
12
+ "rumps>=0.4.0",
13
+ "pyobjc-framework-Cocoa>=10.0",
14
+ "rich>=13.0",
15
+ ]
16
+ classifiers = [
17
+ "Operating System :: MacOS",
18
+ "Programming Language :: Python :: 3",
19
+ "License :: OSI Approved :: MIT License",
20
+ ]
21
+
22
+ [project.scripts]
23
+ pamplemousse = "pamplemousse:main"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["."]
27
+ only-include = ["pamplemousse.py"]
@@ -0,0 +1,3 @@
1
+ -e .
2
+ ruff>=0.9.0
3
+ mypy>=1.14.0