-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patheyerestapp.py
More file actions
263 lines (224 loc) · 10.2 KB
/
eyerestapp.py
File metadata and controls
263 lines (224 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
"""
EyeRest - 20-20-20 Utility
=============================================
Author: N11A
Version: 1.0.0
Final Build Command:
python -m PyInstaller --onefile --noconsole --collect-all screeninfo --name "EyeRest" eyerest.py
Zip and ship:
Compress-Archive -Path "dist\EyeRest.exe", "README.txt" -DestinationPath "dist\EyeRest_Utility.zip"
"""
import tkinter as tk
from tkinter import simpledialog, messagebox
import time
import threading
import os
import ctypes
import json
import sys
import winreg
from PIL import Image, ImageDraw
import pystray
from pystray import MenuItem as item
# 1. Force High-DPI Awareness for Windows (Essential for Multi-Monitor)
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
try:
ctypes.windll.user32.SetProcessDPIAware()
except:
pass
try:
from screeninfo import get_monitors
except ImportError:
get_monitors = None
class EyeRestApp:
def __init__(self):
# 2. Determine Absolute Path for Config (Fixes Startup/CWD issues)
if getattr(sys, 'frozen', False):
# Running as a PyInstaller .exe
self.app_dir = os.path.dirname(sys.executable)
else:
# Running as a normal .py script
self.app_dir = os.path.dirname(os.path.abspath(__file__))
self.config_file = os.path.join(self.app_dir, "eyerest_config.json")
self.version = "1.0.0"
self.author = "N11A"
self.break_interval = 20 * 60
self.rest_duration = 20
self.running = True
self.next_break_time = time.time() + self.break_interval
self.settings_changed_event = threading.Event()
# 3. Load Stats & Preferences
self.load_config()
# 4. Setup hidden Tkinter root
self.root = tk.Tk()
self.root.withdraw()
# 5. Setup System Tray
self.icon = pystray.Icon("EyeRest")
self.icon.menu = self.create_menu()
self.icon.icon = self.create_eye_icon()
self.icon.title = "EyeRest: Active"
threading.Thread(target=self.icon.run, daemon=True).start()
self.monitor_thread = threading.Thread(target=self.run_timer_loop, daemon=True)
self.monitor_thread.start()
self.root.mainloop()
def load_config(self):
"""Loads stats and startup preference from the executable's directory."""
defaults = {"completed": 0, "skipped": 0, "postponed": 0, "startup": False}
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r') as f:
self.config = json.load(f)
except:
self.config = defaults
else:
self.config = defaults
self.stats = {k: self.config.get(k, 0) for k in ["completed", "skipped", "postponed"]}
self.startup_enabled = self.config.get("startup", False)
def save_config(self):
"""Persists config to the absolute path determined at init."""
self.config.update(self.stats)
self.config["startup"] = self.startup_enabled
try:
with open(self.config_file, 'w') as f:
json.dump(self.config, f)
except Exception as e:
print(f"File Save Error: {e}")
if hasattr(self, 'icon'):
self.icon.update_menu()
def show_about(self):
about_text = (
f"EyeRest Utility v{self.version}\n\n"
f"Created by: {self.author}\n\n"
"A dedicated tool to reduce digital eye strain "
"using the 20-20-20 rule."
)
messagebox.showinfo("About EyeRest", about_text, parent=self.root)
def toggle_startup(self):
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
app_name = "EyeRestUtility"
# Always use the path to the current executable
exe_path = os.path.realpath(sys.executable) if getattr(sys, 'frozen', False) else os.path.realpath(__file__)
try:
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE)
if not self.startup_enabled:
winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, f'"{exe_path}"')
self.startup_enabled = True
else:
try:
winreg.DeleteValue(key, app_name)
except FileNotFoundError: pass
self.startup_enabled = False
winreg.CloseKey(key)
self.save_config()
except Exception as e:
messagebox.showerror("Registry Error", f"Could not update startup settings: {e}")
def create_eye_icon(self):
img = Image.new('RGBA', (64, 64), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.ellipse([4, 16, 60, 48], fill="white", outline="black", width=2)
d.ellipse([20, 20, 44, 44], fill="#0078d7")
d.ellipse([28, 28, 36, 36], fill="black")
return img
def create_menu(self):
return pystray.Menu(
item(lambda text: self.get_time_remaining_str(), action=None, enabled=False),
pystray.Menu.SEPARATOR,
item(lambda text: f"Completed: {self.stats['completed']}", action=None, enabled=False),
item(lambda text: f"Skipped: {self.stats['skipped']}", action=None, enabled=False),
item(lambda text: f"Postponed: {self.stats['postponed']}", action=None, enabled=False),
item('Reset Statistics', lambda: self.root.after(0, self.reset_stats)),
pystray.Menu.SEPARATOR,
item('About EyeRest', lambda: self.root.after(0, self.show_about)),
item('Run at Startup', self.toggle_startup, checked=lambda item: self.startup_enabled),
item('Adjust Break Interval (mins)', lambda: self.root.after(0, self.set_break_interval)),
item('Adjust Rest Duration (secs)', lambda: self.root.after(0, self.set_rest_duration)),
pystray.Menu.SEPARATOR,
item('Quit', self.quit_app)
)
def reset_stats(self):
if messagebox.askyesno("Reset", "Clear all break statistics?", parent=self.root):
self.stats = {"completed": 0, "skipped": 0, "postponed": 0}
self.save_config()
def get_time_remaining_str(self):
remaining = int(self.next_break_time - time.time())
mins, secs = divmod(max(0, remaining), 60)
return f"Next break in: {mins:02d}:{secs:02d}"
def set_break_interval(self):
val = simpledialog.askinteger("Settings", "Minutes between breaks:",
parent=self.root, initialvalue=self.break_interval // 60)
if val and val > 0:
self.break_interval = val * 60
self.next_break_time = time.time() + self.break_interval
self.settings_changed_event.set()
def set_rest_duration(self):
val = simpledialog.askinteger("Settings", "Seconds for rest:",
parent=self.root, initialvalue=self.rest_duration)
if val and val > 0:
self.rest_duration = val
def quit_app(self):
self.running = False
self.icon.stop()
os._exit(0)
def fade_in(self, windows, alpha=0.0):
if alpha < 0.99:
new_alpha = min(alpha + 0.05, 0.99)
for w in windows:
if w.winfo_exists():
w.attributes("-alpha", new_alpha)
self.root.after(50, lambda: self.fade_in(windows, new_alpha))
def show_overlay(self):
overlays = []
monitors = get_monitors() if get_monitors else [None]
def close_all(status):
for w in overlays:
if w.winfo_exists(): w.destroy()
if status:
self.stats[status] += 1
self.save_config()
def postpone_break():
self.next_break_time = time.time() + (2 * 60)
self.settings_changed_event.set()
close_all("postponed")
for i, m in enumerate(monitors):
win = tk.Toplevel(self.root)
win.configure(bg='black')
win.attributes("-topmost", True, "-alpha", 0.0)
win.overrideredirect(True)
if m:
win.geometry(f"{m.width}x{m.height}+{m.x}+{m.y}")
else:
win.attributes("-fullscreen", True)
if i == 0:
content = tk.Frame(win, bg="black")
content.place(relx=0.5, rely=0.5, anchor="center")
tk.Label(content, text="Look 20ft away!", fg="#666", bg="black", font=("Helvetica", 30)).pack()
timer_label = tk.Label(content, text="", fg="white", bg="black", font=("Helvetica", 80, "bold"))
timer_label.pack(pady=20)
btn_frame = tk.Frame(content, bg="black")
btn_frame.pack(pady=20)
tk.Button(btn_frame, text="Skip", command=lambda: close_all("skipped"),
bg="#222", fg="white", relief="flat", padx=20, pady=10).pack(side="left", padx=10)
tk.Button(btn_frame, text="Postpone (2m)", command=postpone_break,
bg="#222", fg="#aaa", relief="flat", padx=20, pady=10).pack(side="left", padx=10)
def update_timer(seconds, label=timer_label, main_win=win):
if seconds > 0 and main_win.winfo_exists():
label.config(text=f"{seconds}s")
main_win.after(1000, lambda: update_timer(seconds - 1, label, main_win))
elif main_win.winfo_exists():
close_all("completed")
update_timer(self.rest_duration)
overlays.append(win)
self.fade_in(overlays)
def run_timer_loop(self):
while self.running:
if hasattr(self, 'icon'):
self.icon.title = self.get_time_remaining_str()
if time.time() >= self.next_break_time:
self.root.after(0, self.show_overlay)
self.next_break_time = time.time() + self.break_interval
self.settings_changed_event.wait(timeout=1.0)
self.settings_changed_event.clear()
if __name__ == "__main__":
EyeRestApp()