├── imgs ├── break-timer.png └── tomato-timer.png ├── AUTHORS ├── .github └── workflows │ └── format.yml ├── LICENSE ├── README.md └── polypomo /imgs/break-timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unode/polypomo/HEAD/imgs/break-timer.png -------------------------------------------------------------------------------- /imgs/tomato-timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unode/polypomo/HEAD/imgs/tomato-timer.png -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Originally created by: 2 | 3 | Renato Alves (@unode) 4 | 5 | 6 | Many thanks to the following contributors: 7 | 8 | AYOUB AIT EL AOUAD (@imenyoo2) - implemented SQLite time recording 9 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: black-action 2 | on: [push, pull_request] 3 | jobs: 4 | linter_name: 5 | name: Run black formatter 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: Check files using the black formatter 10 | uses: rickstaa/action-black@v1 11 | id: action_black 12 | with: 13 | black_args: "polypomo" 14 | - name: Annotate diff changes using reviewdog 15 | if: steps.action_black.outputs.is_formatted == 'true' 16 | uses: reviewdog/action-suggester@v1 17 | with: 18 | tool_name: blackfmt 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Renato Alves 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # polypomo - a [polybar](https://polybar.github.io/) [pomodoro](https://en.wikipedia.org/wiki/Pomodoro_Technique) widget 2 | 3 | ## Usage 4 | 5 | Download or clone this repository, then in polybar add: 6 | 7 | ``` 8 | ; In your bar configuration add 9 | modules-right = polypomo 10 | 11 | ; and add a polypomo module 12 | [module/polypomo] 13 | type = custom/script 14 | 15 | exec = /path/to/polypomo 16 | tail = true 17 | 18 | label = %output% 19 | click-left = /path/to/polypomo toggle 20 | click-right = /path/to/polypomo end 21 | click-middle = /path/to/polypomo lock 22 | scroll-up = /path/to/polypomo time +60 23 | scroll-down = /path/to/polypomo time -60 24 | 25 | font-0 = fixed:pixelsize=10;1 26 | font-1 = Noto Emoji:scale=15:antialias=false;0 27 | ``` 28 | 29 | In order to prevent accidental changes to the timer, polypomo starts in `locked` mode. 30 | Middle click the widget or run `polypomo lock` to toggle locked state. 31 | You can then scroll-up/down to change time. 32 | 33 | If you wish to permanently change the default times start polypomo with `--worktime seconds` and `--breaktime seconds`. 34 | 35 | if you want your work sessions to be logged, start polypomo with `--saveto` followed by the path to your database, polypomo will then create a table called `sessions` and store the date, start and stop time of each work session. 36 | 37 | There isn't much else in terms of configuration but if the syntax above is confusing please refer to the [polybar configuration wiki page](https://github.com/polybar/polybar/wiki/Configuration). 38 | 39 | ### Limitations 40 | 41 | polypomo is designed to work as a single widget in your polybar. 42 | Running multiple polypomo instances is not a supported configuration [but some workarounds are possible](https://github.com/unode/polypomo/issues/3#issuecomment-781288256). 43 | 44 | ### Fonts 45 | 46 | In order to display the icons as shown in the screenshots below, 47 | you need to configure a font that includes the Unicode glyphs U+1F345 (🍅) and U+1F3D6 (🏖). 48 | The example above uses the font [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) from the [Noto](https://www.google.com/get/noto/help/emoji/) family of fonts. 49 | 50 | ### About pomodoro technique 51 | 52 | While polypomo implements the `active -> break -> active` pattern it doesn't enforce the longer break after a given number of active sprees. 53 | This is left at the discretion of the user. 54 | 55 | ## Optional dependencies 56 | 57 | polypomo makes use of `notify-send` to send a notification when the timer reaches zero. 58 | 59 | ## Screenshots 60 | 61 | ![pomodoro timer](https://raw.githubusercontent.com/unode/polypomo/master/imgs/tomato-timer.png) 62 | ![break timer](https://raw.githubusercontent.com/unode/polypomo/master/imgs/break-timer.png) 63 | 64 | ## License 65 | 66 | polypomo is licensed under the [MIT](https://github.com/unode/polypomo/blob/master/LICENSE) license 67 | 68 | ## Troubleshooting 69 | 70 | If you are finding that polypomo doesn't start or error messages are visible in polybar, try the following: 71 | 72 | 1. Remove polypomo from polybar's configuration 73 | 2. Ensure no polypomo process is active on your system 74 | 3. Open two terminals/consoles and navigate to the polypomo repository 75 | 4. On the first terminal run `./polypomo`. You should see some output appearing. 76 | 5. On the second terminal issue one of the polypomo commands listed above. For instance `./polypomo toggle`. The output in terminal one should change accordingly. 77 | 78 | If an error appears in either case, please submit a bug report with the full output and the steps to reproduce the problem. 79 | 80 | To stop polypomo on the first terminal run `./polypomo exit` in the second terminal or simply hit `Ctrl + C` to abort the process. 81 | 82 | ### Received exit request... 83 | 84 | If this message appears repeatedly in polybar, you may be running multiple instances of polypomo simultaneously and they are forcing each other to exit. 85 | Reconfigure polybar to run only one polypomo instance or see the [Limitations](#limitations) section above for possible workarounds. 86 | 87 | Alternatively, stop polybar and run the above troubleshooting steps. 88 | If manually running polypomo with the two terminal setup works, review your polybar configuration to ensure only one instance of polypomo is launched. 89 | -------------------------------------------------------------------------------- /polypomo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | import socket 7 | import argparse 8 | import operator 9 | import time 10 | import select 11 | from contextlib import contextmanager 12 | from subprocess import call, DEVNULL 13 | import sqlite3 14 | import pathlib 15 | 16 | 17 | SOCKDIR = os.environ.get("XDG_RUNTIME_DIR", "/var/tmp") 18 | SOCKFILE = os.path.join(SOCKDIR, "polypomo.sock") 19 | TOMATO = "\U0001F345" # Recommended font: Noto Emoji 20 | BREAK = "\U0001F3D6" # Recommended font: Noto Emoji 21 | PAUSE = "\U000023F8" # Recommended font: Siji 22 | 23 | 24 | class Exit(Exception): 25 | pass 26 | 27 | 28 | class Timer: 29 | def __init__(self, remtime): 30 | self.time = remtime 31 | self.notified = False 32 | self.tick() 33 | 34 | def __str__(self): 35 | return self.format_time() 36 | 37 | def tick(self): 38 | self.previous = time.time() 39 | 40 | def format_time(self): 41 | day_factor = 86400 42 | hour_factor = 3600 43 | minute_factor = 60 44 | 45 | if self.time > 0: 46 | rem = self.time 47 | neg = "" 48 | else: 49 | rem = -self.time 50 | neg = "-" 51 | days = int(rem // day_factor) 52 | rem -= days * day_factor 53 | hours = int(rem // hour_factor) 54 | rem -= hours * hour_factor 55 | minutes = int(rem // minute_factor) 56 | rem -= minutes * minute_factor 57 | seconds = int(rem // 1) 58 | 59 | strtime = [] 60 | if days > 0: 61 | strtime.append(str(days)) 62 | if days > 0 or hours > 0: 63 | strtime.append("{:02d}".format(hours)) 64 | 65 | # Always append minutes and seconds 66 | strtime.append("{:02d}".format(minutes)) 67 | strtime.append("{:02d}".format(seconds)) 68 | 69 | return neg + ":".join(strtime) 70 | 71 | def update(self): 72 | now = time.time() 73 | delta = now - self.previous 74 | self.time -= delta 75 | 76 | # Send a notification when timer reaches 0 77 | if not self.notified and self.time < 0: 78 | self.notified = True 79 | try: 80 | call( 81 | [ 82 | "notify-send", 83 | "-t", 84 | "0", 85 | "-u", 86 | "critical", 87 | "Pomodoro", 88 | "Timer reached zero", 89 | ], 90 | stdout=DEVNULL, 91 | stderr=DEVNULL, 92 | ) 93 | except FileNotFoundError: 94 | # Skip if notify-send isn't installed 95 | pass 96 | 97 | def change(self, op, seconds): 98 | self.time = op(self.time, seconds) 99 | 100 | 101 | class Status: 102 | def __init__(self, worktime, breaktime, saveto): 103 | self.worktime = worktime 104 | self.breaktime = breaktime 105 | self.status = "work" # or "break" 106 | self.timer = Timer(self.worktime) 107 | self.active = False 108 | self.locked = True 109 | self.saveto = saveto 110 | 111 | def show(self): 112 | status = TOMATO if self.status == "work" else BREAK 113 | if not self.active: 114 | status = PAUSE 115 | sys.stdout.write("{} {}\n".format(status, self.timer)) 116 | sys.stdout.flush() 117 | 118 | def toggle(self): 119 | self.active = not self.active 120 | if self.saveto and self.status == "work": 121 | self.save() 122 | 123 | def toggle_lock(self): 124 | self.locked = not self.locked 125 | 126 | def update(self): 127 | if self.active: 128 | self.timer.update() 129 | # This ensures the timer counts time since the last iteration 130 | # and not since it was initialized 131 | self.timer.tick() 132 | 133 | def change(self, op, seconds): 134 | if self.locked: 135 | return 136 | 137 | seconds = int(seconds) 138 | op = operator.add if op == "add" else operator.sub 139 | self.timer.change(op, seconds) 140 | 141 | def next_timer(self): 142 | if self.status == "work": 143 | if self.active: 144 | self.active = False 145 | self.save() # save before switching if toggle didn't get called 146 | self.status = "break" 147 | self.timer = Timer(self.breaktime) 148 | elif self.status == "break": 149 | self.active = False 150 | self.status = "work" 151 | self.timer = Timer(self.worktime) 152 | 153 | def save(self): 154 | if self.saveto: 155 | with setup_conn(self.saveto) as conn: 156 | if self.active: 157 | conn.execute( 158 | "INSERT INTO sessions VALUES (date('now'), time('now'), NULL)" 159 | ) 160 | else: 161 | conn.execute( 162 | """UPDATE sessions 163 | SET stop = time('now') 164 | WHERE start IN ( 165 | SELECT start FROM sessions 166 | ORDER BY start DESC 167 | LIMIT 1) 168 | """ 169 | ) 170 | 171 | 172 | @contextmanager 173 | def setup_conn(path): 174 | # check if the database exist 175 | conn = sqlite3.connect(path) 176 | cur = conn.cursor() 177 | cur.execute( 178 | """CREATE TABLE IF NOT EXISTS sessions ( 179 | date text, 180 | start text, 181 | stop text 182 | )""" 183 | ) 184 | try: 185 | yield cur 186 | finally: 187 | conn.commit() 188 | conn.close() 189 | 190 | 191 | @contextmanager 192 | def setup_listener(): 193 | # If there's an existing socket, tell the other to exit and replace it 194 | action_exit(None, warn=False) 195 | 196 | # If there is a socket on disk after sending an exit request, delete it 197 | try: 198 | os.remove(SOCKFILE) 199 | except FileNotFoundError: 200 | pass 201 | 202 | s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) 203 | s.bind(SOCKFILE) 204 | 205 | try: 206 | yield s 207 | finally: 208 | s.close() 209 | # Don't try to delete the socket since at this point it could 210 | # be owned by a different process 211 | # try: 212 | # os.remove(SOCKFILE) 213 | # except FileNotFoundError: 214 | # pass 215 | 216 | 217 | @contextmanager 218 | def setup_client(): 219 | # creates socket object 220 | s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) 221 | 222 | s.connect(SOCKFILE) 223 | 224 | try: 225 | yield s 226 | finally: 227 | s.close() 228 | 229 | # tm = s.recv(1024) # msg can only be 1024 bytes long 230 | 231 | 232 | def wait_for_socket_cleanup(tries=20, wait=0.5): 233 | for i in range(tries): 234 | if not os.path.isfile(SOCKFILE): 235 | return True 236 | else: 237 | time.sleep(wait) 238 | 239 | return False 240 | 241 | 242 | def check_actions(sock, status): 243 | timeout = time.time() + 0.9 244 | 245 | data = "" 246 | 247 | while True: 248 | ready = select.select([sock], [], [], 0.2) 249 | if time.time() > timeout: 250 | break 251 | if ready[0]: 252 | try: 253 | data = sock.recv(1024) 254 | if data: 255 | break 256 | except socket.error as e: 257 | # TODO replace this by logging 258 | print("Lost connection to client. Printing buffer...", e) 259 | break 260 | 261 | if not data: 262 | return 263 | 264 | action = data.decode("utf8") 265 | if action == "toggle": 266 | status.toggle() 267 | elif action == "end": 268 | status.next_timer() 269 | elif action == "lock": 270 | status.toggle_lock() 271 | elif action.startswith("time"): 272 | _, op, seconds = action.split(" ") 273 | status.change(op, seconds) 274 | elif action == "exit": 275 | raise Exit() 276 | 277 | 278 | def action_display(args): 279 | # TODO logging = print("Running display", args) 280 | 281 | status = Status(args.worktime, args.breaktime, args.saveto) 282 | 283 | # Listen on socket 284 | with setup_listener() as sock: 285 | while True: 286 | status.show() 287 | status.update() 288 | try: 289 | check_actions(sock, status) 290 | except Exit: 291 | print("Received exit request...") 292 | break 293 | 294 | 295 | def action_toggle(args): 296 | # TODO logging = print("Running toggle", args) 297 | with setup_client() as s: 298 | msg = "toggle" 299 | s.send(msg.encode("utf8")) 300 | 301 | 302 | def action_end(args): 303 | # TODO logging = print("Running end", args) 304 | with setup_client() as s: 305 | msg = "end" 306 | s.send(msg.encode("utf8")) 307 | 308 | 309 | def action_lock(args): 310 | # TODO logging = print("Running lock", args) 311 | with setup_client() as s: 312 | msg = "lock" 313 | s.send(msg.encode("utf8")) 314 | 315 | 316 | def action_time(args): 317 | # TODO logging = print("Running time", args) 318 | with setup_client() as s: 319 | msg = "time " + " ".join(args.delta) 320 | s.send(msg.encode("utf8")) 321 | 322 | 323 | def action_exit(args, warn=True): 324 | # TODO logging = print("Running exit", args) 325 | try: 326 | with setup_client() as s: 327 | msg = "exit" 328 | s.send(msg.encode("utf8")) 329 | except (FileNotFoundError, ConnectionRefusedError) as e: 330 | if warn: 331 | print("No instance of polypomo listening, error:", e) 332 | else: 333 | if not wait_for_socket_cleanup(): 334 | print("Socket was not removed, assuming it's stale") 335 | 336 | 337 | class ValidateTime(argparse.Action): 338 | def __call__(self, parser, namespace, values, option_string=None): 339 | if values[0] not in "-+": 340 | parser.error( 341 | "Time format should be +num or -num to add or remove time, respectively" 342 | ) 343 | if not values[1:].isdigit(): 344 | parser.error("Expected number after +/- but saw '{}'".format(values[1:])) 345 | 346 | # action = operator.add if values[0] == '+' else operator.sub 347 | # value = int(values[1:]) 348 | action = "add" if values[0] == "+" else "sub" 349 | value = values[1:] 350 | 351 | setattr(namespace, self.dest, (action, value)) 352 | 353 | 354 | def parse_args(): 355 | parser = argparse.ArgumentParser( 356 | description="Pomodoro timer to be used with polybar" 357 | ) 358 | # Display - main loop showing status 359 | parser.add_argument( 360 | "--worktime", 361 | type=int, 362 | default=25 * 60, 363 | help="Default work timer time in seconds", 364 | ) 365 | parser.add_argument( 366 | "--breaktime", 367 | type=int, 368 | default=3 * 60, 369 | help="Default break timer time in seconds", 370 | ) 371 | parser.add_argument( 372 | "--saveto", type=pathlib.Path, default=None, help="Path to database" 373 | ) 374 | parser.set_defaults(func=action_display) 375 | 376 | sub = parser.add_subparsers() 377 | 378 | # start/stop timer 379 | toggle = sub.add_parser("toggle", help="start/stop timer") 380 | toggle.set_defaults(func=action_toggle) 381 | 382 | # end timer 383 | end = sub.add_parser("end", help="end current timer") 384 | end.set_defaults(func=action_end) 385 | 386 | # lock timer changes 387 | lock = sub.add_parser("lock", help="lock time actions - prevent changing time") 388 | lock.set_defaults(func=action_lock) 389 | 390 | # lock timer changes 391 | exit = sub.add_parser( 392 | "exit", help="exit any listening polypomo instances gracefully" 393 | ) 394 | exit.set_defaults(func=action_exit) 395 | 396 | # change timer 397 | time = sub.add_parser("time", help="add/remove time to current timer") 398 | time.add_argument( 399 | "delta", 400 | action=ValidateTime, 401 | help="Time to add/remove to current timer (in seconds)", 402 | ) 403 | time.set_defaults(func=action_time) 404 | 405 | return parser.parse_args() 406 | 407 | 408 | def main(): 409 | args = parse_args() 410 | args.func(args) 411 | 412 | 413 | if __name__ == "__main__": 414 | main() 415 | 416 | # vim: ai sts=4 et sw=4 417 | --------------------------------------------------------------------------------