├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── header.png ├── icon.icns ├── main.py ├── poetry.lock ├── pyproject.toml ├── screenshot.png ├── setup.py └── timebox.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | __pycache__/ 4 | node_modules 5 | venv 6 | dist 7 | build 8 | .npm 9 | .node_repl_history 10 | .eggs -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Camillo Visini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APP_NAME := Timebox 2 | 3 | ifdef VERSION 4 | VERSION_SET := 1 5 | endif 6 | 7 | install: 8 | poetry install 9 | dev: 10 | poetry run python main.py 11 | compile: 12 | export APP_NAME=$(APP_NAME) && poetry run python setup.py py2app 13 | debug: compile 14 | open "dist/$(APP_NAME).app/Contents/MacOS/$(APP_NAME)" 15 | release: 16 | ifeq ($(VERSION_SET),1) 17 | export VERSION=$(VERSION) && export APP_NAME=$(APP_NAME) && poetry run python setup.py py2app 18 | poetry version $(VERSION) 19 | git add pyproject.toml 20 | git commit -m "Release $(VERSION)" 21 | git push 22 | cd dist && zip -r "$(APP_NAME).app.zip" "$(APP_NAME).app" 23 | gh release create $(VERSION) 'dist/$(APP_NAME).app.zip#$(APP_NAME).app.zip' 24 | else 25 | $(error VERSION not defined - use like this: make release VERSION=...) 26 | endif 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Header](header.png) 2 | 3 | # Timebox – macOS Menubar App 4 | 5 | Menu bar utility app (macOS) for adding [Timeboxing](https://en.wikipedia.org/wiki/Timeboxing) and [Pomodoro](https://en.wikipedia.org/wiki/Pomodoro_Technique) workflow support to [Things 3](https://culturedcode.com/things/). 6 | 7 | ![Screenshot](screenshot.png) 8 | 9 | See `Makefile` for how to install, debug, build, and release. Or download the [latest release](https://github.com/visini/timebox/releases). 10 | 11 | Note: Since April 14, 2023, Things 3 stores data in a unique location - you will have to build the app after setting the proper path in `main.py` via `THINGS_SQLITE_PATH`. 12 | 13 | Download using GitHub CLI: 14 | 15 | ```shell 16 | gh release -R visini/timebox download -D ~/Downloads -p "*.app.zip" 17 | ``` 18 | 19 | Note: If you can't open `Timebox.app`, you could try running `xattr -cr Timebox.app`. 20 | 21 | --- 22 | 23 | See also: 24 | 25 | - [mk1123/timebox](https://github.com/mk1123/timebox) 26 | - [visini/pomodoro](https://github.com/visini/pomodoro) 27 | -------------------------------------------------------------------------------- /header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visini/timebox/fa55485adf1d25e0262ed5c3cab57ab3595895fe/header.png -------------------------------------------------------------------------------- /icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visini/timebox/fa55485adf1d25e0262ed5c3cab57ab3595895fe/icon.icns -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import rumps 2 | import time 3 | import subprocess 4 | import shlex 5 | import sqlite3 6 | import os 7 | 8 | THINGS_SQLITE_PATH = "~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-4R6L5/Things Database.thingsdatabase/main.sqlite" 9 | 10 | def timez(): 11 | return time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.localtime()) 12 | 13 | def get_things_today_tasks(index=0, complete_task=False): 14 | conn = sqlite3.connect(os.path.expanduser(THINGS_SQLITE_PATH)) 15 | sql = ( 16 | "SELECT\n" 17 | " TAG.title,\n" 18 | " TASK.title,\n" 19 | ' "things:///show?id=" || TASK.uuid\n' 20 | " FROM TMTask as TASK\n" 21 | " LEFT JOIN TMTaskTag TAGS ON TAGS.tasks = TASK.uuid\n" 22 | " LEFT JOIN TMTag TAG ON TAGS.tags = TAG.uuid\n" 23 | " LEFT OUTER JOIN TMTask PROJECT ON TASK.project = PROJECT.uuid\n" 24 | " LEFT OUTER JOIN TMArea AREA ON TASK.area = AREA.uuid\n" 25 | " WHERE TASK.trashed = 0 AND TASK.status = 0 AND TASK.type = 0 AND TAG.title IS NOT NULL\n" 26 | " AND TASK.start = 1\n" 27 | " AND TASK.startdate is NOT NULL\n" 28 | " ORDER BY TASK.todayIndex\n" 29 | " LIMIT 100" 30 | ) 31 | tasks = [] 32 | try: 33 | for row in conn.execute(sql): 34 | tasks.append(row) 35 | except: 36 | pass 37 | conn.close() 38 | return tasks 39 | 40 | 41 | def process_tasks(list_of_tasks): 42 | processed_tasks = {} 43 | 44 | for task_tuple in list_of_tasks: 45 | if task_tuple[0][-3:] == "min": 46 | processed_tasks[task_tuple[1]] = ( 47 | int(task_tuple[0][:-3]), 48 | task_tuple[2], 49 | ) 50 | 51 | return processed_tasks 52 | 53 | 54 | def hour_formatter(minutes): 55 | if minutes // 60 > 0: 56 | if spare_min := minutes % 60: 57 | return f"{minutes // 60}h and {spare_min}min of work today!" 58 | else: 59 | return f"{minutes // 60}h of work today!" 60 | else: 61 | return f"{minutes}min of work today!" 62 | 63 | 64 | class TimerApp(object): 65 | def toggle_button(self, sender): 66 | sender.state = not sender.state 67 | 68 | def __init__(self, timer_interval=1): 69 | self.timer = rumps.Timer(self.on_tick, 1) 70 | self.timer.stop() # timer running when initialized 71 | self.timer.count = 0 72 | self.app = rumps.App("Timebox", "🥊") 73 | self.interval = 60 74 | self.current_things_task_url = None 75 | self.start_pause_button = rumps.MenuItem( 76 | title="Start Timer", 77 | callback=lambda _: self.start_timer(_, self.interval), 78 | key="s", 79 | ) 80 | self.stop_button = rumps.MenuItem(title="Stop Timer", callback=None) 81 | self.buttons = {} 82 | self.buttons_callback = {} 83 | for i in [5, 10, 15, 20, 25]: 84 | title = str(i) + " Minutes" 85 | callback = lambda _, j=i: self.set_mins(_, j, None) 86 | self.buttons["btn_" + str(i)] = rumps.MenuItem( 87 | title=title, callback=callback 88 | ) 89 | self.buttons_callback[title] = callback 90 | 91 | self.sync_button = rumps.MenuItem( 92 | title="Sync", callback=lambda _: self.sync_data(), key="r" 93 | ) 94 | 95 | self.sum_menu_item = rumps.MenuItem( 96 | title="sum_total_time", callback=None 97 | ) 98 | 99 | self.app.menu = [ 100 | self.start_pause_button, 101 | self.sync_button, 102 | None, 103 | self.sum_menu_item, 104 | # *self.things_buttons.values(), 105 | None, 106 | *self.buttons.values(), 107 | None, 108 | self.stop_button, 109 | ] 110 | 111 | self.sync_data() 112 | 113 | def sync_data(self): 114 | 115 | for key, btn in self.buttons.items(): 116 | btn.set_callback(self.buttons_callback[btn.title]) 117 | 118 | self.things_tasks = get_things_today_tasks() 119 | 120 | self.things_processed_tasks = process_tasks(self.things_tasks) 121 | 122 | self.sum_of_tasks_scheduled = sum( 123 | [x[0] for x in self.things_processed_tasks.values()] 124 | ) 125 | 126 | self.app.menu[ 127 | "sum_total_time" 128 | ].title = f"{hour_formatter(self.sum_of_tasks_scheduled)}" 129 | 130 | if hasattr(self, "things_buttons"): 131 | prev_things_buttons = self.things_buttons 132 | for title in prev_things_buttons.keys(): 133 | del self.app.menu[prev_things_buttons[title].title] 134 | 135 | self.things_buttons = { 136 | f"{title} → {time}min": rumps.MenuItem( 137 | title=f"{title} → {time}min", 138 | callback=lambda _, j=time, k=task_url: self.set_mins(_, j, k), 139 | ) 140 | for title, (time, task_url) in self.things_processed_tasks.items() 141 | } 142 | 143 | for title, menu_item in reversed(self.things_buttons.items()): 144 | self.app.menu.insert_after("sum_total_time", menu_item) 145 | 146 | def run(self): 147 | self.app.menu[ 148 | "sum_total_time" 149 | ].title = f"{hour_formatter(self.sum_of_tasks_scheduled)}" 150 | self.app.run() 151 | 152 | def set_mins(self, sender, interval, task_url): 153 | for btn in [*self.things_buttons.values(), *self.buttons.values()]: 154 | if sender.title == btn.title: 155 | self.interval = interval * 60 156 | cleaned_title = " ".join(sender.title.split()[:-2]) 157 | if task_url is not None: 158 | self.menu_title = " → " + cleaned_title 159 | self.current_things_task_url = task_url 160 | else: 161 | self.menu_title = "" 162 | btn.state = True 163 | elif sender.title != btn.title: 164 | btn.state = False 165 | 166 | def start_timer(self, sender, interval): 167 | for btn in [*self.things_buttons.values(), *self.buttons.values()]: 168 | btn.set_callback(None) 169 | 170 | if sender.title.lower().startswith(("start", "continue")): 171 | 172 | if sender.title == "Start Timer": 173 | # reset timer & set stop time 174 | self.timer.count = 0 175 | self.timer.end = interval 176 | 177 | # change title of MenuItem from 'Start timer' to 'Pause timer' 178 | sender.title = "Pause Timer" 179 | 180 | # lift off! start the timer 181 | self.timer.start() 182 | else: # 'Pause Timer' 183 | sender.title = "Continue Timer" 184 | self.timer.stop() 185 | 186 | def on_tick(self, sender): 187 | time_left = sender.end - sender.count 188 | mins = time_left // 60 if time_left >= 0 else time_left // 60 + 1 189 | secs = time_left % 60 if time_left >= 0 else (-1 * time_left) % 60 190 | if mins == 0 and time_left < 0: 191 | rumps.notification( 192 | title="Timebox", 193 | subtitle="Time is up! Take a break :)", 194 | message="", 195 | ) 196 | if self.current_things_task_url is not None: 197 | # print("opening url", self.current_things_task_url) 198 | subprocess.call( 199 | shlex.split("open '" + self.current_things_task_url + "'") 200 | ) 201 | self.current_things_task_url = None 202 | self.stop_timer(sender) 203 | self.stop_button.set_callback(None) 204 | self.sync_data() 205 | else: 206 | self.stop_button.set_callback(self.stop_timer) 207 | self.app.title = "{:2d}:{:02d} {}".format( 208 | mins, secs, getattr(self, "menu_title", "") 209 | ) 210 | sender.count += 1 211 | 212 | def stop_timer(self, sender=None): 213 | self.timer.stop() 214 | self.timer.count = 0 215 | self.app.title = "🥊" 216 | self.stop_button.set_callback(None) 217 | 218 | for key, btn in self.buttons.items(): 219 | btn.set_callback(self.buttons_callback[btn.title]) 220 | 221 | for (title, btn) in self.things_buttons.items(): 222 | btn.set_callback( 223 | lambda _: self.set_mins( 224 | _, self.things_processed_tasks[title], None 225 | ) 226 | ) 227 | 228 | self.start_pause_button.title = "Start Timer" 229 | self.sync_data() 230 | 231 | 232 | if __name__ == "__main__": 233 | app = TimerApp(timer_interval=1) 234 | app.run() 235 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.4.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "pyobjc-core" 5 | version = "9.1.1" 6 | description = "Python<->ObjC Interoperability Module" 7 | category = "main" 8 | optional = false 9 | python-versions = ">=3.7" 10 | files = [ 11 | {file = "pyobjc-core-9.1.1.tar.gz", hash = "sha256:4b6cb9053b5fcd3c0e76b8c8105a8110786b20f3403c5643a688c5ec51c55c6b"}, 12 | {file = "pyobjc_core-9.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4bd07049fd9fe5b40e4b7c468af9cf942508387faf383a5acb043d20627bad2c"}, 13 | {file = "pyobjc_core-9.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1a8307527621729ff2ab67860e7ed84f76ad0da881b248c2ef31e0da0088e4ba"}, 14 | {file = "pyobjc_core-9.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:083004d28b92ccb483a41195c600728854843b0486566aba2d6e63eef51f80e6"}, 15 | {file = "pyobjc_core-9.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d61e9517d451bc062a7fae8b3648f4deba4fa54a24926fa1cf581b90ef4ced5a"}, 16 | {file = "pyobjc_core-9.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1626909916603a3b04c07c721cf1af0e0b892cec85bb3db98d05ba024f1786fc"}, 17 | {file = "pyobjc_core-9.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2dde96462b52e952515d142e2afbb6913624a02c13582047e06211e6c3993728"}, 18 | ] 19 | 20 | [[package]] 21 | name = "pyobjc-framework-cocoa" 22 | version = "9.1.1" 23 | description = "Wrappers for the Cocoa frameworks on macOS" 24 | category = "main" 25 | optional = false 26 | python-versions = ">=3.7" 27 | files = [ 28 | {file = "pyobjc-framework-Cocoa-9.1.1.tar.gz", hash = "sha256:345c32b6d1f3db45f635e400f2d0d6c0f0f7349d45ec823f76fc1df43d13caeb"}, 29 | {file = "pyobjc_framework_Cocoa-9.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9176a4276f3b4b4758e9b9ca10698be5341ceffaeaa4fa055133417179e6bc37"}, 30 | {file = "pyobjc_framework_Cocoa-9.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5e1e96fb3461f46ff951413515f2029e21be268b0e033db6abee7b64ec8e93d3"}, 31 | {file = "pyobjc_framework_Cocoa-9.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:083b195c496d30c6b9dd86126a6093c4b95e0138e9b052b13e54103fcc0b4872"}, 32 | {file = "pyobjc_framework_Cocoa-9.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a1b3333b1aa045608848bd68bbab4c31171f36aeeaa2fabeb4527c6f6f1e33cd"}, 33 | {file = "pyobjc_framework_Cocoa-9.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:54c017354671f0d955432986c42218e452ca69906a101c8e7acde8510432303a"}, 34 | {file = "pyobjc_framework_Cocoa-9.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:10c0075688ce95b92caf59e368585fffdcc98c919bc345067af070222f5d01d2"}, 35 | ] 36 | 37 | [package.dependencies] 38 | pyobjc-core = ">=9.1.1" 39 | 40 | [[package]] 41 | name = "rumps" 42 | version = "0.4.0" 43 | description = "Ridiculously Uncomplicated MacOS Python Statusbar apps." 44 | category = "main" 45 | optional = false 46 | python-versions = "*" 47 | files = [ 48 | {file = "rumps-0.4.0.tar.gz", hash = "sha256:17fb33c21b54b1e25db0d71d1d793dc19dc3c0b7d8c79dc6d833d0cffc8b1596"}, 49 | ] 50 | 51 | [package.dependencies] 52 | pyobjc-framework-Cocoa = "*" 53 | 54 | [package.extras] 55 | dev = ["pytest (>=4.3)", "pytest-mock (>=2.0.0)", "tox (>=3.8)"] 56 | 57 | [metadata] 58 | lock-version = "2.0" 59 | python-versions = "^3.10" 60 | content-hash = "d8a5d6e79ad43f10ed90c4eea279543b99b920cab10e6d8a599ddd1c40a19eac" 61 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "timebox" 3 | version = "0.5.0" 4 | description = "Menu bar utility app (macOS) adding timeboxing functionality to Things 3." 5 | authors = ["Camillo Visini "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | rumps = "^0.4.0" 11 | 12 | [tool.poetry.dev-dependencies] 13 | 14 | [build-system] 15 | requires = ["poetry-core>=1.0.0"] 16 | build-backend = "poetry.core.masonry.api" 17 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visini/timebox/fa55485adf1d25e0262ed5c3cab57ab3595895fe/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | VERSION = "0.0.1" 5 | if "VERSION" in os.environ: 6 | VERSION = os.getenv("VERSION") 7 | 8 | 9 | APP_NAME = "App" 10 | if "APP_NAME" in os.environ: 11 | APP_NAME = os.getenv("APP_NAME") 12 | 13 | 14 | APP = ["main.py"] 15 | DATA_FILES = [] 16 | OPTIONS = { 17 | "argv_emulation": True, 18 | "iconfile": "icon.icns", 19 | "plist": { 20 | "CFBundleShortVersionString": VERSION, 21 | "LSUIElement": True, 22 | }, 23 | "packages": ["rumps", "paramiko", "cffi"], 24 | } 25 | 26 | setup( 27 | app=APP, 28 | name=APP_NAME, 29 | data_files=DATA_FILES, 30 | options={"py2app": OPTIONS}, 31 | setup_requires=["py2app"], 32 | ) 33 | -------------------------------------------------------------------------------- /timebox.py: -------------------------------------------------------------------------------- 1 | import rumps 2 | import time 3 | import subprocess 4 | import shlex 5 | import csv 6 | import sqlite3 7 | import os 8 | 9 | try: 10 | from urllib import urlretrieve 11 | except ImportError: 12 | from urllib.request import urlretrieve 13 | 14 | rumps.debug_mode(True) 15 | 16 | SEC_TO_MIN = 60 17 | 18 | 19 | def timez(): 20 | return time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.localtime()) 21 | 22 | 23 | def get_things_min(index=0, complete_task=False): 24 | try: 25 | conn = sqlite3.connect(os.path.expanduser('~/Library/Containers/com.culturedcode.ThingsMac/Data/Library/Application Support/Cultured Code/Things/Things.sqlite3')) 26 | sql = ("SELECT\n" 27 | " TAG.title,\n" 28 | " TASK.title,\n" 29 | " \"things:///show?id=\" || TASK.uuid\n" 30 | " FROM TMTask as TASK\n" 31 | " LEFT JOIN TMTaskTag TAGS ON TAGS.tasks = TASK.uuid\n" 32 | " LEFT JOIN TMTag TAG ON TAGS.tags = TAG.uuid\n" 33 | " LEFT OUTER JOIN TMTask PROJECT ON TASK.project = PROJECT.uuid\n" 34 | " LEFT OUTER JOIN TMArea AREA ON TASK.area = AREA.uuid\n" 35 | " LEFT OUTER JOIN TMTask HEADING ON TASK.actionGroup = HEADING.uuid\n" 36 | " WHERE TASK.trashed = 0 AND TASK.status = 0 AND TASK.type = 0 AND TAG.title IS NOT NULL\n" 37 | " AND TASK.start = 1\n" 38 | " AND TASK.startdate is NOT NULL\n" 39 | " ORDER BY TASK.todayIndex\n" 40 | " LIMIT 100") 41 | tasks = [] 42 | for row in conn.execute(sql): 43 | tasks.append([*row]) 44 | conn.close() 45 | task = tasks[index] 46 | if not complete_task: 47 | if task[0]: 48 | return int(task[0].replace("min", "")) 49 | else: 50 | if task[2]: 51 | # print("open the following url: ", task[2]) 52 | subprocess.call(shlex.split('open '+task[2])) 53 | except: 54 | return 60 55 | 56 | 57 | class TimerApp(object): 58 | def __init__(self, timer_interval=1): 59 | self.timer = rumps.Timer(self.on_tick, 1) 60 | self.timer.stop() # timer running when initialized 61 | self.timer.count = 0 62 | self.app = rumps.App("Timebox", "🥊") 63 | self.start_pause_button = rumps.MenuItem(title='Start Timer', 64 | callback=lambda _: self.start_timer(_, self.interval)) 65 | self.stop_button = rumps.MenuItem(title='Stop Timer', 66 | callback=None) 67 | self.buttons = {} 68 | self.buttons_callback = {} 69 | for i in [5, 10, 15, 20, 25]: 70 | title = str(i) + ' Minutes' 71 | callback = lambda _, j=i: self.set_mins(_, j) 72 | self.buttons["btn_" + str(i)] = rumps.MenuItem(title=title, callback=callback) 73 | self.buttons_callback[title] = callback 74 | self.interval = get_things_min()*SEC_TO_MIN 75 | self.button_things = rumps.MenuItem(title="Things Interval ("+str(round(self.interval/SEC_TO_MIN))+"min)", callback=lambda _: self.set_things_mins(_)) 76 | self.button_things.state = True 77 | self.app.menu = [ 78 | self.start_pause_button, 79 | None, 80 | self.button_things, 81 | None, 82 | *self.buttons.values(), 83 | None, 84 | self.stop_button] 85 | 86 | def run(self): 87 | self.app.run() 88 | 89 | def set_things_mins(self, sender): 90 | pass_interval = get_things_min() 91 | print("pass_interval is now", pass_interval) 92 | self.button_things.title = "Things Interval (" + str(round(pass_interval)) + "min)" 93 | self.set_mins(sender, pass_interval) 94 | 95 | def set_mins(self, sender, interval): 96 | for btn in [self.button_things, *self.buttons.values()]: 97 | if sender.title == btn.title: 98 | self.interval = interval*SEC_TO_MIN 99 | btn.state = True 100 | elif sender.title != btn.title: 101 | btn.state = False 102 | 103 | def start_timer(self, sender, interval): 104 | for btn in [self.button_things, *self.buttons.values()]: 105 | btn.set_callback(None) 106 | 107 | if sender.title.lower().startswith(("start", "continue")): 108 | 109 | if sender.title == 'Start Timer': 110 | # reset timer & set stop time 111 | self.timer.count = 0 112 | self.timer.end = interval 113 | 114 | # change title of MenuItem from 'Start timer' to 'Pause timer' 115 | sender.title = 'Pause Timer' 116 | 117 | # lift off! start the timer 118 | self.timer.start() 119 | else: # 'Pause Timer' 120 | sender.title = 'Continue Timer' 121 | self.timer.stop() 122 | 123 | def on_tick(self, sender): 124 | time_left = sender.end - sender.count 125 | mins = time_left // 60 if time_left >= 0 else time_left // 60 + 1 126 | secs = time_left % 60 if time_left >= 0 else (-1 * time_left) % 60 127 | if mins == 0 and time_left < 0: 128 | rumps.notification(title='Timebox', 129 | subtitle='Time is up! Take a break :)', 130 | message='') 131 | self.stop_timer(sender) 132 | self.stop_button.set_callback(None) 133 | else: 134 | self.stop_button.set_callback(self.stop_timer) 135 | self.app.title = '{:2d}:{:02d}'.format(mins, secs) 136 | sender.count += 1 137 | 138 | def stop_timer(self, sender=None): 139 | self.timer.stop() 140 | self.timer.count = 0 141 | self.app.title = "🥊" 142 | self.stop_button.set_callback(None) 143 | 144 | for key, btn in self.buttons.items(): 145 | btn.set_callback(self.buttons_callback[btn.title]) 146 | self.button_things.set_callback(lambda _: self.set_things_mins(_)) 147 | 148 | if self.button_things.state: 149 | self.interval = get_things_min(1)*SEC_TO_MIN 150 | self.button_things.title = "Things Interval ("+str(round(self.interval/SEC_TO_MIN))+"min)" 151 | get_things_min(0, True) 152 | 153 | self.start_pause_button.title = 'Start Timer' 154 | 155 | 156 | if __name__ == '__main__': 157 | app = TimerApp(timer_interval=1) 158 | app.run() 159 | --------------------------------------------------------------------------------