pomlock 1.2.1__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.
- pomlock-1.2.1/LICENSE +21 -0
- pomlock-1.2.1/PKG-INFO +164 -0
- pomlock-1.2.1/README.md +146 -0
- pomlock-1.2.1/pyproject.toml +36 -0
- pomlock-1.2.1/src/pomlock/__init__.py +0 -0
- pomlock-1.2.1/src/pomlock/app.py +475 -0
pomlock-1.2.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Luis Cadillo
|
|
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.
|
pomlock-1.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pomlock
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: A Linux utility that enforces regular breaks by temporarily blocking input devices
|
|
5
|
+
Keywords: pomodoro,timer,productivity,focus,linux,cli,input-blocker,break-timer,time-management
|
|
6
|
+
Author: Luis Cadillo
|
|
7
|
+
Author-email: Luis Cadillo <luiscaf3r@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Project-URL: Issues, https://github.com/luiisca/pomlock/issues
|
|
16
|
+
Project-URL: Repository, https://github.com/luiisca/pomlock
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Pomlock - A pomodoro application for Linux
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
A Linux utility that enforces regular breaks by temporarily blocking input devices. Perfect for developers, writers, and anyone who needs help stepping away from the keyboard.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- **Flexible Timer System**: Supports multiple timer presets, including the classic Pomodoro, Ultradian Rhythm (90/20), and a 50/10 cycle. New presets can be defined in the configuration file, and a one-time custom timer can be passed as a command-line argument.
|
|
28
|
+
- **Input Blocking**: Disables all input devices during break periods to ensure you step away.
|
|
29
|
+
- **Customizable Overlay**: A full-screen display during breaks with configurable font, colors, and opacity.
|
|
30
|
+
- **Desktop Notifications**: Get native desktop notifications when a break starts.
|
|
31
|
+
- **Activity Logging**: Keeps a simple log of work and break cycles at `~/.local/share/pomlock/pomlock.log`.
|
|
32
|
+
- **Safe Mode**: Run the timer without input blocking using the `--enable-input-during-break` flag.
|
|
33
|
+
- **Smart Configuration**: Settings are loaded in a logical order: Defaults < Config File < CLI Arguments. CLI flags always have the final say.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
### pip
|
|
39
|
+
```bash
|
|
40
|
+
pip install pomlock
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### uv
|
|
44
|
+
```bash
|
|
45
|
+
uv tool install pomlock
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
<!-- ### Arch Linux (AUR) -->
|
|
49
|
+
<!-- ```bash -->
|
|
50
|
+
<!-- yay -S pomlock -->
|
|
51
|
+
<!-- ``` -->
|
|
52
|
+
<!-- ```bash -->
|
|
53
|
+
<!-- paru -S pomlock -->
|
|
54
|
+
<!-- ``` -->
|
|
55
|
+
<!---->
|
|
56
|
+
<!-- ### Manual -->
|
|
57
|
+
<!---->
|
|
58
|
+
<!-- <!-- TODO: some ideas --> -->
|
|
59
|
+
<!-- options: -->
|
|
60
|
+
<!-- 1. curl command: -->
|
|
61
|
+
<!-- [uv package manager](https://github.com/astral-sh/uv?tab=readme-ov-file#installation) -->
|
|
62
|
+
<!-- ```bash -->
|
|
63
|
+
<!-- curl -LsSf https://astral.sh/uv/install.sh | sh -->
|
|
64
|
+
<!-- ``` -->
|
|
65
|
+
<!-- [yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/Installation#installing-the-release-binary) -->
|
|
66
|
+
<!-- ```bash -->
|
|
67
|
+
<!-- curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o ~/.local/bin/yt-dlp -->
|
|
68
|
+
<!-- chmod a+rx ~/.local/bin/yt-dlp # Make executable -->
|
|
69
|
+
<!-- ``` -->
|
|
70
|
+
<!---->
|
|
71
|
+
<!-- 2. pip -->
|
|
72
|
+
<!-- [uv](https://github.com/astral-sh/uv?tab=readme-ov-file#installation) -->
|
|
73
|
+
<!-- ```bash -->
|
|
74
|
+
<!-- # With pip. -->
|
|
75
|
+
<!-- pip install uv -->
|
|
76
|
+
<!-- ``` -->
|
|
77
|
+
<!-- [yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/Installation#with-pip) -->
|
|
78
|
+
<!-- ```bash -->
|
|
79
|
+
<!-- python3 -m pip install -U "yt-dlp[default]" -->
|
|
80
|
+
<!-- ``` -->
|
|
81
|
+
<!---->
|
|
82
|
+
<!-- 3. pacman -->
|
|
83
|
+
<!-- [yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/Installation#pacman) -->
|
|
84
|
+
<!-- ```bash -->
|
|
85
|
+
<!-- sudo pacman -Syu yt-dlp -->
|
|
86
|
+
<!-- ``` -->
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Start with the default 'standard' preset (25min work, 5min break)
|
|
93
|
+
pomlock
|
|
94
|
+
|
|
95
|
+
# Use the 'ultradian' preset for a long deep-work session (90min work, 20min break)
|
|
96
|
+
pomlock --timer ultradian
|
|
97
|
+
|
|
98
|
+
# Use the 'fifty_ten' preset for a 50/10 work-break cycle.
|
|
99
|
+
pomlock --timer fifty_ten
|
|
100
|
+
|
|
101
|
+
# Set a custom timer: 45min work, 15min short break, 30min long break after 3 cycles
|
|
102
|
+
pomlock --timer "45 15 30 3"
|
|
103
|
+
|
|
104
|
+
# Set a custom overlay text color for the session
|
|
105
|
+
pomlock --overlay-color "lime"
|
|
106
|
+
|
|
107
|
+
# Run without blocking input devices
|
|
108
|
+
pomlock --enable-input-during-break
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Configuration
|
|
112
|
+
|
|
113
|
+
You can create a custom configuration file at `~/.config/pomlock/pomlock.conf` to override the default settings. CLI arguments will always override settings from this file.
|
|
114
|
+
|
|
115
|
+
Here is an example configuration showing all available options:
|
|
116
|
+
```ini
|
|
117
|
+
# ~/.config/pomlock/pomlock.conf
|
|
118
|
+
|
|
119
|
+
[pomodoro]
|
|
120
|
+
# These values define the timer components when not using a preset.
|
|
121
|
+
# timer = 30 5 15 4
|
|
122
|
+
pomodoro = 30
|
|
123
|
+
short_break = 5
|
|
124
|
+
long_break = 15
|
|
125
|
+
cycles_before_long = 4
|
|
126
|
+
enable_input_during_break = false
|
|
127
|
+
|
|
128
|
+
[overlay]
|
|
129
|
+
# Customize the appearance of the break screen.
|
|
130
|
+
font_size = 64
|
|
131
|
+
color = red
|
|
132
|
+
bg_color = white
|
|
133
|
+
opacity = 0.5
|
|
134
|
+
notify = false
|
|
135
|
+
# notify_message = Time for a break!
|
|
136
|
+
|
|
137
|
+
[presets]
|
|
138
|
+
# Define your own custom timer presets.
|
|
139
|
+
# Format: "WORK SHORT_BREAK LONG_BREAK CYCLES"
|
|
140
|
+
standard = 25 5 20 4
|
|
141
|
+
ultradian = 90 20 20 1
|
|
142
|
+
fifty_ten = 50 10 10 1
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Log File
|
|
146
|
+
|
|
147
|
+
**Location**: `~/.local/share/pomlock/pomlock.log`
|
|
148
|
+
|
|
149
|
+
**Example Log Output**:
|
|
150
|
+
```log
|
|
151
|
+
2023-10-27 10:00:00 - INFO - Pomodoro started (25 minutes).
|
|
152
|
+
2023-10-27 10:25:00 - INFO - Pomodoro completed (Duration: 25m) (Cycle: 1)
|
|
153
|
+
2023-10-27 10:25:00 - INFO - Short break started (Duration: 5m) (Cycle: 1)
|
|
154
|
+
2023-10-27 10:30:00 - INFO - Break completed (Cycle: 1)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Safety
|
|
158
|
+
|
|
159
|
+
- **Automatic Restoration**: Input devices are automatically re-enabled when the program exits cleanly or is interrupted (Ctrl+C).
|
|
160
|
+
- **Non-Blocking Mode**: Use `--enable-input-during-break` for safe, non-blocking monitoring.
|
|
161
|
+
- **Force Quit**: If the application becomes unresponsive, you can force it to close and restore input by running:
|
|
162
|
+
```bash
|
|
163
|
+
pkill -f pomlock.py
|
|
164
|
+
```
|
pomlock-1.2.1/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Pomlock - A pomodoro application for Linux
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A Linux utility that enforces regular breaks by temporarily blocking input devices. Perfect for developers, writers, and anyone who needs help stepping away from the keyboard.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Flexible Timer System**: Supports multiple timer presets, including the classic Pomodoro, Ultradian Rhythm (90/20), and a 50/10 cycle. New presets can be defined in the configuration file, and a one-time custom timer can be passed as a command-line argument.
|
|
10
|
+
- **Input Blocking**: Disables all input devices during break periods to ensure you step away.
|
|
11
|
+
- **Customizable Overlay**: A full-screen display during breaks with configurable font, colors, and opacity.
|
|
12
|
+
- **Desktop Notifications**: Get native desktop notifications when a break starts.
|
|
13
|
+
- **Activity Logging**: Keeps a simple log of work and break cycles at `~/.local/share/pomlock/pomlock.log`.
|
|
14
|
+
- **Safe Mode**: Run the timer without input blocking using the `--enable-input-during-break` flag.
|
|
15
|
+
- **Smart Configuration**: Settings are loaded in a logical order: Defaults < Config File < CLI Arguments. CLI flags always have the final say.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
### pip
|
|
21
|
+
```bash
|
|
22
|
+
pip install pomlock
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### uv
|
|
26
|
+
```bash
|
|
27
|
+
uv tool install pomlock
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
<!-- ### Arch Linux (AUR) -->
|
|
31
|
+
<!-- ```bash -->
|
|
32
|
+
<!-- yay -S pomlock -->
|
|
33
|
+
<!-- ``` -->
|
|
34
|
+
<!-- ```bash -->
|
|
35
|
+
<!-- paru -S pomlock -->
|
|
36
|
+
<!-- ``` -->
|
|
37
|
+
<!---->
|
|
38
|
+
<!-- ### Manual -->
|
|
39
|
+
<!---->
|
|
40
|
+
<!-- <!-- TODO: some ideas --> -->
|
|
41
|
+
<!-- options: -->
|
|
42
|
+
<!-- 1. curl command: -->
|
|
43
|
+
<!-- [uv package manager](https://github.com/astral-sh/uv?tab=readme-ov-file#installation) -->
|
|
44
|
+
<!-- ```bash -->
|
|
45
|
+
<!-- curl -LsSf https://astral.sh/uv/install.sh | sh -->
|
|
46
|
+
<!-- ``` -->
|
|
47
|
+
<!-- [yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/Installation#installing-the-release-binary) -->
|
|
48
|
+
<!-- ```bash -->
|
|
49
|
+
<!-- curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o ~/.local/bin/yt-dlp -->
|
|
50
|
+
<!-- chmod a+rx ~/.local/bin/yt-dlp # Make executable -->
|
|
51
|
+
<!-- ``` -->
|
|
52
|
+
<!---->
|
|
53
|
+
<!-- 2. pip -->
|
|
54
|
+
<!-- [uv](https://github.com/astral-sh/uv?tab=readme-ov-file#installation) -->
|
|
55
|
+
<!-- ```bash -->
|
|
56
|
+
<!-- # With pip. -->
|
|
57
|
+
<!-- pip install uv -->
|
|
58
|
+
<!-- ``` -->
|
|
59
|
+
<!-- [yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/Installation#with-pip) -->
|
|
60
|
+
<!-- ```bash -->
|
|
61
|
+
<!-- python3 -m pip install -U "yt-dlp[default]" -->
|
|
62
|
+
<!-- ``` -->
|
|
63
|
+
<!---->
|
|
64
|
+
<!-- 3. pacman -->
|
|
65
|
+
<!-- [yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/Installation#pacman) -->
|
|
66
|
+
<!-- ```bash -->
|
|
67
|
+
<!-- sudo pacman -Syu yt-dlp -->
|
|
68
|
+
<!-- ``` -->
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Start with the default 'standard' preset (25min work, 5min break)
|
|
75
|
+
pomlock
|
|
76
|
+
|
|
77
|
+
# Use the 'ultradian' preset for a long deep-work session (90min work, 20min break)
|
|
78
|
+
pomlock --timer ultradian
|
|
79
|
+
|
|
80
|
+
# Use the 'fifty_ten' preset for a 50/10 work-break cycle.
|
|
81
|
+
pomlock --timer fifty_ten
|
|
82
|
+
|
|
83
|
+
# Set a custom timer: 45min work, 15min short break, 30min long break after 3 cycles
|
|
84
|
+
pomlock --timer "45 15 30 3"
|
|
85
|
+
|
|
86
|
+
# Set a custom overlay text color for the session
|
|
87
|
+
pomlock --overlay-color "lime"
|
|
88
|
+
|
|
89
|
+
# Run without blocking input devices
|
|
90
|
+
pomlock --enable-input-during-break
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Configuration
|
|
94
|
+
|
|
95
|
+
You can create a custom configuration file at `~/.config/pomlock/pomlock.conf` to override the default settings. CLI arguments will always override settings from this file.
|
|
96
|
+
|
|
97
|
+
Here is an example configuration showing all available options:
|
|
98
|
+
```ini
|
|
99
|
+
# ~/.config/pomlock/pomlock.conf
|
|
100
|
+
|
|
101
|
+
[pomodoro]
|
|
102
|
+
# These values define the timer components when not using a preset.
|
|
103
|
+
# timer = 30 5 15 4
|
|
104
|
+
pomodoro = 30
|
|
105
|
+
short_break = 5
|
|
106
|
+
long_break = 15
|
|
107
|
+
cycles_before_long = 4
|
|
108
|
+
enable_input_during_break = false
|
|
109
|
+
|
|
110
|
+
[overlay]
|
|
111
|
+
# Customize the appearance of the break screen.
|
|
112
|
+
font_size = 64
|
|
113
|
+
color = red
|
|
114
|
+
bg_color = white
|
|
115
|
+
opacity = 0.5
|
|
116
|
+
notify = false
|
|
117
|
+
# notify_message = Time for a break!
|
|
118
|
+
|
|
119
|
+
[presets]
|
|
120
|
+
# Define your own custom timer presets.
|
|
121
|
+
# Format: "WORK SHORT_BREAK LONG_BREAK CYCLES"
|
|
122
|
+
standard = 25 5 20 4
|
|
123
|
+
ultradian = 90 20 20 1
|
|
124
|
+
fifty_ten = 50 10 10 1
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Log File
|
|
128
|
+
|
|
129
|
+
**Location**: `~/.local/share/pomlock/pomlock.log`
|
|
130
|
+
|
|
131
|
+
**Example Log Output**:
|
|
132
|
+
```log
|
|
133
|
+
2023-10-27 10:00:00 - INFO - Pomodoro started (25 minutes).
|
|
134
|
+
2023-10-27 10:25:00 - INFO - Pomodoro completed (Duration: 25m) (Cycle: 1)
|
|
135
|
+
2023-10-27 10:25:00 - INFO - Short break started (Duration: 5m) (Cycle: 1)
|
|
136
|
+
2023-10-27 10:30:00 - INFO - Break completed (Cycle: 1)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Safety
|
|
140
|
+
|
|
141
|
+
- **Automatic Restoration**: Input devices are automatically re-enabled when the program exits cleanly or is interrupted (Ctrl+C).
|
|
142
|
+
- **Non-Blocking Mode**: Use `--enable-input-during-break` for safe, non-blocking monitoring.
|
|
143
|
+
- **Force Quit**: If the application becomes unresponsive, you can force it to close and restore input by running:
|
|
144
|
+
```bash
|
|
145
|
+
pkill -f pomlock.py
|
|
146
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.8.7,<0.9.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pomlock"
|
|
7
|
+
version = "1.2.1"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Luis Cadillo", email="luiscaf3r@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "A Linux utility that enforces regular breaks by temporarily blocking input devices"
|
|
12
|
+
keywords = ["pomodoro", "timer", "productivity", "focus", "linux", "cli", "input-blocker", "break-timer", "time-management"]
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.9"
|
|
15
|
+
dependencies = []
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Topic :: Utilities",
|
|
21
|
+
]
|
|
22
|
+
license = "MIT"
|
|
23
|
+
license-files = ["LICEN[CS]E*"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/luiisca/pomlock"
|
|
27
|
+
Issues = "https://github.com/luiisca/pomlock/issues"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
pomlock = "pomlock.app:main"
|
|
31
|
+
|
|
32
|
+
# [[tool.uv.index]]
|
|
33
|
+
# name = "testpypi"
|
|
34
|
+
# url = "https://test.pypi.org/simple/"
|
|
35
|
+
# publish-url = "https://test.pypi.org/legacy/"
|
|
36
|
+
# explicit = true
|
|
File without changes
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# Standard library imports
|
|
4
|
+
import argparse
|
|
5
|
+
import configparser
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import tkinter as tk
|
|
13
|
+
from tkinter import font
|
|
14
|
+
|
|
15
|
+
# --- Application Constants ---
|
|
16
|
+
APP_NAME = "pomlock"
|
|
17
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / APP_NAME
|
|
18
|
+
DEFAULT_DATA_DIR = Path.home() / ".local" / "share" / APP_NAME
|
|
19
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / f"{APP_NAME}.conf"
|
|
20
|
+
DEFAULT_LOG_FILE = DEFAULT_DATA_DIR / f"{APP_NAME}.log"
|
|
21
|
+
|
|
22
|
+
# --- Argument and Configuration Single Source of Truth ---
|
|
23
|
+
# This dictionary drives the entire configuration system:
|
|
24
|
+
# - 'group': Maps the setting to a section in the .config config file.
|
|
25
|
+
# - 'default': The ultimate fallback value.
|
|
26
|
+
# - 'type', 'action', 'help': Used to dynamically build the argparse parser.
|
|
27
|
+
# - 'short', 'long': The command-line flags.
|
|
28
|
+
ARGUMENT_CONFIG = {
|
|
29
|
+
# Pomodoro Timer Settings
|
|
30
|
+
'timer': {
|
|
31
|
+
'group': 'pomodoro',
|
|
32
|
+
'default': 'standard',
|
|
33
|
+
'type': str,
|
|
34
|
+
'short': '-t', 'long': '--timer',
|
|
35
|
+
'help': """Set a timer preset or custom values: 'POMODORO SHORT_BREAK LONG_BREAK CYCLES'.
|
|
36
|
+
Example: --timer "25 5 15 4"."""
|
|
37
|
+
},
|
|
38
|
+
'pomodoro': {
|
|
39
|
+
'group': 'pomodoro',
|
|
40
|
+
'default': 25,
|
|
41
|
+
'type': int,
|
|
42
|
+
'long': '--pomodoro', 'help': "Interval of work time in minutes."
|
|
43
|
+
},
|
|
44
|
+
'short_break': {
|
|
45
|
+
'group': 'pomodoro',
|
|
46
|
+
'default': 5,
|
|
47
|
+
'type': int,
|
|
48
|
+
'long': '--short-break', 'help': "Short break duration in minutes."
|
|
49
|
+
},
|
|
50
|
+
'long_break': {
|
|
51
|
+
'group': 'pomodoro',
|
|
52
|
+
'default': 20,
|
|
53
|
+
'type': int,
|
|
54
|
+
'long': '--long-break', 'help': "Long break duration in minutes."
|
|
55
|
+
},
|
|
56
|
+
'cycles_before_long': {
|
|
57
|
+
'group': 'pomodoro',
|
|
58
|
+
'default': 4,
|
|
59
|
+
'type': int,
|
|
60
|
+
'long': '--cycles-before-long', 'help': "Cycles before a long break."
|
|
61
|
+
},
|
|
62
|
+
'enable_input_during_break': {
|
|
63
|
+
'group': 'pomodoro',
|
|
64
|
+
'default': False,
|
|
65
|
+
'long': '--enable-input-during-break',
|
|
66
|
+
'action': argparse.BooleanOptionalAction,
|
|
67
|
+
'help': "Enable/disable keyboard/mouse input during break time."
|
|
68
|
+
},
|
|
69
|
+
# Overlay Settings
|
|
70
|
+
'overlay_font_size': {
|
|
71
|
+
'group': 'overlay',
|
|
72
|
+
'default': 48,
|
|
73
|
+
'type': int,
|
|
74
|
+
'long': '--overlay-font-size',
|
|
75
|
+
'help': "Font size for overlay timer."
|
|
76
|
+
},
|
|
77
|
+
'overlay_color': {
|
|
78
|
+
'group': 'overlay',
|
|
79
|
+
'default': 'white',
|
|
80
|
+
'type': str,
|
|
81
|
+
'long': '--overlay-color',
|
|
82
|
+
'help': "Text color for overlay (e.g., 'white', '#FF0000')."
|
|
83
|
+
},
|
|
84
|
+
'overlay_bg_color': {
|
|
85
|
+
'group': 'overlay',
|
|
86
|
+
'default': 'black',
|
|
87
|
+
'type': str,
|
|
88
|
+
'long': '--overlay-bg-color',
|
|
89
|
+
'help': "Background color for overlay."
|
|
90
|
+
},
|
|
91
|
+
'overlay_opacity': {
|
|
92
|
+
'group': 'overlay',
|
|
93
|
+
'default': 0.8,
|
|
94
|
+
'type': float,
|
|
95
|
+
'long': '--overlay-opacity',
|
|
96
|
+
'help': "Opacity for overlay (0.0 to 1.0)."
|
|
97
|
+
},
|
|
98
|
+
'overlay_notify': {
|
|
99
|
+
'group': 'overlay',
|
|
100
|
+
'default': True,
|
|
101
|
+
'long': '--overlay-notify',
|
|
102
|
+
'action': argparse.BooleanOptionalAction,
|
|
103
|
+
'help': "Enable/disable desktop notification for breaks."
|
|
104
|
+
},
|
|
105
|
+
'overlay_notify_msg': {
|
|
106
|
+
'group': 'overlay',
|
|
107
|
+
'default': 'Time for a break!',
|
|
108
|
+
'type': str,
|
|
109
|
+
'long': '--overlay-notify-msg',
|
|
110
|
+
'help': "Custom message for desktop notification."
|
|
111
|
+
},
|
|
112
|
+
# Presets - not a CLI arg, but part of config
|
|
113
|
+
'presets': {
|
|
114
|
+
'group': 'presets',
|
|
115
|
+
'default': {
|
|
116
|
+
"standard": "25 5 20 4",
|
|
117
|
+
"ultradian": "90 20 20 1",
|
|
118
|
+
"fifty_ten": "50 10 10 1"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- Logging Setup ---
|
|
125
|
+
logger = logging.getLogger(APP_NAME)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def setup_logging(log_file_path_str: str, verbose: bool):
|
|
129
|
+
log_file_path = Path(log_file_path_str)
|
|
130
|
+
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
|
|
132
|
+
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
|
133
|
+
|
|
134
|
+
# add file handler
|
|
135
|
+
file_handler = logging.FileHandler(log_file_path)
|
|
136
|
+
file_handler.setFormatter(formatter)
|
|
137
|
+
logger.addHandler(file_handler)
|
|
138
|
+
|
|
139
|
+
# add console handler
|
|
140
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
141
|
+
console_handler.setFormatter(formatter)
|
|
142
|
+
console_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
143
|
+
logger.addHandler(console_handler)
|
|
144
|
+
|
|
145
|
+
logger.setLevel(logging.DEBUG)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def log_event(event_type: str, duration_minutes: int = 0, cycle_count: int = -1):
|
|
149
|
+
message_parts = [event_type]
|
|
150
|
+
if duration_minutes > 0:
|
|
151
|
+
message_parts.append(f"(Duration: {duration_minutes}m)")
|
|
152
|
+
if cycle_count != -1:
|
|
153
|
+
message_parts.append(f"(Cycle: {cycle_count})")
|
|
154
|
+
logger.info(" ".join(message_parts))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- XInput Device Control ---
|
|
158
|
+
SLAVE_KBD_PATTERN = re.compile(
|
|
159
|
+
r'↳(?!.*xtest).*id=(\d+).*slav[e\s]+keyboard', re.IGNORECASE)
|
|
160
|
+
SLAVE_POINTER_PATTERN = re.compile(
|
|
161
|
+
r'↳(?!.*xtest).*id=(\d+).*slav[e\s]+pointer', re.IGNORECASE)
|
|
162
|
+
FLOATING_SLAVE_PATTERN = re.compile(
|
|
163
|
+
r'.*id=(\d+).*\[floating\s*slave\]', re.IGNORECASE)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _get_xinput_ids(pattern: re.Pattern) -> list[str]:
|
|
167
|
+
ids = []
|
|
168
|
+
try:
|
|
169
|
+
result = subprocess.run(
|
|
170
|
+
['xinput', 'list'], capture_output=True, text=True, check=True)
|
|
171
|
+
for line in result.stdout.splitlines():
|
|
172
|
+
match = pattern.search(line)
|
|
173
|
+
if match:
|
|
174
|
+
ids.append(match.group(1))
|
|
175
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as e:
|
|
176
|
+
logger.error(f"xinput command failed: {e}")
|
|
177
|
+
return ids
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _set_device_state(device_ids: list[str], action: str):
|
|
181
|
+
if not device_ids:
|
|
182
|
+
return
|
|
183
|
+
for device_id in device_ids:
|
|
184
|
+
try:
|
|
185
|
+
subprocess.run(['xinput', action, device_id],
|
|
186
|
+
check=True, capture_output=True)
|
|
187
|
+
logger.debug(f"{action.capitalize()}d device ID: {device_id}")
|
|
188
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as e:
|
|
189
|
+
logger.error(f"Failed to {action} device {device_id}: {e}")
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def disable_input_devices():
|
|
194
|
+
logger.info("Disabling input devices...")
|
|
195
|
+
_set_device_state(_get_xinput_ids(SLAVE_KBD_PATTERN), "disable")
|
|
196
|
+
_set_device_state(_get_xinput_ids(SLAVE_POINTER_PATTERN), "disable")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def enable_input_devices():
|
|
200
|
+
logger.info("Enabling input devices...")
|
|
201
|
+
_set_device_state(_get_xinput_ids(FLOATING_SLAVE_PATTERN), "enable")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- Configuration Loading ---
|
|
205
|
+
def get_default_settings() -> dict:
|
|
206
|
+
"""Generates the default settings dictionary from the single source of truth."""
|
|
207
|
+
defaults = {}
|
|
208
|
+
# Create a nested dictionary for overlay options
|
|
209
|
+
defaults['overlay'] = {}
|
|
210
|
+
for key, config in ARGUMENT_CONFIG.items():
|
|
211
|
+
if key.startswith('overlay_'):
|
|
212
|
+
# Strip 'overlay_' prefix for the key inside overlay
|
|
213
|
+
opt_key = key.replace('overlay_', '', 1)
|
|
214
|
+
defaults['overlay'][opt_key] = config['default']
|
|
215
|
+
else:
|
|
216
|
+
defaults[key] = config['default']
|
|
217
|
+
return defaults
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def load_configuration(config_file_path_str: str) -> dict:
|
|
221
|
+
"""
|
|
222
|
+
Loads configuration from a .config file, using ARGUMENT_CONFIG for defaults.
|
|
223
|
+
"""
|
|
224
|
+
settings = get_default_settings()
|
|
225
|
+
config_file_path = Path(config_file_path_str)
|
|
226
|
+
|
|
227
|
+
if not config_file_path.exists():
|
|
228
|
+
config_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
logger.info(f"Config file not found at {
|
|
230
|
+
config_file_path}. Using default settings.")
|
|
231
|
+
return settings
|
|
232
|
+
|
|
233
|
+
logger.info(f"Loading configuration from {config_file_path}")
|
|
234
|
+
parser = configparser.ConfigParser()
|
|
235
|
+
try:
|
|
236
|
+
parser.read(config_file_path)
|
|
237
|
+
except configparser.Error as e:
|
|
238
|
+
logger.error(f"Error reading config file {
|
|
239
|
+
config_file_path}: {e}. Using defaults.")
|
|
240
|
+
return settings
|
|
241
|
+
|
|
242
|
+
# override default settings with config file
|
|
243
|
+
for key, arg_config in ARGUMENT_CONFIG.items():
|
|
244
|
+
group = arg_config.get('group')
|
|
245
|
+
if not group or group not in parser:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
if group == 'presets':
|
|
249
|
+
for name, value in parser['presets'].items():
|
|
250
|
+
settings['presets'][name.lower()] = value
|
|
251
|
+
elif key in parser[group]:
|
|
252
|
+
# Determine the correct 'get' method based on the defined type
|
|
253
|
+
value_type = arg_config.get('type', str)
|
|
254
|
+
try:
|
|
255
|
+
if value_type == int:
|
|
256
|
+
value = parser[group].getint(key)
|
|
257
|
+
elif value_type == float:
|
|
258
|
+
value = parser[group].getfloat(key)
|
|
259
|
+
elif arg_config.get('action') == argparse.BooleanOptionalAction:
|
|
260
|
+
value = parser[group].getboolean(key)
|
|
261
|
+
else:
|
|
262
|
+
value = parser[group].get(key)
|
|
263
|
+
|
|
264
|
+
# Place value in the correct part of the settings dict
|
|
265
|
+
if key.startswith('overlay_'):
|
|
266
|
+
settings['overlay'][key.replace(
|
|
267
|
+
'overlay_', '', 1)] = value
|
|
268
|
+
else:
|
|
269
|
+
settings[key] = value
|
|
270
|
+
except (ValueError, configparser.NoOptionError) as e:
|
|
271
|
+
logger.warning(f"Could not parse '{
|
|
272
|
+
key}' from config file: {e}. Using default.")
|
|
273
|
+
|
|
274
|
+
return settings
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --- Overlay Display Logic ---
|
|
278
|
+
def show_break_overlay(duration_seconds: int, overlay_config: dict):
|
|
279
|
+
if overlay_config.get('notify', False):
|
|
280
|
+
try:
|
|
281
|
+
subprocess.Popen(
|
|
282
|
+
['notify-send', overlay_config.get('notify_msg', 'Time for a break!')])
|
|
283
|
+
except (FileNotFoundError, Exception) as e:
|
|
284
|
+
logger.warning(f"Failed to send notification: {e}")
|
|
285
|
+
|
|
286
|
+
root = tk.Tk()
|
|
287
|
+
root.title("Pomlock Break")
|
|
288
|
+
root.attributes('-fullscreen', True)
|
|
289
|
+
root.attributes('-alpha', overlay_config.get('opacity', 0.8))
|
|
290
|
+
root.configure(background=overlay_config.get('bg_color', 'black'))
|
|
291
|
+
root.attributes('-topmost', True)
|
|
292
|
+
root.focus_force()
|
|
293
|
+
root.config(cursor="none")
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
label_font = font.Font(family="Helvetica", size=int(
|
|
297
|
+
overlay_config.get('font_size', 48)))
|
|
298
|
+
except tk.TclError:
|
|
299
|
+
logger.warning("Helvetica font not found. Using fallback.")
|
|
300
|
+
label_font = font.Font(family="Arial", size=36)
|
|
301
|
+
|
|
302
|
+
timer_label = tk.Label(root, text="",
|
|
303
|
+
fg=overlay_config.get('color', 'white'),
|
|
304
|
+
bg=overlay_config.get('bg_color', 'black'),
|
|
305
|
+
font=label_font)
|
|
306
|
+
timer_label.pack(expand=True)
|
|
307
|
+
|
|
308
|
+
start_time = time.time()
|
|
309
|
+
|
|
310
|
+
def update_timer_display():
|
|
311
|
+
remaining_seconds = duration_seconds - (time.time() - start_time)
|
|
312
|
+
if remaining_seconds <= 0:
|
|
313
|
+
root.destroy()
|
|
314
|
+
return
|
|
315
|
+
mins, secs = divmod(int(remaining_seconds), 60)
|
|
316
|
+
timer_label.config(text=f"BREAK TIME\n{mins:02d}:{secs:02d}")
|
|
317
|
+
root.after(1000, update_timer_display)
|
|
318
|
+
|
|
319
|
+
def on_key_press(event):
|
|
320
|
+
if event.keysym.lower() in ['escape', 'q']:
|
|
321
|
+
logger.debug("Overlay closed by user.")
|
|
322
|
+
root.destroy()
|
|
323
|
+
|
|
324
|
+
root.bind("<KeyPress>", on_key_press)
|
|
325
|
+
update_timer_display()
|
|
326
|
+
root.mainloop()
|
|
327
|
+
logger.debug("Overlay mainloop finished.")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# --- Main Application Logic ---
|
|
331
|
+
def run_pomodoro(config: dict):
|
|
332
|
+
work_m = config['pomodoro']
|
|
333
|
+
short_m = config['short_break']
|
|
334
|
+
long_m = config['long_break']
|
|
335
|
+
cycles_long = config['cycles_before_long']
|
|
336
|
+
|
|
337
|
+
log_event(f"Session started - Work: {work_m}m, Short: {
|
|
338
|
+
short_m}m, Long: {long_m}m, Cycles: {cycles_long}")
|
|
339
|
+
|
|
340
|
+
cycle_count = 0
|
|
341
|
+
try:
|
|
342
|
+
while True:
|
|
343
|
+
logger.info(f"Pomodoro started ({work_m} minutes).")
|
|
344
|
+
time.sleep(work_m * 60)
|
|
345
|
+
log_event("Pomodoro completed", work_m, cycle_count + 1)
|
|
346
|
+
|
|
347
|
+
cycle_count += 1
|
|
348
|
+
if cycle_count >= cycles_long:
|
|
349
|
+
break_m, break_type = long_m, "Long break"
|
|
350
|
+
log_event(break_type + " started", break_m, cycle_count)
|
|
351
|
+
cycle_count = 0
|
|
352
|
+
else:
|
|
353
|
+
break_m, break_type = short_m, "Short break"
|
|
354
|
+
log_event(break_type + " started", break_m, cycle_count)
|
|
355
|
+
|
|
356
|
+
if not config['enable_input_during_break']:
|
|
357
|
+
disable_input_devices()
|
|
358
|
+
|
|
359
|
+
show_break_overlay(break_m * 60, config['overlay'])
|
|
360
|
+
logger.info(f"{break_type} completed.")
|
|
361
|
+
log_event(
|
|
362
|
+
"Break completed", cycle_count=cycle_count if cycle_count != 0 else cycles_long)
|
|
363
|
+
|
|
364
|
+
if not config['enable_input_during_break']:
|
|
365
|
+
enable_input_devices()
|
|
366
|
+
|
|
367
|
+
except KeyboardInterrupt:
|
|
368
|
+
logger.info("Session interrupted by user. Exiting.")
|
|
369
|
+
finally:
|
|
370
|
+
if not config.get('enable_input_during_break', False):
|
|
371
|
+
logger.info("Ensuring input devices are enabled on exit...")
|
|
372
|
+
enable_input_devices()
|
|
373
|
+
log_event("Session ended")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def main():
|
|
377
|
+
user_provided_flags = {arg for arg in sys.argv[1:] if arg.startswith('-')}
|
|
378
|
+
|
|
379
|
+
parser = argparse.ArgumentParser(
|
|
380
|
+
description=f"A Pomodoro timer with input locking. Config: '{
|
|
381
|
+
DEFAULT_CONFIG_FILE}', Log: '{DEFAULT_LOG_FILE}'.",
|
|
382
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# --- Dynamically build parser from ARGUMENT_CONFIG ---
|
|
386
|
+
for dest, config in ARGUMENT_CONFIG.items():
|
|
387
|
+
if 'long' not in config:
|
|
388
|
+
continue # Skip config-only entries like 'presets'
|
|
389
|
+
|
|
390
|
+
names = [config['long']]
|
|
391
|
+
if 'short' in config:
|
|
392
|
+
names.append(config['short'])
|
|
393
|
+
|
|
394
|
+
# Use **kwargs to unpack the dictionary of arguments into the function call
|
|
395
|
+
kwargs = {'dest': dest,
|
|
396
|
+
'help': config['help'], 'default': config['default']}
|
|
397
|
+
if 'type' in config:
|
|
398
|
+
kwargs['type'] = config['type']
|
|
399
|
+
if 'action' in config:
|
|
400
|
+
kwargs['action'] = config['action']
|
|
401
|
+
|
|
402
|
+
# Default is not set here so we can reliably detect if user provided the arg
|
|
403
|
+
parser.add_argument(*names, **kwargs)
|
|
404
|
+
|
|
405
|
+
# Add arguments not in the main config system
|
|
406
|
+
parser.add_argument("-c", "--config-file", type=str,
|
|
407
|
+
default=str(DEFAULT_CONFIG_FILE), help="Path to configuration file.")
|
|
408
|
+
parser.add_argument("-l", "--log-file", type=str,
|
|
409
|
+
default=str(DEFAULT_LOG_FILE), help="Path to log file.")
|
|
410
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
411
|
+
help="Enable verbose output to console.")
|
|
412
|
+
|
|
413
|
+
args = parser.parse_args()
|
|
414
|
+
|
|
415
|
+
# --- Settings layering: Defaults -> Config File -> CLI Args ---
|
|
416
|
+
# 1. Load settings from config file (will include defaults where applicable)
|
|
417
|
+
config = load_configuration(args.config_file)
|
|
418
|
+
|
|
419
|
+
# 2. Setup logging
|
|
420
|
+
setup_logging(args.log_file, args.verbose)
|
|
421
|
+
logger.debug(f"User provided flags: {user_provided_flags}")
|
|
422
|
+
logger.debug(f"Config after loading file: {config}")
|
|
423
|
+
|
|
424
|
+
# 3. Override with any explicit CLI arguments
|
|
425
|
+
for dest, arg_config in ARGUMENT_CONFIG.items():
|
|
426
|
+
# Check if the long or short flag was passed by the user
|
|
427
|
+
was_provided = arg_config.get('long') in user_provided_flags or \
|
|
428
|
+
arg_config.get('short') in user_provided_flags
|
|
429
|
+
|
|
430
|
+
if was_provided:
|
|
431
|
+
value = getattr(args, dest)
|
|
432
|
+
if dest.startswith('overlay_'):
|
|
433
|
+
config['overlay'][dest.replace('overlay_', '', 1)] = value
|
|
434
|
+
else:
|
|
435
|
+
config[dest] = value
|
|
436
|
+
logger.debug(f"CLI override: '{dest}' set to '{value}'")
|
|
437
|
+
|
|
438
|
+
# --- Process complex settings like timer presets ---
|
|
439
|
+
if config.get('timer'):
|
|
440
|
+
timer_val = config['timer'].lower()
|
|
441
|
+
timer_str = config['presets'].get(
|
|
442
|
+
timer_val, timer_val if ' ' in timer_val else None)
|
|
443
|
+
|
|
444
|
+
if timer_str:
|
|
445
|
+
logger.debug(f"Applying timer setting: '{timer_str}'")
|
|
446
|
+
try:
|
|
447
|
+
values = [int(v) for v in timer_str.split()]
|
|
448
|
+
if len(values) == 4:
|
|
449
|
+
config['pomodoro'], config['short_break'], config['long_break'], config['cycles_before_long'] = values
|
|
450
|
+
else:
|
|
451
|
+
logger.warning(f"Invalid timer format '{
|
|
452
|
+
timer_str}'. Expected 4 numbers.")
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
except ValueError:
|
|
455
|
+
logger.warning(
|
|
456
|
+
f"Invalid numbers in timer string '{timer_str}'.")
|
|
457
|
+
|
|
458
|
+
logger.debug(f"Effective configuration: {config}")
|
|
459
|
+
|
|
460
|
+
# --- Final Validation ---
|
|
461
|
+
for key in ['pomodoro', 'short_break', 'long_break', 'cycles_before_long']:
|
|
462
|
+
if not (isinstance(config.get(key), int) and config.get(key, 0) > 0):
|
|
463
|
+
logger.error(f"{key.replace('_', ' ').capitalize()
|
|
464
|
+
} must be a positive integer. Exiting.")
|
|
465
|
+
sys.exit(1)
|
|
466
|
+
if not (0.0 <= config['overlay'].get('opacity', 0.8) <= 1.0):
|
|
467
|
+
logger.error(f"Overlay opacity must be between 0.0 and 1.0. Exiting.")
|
|
468
|
+
sys.exit(1)
|
|
469
|
+
|
|
470
|
+
print("final config", config)
|
|
471
|
+
run_pomodoro(config)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
if __name__ == "__main__":
|
|
475
|
+
main()
|