"""Focus Tracker: main window + tiny always-on-top corner icon + global hotkey to start/pause.""" import ctypes import queue import sys import threading import time import tkinter as tk import winreg from ctypes import wintypes from datetime import datetime from pathlib import Path from tkinter import messagebox, ttk import keyboard import pystray from PIL import Image, ImageDraw, ImageTk from db import DB, DB_PATH from mainwindow import GRAY, GREEN, MainWindow, plan_color DISC, TRACK, KEY, SIZE = "#1c1c1e", "#3a3a3c", "#010101", 92 # KEY = transparent corner color RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" HEARTBEAT_S = 30 ERROR_ALREADY_EXISTS = 183 k32 = ctypes.WinDLL("kernel32", use_last_error=True) k32.CreateMutexW.restype = wintypes.HANDLE k32.CreateEventW.restype = wintypes.HANDLE k32.SetEvent.argtypes = [wintypes.HANDLE] k32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] class LASTINPUTINFO(ctypes.Structure): _fields_ = [("cbSize", wintypes.UINT), ("dwTime", wintypes.DWORD)] def idle_seconds(): info = LASTINPUTINFO(cbSize=ctypes.sizeof(LASTINPUTINFO)) ctypes.windll.user32.GetLastInputInfo(ctypes.byref(info)) return ((k32.GetTickCount() & 0xFFFFFFFF) - info.dwTime) / 1000 def work_area(): rect = wintypes.RECT() ctypes.windll.user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(rect), 0) # SPI_GETWORKAREA return rect def app_icon(size=64, color=GREEN): im = Image.new("RGBA", (size, size), (0, 0, 0, 0)) d = ImageDraw.Draw(im) d.ellipse((1, 1, size - 2, size - 2), fill=DISC) pad, w = size * 0.16, max(2, size // 9) d.arc((pad, pad, size - pad, size - pad), -90, 200, fill=color, width=w) return im class App: def __init__(self, minimized=False): ctypes.windll.shcore.SetProcessDpiAwareness(1) self.db = DB() self.db_path = DB_PATH self.root = tk.Tk() self.icon = ImageTk.PhotoImage(app_icon()) self.root.iconphoto(True, self.icon) self.root.protocol("WM_DELETE_WINDOW", self.hide_window) # keeps running in the tray self.ui_queue = queue.Queue() # tray-thread callbacks, run on the Tk thread self.tray_state = None self.project = self.db.get("project", "") self.plan_min = int(float(self.db.get("plan_min", 20))) self.hotkey = self.db.get("hotkey", "ctrl+alt+f") self.hotkey_var = tk.StringVar(value=self.hotkey) self.hotkey_handle = None self.session = None # (id, start_ts, plan_min) while running self.idle_paused = False self.today_base = self.db.seconds_since(self.midnight(), int(time.time())) self.last_beat = 0 self.autostart_var = tk.BooleanVar(value=self.autostart_enabled()) self.overlay_var = tk.BooleanVar(value=self.db.get("show_overlay", "1") == "1") self.build_overlay() self.main = MainWindow(self) self.restore_window_state() if minimized: self.root.withdraw() self.register_hotkey() self.build_tray() self.pump() self.tick() @staticmethod def midnight(): return int(datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp()) # ---- corner icon ---- def build_overlay(self): ov = self.ov = tk.Toplevel(self.root) ov.overrideredirect(True) ov.attributes("-topmost", True, "-alpha", 0.6, "-transparentcolor", KEY) ov.configure(bg=KEY) self.canvas = c = tk.Canvas(ov, width=SIZE, height=SIZE, bg=KEY, highlightthickness=0) c.pack() pad, ring = 4, 7 c.create_oval(pad, pad, SIZE - pad, SIZE - pad, fill=DISC, outline="") box = (pad + ring, pad + ring, SIZE - pad - ring, SIZE - pad - ring) c.create_oval(*box, outline=TRACK, width=5) self.arc = c.create_arc(*box, start=90, extent=0, style="arc", outline=GRAY, width=5) self.label = c.create_text(SIZE / 2, SIZE / 2 - 8, fill="white", font=("Segoe UI Semibold", 20), text="0") self.unit = c.create_text(SIZE / 2, SIZE / 2 + 18, fill=GRAY, font=("Segoe UI", 8), text="TODAY") c.bind("", self.drag_start) c.bind("", self.drag_move) c.bind("", self.drag_end) c.bind("", self.show_menu) ov.bind("", lambda e: ov.attributes("-alpha", 0.95)) ov.bind("", lambda e: ov.attributes("-alpha", 0.6)) ov.update_idletasks() r = work_area() x, y = r.right - SIZE - 10, r.bottom - SIZE - 10 saved = self.db.get("ov_pos") if saved and "," in saved: sx, sy = (int(v) for v in saved.split(",")) if 0 <= sx <= self.root.winfo_vrootwidth() - SIZE and 0 <= sy <= self.root.winfo_vrootheight() - SIZE: x, y = sx, sy ov.geometry(f"+{x}+{y}") self.menu = tk.Menu(ov, tearoff=0) self.apply_overlay_visibility() def drag_start(self, e): self.drag = (e.x_root, e.y_root, self.ov.winfo_x(), self.ov.winfo_y(), False) def drag_move(self, e): x0, y0, wx, wy, moved = self.drag if moved or abs(e.x_root - x0) + abs(e.y_root - y0) > 6: self.drag = (x0, y0, wx, wy, True) self.ov.geometry(f"+{wx + e.x_root - x0}+{wy + e.y_root - y0}") def drag_end(self, e): if self.drag[4]: # it was dragged: remember the spot instead of toggling self.db.set("ov_pos", f"{self.ov.winfo_x()},{self.ov.winfo_y()}") else: self.toggle() def apply_overlay_visibility(self): self.db.set("show_overlay", "1" if self.overlay_var.get() else "0") if self.overlay_var.get(): self.ov.deiconify() else: self.ov.withdraw() def show_menu(self, event): m = self.menu m.delete(0, "end") m.add_command(label="Open window", command=self.show_window) m.add_command(label="Pause" if self.session else "Start", command=self.toggle) m.add_command(label="Reset icon position", command=self.reset_overlay_pos) m.add_separator() m.add_command(label="Quit", command=self.quit) m.tk_popup(event.x_root, event.y_root) def reset_overlay_pos(self): r = work_area() self.ov.geometry(f"+{r.right - SIZE - 10}+{r.bottom - SIZE - 10}") self.db.set("ov_pos", "") def render(self): now = time.time() if self.session: el, plan_s = now - self.session[1], self.session[2] * 60 color, minutes, progress = plan_color(el, plan_s), int(el // 60), el / plan_s unit = f"/ {self.session[2]} MIN" if el < plan_s else "GREAT!" else: goal = float(self.db.get("goal_hours", 4)) * 3600 color, minutes, progress = GRAY, int(self.today_base // 60), self.today_base / goal unit = "IDLE" if self.idle_paused else "TODAY" if self.session: self.update_tray(color, f"Focusing on {self.project}: {minutes} min") else: self.update_tray(GRAY, f"Paused - today {int(self.today_base // 60)} min") c = self.canvas c.itemconfig(self.arc, outline=color, extent=-min(359.9, 360 * progress)) c.itemconfig(self.label, text=str(minutes), font=("Segoe UI Semibold", 20 if minutes < 1000 else 15)) c.itemconfig(self.unit, text=unit, fill=color) # ---- window ---- def hide_window(self): self.save_window_state() self.root.withdraw() if not self.db.get("tray_hint_shown"): self.db.set("tray_hint_shown", "1") self.tray.notify("Still running here in the tray. Click the icon to open it again.", "Focus Tracker") def save_window_state(self): state = self.root.state() if state == "zoomed": self.db.set("win_zoomed", "1") elif state == "normal": self.db.set("win_zoomed", "0") self.db.set("win_geom", self.root.geometry()) def restore_window_state(self): geom = self.db.get("win_geom") if geom: try: w, h, x, y = (int(v) for v in geom.replace("x", "+").split("+")) if -w + 100 < x < self.root.winfo_vrootwidth() - 100 and 0 <= y < self.root.winfo_vrootheight() - 100: self.root.geometry(geom) except ValueError: pass if self.db.get("win_zoomed") == "1": self.root.state("zoomed") def show_window(self): self.root.deiconify() if self.root.state() == "iconic": self.root.state("normal") self.root.lift() self.root.attributes("-topmost", True) # Windows may refuse focus to background apps self.root.after(200, lambda: self.root.attributes("-topmost", False)) self.root.focus_force() self.main.refresh() # ---- timing ---- def toggle(self): if self.session: self.pause() else: self.start() def start(self): if not self.project: self.show_window() self.main.project_box.focus_set() messagebox.showinfo("Focus Tracker", "Choose or type a project first, then press Start.", parent=self.root) return now = int(time.time()) self.today_base = self.db.seconds_since(self.midnight(), now) self.session = (self.db.start(self.project, now, self.plan_min), now, self.plan_min) self.idle_paused = False self.last_beat = now self.render() def pause(self, end_ts=None): if not self.session: return sid, start, _ = self.session self.db.stop(sid, max(end_ts or int(time.time()), start)) self.session = None self.today_base = self.db.seconds_since(self.midnight(), int(time.time())) self.render() def refresh_today(self): """Re-read today's total after sessions were edited, deleted or imported.""" self.today_base = self.db.seconds_since(self.midnight(), int(time.time())) self.render() def set_project(self, name): running = self.session is not None if running: self.pause() self.project = name self.db.set("project", name) self.db.add_project(name) if running: self.start() def set_plan(self, minutes): """New plan applies to the next session and to the running one.""" self.plan_min = int(minutes) self.db.set("plan_min", self.plan_min) if self.session: self.session = (self.session[0], self.session[1], self.plan_min) self.db.set_plan(self.session[0], self.plan_min) def tick(self): now = int(time.time()) if self.session: limit = float(self.db.get("idle_minutes", 5)) * 60 idle = idle_seconds() if limit and idle > limit: self.idle_paused = True self.pause(end_ts=int(now - idle)) elif now - self.last_beat >= HEARTBEAT_S: self.db.heartbeat(self.session[0], now) self.last_beat = now if k32.WaitForSingleObject(OPEN_EVENT, 0) == 0: # a second launch asked us to show the window self.show_window() self.render() self.root.after(1000, self.tick) # ---- tray icon ---- def build_tray(self): menu = pystray.Menu( pystray.MenuItem("Open Focus Tracker", lambda: self.post(self.show_window), default=True), pystray.MenuItem(lambda item: "Pause" if self.session else "Start", lambda: self.post(self.toggle)), pystray.Menu.SEPARATOR, pystray.MenuItem("Quit", lambda: self.post(self.quit))) self.tray = pystray.Icon("FocusTracker", app_icon(64, GRAY), "Focus Tracker", menu) threading.Thread(target=self.tray.run, daemon=True).start() def post(self, fn): self.ui_queue.put(fn) def pump(self): while not self.ui_queue.empty(): self.ui_queue.get()() self.root.after(100, self.pump) def update_tray(self, color, text): state = (color, text) if state != self.tray_state: self.tray_state = state self.tray.icon = app_icon(64, color) self.tray.title = text # ---- hotkey ---- def register_hotkey(self): if self.hotkey_handle is not None: keyboard.remove_hotkey(self.hotkey_handle) self.hotkey_handle = None try: # suppress=True keeps Windows from also acting on it (e.g. launching Copilot) self.hotkey_handle = keyboard.add_hotkey( self.hotkey, lambda: self.root.after(0, self.toggle), suppress=True) except (ValueError, KeyError) as e: messagebox.showerror("Hotkey", f"Cannot use hotkey '{self.hotkey}': {e}", parent=self.root) def capture_hotkey(self): if self.hotkey_handle is not None: keyboard.remove_hotkey(self.hotkey_handle) self.hotkey_handle = None win = tk.Toplevel(self.root) win.title("Set hotkey") win.attributes("-topmost", True) ttk.Label(win, text="Press the key or combination you want to use...", padding=24).pack() win.update_idletasks() win.geometry(f"+{self.root.winfo_screenwidth() // 2 - 180}+{self.root.winfo_screenheight() // 2 - 50}") def worker(): try: combo = keyboard.read_hotkey(suppress=False) except Exception: combo = None self.root.after(0, done, combo) def done(combo): win.destroy() if combo: self.hotkey = combo self.hotkey_var.set(combo) self.db.set("hotkey", combo) self.register_hotkey() threading.Thread(target=worker, daemon=True).start() # ---- settings ---- @staticmethod def autostart_enabled(): try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as k: winreg.QueryValueEx(k, "FocusTracker") return True except OSError: return False def toggle_autostart(self): with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as k: if self.autostart_var.get(): if getattr(sys, "frozen", False): cmd = f'"{sys.executable}"' else: cmd = f'"{Path(sys.executable).with_name("pythonw.exe")}" "{Path(__file__).resolve()}"' winreg.SetValueEx(k, "FocusTracker", 0, winreg.REG_SZ, cmd + " --minimized") else: winreg.DeleteValue(k, "FocusTracker") def quit(self): self.pause() self.save_window_state() self.tray.stop() keyboard.unhook_all() self.root.destroy() def run(self): self.root.mainloop() def single_instance(): """True if we are the first copy; otherwise ask the running copy to show its window.""" global OPEN_EVENT k32.CreateMutexW(None, False, "FocusTrackerSingleInstance") exists = ctypes.get_last_error() == ERROR_ALREADY_EXISTS OPEN_EVENT = k32.CreateEventW(None, False, False, "FocusTrackerOpenWindow") if exists: k32.SetEvent(OPEN_EVENT) return not exists if __name__ == "__main__": if single_instance(): App(minimized="--minimized" in sys.argv).run()