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,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"]
|