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 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
+ ![Demo Preview](demo-preview.gif)
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
+ ```
@@ -0,0 +1,146 @@
1
+ # Pomlock - A pomodoro application for Linux
2
+
3
+ ![Demo Preview](demo-preview.gif)
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()