This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
"""Native desktop client for Kadence (PyQt6 + QML, Material style)."""
__version__ = "0.1.0"
APP_ID = "org.kadence.Desktop"
+84
View File
@@ -0,0 +1,84 @@
"""Entry point.
Run with no arguments to open the app. The verbs (`--toggle`, `--status`, …)
talk to a *running* instance over D-Bus instead of starting a second one, which
is what makes them usable as global shortcuts:
kadence-desktop --toggle # bind this to a key in System Settings
kadence-desktop --status # prints "Focus 18:42 · Write the docs · 3/8 today"
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
REMOTE_VERBS = {
"toggle": "Toggle",
"start": "StartWork",
"break": "StartBreak",
"pause": "Pause",
"resume": "Resume",
"complete": "Complete",
"stop": "Stop",
"extend": "Extend",
"interrupt": "Interrupt",
"status": "Status",
"report": "SendReport",
"show": "Raise",
}
def _remote(verb: str) -> int:
"""Call the running instance. Returns a process exit code."""
from PyQt6.QtCore import QCoreApplication
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
_app = QCoreApplication(sys.argv) # noqa: F841 - needed for the D-Bus loop
interface = QDBusInterface(
"org.kadence.Desktop", "/Timer", "org.kadence.Timer", QDBusConnection.sessionBus()
)
if not interface.isValid():
print("Kadence is not running.", file=sys.stderr)
return 1
reply = interface.call(REMOTE_VERBS[verb])
arguments = reply.arguments()
if arguments:
print(arguments[0])
return 0
def main() -> int:
parser = argparse.ArgumentParser(prog="kadence-desktop", description=__doc__)
parser.add_argument("--minimized", action="store_true", help="start in the tray only")
parser.add_argument("--verbose", action="store_true")
for verb in REMOTE_VERBS:
parser.add_argument(
f"--{verb}", action="store_true", help=f"ask the running instance to {verb}"
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else os.environ.get("KADENCE_LOG", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
for verb in REMOTE_VERBS:
if getattr(args, verb):
return _remote(verb)
from kadence_desktop.app import KadenceApp
from kadence_desktop.integrations.dbus_service import call_existing
# Single instance: if somebody already owns the name, just raise their window.
if call_existing("Raise"):
print("Kadence is already running — raised its window.")
return 0
return KadenceApp(sys.argv, minimized=args.minimized).run()
if __name__ == "__main__":
raise SystemExit(main())
+151
View File
@@ -0,0 +1,151 @@
"""HTTP client for the backend.
Deliberately stdlib-only (`urllib`): the desktop app should start with the
system Python on a fresh machine, and a pomodoro client does not need an async
HTTP stack to call eight endpoints.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
class ApiError(RuntimeError):
"""A call failed. The message is meant to be shown in the UI."""
def __init__(self, message: str, status: int = 0) -> None:
super().__init__(message)
self.status = status
class Api:
def __init__(self, base_url: str, token: str = "", timeout: float = 10.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout = timeout
def configure(self, base_url: str, token: str) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
# ---- plumbing ----------------------------------------------------------
def _request(self, method: str, path: str, payload: Any | None = None) -> Any:
url = f"{self.base_url}{path}"
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
if self.token:
headers["X-API-Token"] = self.token
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = response.read()
return json.loads(body) if body else None
except urllib.error.HTTPError as exc:
detail = _detail(exc)
raise ApiError(detail, exc.code) from exc
except (urllib.error.URLError, TimeoutError) as exc:
raise ApiError(
f"Cannot reach the backend at {self.base_url} ({exc}). "
"Is `docker compose up` running?"
) from exc
except json.JSONDecodeError as exc:
raise ApiError(f"The backend sent something that is not JSON: {exc}") from exc
# ---- endpoints ---------------------------------------------------------
def health(self) -> dict:
return self._request("GET", "/api/health")
def timer_state(self) -> dict:
return self._request("GET", "/api/timer/state")
def start(self, kind: str | None = None, task_id: int | None = None, minutes: int | None = None) -> dict:
return self._request(
"POST", "/api/timer/start", {"kind": kind, "task_id": task_id, "minutes": minutes}
)
def pause(self) -> dict:
return self._request("POST", "/api/timer/pause", {})
def resume(self) -> dict:
return self._request("POST", "/api/timer/resume", {})
def complete(self, auto_next: bool = True) -> dict:
return self._request("POST", "/api/timer/complete", {"auto_next": auto_next})
def abandon(self, reason: str | None = None) -> dict:
return self._request("POST", "/api/timer/abandon", {"reason": reason})
def extend(self, minutes: int = 5) -> dict:
return self._request("POST", "/api/timer/extend", {"minutes": minutes})
def interrupt(self) -> dict:
return self._request("POST", "/api/timer/interrupt", {})
def attach_task(self, task_id: int | None) -> dict:
return self._request("POST", "/api/timer/task", {"task_id": task_id})
def tasks(self) -> dict:
return self._request("GET", "/api/tasks")
def refresh_tasks(self) -> dict:
return self._request("POST", "/api/tasks/refresh", {})
def task_done(self, task_id: int, done: bool = True) -> dict:
return self._request("POST", f"/api/tasks/{task_id}/done", {"done": done})
def create_task(self, title: str, due_date: str | None = None) -> dict:
return self._request("POST", "/api/tasks", {"title": title, "due_date": due_date})
def blocker_policy(self) -> dict:
return self._request("GET", "/api/blocker/policy")
def blocker_events(self) -> dict:
return self._request("GET", "/api/blocker/events")
def report_block(self, kind: str, target: str, action: str = "blocked", details: str | None = None) -> dict:
return self._request(
"POST",
"/api/blocker/events",
{"kind": kind, "target": target, "action": action, "details": details},
)
def daily_report(self) -> dict:
return self._request("GET", "/api/reports/daily")
def send_report(self) -> dict:
return self._request("POST", "/api/reports/daily/send", {"force": True})
def stats(self) -> dict:
return self._request("GET", "/api/stats/summary?days=30")
def settings(self) -> dict:
return self._request("GET", "/api/settings")
def patch_settings(self, patch: dict) -> dict:
return self._request("PUT", "/api/settings", patch)
def _detail(exc: urllib.error.HTTPError) -> str:
try:
body = json.loads(exc.read())
except Exception:
return f"{exc.code} {exc.reason}"
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, str):
return detail
if isinstance(detail, list):
return "; ".join(
f"{'.'.join(str(part) for part in item.get('loc', [])[1:])}: {item.get('msg', '')}"
for item in detail
)
return f"{exc.code} {exc.reason}"
+146
View File
@@ -0,0 +1,146 @@
"""Application wiring: Qt, QML, the tray, and the D-Bus service.
Qt Quick Controls' **Material** style gives real Material 3 widgets rendered by
Qt itself — no web view, no bundled browser; the window is a native Wayland
surface with the session's own fonts, scaling and colour scheme.
"""
from __future__ import annotations
import logging
import os
import signal
from pathlib import Path
# The style has to be chosen before the first Qt object exists. PyQt6 on Arch
# ships no QtQuickControls2 binding, so the environment variable — which Qt
# itself reads — is the portable way to ask for Material.
os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Material")
os.environ.setdefault("QT_QUICK_CONTROLS_MATERIAL_VARIANT", "Normal")
from PyQt6.QtCore import QObject, QTimer, QUrl
from PyQt6.QtGui import QIcon
from PyQt6.QtQml import QQmlApplicationEngine
from PyQt6.QtQuick import QQuickWindow
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon
from kadence_desktop import APP_ID, __version__
from kadence_desktop.config import DesktopConfig
from kadence_desktop.controller import Controller
from kadence_desktop.integrations import dbus_service
from kadence_desktop.tray import Tray, render_icon
log = logging.getLogger(__name__)
QML_DIR = Path(__file__).resolve().parent / "qml"
class KadenceApp(QObject):
def __init__(self, argv: list[str], *, minimized: bool = False) -> None:
super().__init__()
# QApplication (not QGuiApplication): the tray icon and its menu are
# widgets, and they are what makes the app usable with no window open.
self._app = QApplication(argv)
self._app.setApplicationName("Kadence")
self._app.setApplicationDisplayName("Kadence")
self._app.setOrganizationName("Kadence")
self._app.setApplicationVersion(__version__)
# Wayland takes the window's app id from here: without it the window
# has no icon and the task manager cannot group it.
self._app.setDesktopFileName("kadence")
self._app.setWindowIcon(_load_icon())
self._app.setQuitOnLastWindowClosed(False)
self._config = DesktopConfig.load()
self._controller = Controller(self._config)
self._engine = QQmlApplicationEngine()
self._engine.rootContext().setContextProperty("controller", self._controller)
self._engine.rootContext().setContextProperty("appVersion", __version__)
self._engine.addImportPath(str(QML_DIR))
self._engine.load(QUrl.fromLocalFile(str(QML_DIR / "Main.qml")))
if not self._engine.rootObjects():
raise SystemExit("Could not load the QML interface.")
self._window: QQuickWindow = self._engine.rootObjects()[0]
self._tray: Tray | None = None
if self._config.tray_icon and QSystemTrayIcon.isSystemTrayAvailable():
self._tray = Tray(self._controller, self)
self._tray.showRequested.connect(self.toggle_window)
self._tray.quitRequested.connect(self.quit)
self._tray.show()
self._controller.showWindowRequested.connect(self.show_window)
self._app.aboutToQuit.connect(self._controller.shutdown)
if self._config.dbus_service:
self._register_dbus()
if minimized or self._config.start_minimized:
self._window.hide()
else:
self.show_window()
# Let Ctrl+C in a terminal actually kill the app (Qt swallows SIGINT
# unless the event loop gets a chance to run Python code now and then).
signal.signal(signal.SIGINT, lambda *_args: self.quit())
self._wakeup = QTimer(self)
self._wakeup.start(500)
self._wakeup.timeout.connect(lambda: None)
# ---- window ------------------------------------------------------------
def show_window(self) -> None:
self._window.show()
self._window.raise_()
self._window.requestActivate()
def toggle_window(self) -> None:
if self._window.isVisible() and self._window.isActive():
self._window.hide()
else:
self.show_window()
# ---- D-Bus -------------------------------------------------------------
def _register_dbus(self) -> None:
controller = self._controller
actions = {
"start_work": lambda: (controller.startWork(), "started")[1],
"start_break": lambda: (controller.startBreak(), "break started")[1],
"toggle": lambda: (controller.toggle(), "toggled")[1],
"pause": lambda: (controller.pause(), "paused")[1],
"resume": lambda: (controller.resume(), "resumed")[1],
"complete": lambda: (controller.complete(), "completed")[1],
"stop": lambda: (controller.abandon("stopped over D-Bus"), "stopped")[1],
"extend": lambda: (controller.extend(5), "extended")[1],
"interrupt": lambda: (controller.interrupt(), "noted")[1],
"status": controller.statusLine,
"send_report": lambda: (controller.sendReport(), "sending")[1],
"raise_window": lambda: (self.show_window(), "shown")[1],
}
self._dbus = dbus_service.TimerDBusService(actions, self)
if not dbus_service.register(self._dbus):
log.warning("another instance owns %s — this one will not be scriptable", APP_ID)
# ---- lifecycle ---------------------------------------------------------
def quit(self) -> None:
self._controller.shutdown()
if self._tray:
self._tray.hide()
self._app.quit()
def run(self) -> int:
return self._app.exec()
def _load_icon() -> QIcon:
"""The packaged icon if it is installed, else the one the tray paints."""
themed = QIcon.fromTheme("kadence")
if not themed.isNull():
return themed
packaged = Path(__file__).resolve().parent.parent / "packaging" / "kadence.svg"
if packaged.is_file():
return QIcon(str(packaged))
return render_icon(0.0, "work", False, True, False)
@@ -0,0 +1,6 @@
"""Enforcement: the part that actually stands between you and the distraction.
The *rules* live on the server (so the web UI can edit them and a second machine
obeys the same list); this package is what turns a rule into a redirected
hostname and a terminated process on this particular computer.
"""
+146
View File
@@ -0,0 +1,146 @@
"""Website blocking through `/etc/hosts`.
Why the hosts file rather than a proxy or an extension: it needs no browser
support, survives a browser restart, covers every app on the machine, and is
trivially auditable — you can read exactly what is blocked with `cat`.
Writing it needs root, which a desktop app must not have, so all writes go
through the small privileged helper (`kadence-helper`) under polkit. The helper
only ever rewrites the region between its own markers and validates every
hostname, so the worst a compromised client can do is block a site.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import tempfile
log = logging.getLogger(__name__)
HOSTS_PATH = "/etc/hosts"
BEGIN_MARKER = "# >>> kadence block >>>"
END_MARKER = "# <<< kadence block <<<"
_HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
def valid_hostname(name: str) -> bool:
return bool(_HOSTNAME_RE.match(name.strip().lower()))
def current_block() -> list[str]:
"""The hostnames currently inside our managed region of `/etc/hosts`."""
try:
with open(HOSTS_PATH, "r", encoding="utf-8") as handle:
lines = handle.read().splitlines()
except OSError:
return []
inside = False
hosts: list[str] = []
for line in lines:
if line.strip() == BEGIN_MARKER:
inside = True
continue
if line.strip() == END_MARKER:
inside = False
continue
if inside:
parts = line.split()
if len(parts) >= 2 and not line.lstrip().startswith("#"):
hosts.extend(parts[1:])
return hosts
class HostsBlocker:
"""Applies and clears the managed block, as rarely as possible.
Every change costs a polkit prompt in the worst case, so the desired state is
diffed against what is already in the file and a no-op is really a no-op.
"""
def __init__(self, helper_path: str = "/usr/local/bin/kadence-helper") -> None:
self.helper_path = helper_path
self._last_error: str | None = None
# ---- availability ------------------------------------------------------
@property
def helper_installed(self) -> bool:
return os.path.isfile(self.helper_path) and os.access(self.helper_path, os.X_OK)
@property
def last_error(self) -> str | None:
return self._last_error
def _runner(self) -> list[str] | None:
"""How to become root: polkit if we can, passwordless sudo otherwise."""
if not self.helper_installed:
self._last_error = (
f"The privileged helper is not installed at {self.helper_path}. "
"Run desktop/helper/install.sh once."
)
return None
if shutil.which("pkexec") and os.environ.get("DISPLAY", os.environ.get("WAYLAND_DISPLAY")):
return ["pkexec", self.helper_path]
if shutil.which("sudo"):
return ["sudo", "-n", self.helper_path]
self._last_error = "Neither pkexec nor sudo is available to run the helper."
return None
# ---- operations --------------------------------------------------------
def apply(self, domains: list[str], redirect_ip: str = "127.0.0.1") -> bool:
"""Make the managed region exactly `domains`. True when it already was."""
wanted = sorted({d.strip().lower() for d in domains if valid_hostname(d)})
if sorted(set(current_block())) == wanted:
return True
if not wanted:
return self.clear()
runner = self._runner()
if runner is None:
return False
with tempfile.NamedTemporaryFile("w", suffix=".kadence", delete=False, encoding="utf-8") as handle:
handle.write("\n".join(wanted))
list_path = handle.name
os.chmod(list_path, 0o644) # the helper reads it as root from /tmp
try:
return self._run(runner + ["apply", "--ip", redirect_ip, "--file", list_path])
finally:
try:
os.unlink(list_path)
except OSError:
pass
def clear(self) -> bool:
"""Remove the managed region. Safe to call when it is not there."""
if not current_block():
return True
runner = self._runner()
if runner is None:
return False
return self._run(runner + ["clear"])
def _run(self, command: list[str]) -> bool:
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False)
except (OSError, subprocess.SubprocessError) as exc:
self._last_error = f"Could not run the helper: {exc}"
log.warning(self._last_error)
return False
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()[:300]
# 126/127 is polkit's "you said no" — worth saying plainly.
if result.returncode in (126, 127):
detail = detail or "the authorisation prompt was dismissed"
self._last_error = f"Helper failed: {detail}"
log.warning(self._last_error)
return False
self._last_error = None
return True
+109
View File
@@ -0,0 +1,109 @@
"""Turning the server's policy into what this machine actually does.
Called on every poll with the policy the backend computed. It keeps the hosts
file and the process list in step with it, reports every turned-away attempt
back to the API (that is what makes the daily report fun), and — importantly —
puts everything back the moment the policy says blocking is off, including when
the app exits or crashes mid-interval.
"""
from __future__ import annotations
import logging
from typing import Callable
from kadence_desktop.blocking import processes
from kadence_desktop.blocking.hosts import HostsBlocker
log = logging.getLogger(__name__)
class BlockerManager:
def __init__(
self,
*,
helper_path: str,
report: Callable[[str, str, str, str | None], None],
notify: Callable[[str, str], None] | None = None,
) -> None:
self._hosts = HostsBlocker(helper_path)
self._report = report
self._notify = notify
self._active = False
self._applied: list[str] = []
self._announced: set[int] = set()
self.enforce_hosts = True
self.enforce_apps = True
self.last_error: str | None = None
self.killed_today = 0
# ---- state -------------------------------------------------------------
@property
def active(self) -> bool:
return self._active
@property
def helper_installed(self) -> bool:
return self._hosts.helper_installed
@property
def blocked_domains(self) -> list[str]:
return list(self._applied)
# ---- the one entry point ----------------------------------------------
def sync(self, policy: dict) -> None:
"""Bring the machine in line with `policy` (from /api/blocker/policy)."""
should_block = bool(policy.get("active")) and bool(policy.get("enabled"))
domains = [str(item) for item in policy.get("domains", [])]
apps = [str(item) for item in policy.get("apps", [])]
redirect_ip = str(policy.get("redirect_ip") or "127.0.0.1")
if should_block:
if self.enforce_hosts and domains != self._applied:
if self._hosts.apply(domains, redirect_ip):
self._applied = domains
self.last_error = None
log.info("blocking %s domain(s)", len(domains))
else:
self.last_error = self._hosts.last_error
if self.enforce_apps and apps:
self._enforce_apps(apps, kill=bool(policy.get("kill_apps", True)))
self._active = True
else:
self.release()
def release(self) -> None:
"""Unblock everything. Called when the interval ends, and on shutdown."""
if self._applied or self._hosts_dirty():
if self._hosts.clear():
self._applied = []
self.last_error = None
else:
self.last_error = self._hosts.last_error
self._active = False
self._announced.clear()
def _hosts_dirty(self) -> bool:
from kadence_desktop.blocking.hosts import current_block
return bool(current_block())
# ---- apps --------------------------------------------------------------
def _enforce_apps(self, patterns: list[str], *, kill: bool) -> None:
for app in processes.scan(patterns):
if app.pid in self._announced:
continue
self._announced.add(app.pid)
if kill and processes.terminate(app):
self.killed_today += 1
self._report("app", app.matched, "killed", f"{app.name} (pid {app.pid})")
if self._notify:
self._notify("Closed a distraction", f"{app.name} is blocked during focus.")
else:
self._report("app", app.matched, "warned", f"{app.name} (pid {app.pid})")
if self._notify:
self._notify("That app is blocked", f"{app.name} is on your block list.")
@@ -0,0 +1,102 @@
"""Application blocking by watching `/proc`.
No root needed: these are your own processes. The scan reads `/proc/*/comm` and
the cmdline, matches against the configured names, and either terminates the
process (SIGTERM, then SIGKILL if it ignores us) or just reports it, depending
on the setting.
Matching is deliberately forgiving — `steam` should catch `steamwebhelper`, and
`telegram` should catch `Telegram` — but it never matches ourselves, PID 1, or
anything outside the current user's processes.
"""
from __future__ import annotations
import logging
import os
import signal
import time
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger(__name__)
_SELF_MARKERS = ("kadence", "python3 -m kadence_desktop")
@dataclass(slots=True)
class RunningApp:
pid: int
name: str
cmdline: str
matched: str
def _read(path: Path) -> str:
try:
return path.read_text("utf-8", errors="replace")
except OSError:
return ""
def scan(patterns: list[str]) -> list[RunningApp]:
"""Every live process of this user matching one of `patterns`."""
wanted = [pattern.strip().lower() for pattern in patterns if pattern.strip()]
if not wanted:
return []
uid = os.getuid()
self_pid = os.getpid()
found: list[RunningApp] = []
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid in (1, self_pid):
continue
try:
if entry.stat().st_uid != uid:
continue
except OSError:
continue
comm = _read(entry / "comm").strip()
if not comm:
continue
cmdline = _read(entry / "cmdline").replace("\x00", " ").strip()
haystack = f"{comm} {cmdline}".lower()
if any(marker in haystack for marker in _SELF_MARKERS):
continue
match = next((pattern for pattern in wanted if pattern in haystack), None)
if match:
found.append(RunningApp(pid=pid, name=comm, cmdline=cmdline[:200], matched=match))
return found
def terminate(app: RunningApp, *, grace_seconds: float = 2.0) -> bool:
"""Ask the process to quit, then insist. True when it is gone."""
try:
os.kill(app.pid, signal.SIGTERM)
except ProcessLookupError:
return True
except PermissionError:
log.warning("not allowed to stop %s (pid %s)", app.name, app.pid)
return False
deadline = time.monotonic() + grace_seconds
while time.monotonic() < deadline:
try:
os.kill(app.pid, 0)
except ProcessLookupError:
return True
time.sleep(0.1)
try:
os.kill(app.pid, signal.SIGKILL)
except ProcessLookupError:
return True
except PermissionError:
return False
return True
+97
View File
@@ -0,0 +1,97 @@
"""Desktop-local configuration.
Only what is *about this machine* lives here — where the backend is, and which
system integrations this box may use. Everything else (timer lengths, block
lists, Telegram) is server-side, so the phone, the web UI and this app always
agree.
Stored as JSON in `$XDG_CONFIG_HOME/kadence/desktop.json`.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
def config_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
path = Path(base) / "kadence"
path.mkdir(parents=True, exist_ok=True)
return path
CONFIG_PATH = config_dir() / "desktop.json"
@dataclass
class DesktopConfig:
"""What this machine needs to know."""
server_url: str = "http://localhost:8000"
api_token: str = ""
poll_seconds: int = 5
"""How often to ask the backend for the timer state and the block policy."""
start_minimized: bool = False
close_to_tray: bool = True
# ---- system integration (each one is a real desktop feature) -----------
tray_icon: bool = True
notifications: bool = True
notification_actions: bool = True
"""Buttons on the notification itself: "Start break", "Skip", "+5 min"."""
inhibit_screensaver: bool = True
"""Keep the screen awake while a work interval runs."""
do_not_disturb: bool = True
"""Silence other apps' notifications during a work interval."""
taskbar_progress: bool = True
"""Progress on the dock/taskbar icon (Unity LauncherEntry — KDE honours it)."""
pause_media: bool = True
"""Pause whatever is playing (MPRIS) when focus starts."""
pause_on_lock: bool = True
"""Pause the timer when the session locks, resume when you come back."""
pause_on_idle_minutes: int = 0
"""Pause after this many idle minutes. 0 = never."""
dbus_service: bool = True
"""Expose org.kadence.Desktop so shortcuts and scripts can drive it."""
# ---- blocking (enforcement; the rules themselves are server-side) ------
enforce_blocking: bool = True
block_hosts_file: bool = True
block_apps: bool = True
helper_path: str = "/usr/local/bin/kadence-helper"
window: dict = field(default_factory=lambda: {"width": 1100, "height": 760})
@classmethod
def load(cls) -> "DesktopConfig":
if not CONFIG_PATH.exists():
config = cls()
config.save()
return config
try:
data = json.loads(CONFIG_PATH.read_text("utf-8"))
except (OSError, json.JSONDecodeError):
return cls()
known = {key: value for key, value in data.items() if key in cls.__dataclass_fields__}
return cls(**known)
def save(self) -> None:
CONFIG_PATH.write_text(json.dumps(asdict(self), indent=2), "utf-8")
def update(self, **changes: object) -> None:
for key, value in changes.items():
if key in self.__dataclass_fields__:
setattr(self, key, value)
self.save()
+639
View File
@@ -0,0 +1,639 @@
"""The object QML talks to, and the place the desktop integrations are wired up.
It holds the last known state from the server, ticks the countdown locally
between polls, and reacts to the *transitions* — a focus interval starting is
what inhibits the screensaver, silences notifications, pauses your music and
turns the blocker on; it ending is what gives all of that back.
"""
from __future__ import annotations
import logging
from typing import Any
from PyQt6.QtCore import (
QObject,
QThread,
QTimer,
QUrl,
pyqtProperty,
pyqtSignal,
pyqtSlot,
)
from PyQt6.QtGui import QDesktopServices
from kadence_desktop.api import Api
from kadence_desktop.blocking.manager import BlockerManager
from kadence_desktop.config import DesktopConfig
from kadence_desktop.integrations import autostart
from kadence_desktop.integrations.inhibit import DoNotDisturb, ScreensaverInhibitor
from kadence_desktop.integrations.launcher import LauncherEntry
from kadence_desktop.integrations.mpris import MediaController
from kadence_desktop.integrations.notifications import URGENCY_CRITICAL, Notifier
from kadence_desktop.integrations.session import SessionWatcher
from kadence_desktop.worker import ApiWorker
log = logging.getLogger(__name__)
KIND_LABELS = {"work": "Focus", "short_break": "Short break", "long_break": "Long break"}
def clock(seconds: int) -> str:
seconds = max(0, int(seconds))
return f"{seconds // 60:02d}:{seconds % 60:02d}"
class Controller(QObject):
# ---- notifications to QML ---------------------------------------------
stateChanged = pyqtSignal()
tasksChanged = pyqtSignal()
statsChanged = pyqtSignal()
reportChanged = pyqtSignal()
settingsChanged = pyqtSignal()
blockerChanged = pyqtSignal()
connectionChanged = pyqtSignal()
configChanged = pyqtSignal()
toast = pyqtSignal(str)
showWindowRequested = pyqtSignal()
def __init__(self, config: DesktopConfig, parent: QObject | None = None) -> None:
super().__init__(parent)
self._config = config
self._api = Api(config.server_url, config.api_token)
# ---- state mirrors --------------------------------------------------
self._state: dict[str, Any] = {}
self._policy: dict[str, Any] = {}
self._tasks: list[dict[str, Any]] = []
self._task_counts: dict[str, Any] = {}
self._stats: dict[str, Any] = {}
self._report: dict[str, Any] = {}
self._settings: dict[str, Any] = {}
self._connected = False
self._connection_error = ""
self._remaining = 0
self._last_session_id: int | None = None
self._last_kind = ""
self._last_status = ""
self._completing = False
self._paused_by_lock = False
# ---- system integrations -------------------------------------------
self._notifier = Notifier()
self._screensaver = ScreensaverInhibitor()
self._dnd = DoNotDisturb()
self._launcher = LauncherEntry()
self._media = MediaController()
self._session = SessionWatcher(self)
self._session.locked.connect(self._on_session_locked)
self._session.unlocked.connect(self._on_session_unlocked)
self._session.sleeping.connect(self._on_session_locked)
self._blocker = BlockerManager(
helper_path=config.helper_path,
report=self._report_block,
notify=self._blocker_notice,
)
self._blocker.enforce_hosts = config.block_hosts_file
self._blocker.enforce_apps = config.block_apps
# ---- network thread -------------------------------------------------
self._thread = QThread(self)
self._worker = ApiWorker(self._api, config.poll_seconds)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.start)
self._worker.stateChanged.connect(self._on_state)
self._worker.policyChanged.connect(self._on_policy)
self._worker.tasksChanged.connect(self._on_tasks)
self._worker.statsChanged.connect(self._on_stats)
self._worker.reportChanged.connect(self._on_report)
self._worker.settingsChanged.connect(self._on_settings)
self._worker.connectionChanged.connect(self._on_connection)
self._worker.commandFailed.connect(self._on_command_failed)
self._worker.commandDone.connect(self._on_command_done)
self._thread.start()
# ---- local clock ----------------------------------------------------
self._tick = QTimer(self)
self._tick.setInterval(1000)
self._tick.timeout.connect(self._on_tick)
self._tick.start()
self._idle_check = QTimer(self)
self._idle_check.setInterval(30_000)
self._idle_check.timeout.connect(self._check_idle)
self._idle_check.start()
# ======================================================================
# Properties for QML
# ======================================================================
@pyqtProperty(bool, notify=connectionChanged)
def connected(self) -> bool:
return self._connected
@pyqtProperty(str, notify=connectionChanged)
def connectionError(self) -> str:
return self._connection_error
@pyqtProperty(bool, notify=stateChanged)
def running(self) -> bool:
return bool(self._state.get("current"))
@pyqtProperty(str, notify=stateChanged)
def kind(self) -> str:
current = self._state.get("current")
return str(current["kind"]) if current else str(self._state.get("next_kind", "work"))
@pyqtProperty(str, notify=stateChanged)
def kindLabel(self) -> str:
return KIND_LABELS.get(self.kind, self.kind)
@pyqtProperty(str, notify=stateChanged)
def status(self) -> str:
current = self._state.get("current")
return str(current["status"]) if current else "idle"
@pyqtProperty(int, notify=stateChanged)
def remainingSeconds(self) -> int:
if self._state.get("current"):
return int(self._remaining)
durations = self._state.get("durations") or {}
return int(durations.get(self.kind, 25)) * 60
@pyqtProperty(int, notify=stateChanged)
def plannedSeconds(self) -> int:
current = self._state.get("current")
if current:
return int(current["planned_seconds"])
durations = self._state.get("durations") or {}
return int(durations.get(self.kind, 25)) * 60
@pyqtProperty(float, notify=stateChanged)
def progress(self) -> float:
planned = self.plannedSeconds
if planned <= 0:
return 0.0
return max(0.0, min(1.0, 1 - self.remainingSeconds / planned))
@pyqtProperty(str, notify=stateChanged)
def clockText(self) -> str:
return clock(self.remainingSeconds)
@pyqtProperty(str, notify=stateChanged)
def taskTitle(self) -> str:
current = self._state.get("current") or {}
task = current.get("task") or {}
return str(task.get("title", ""))
@pyqtProperty(int, notify=stateChanged)
def taskId(self) -> int:
for task in self._tasks:
current = self._state.get("current") or {}
attached = current.get("task") or {}
if attached and task.get("external_id") == attached.get("external_id"):
return int(task.get("id", -1))
return -1
@pyqtProperty(int, notify=stateChanged)
def interruptions(self) -> int:
current = self._state.get("current") or {}
return int(current.get("interruptions", 0))
@pyqtProperty(int, notify=stateChanged)
def completedToday(self) -> int:
return int(self._state.get("completed_today", 0))
@pyqtProperty(int, notify=stateChanged)
def goal(self) -> int:
return int(self._state.get("goal", 8))
@pyqtProperty(int, notify=stateChanged)
def cyclePosition(self) -> int:
return int(self._state.get("cycle_position", 1))
@pyqtProperty(int, notify=stateChanged)
def cycleLength(self) -> int:
return int(self._state.get("cycles_before_long_break", 4))
@pyqtProperty(str, notify=stateChanged)
def nextKindLabel(self) -> str:
return KIND_LABELS.get(str(self._state.get("next_kind", "work")), "Focus")
@pyqtProperty("QVariantList", notify=tasksChanged)
def tasks(self) -> list[dict[str, Any]]:
return self._tasks
@pyqtProperty("QVariantMap", notify=tasksChanged)
def taskCounts(self) -> dict[str, Any]:
return self._task_counts
@pyqtProperty("QVariantMap", notify=statsChanged)
def stats(self) -> dict[str, Any]:
return self._stats
@pyqtProperty("QVariantMap", notify=reportChanged)
def report(self) -> dict[str, Any]:
return self._report
@pyqtProperty("QVariantMap", notify=settingsChanged)
def settings(self) -> dict[str, Any]:
return self._settings
@pyqtProperty(bool, notify=blockerChanged)
def blockerActive(self) -> bool:
return bool(self._policy.get("active"))
@pyqtProperty(str, notify=blockerChanged)
def blockerReason(self) -> str:
return str(self._policy.get("reason", ""))
@pyqtProperty("QVariantList", notify=blockerChanged)
def blockedDomains(self) -> list[str]:
return [str(item) for item in self._policy.get("all_domains", [])]
@pyqtProperty("QVariantList", notify=blockerChanged)
def blockedApps(self) -> list[str]:
return [str(item) for item in self._policy.get("all_apps", [])]
@pyqtProperty(bool, notify=blockerChanged)
def helperInstalled(self) -> bool:
return self._blocker.helper_installed
@pyqtProperty(str, notify=blockerChanged)
def blockerError(self) -> str:
return self._blocker.last_error or ""
@pyqtProperty("QVariantMap", notify=configChanged)
def config(self) -> dict[str, Any]:
from dataclasses import asdict
data = asdict(self._config)
data["autostart"] = autostart.autostart_enabled()
data["service_enabled"] = autostart.service_enabled()
return data
# ======================================================================
# Commands from QML (and from the tray, and from D-Bus)
# ======================================================================
def _send(self, name: str, args: dict[str, Any] | None = None) -> None:
from PyQt6.QtCore import Q_ARG, QMetaObject, Qt
QMetaObject.invokeMethod(
self._worker,
"command",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, name),
Q_ARG(dict, args or {}),
)
@pyqtSlot()
@pyqtSlot(int)
def startWork(self, minutes: int = 0) -> None:
args: dict[str, Any] = {"kind": "work"}
if minutes:
args["minutes"] = minutes
current_task = self._state.get("current") or {}
if not current_task.get("task"):
pinned = next((task for task in self._tasks if task.get("pinned")), None)
if pinned:
args["task_id"] = pinned["id"]
self._send("start", args)
@pyqtSlot()
def startBreak(self) -> None:
kind = str(self._state.get("next_kind", "short_break"))
self._send("start", {"kind": "long_break" if kind == "long_break" else "short_break"})
@pyqtSlot()
def toggle(self) -> None:
"""One verb for a global hotkey: start → pause → resume."""
if not self.running:
self.startWork()
elif self.status == "paused":
self.resume()
else:
self.pause()
@pyqtSlot()
def pause(self) -> None:
self._send("pause")
@pyqtSlot()
def resume(self) -> None:
self._send("resume")
@pyqtSlot()
def complete(self) -> None:
self._send("complete", {"auto_next": True})
@pyqtSlot()
@pyqtSlot(str)
def abandon(self, reason: str = "") -> None:
self._send("abandon", {"reason": reason or None})
@pyqtSlot()
@pyqtSlot(int)
def extend(self, minutes: int = 5) -> None:
self._send("extend", {"minutes": minutes})
@pyqtSlot()
def interrupt(self) -> None:
self._send("interrupt")
@pyqtSlot(int)
def attachTask(self, task_id: int) -> None:
self._send("attach_task", {"task_id": task_id if task_id >= 0 else None})
@pyqtSlot(int, bool)
def setTaskDone(self, task_id: int, done: bool) -> None:
self._send("task_done", {"task_id": task_id, "done": done})
@pyqtSlot(str, str)
def createTask(self, title: str, due_date: str) -> None:
self._send("create_task", {"title": title, "due_date": due_date or None})
@pyqtSlot()
def refreshSources(self) -> None:
self.toast.emit("Pulling Jira, Todoist, Google Tasks…")
self._send("refresh_sources")
@pyqtSlot()
def sendReport(self) -> None:
self._send("send_report")
@pyqtSlot()
def loadReport(self) -> None:
from PyQt6.QtCore import QMetaObject, Qt
QMetaObject.invokeMethod(self._worker, "refresh_report", Qt.ConnectionType.QueuedConnection)
@pyqtSlot()
def loadSettings(self) -> None:
from PyQt6.QtCore import QMetaObject, Qt
QMetaObject.invokeMethod(self._worker, "refresh_settings", Qt.ConnectionType.QueuedConnection)
@pyqtSlot("QVariantMap")
def patchSettings(self, patch: dict[str, Any]) -> None:
self._send("patch_settings", {"patch": patch})
@pyqtSlot(str, "QVariant")
def setConfig(self, key: str, value: Any) -> None:
"""Change a desktop-local setting and apply it immediately."""
if key == "autostart":
autostart.set_autostart(bool(value), minimized=self._config.start_minimized)
elif key == "service_enabled":
if value:
self.toast.emit(autostart.install_service(enable=True))
else:
autostart.remove_service()
self.toast.emit("systemd user service removed")
else:
self._config.update(**{key: value})
if key in ("server_url", "api_token"):
from PyQt6.QtCore import Q_ARG, QMetaObject, Qt
QMetaObject.invokeMethod(
self._worker,
"configure",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, self._config.server_url),
Q_ARG(str, self._config.api_token),
)
elif key == "poll_seconds":
from PyQt6.QtCore import Q_ARG, QMetaObject, Qt
QMetaObject.invokeMethod(
self._worker,
"set_interval",
Qt.ConnectionType.QueuedConnection,
Q_ARG(int, int(value)),
)
elif key == "block_hosts_file":
self._blocker.enforce_hosts = bool(value)
elif key == "block_apps":
self._blocker.enforce_apps = bool(value)
elif key == "enforce_blocking" and not value:
self._blocker.release()
self.configChanged.emit()
@pyqtSlot()
def openWebUi(self) -> None:
QDesktopServices.openUrl(QUrl(self._config.server_url))
@pyqtSlot(str)
def openUrl(self, url: str) -> None:
if url:
QDesktopServices.openUrl(QUrl(url))
@pyqtSlot(result=str)
def statusLine(self) -> str:
"""One line for the tray tooltip and the D-Bus `Status` method."""
if not self._connected:
return "Kadence — backend unreachable"
if not self.running:
return f"Kadence — idle · {self.completedToday}/{self.goal} today"
return (
f"{self.kindLabel} {clock(self.remainingSeconds)}"
+ (f" · {self.taskTitle}" if self.taskTitle else "")
+ f" · {self.completedToday}/{self.goal} today"
)
# ======================================================================
# Worker signals
# ======================================================================
def _on_state(self, state: dict[str, Any]) -> None:
previous_current = self._state.get("current") or {}
self._state = state
current = state.get("current") or {}
self._remaining = int(current.get("remaining_seconds", 0))
session_id = current.get("id")
kind = str(current.get("kind", ""))
status = str(current.get("status", "idle"))
if session_id != self._last_session_id or kind != self._last_kind or status != self._last_status:
self._on_transition(previous_current, current)
self._last_session_id = session_id
self._last_kind = kind
self._last_status = status
self._completing = False
self.stateChanged.emit()
self._update_launcher()
def _on_policy(self, policy: dict[str, Any]) -> None:
self._policy = policy
if self._config.enforce_blocking:
self._blocker.sync(policy)
self.blockerChanged.emit()
def _on_tasks(self, payload: dict[str, Any]) -> None:
self._tasks = list(payload.get("tasks", []))
self._task_counts = dict(payload.get("counts", {}))
self.tasksChanged.emit()
def _on_stats(self, payload: dict[str, Any]) -> None:
self._stats = payload
self.statsChanged.emit()
def _on_report(self, payload: dict[str, Any]) -> None:
self._report = payload
self.reportChanged.emit()
def _on_settings(self, payload: dict[str, Any]) -> None:
self._settings = payload.get("settings", payload)
self.settingsChanged.emit()
def _on_connection(self, connected: bool, detail: str) -> None:
self._connected = connected
self._connection_error = detail
self.connectionChanged.emit()
if not connected:
self.toast.emit(detail or "Backend unreachable")
def _on_command_failed(self, name: str, detail: str) -> None:
log.warning("%s failed: %s", name, detail)
self.toast.emit(detail)
def _on_command_done(self, name: str, result: dict[str, Any]) -> None:
if name == "complete":
finished = result.get("finished") or {}
self._announce_finished(str(finished.get("kind", "work")), result)
elif name == "send_report":
self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings")
elif name == "refresh_sources":
failed = [item for item in result.get("sources", []) if not item.get("ok")]
self.toast.emit(
f"{len(failed)} source(s) failed" if failed else f"{result.get('total', 0)} tasks pulled"
)
elif name == "task_done" and result.get("warning"):
self.toast.emit(str(result["warning"]))
# ======================================================================
# Transitions → the system integrations
# ======================================================================
def _on_transition(self, previous: dict[str, Any], current: dict[str, Any]) -> None:
focusing = current.get("kind") == "work" and current.get("status") == "running"
if focusing:
if self._config.inhibit_screensaver:
self._screensaver.hold()
if self._config.do_not_disturb:
self._dnd.hold()
if self._config.pause_media and previous.get("kind") != "work":
self._media.pause_all()
else:
self._screensaver.release()
self._dnd.release()
if self._config.pause_media and previous.get("kind") == "work":
self._media.resume_paused()
def _announce_finished(self, kind: str, result: dict[str, Any]) -> None:
auto = result.get("auto_started") or {}
done = int(result.get("completed_today", self.completedToday))
goal = int(result.get("goal", self.goal))
if kind == "work":
summary = "Pomodoro done 🍅"
body = f"{done}/{goal} today."
if auto:
body += f" {KIND_LABELS.get(str(auto.get('kind')), 'Break')} started automatically."
actions = (
{
"break": ("Start break", self.startBreak),
"again": ("Another pomodoro", lambda: self.startWork()),
}
if not auto
else {"skip": ("Skip the break", lambda: self.startWork())}
)
else:
summary = "Break over ☕"
body = "Back to it — one more interval."
actions = {"work": ("Start focus", lambda: self.startWork()), "five": ("+5 min break", lambda: self.extend(5))}
if self._config.notifications:
self._notifier.notify(
summary,
body,
actions=actions if self._config.notification_actions else None,
urgency=URGENCY_CRITICAL if kind == "work" else 1,
timeout_ms=0 if kind != "work" else 12000,
)
if result.get("celebration"):
self.toast.emit("Milestone reached — Telegram got a cheer 🎉")
def _update_launcher(self) -> None:
if not self._config.taskbar_progress:
return
if self.running:
self._launcher.update(progress=self.progress, count=self.completedToday or None)
else:
self._launcher.update(progress=None, count=self.completedToday or None)
# ---- local clock -------------------------------------------------------
def _on_tick(self) -> None:
if not self.running or self.status == "paused":
return
if self._remaining > 0:
self._remaining -= 1
self.stateChanged.emit()
if self._remaining % 5 == 0:
self._update_launcher()
return
if not self._completing:
# The countdown reached zero: the server books the interval, and its
# reply decides what happens next (auto-break, or nothing).
self._completing = True
self.complete()
def _check_idle(self) -> None:
limit = self._config.pause_on_idle_minutes
if not limit or not self.running or self.status != "running" or self.kind != "work":
return
idle = self._session.idle_seconds()
if idle >= 0 and idle >= limit * 60:
self.pause()
self.toast.emit(f"Paused — no input for {limit} minutes")
if self._config.notifications:
self._notifier.notify("Timer paused", f"You have been away for {limit} minutes.")
def _on_session_locked(self) -> None:
if not self._config.pause_on_lock:
return
if self.running and self.status == "running":
self._paused_by_lock = True
self.pause()
def _on_session_unlocked(self) -> None:
if self._paused_by_lock and self.running and self.status == "paused":
self.resume()
if self._config.notifications:
self._notifier.notify("Welcome back", "Your interval is running again.")
self._paused_by_lock = False
# ---- blocker callbacks -------------------------------------------------
def _report_block(self, kind: str, target: str, action: str, details: str | None) -> None:
self._send("report_block", {"kind": kind, "target": target, "action": action, "details": details})
def _blocker_notice(self, summary: str, body: str) -> None:
if self._config.notifications:
self._notifier.notify(summary, body, urgency=URGENCY_CRITICAL, timeout_ms=6000)
self.toast.emit(body)
# ---- shutdown ----------------------------------------------------------
def shutdown(self) -> None:
"""Hand everything back: inhibits, DND, and the hosts file."""
self._tick.stop()
self._idle_check.stop()
self._screensaver.release()
self._dnd.release()
self._launcher.clear()
try:
self._blocker.release()
except Exception: # pragma: no cover
log.exception("could not clear the block on exit")
self._thread.quit()
self._thread.wait(2000)
@@ -0,0 +1,6 @@
"""Desktop-environment integration.
Each module here wraps one real system facility over D-Bus (or a file in the
right place), and every one of them degrades to a no-op when the running desktop
does not provide it — so the same build works on KDE, GNOME and a bare WM.
"""
@@ -0,0 +1,140 @@
"""Starting with the session, the way the desktop expects.
Two mechanisms, because the two desktops disagree about which is canonical:
* a `.desktop` file in `~/.config/autostart` — the XDG standard, honoured by
KDE, GNOME, XFCE and friends;
* a **systemd user unit**, which is what you actually want on a modern box: it
restarts the app if it dies and keeps its logs in the journal.
Either can be switched on alone; the app only ever touches files under the
user's own config, never system paths.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
log = logging.getLogger(__name__)
DESKTOP_FILE_NAME = "kadence.desktop"
SERVICE_NAME = "kadence-desktop.service"
def _autostart_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
path = Path(base) / "autostart"
path.mkdir(parents=True, exist_ok=True)
return path
def _systemd_user_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
path = Path(base) / "systemd" / "user"
path.mkdir(parents=True, exist_ok=True)
return path
def launch_command() -> str:
"""How to start this app again, whether installed or run from a checkout."""
executable = shutil.which("kadence-desktop")
if executable:
return executable
return f"{sys.executable} -m kadence_desktop"
# --------------------------------------------------------------------------
# XDG autostart
# --------------------------------------------------------------------------
def autostart_enabled() -> bool:
return (_autostart_dir() / DESKTOP_FILE_NAME).exists()
def set_autostart(enabled: bool, *, minimized: bool = True) -> None:
path = _autostart_dir() / DESKTOP_FILE_NAME
if not enabled:
path.unlink(missing_ok=True)
return
command = launch_command() + (" --minimized" if minimized else "")
path.write_text(
"\n".join(
[
"[Desktop Entry]",
"Type=Application",
"Name=Kadence",
"Comment=Pomodoro timer, task list and focus guard",
f"Exec={command}",
"Icon=kadence",
"Terminal=false",
"Categories=Utility;Office;",
"X-GNOME-Autostart-enabled=true",
"",
]
),
"utf-8",
)
log.info("autostart entry written to %s", path)
# --------------------------------------------------------------------------
# systemd user service
# --------------------------------------------------------------------------
def service_installed() -> bool:
return (_systemd_user_dir() / SERVICE_NAME).exists()
def service_enabled() -> bool:
if not shutil.which("systemctl"):
return False
result = subprocess.run(
["systemctl", "--user", "is-enabled", SERVICE_NAME],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() == "enabled"
def install_service(*, enable: bool = True) -> str:
"""Write (and optionally enable) the user unit. Returns a status line."""
path = _systemd_user_dir() / SERVICE_NAME
path.write_text(
"\n".join(
[
"[Unit]",
"Description=Kadence desktop client",
"PartOf=graphical-session.target",
"After=graphical-session.target",
"",
"[Service]",
"Type=simple",
f"ExecStart={launch_command()} --minimized",
"Restart=on-failure",
"RestartSec=5",
"",
"[Install]",
"WantedBy=graphical-session.target",
"",
]
),
"utf-8",
)
if not shutil.which("systemctl"):
return f"unit written to {path} (systemctl not found)"
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
if enable:
subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=False)
return f"unit written to {path}" + (" and enabled" if enable else "")
def remove_service() -> None:
if shutil.which("systemctl"):
subprocess.run(["systemctl", "--user", "disable", "--now", SERVICE_NAME], check=False)
(_systemd_user_dir() / SERVICE_NAME).unlink(missing_ok=True)
if shutil.which("systemctl"):
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
@@ -0,0 +1,122 @@
"""Kadence's own D-Bus API: `org.kadence.Desktop`.
This is what makes the app scriptable and bindable. KDE's System Settings →
Shortcuts → Custom Shortcuts can call a D-Bus method directly, so a global
hotkey for "start a pomodoro" needs no X11 key-grabbing hack:
qdbus org.kadence.Desktop /Timer org.kadence.Timer.StartWork
Owning the name also gives single-instance behaviour for free: a second launch
sees the name is taken, calls `Raise()` on the first, and exits.
"""
from __future__ import annotations
import logging
from typing import Callable
from PyQt6.QtCore import QObject, pyqtSlot
from PyQt6.QtDBus import QDBusConnection
log = logging.getLogger(__name__)
SERVICE_NAME = "org.kadence.Desktop"
OBJECT_PATH = "/Timer"
INTERFACE = "org.kadence.Timer"
class TimerDBusService(QObject):
"""The exported object. Every slot is a verb you can bind to a key."""
def __init__(self, actions: dict[str, Callable[[], object]], parent: QObject | None = None) -> None:
super().__init__(parent)
self._actions = actions
def _run(self, name: str) -> str:
action = self._actions.get(name)
if action is None:
return f"unknown action: {name}"
try:
result = action()
return str(result) if result is not None else "ok"
except Exception as exc: # pragma: no cover - never let a caller crash us
log.exception("D-Bus action %s failed", name)
return f"error: {exc}"
@pyqtSlot(result=str)
def StartWork(self) -> str: # noqa: N802 - D-Bus methods are PascalCase
"""Start a focus interval."""
return self._run("start_work")
@pyqtSlot(result=str)
def StartBreak(self) -> str: # noqa: N802
return self._run("start_break")
@pyqtSlot(result=str)
def Toggle(self) -> str: # noqa: N802
"""Start if idle, pause if running, resume if paused — one hotkey."""
return self._run("toggle")
@pyqtSlot(result=str)
def Pause(self) -> str: # noqa: N802
return self._run("pause")
@pyqtSlot(result=str)
def Resume(self) -> str: # noqa: N802
return self._run("resume")
@pyqtSlot(result=str)
def Complete(self) -> str: # noqa: N802
return self._run("complete")
@pyqtSlot(result=str)
def Stop(self) -> str: # noqa: N802
return self._run("stop")
@pyqtSlot(result=str)
def Extend(self) -> str: # noqa: N802
return self._run("extend")
@pyqtSlot(result=str)
def Interrupt(self) -> str: # noqa: N802
"""Count a distraction without stopping the clock."""
return self._run("interrupt")
@pyqtSlot(result=str)
def Status(self) -> str: # noqa: N802
"""One line: what is running and how much is left."""
return self._run("status")
@pyqtSlot(result=str)
def SendReport(self) -> str: # noqa: N802
return self._run("send_report")
@pyqtSlot(result=str)
def Raise(self) -> str: # noqa: N802
"""Show the window (used by the second instance)."""
return self._run("raise_window")
def register(service: TimerDBusService) -> bool:
"""Own the name and export the object. False means another instance has it."""
bus = QDBusConnection.sessionBus()
if not bus.registerService(SERVICE_NAME):
return False
bus.registerObject(
OBJECT_PATH,
service,
QDBusConnection.RegisterOption.ExportAllSlots | QDBusConnection.RegisterOption.ExportAllInvokables,
)
log.info("D-Bus service %s%s is up", SERVICE_NAME, OBJECT_PATH)
return True
def call_existing(method: str = "Raise") -> bool:
"""Poke the instance that already owns the name."""
from PyQt6.QtDBus import QDBusInterface
interface = QDBusInterface(SERVICE_NAME, OBJECT_PATH, INTERFACE, QDBusConnection.sessionBus())
if not interface.isValid():
return False
interface.call(method)
return True
@@ -0,0 +1,106 @@
"""Two things a focus app owes you: the screen stays on, and the world shuts up.
* **Screensaver inhibit** — `org.freedesktop.ScreenSaver.Inhibit`, honoured by
KDE, GNOME and anything XDG-ish. Without it a 50-minute interval spent reading
ends with a lock screen.
* **Do Not Disturb** — Plasma (and any daemon implementing the spec's optional
`Inhibit`) silences other apps' banners while you focus. GNOME has no such
method, so there we flip the `show-banners` gsetting and put it back after.
Both are strictly paired: every inhibit is released on exit, including a crash,
because the cookie dies with the D-Bus connection.
"""
from __future__ import annotations
import logging
import shutil
import subprocess
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
log = logging.getLogger(__name__)
_SCREENSAVER = ("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver")
_NOTIFICATIONS = ("org.freedesktop.Notifications", "/org/freedesktop/Notifications", "org.freedesktop.Notifications")
class ScreensaverInhibitor:
"""Keeps the session awake for as long as `hold()` is in effect."""
def __init__(self, app_name: str = "Kadence") -> None:
self._app_name = app_name
self._interface = QDBusInterface(*_SCREENSAVER, QDBusConnection.sessionBus())
self._cookie: int | None = None
@property
def active(self) -> bool:
return self._cookie is not None
def hold(self, reason: str = "Focus interval in progress") -> None:
if self._cookie is not None or not self._interface.isValid():
return
reply = self._interface.call("Inhibit", self._app_name, reason)
arguments = reply.arguments()
if arguments:
self._cookie = int(arguments[0])
log.debug("screensaver inhibited (cookie %s)", self._cookie)
def release(self) -> None:
if self._cookie is None:
return
self._interface.call("UnInhibit", self._cookie)
log.debug("screensaver released")
self._cookie = None
class DoNotDisturb:
"""Silences other applications' notifications during focus."""
def __init__(self, app_name: str = "Kadence") -> None:
self._app_name = app_name
self._interface = QDBusInterface(*_NOTIFICATIONS, QDBusConnection.sessionBus())
self._cookie: int | None = None
self._gnome_restored = True
@property
def active(self) -> bool:
return self._cookie is not None or not self._gnome_restored
def hold(self, reason: str = "Focus interval in progress") -> None:
if self.active:
return
if self._interface.isValid():
reply = self._interface.call("Inhibit", self._app_name, reason, {})
arguments = reply.arguments()
if arguments and not reply.errorName():
self._cookie = int(arguments[0])
return
self._gnome_dnd(True)
def release(self) -> None:
if self._cookie is not None:
self._interface.call("UnInhibit", self._cookie)
self._cookie = None
if not self._gnome_restored:
self._gnome_dnd(False)
def _gnome_dnd(self, enable: bool) -> None:
"""GNOME fallback: toggle the banner gsetting, and remember to undo it."""
if not shutil.which("gsettings"):
return
try:
subprocess.run(
[
"gsettings",
"set",
"org.gnome.desktop.notifications",
"show-banners",
"false" if enable else "true",
],
check=False,
timeout=5,
)
self._gnome_restored = not enable
except (OSError, subprocess.SubprocessError) as exc: # pragma: no cover
log.debug("gsettings DND failed: %s", exc)
@@ -0,0 +1,43 @@
"""Progress on the dock icon, via `com.canonical.Unity.LauncherEntry`.
A signal, not a service: anything listening (Plasma's task manager, Dash to
Dock, Latte) picks it up, and nothing breaks where nobody listens. The result is
a progress bar filling under the icon as the interval runs and a badge with the
pomodoro count for the day — the timer stays readable with the window closed.
"""
from __future__ import annotations
from PyQt6.QtCore import QVariant
from PyQt6.QtDBus import QDBusConnection, QDBusMessage
_PATH = "/org/kadence/Desktop"
_IFACE = "com.canonical.Unity.LauncherEntry"
_APP_URI = "application://kadence.desktop"
class LauncherEntry:
def __init__(self) -> None:
self._bus = QDBusConnection.sessionBus()
def update(self, *, progress: float | None = None, count: int | None = None, urgent: bool = False) -> None:
"""Push a new state. `progress` is 0..1; `count` is the badge number."""
properties: dict[str, QVariant] = {}
if progress is None:
properties["progress-visible"] = QVariant(False)
else:
properties["progress"] = QVariant(max(0.0, min(1.0, progress)))
properties["progress-visible"] = QVariant(True)
if count is None:
properties["count-visible"] = QVariant(False)
else:
properties["count"] = QVariant(int(count))
properties["count-visible"] = QVariant(True)
properties["urgent"] = QVariant(bool(urgent))
message = QDBusMessage.createSignal(_PATH, _IFACE, "Update")
message.setArguments([_APP_URI, properties])
self._bus.send(message)
def clear(self) -> None:
self.update(progress=None, count=None, urgent=False)
@@ -0,0 +1,69 @@
"""Pause whatever is playing when focus starts — and only that.
Every modern player (Spotify, VLC, mpv, browsers, Elisa) speaks MPRIS, so this
needs no per-app support: list the `org.mpris.MediaPlayer2.*` names on the
session bus, pause the ones that are actually playing, and remember exactly
which ones so the break can hand the music back.
"""
from __future__ import annotations
import logging
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
log = logging.getLogger(__name__)
_PREFIX = "org.mpris.MediaPlayer2."
_PATH = "/org/mpris/MediaPlayer2"
_PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"
_PROPS_IFACE = "org.freedesktop.DBus.Properties"
class MediaController:
def __init__(self) -> None:
self._bus = QDBusConnection.sessionBus()
self._paused_by_us: list[str] = []
def _players(self) -> list[str]:
interface = QDBusInterface(
"org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", self._bus
)
reply = interface.call("ListNames")
arguments = reply.arguments()
names = list(arguments[0]) if arguments else []
return [name for name in names if name.startswith(_PREFIX)]
def _status(self, service: str) -> str:
properties = QDBusInterface(service, _PATH, _PROPS_IFACE, self._bus)
reply = properties.call("Get", _PLAYER_IFACE, "PlaybackStatus")
arguments = reply.arguments()
return str(arguments[0]) if arguments else ""
def pause_all(self) -> list[str]:
"""Pause every playing player; returns the ones that were paused."""
paused: list[str] = []
for service in self._players():
try:
if self._status(service) != "Playing":
continue
QDBusInterface(service, _PATH, _PLAYER_IFACE, self._bus).call("Pause")
paused.append(service)
except Exception as exc: # pragma: no cover - a rude player is not our problem
log.debug("could not pause %s: %s", service, exc)
self._paused_by_us = paused
if paused:
log.info("paused %s media player(s)", len(paused))
return paused
def resume_paused(self) -> None:
"""Resume only what we paused, and only if it is still around."""
alive = set(self._players())
for service in self._paused_by_us:
if service not in alive:
continue
try:
QDBusInterface(service, _PATH, _PLAYER_IFACE, self._bus).call("Play")
except Exception as exc: # pragma: no cover
log.debug("could not resume %s: %s", service, exc)
self._paused_by_us = []
@@ -0,0 +1,133 @@
"""Desktop notifications with buttons, over org.freedesktop.Notifications.
Qt's `QSystemTrayIcon.showMessage` can only show text. The real spec — which
KDE, GNOME and anything using a freedesktop daemon implement — takes *actions*,
so "Pomodoro finished" can carry [Start break] [Skip] [+5 min] and act without
you finding the window. Notifications are also replaced in place (the same id),
so a running timer never stacks up a column of toasts.
"""
from __future__ import annotations
import logging
from typing import Callable
from PyQt6.QtCore import QMetaType, QObject, QVariant, pyqtSlot
from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
log = logging.getLogger(__name__)
_SERVICE = "org.freedesktop.Notifications"
_PATH = "/org/freedesktop/Notifications"
_IFACE = "org.freedesktop.Notifications"
# Urgency levels from the freedesktop spec.
URGENCY_LOW, URGENCY_NORMAL, URGENCY_CRITICAL = 0, 1, 2
class Notifier(QObject):
"""Sends notifications and routes their button presses back to callables."""
def __init__(self, app_name: str = "Kadence", parent: QObject | None = None) -> None:
super().__init__(parent)
self._app_name = app_name
self._bus = QDBusConnection.sessionBus()
self._interface = QDBusInterface(_SERVICE, _PATH, _IFACE, self._bus)
self._handlers: dict[int, dict[str, Callable[[], None]]] = {}
self._last_id = 0
self._available = self._interface.isValid()
if self._available:
self._bus.connect(_SERVICE, _PATH, _IFACE, "ActionInvoked", self._on_action)
self._bus.connect(_SERVICE, _PATH, _IFACE, "NotificationClosed", self._on_closed)
@property
def available(self) -> bool:
return self._available
def capabilities(self) -> list[str]:
if not self._available:
return []
reply = self._interface.call("GetCapabilities")
arguments = reply.arguments()
return list(arguments[0]) if arguments else []
def notify(
self,
summary: str,
body: str = "",
*,
actions: dict[str, tuple[str, Callable[[], None]]] | None = None,
urgency: int = URGENCY_NORMAL,
timeout_ms: int = 8000,
replace: bool = True,
icon: str = "kadence",
sound: bool = True,
) -> int:
"""Show a notification. `actions` maps key -> (label, callback)."""
if not self._available:
return 0
action_list: list[str] = []
handlers: dict[str, Callable[[], None]] = {}
for key, (label, callback) in (actions or {}).items():
action_list += [key, label]
handlers[key] = callback
hints = {
"urgency": QVariant(urgency),
"desktop-entry": QVariant("kadence"),
"x-kde-origin-name": QVariant(self._app_name),
}
if not sound:
hints["suppress-sound"] = QVariant(True)
# The spec's signature is `susssasa{sv}i`: `replaces_id` is a uint32 and
# `actions` is an array of strings. Python ints marshal as int32 and a
# Python list as an array of variants, so both need spelling out or the
# daemon rejects the call as "no such method".
reply = self._interface.call(
"Notify",
self._app_name,
QDBusArgument(self._last_id if replace else 0, QMetaType.Type.UInt.value),
icon,
summary,
body,
QDBusArgument(action_list, QMetaType.Type.QStringList.value),
hints,
timeout_ms,
)
if reply.errorName():
log.warning("notification refused: %s — %s", reply.errorName(), reply.errorMessage())
return 0
arguments = reply.arguments()
try:
notification_id = int(arguments[0]) if arguments else 0
except (TypeError, ValueError):
log.warning("unexpected Notify reply: %r", arguments)
return 0
self._last_id = notification_id
if handlers:
self._handlers[notification_id] = handlers
return notification_id
def close(self, notification_id: int) -> None:
if self._available and notification_id:
self._interface.call("CloseNotification", notification_id)
@pyqtSlot(QDBusMessage)
def _on_action(self, message: QDBusMessage) -> None:
arguments = message.arguments()
if len(arguments) < 2:
return
notification_id, key = int(arguments[0]), str(arguments[1])
handler = self._handlers.get(notification_id, {}).get(key)
if handler:
handler()
@pyqtSlot(QDBusMessage)
def _on_closed(self, message: QDBusMessage) -> None:
arguments = message.arguments()
if arguments:
self._handlers.pop(int(arguments[0]), None)
@@ -0,0 +1,119 @@
"""Knowing when you walked away.
Two signals, both from the session itself rather than from guesswork:
* **logind** emits `Lock`/`Unlock` on your session object, and `PrepareForSleep`
when the machine suspends. Locking the screen is the clearest possible "I am
not at this desk" — the timer pauses, and resumes when you come back.
* **Idle time** comes from KDE's `org.kde.screensaver.GetSessionIdleTime` or
GNOME's `org.gnome.Mutter.IdleMonitor`, so an interval you forgot to stop does
not quietly bank half an hour of "focus" you spent in the kitchen.
"""
from __future__ import annotations
import logging
import os
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
from PyQt6.QtDBus import QDBusConnection, QDBusInterface, QDBusMessage
log = logging.getLogger(__name__)
_LOGIND = "org.freedesktop.login1"
_MANAGER_PATH = "/org/freedesktop/login1"
_MANAGER_IFACE = "org.freedesktop.login1.Manager"
_SESSION_IFACE = "org.freedesktop.login1.Session"
class SessionWatcher(QObject):
"""Emits lock/unlock/sleep, and answers "how long have they been idle?"."""
locked = pyqtSignal()
unlocked = pyqtSignal()
sleeping = pyqtSignal()
woke = pyqtSignal()
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._system_bus = QDBusConnection.systemBus()
self._session_bus = QDBusConnection.sessionBus()
self._session_path = self._find_session_path()
if self._session_path:
for signal_name, slot in (("Lock", self._on_lock), ("Unlock", self._on_unlock)):
self._system_bus.connect(_LOGIND, self._session_path, _SESSION_IFACE, signal_name, slot)
log.debug("watching logind session %s", self._session_path)
self._system_bus.connect(_LOGIND, _MANAGER_PATH, _MANAGER_IFACE, "PrepareForSleep", self._on_sleep)
# Plasma's own lock signal, for setups where logind's does not arrive.
self._session_bus.connect(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver",
"ActiveChanged",
self._on_screensaver,
)
def _find_session_path(self) -> str:
"""Ask logind for *this* session's object path."""
manager = QDBusInterface(_LOGIND, _MANAGER_PATH, _MANAGER_IFACE, self._system_bus)
if not manager.isValid():
return ""
session_id = os.environ.get("XDG_SESSION_ID", "")
if session_id:
reply = manager.call("GetSession", session_id)
arguments = reply.arguments()
if arguments and not reply.errorName():
return str(arguments[0].path() if hasattr(arguments[0], "path") else arguments[0])
reply = manager.call("GetSessionByPID", os.getpid())
arguments = reply.arguments()
if arguments and not reply.errorName():
value = arguments[0]
return str(value.path() if hasattr(value, "path") else value)
return ""
def idle_seconds(self) -> int:
"""Seconds since the last input, or -1 when nothing can tell us."""
kde = QDBusInterface(
"org.freedesktop.ScreenSaver", "/ScreenSaver", "org.kde.screensaver", self._session_bus
)
if kde.isValid():
reply = kde.call("GetSessionIdleTime")
arguments = reply.arguments()
if arguments and not reply.errorName():
return int(arguments[0]) // 1000
mutter = QDBusInterface(
"org.gnome.Mutter.IdleMonitor",
"/org/gnome/Mutter/IdleMonitor/Core",
"org.gnome.Mutter.IdleMonitor",
self._session_bus,
)
if mutter.isValid():
reply = mutter.call("GetIdletime")
arguments = reply.arguments()
if arguments and not reply.errorName():
return int(arguments[0]) // 1000
return -1
@pyqtSlot(QDBusMessage)
def _on_lock(self, _message: QDBusMessage) -> None:
self.locked.emit()
@pyqtSlot(QDBusMessage)
def _on_unlock(self, _message: QDBusMessage) -> None:
self.unlocked.emit()
@pyqtSlot(QDBusMessage)
def _on_sleep(self, message: QDBusMessage) -> None:
arguments = message.arguments()
going_to_sleep = bool(arguments[0]) if arguments else False
(self.sleeping if going_to_sleep else self.woke).emit()
@pyqtSlot(QDBusMessage)
def _on_screensaver(self, message: QDBusMessage) -> None:
arguments = message.arguments()
if not arguments:
return
(self.locked if bool(arguments[0]) else self.unlocked).emit()
+191
View File
@@ -0,0 +1,191 @@
// What the blocker is doing right now, and what it will do.
//
// The rules themselves are server-side (edit them in the web UI or below); this
// page is about *enforcement on this machine* — the helper, the hosts file and
// the process watcher.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
ColumnLayout {
width: page.availableWidth
spacing: 16
Banner {
Layout.fillWidth: true
visible: !controller.helperInstalled
text: "The privileged helper is not installed, so websites cannot be blocked. "
+ "Run: sudo desktop/helper/install.sh"
actionText: ""
}
Banner {
Layout.fillWidth: true
visible: controller.blockerError !== ""
text: controller.blockerError
}
ColumnLayout {
Layout.fillWidth: true
Layout.margins: 20
spacing: 16
Card {
title: controller.blockerActive ? "🛡 Blocking is ON" : "Blocking is off"
subtitle: controller.blockerReason
RowLayout {
Layout.fillWidth: true
spacing: 20
StatTile {
label: "Domains"
value: controller.blockedDomains.length
caption: "redirected to 127.0.0.1"
}
StatTile {
label: "Apps"
value: controller.blockedApps.length
caption: "closed on sight"
}
StatTile {
label: "Attempts today"
value: controller.stats.today ? controller.stats.today.blocked_attempts : 0
caption: "and counting"
}
}
RowLayout {
Layout.fillWidth: true
Switch {
text: "Enforce on this machine"
checked: controller.config.enforce_blocking
onToggled: controller.setConfig("enforce_blocking", checked)
}
Switch {
text: "Websites"
checked: controller.config.block_hosts_file
onToggled: controller.setConfig("block_hosts_file", checked)
}
Switch {
text: "Apps"
checked: controller.config.block_apps
onToggled: controller.setConfig("block_apps", checked)
}
}
}
Card {
title: "Blocked websites"
subtitle: "One hostname per line. `www.` and `m.` are added automatically."
TextArea {
id: domainsArea
Layout.fillWidth: true
Layout.minimumHeight: 140
wrapMode: TextArea.NoWrap
font.family: "Roboto Mono, monospace"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("\n")
placeholderText: "youtube.com\nreddit.com\ntwitter.com"
}
RowLayout {
PrimaryButton {
text: "Save websites"
onClicked: {
var list = domainsArea.text.split("\n").map(function (s) { return s.trim() })
.filter(function (s) { return s.length > 0 })
controller.patchSettings({ blocker: { blocked_domains: list } })
}
}
Item { Layout.fillWidth: true }
}
}
Card {
title: "Blocked applications"
subtitle: "Process names, matched loosely: `steam` also catches `steamwebhelper`."
TextArea {
id: appsArea
Layout.fillWidth: true
Layout.minimumHeight: 100
wrapMode: TextArea.NoWrap
font.family: "Roboto Mono, monospace"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("\n")
placeholderText: "steam\ndiscord\ntelegram-desktop"
}
RowLayout {
PrimaryButton {
text: "Save applications"
onClicked: {
var list = appsArea.text.split("\n").map(function (s) { return s.trim() })
.filter(function (s) { return s.length > 0 })
controller.patchSettings({ blocker: { blocked_apps: list } })
}
}
Item { Layout.fillWidth: true }
}
}
Card {
title: "When to block"
subtitle: "Server-side, so every device agrees."
RowLayout {
Layout.fillWidth: true
Switch {
text: "Blocking enabled"
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
}
ComboBox {
Layout.preferredWidth: 220
model: ["During focus intervals", "Always", "On a schedule"]
currentIndex: {
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
}
onActivated: function (index) {
var modes = ["during_work", "always", "scheduled"]
controller.patchSettings({ blocker: { mode: modes[index] } })
}
}
Switch {
text: "Keep blocking during breaks"
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
}
}
}
Card {
title: "Today's temptations"
subtitle: "Every attempt the blocker turned away — this is what the daily report jokes about."
Repeater {
model: controller.stats.today ? controller.stats.today.blocked_top : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
Label { text: modelData.target; Layout.fillWidth: true }
Label { text: "× " + modelData.count; font.weight: Font.DemiBold }
}
}
Label {
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
text: "Nothing yet today. Either the blocker is off, or you are behaving."
opacity: 0.6
font.pixelSize: 12
}
}
}
}
Component.onCompleted: controller.loadSettings()
}
+234
View File
@@ -0,0 +1,234 @@
// The window.
//
// Qt Quick Controls' Material style, following the session's own light/dark
// preference, with a navigation rail on the left and the pages stacked on the
// right. Everything it shows comes from `controller`, the Python object bound
// into the QML context — there is no second copy of the state here.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import QtQuick.Window
import "components"
ApplicationWindow {
id: window
width: 1100
height: 760
minimumWidth: 420
minimumHeight: 560
visible: false
title: controller.running
? controller.clockText + " · " + controller.kindLabel + " — Kadence"
: "Kadence"
Material.theme: Material.System
Material.primary: "#6750A4"
Material.accent: "#6750A4"
readonly property color workColor: "#2a78d6"
readonly property color shortBreakColor: "#eb6834"
readonly property color longBreakColor: "#1baf7a"
readonly property bool wide: width >= 820
property int page: 0
function kindColor(kind) {
if (kind === "short_break") return shortBreakColor
if (kind === "long_break") return longBreakColor
return workColor
}
// Closing the window keeps the app alive in the tray — the timer is still
// running, and that is the whole point of a tray icon.
onClosing: function (close) {
if (controller.config.close_to_tray && controller.config.tray_icon) {
close.accepted = false
window.hide()
}
}
Connections {
target: controller
function onToast(message) { snackbar.show(message) }
function onShowWindowRequested() { window.show(); window.raise(); window.requestActivate() }
}
// ---- shortcuts ---------------------------------------------------------
Shortcut { sequence: "Space"; onActivated: controller.toggle() }
Shortcut { sequence: "Ctrl+Return"; onActivated: controller.complete() }
Shortcut { sequence: "Ctrl+E"; onActivated: controller.extend(5) }
Shortcut { sequence: "Ctrl+R"; onActivated: controller.refreshSources() }
Shortcut { sequence: "Ctrl+1"; onActivated: window.page = 0 }
Shortcut { sequence: "Ctrl+2"; onActivated: window.page = 1 }
Shortcut { sequence: "Ctrl+3"; onActivated: window.page = 2 }
Shortcut { sequence: "Ctrl+4"; onActivated: window.page = 3 }
Shortcut { sequence: "Ctrl+5"; onActivated: window.page = 4 }
Shortcut { sequence: "Ctrl+W"; onActivated: window.hide() }
header: ToolBar {
visible: !window.wide
Material.background: Material.backgroundColor
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
Label {
text: ["Timer", "Tasks", "Blocker", "Report", "Settings"][window.page]
font.pixelSize: 18
font.weight: Font.Medium
Layout.fillWidth: true
}
StatusPill {
running: controller.running
paused: controller.status === "paused"
connected: controller.connected
text: controller.connected ? controller.clockText : "offline"
accent: window.kindColor(controller.kind)
}
}
}
RowLayout {
anchors.fill: parent
spacing: 0
// ---- navigation rail ------------------------------------------------
Pane {
visible: window.wide
Layout.fillHeight: true
Layout.preferredWidth: 212
Material.elevation: 0
padding: 12
ColumnLayout {
anchors.fill: parent
spacing: 4
RowLayout {
Layout.bottomMargin: 16
Layout.leftMargin: 8
spacing: 10
Rectangle {
width: 36; height: 36; radius: 18
color: Material.accent
Label { anchors.centerIn: parent; text: "🍅"; font.pixelSize: 18 }
}
ColumnLayout {
spacing: 0
Label { text: "Kadence"; font.pixelSize: 17; font.weight: Font.DemiBold }
Label {
text: controller.connected ? "connected" : "offline"
font.pixelSize: 11
opacity: 0.6
}
}
}
Repeater {
model: [
{ label: "Timer", icon: "⏱" },
{ label: "Tasks", icon: "✓" },
{ label: "Blocker", icon: "🛡" },
{ label: "Report", icon: "📊" },
{ label: "Settings", icon: "⚙" }
]
delegate: ItemDelegate {
required property int index
required property var modelData
Layout.fillWidth: true
height: 44
highlighted: window.page === index
onClicked: window.page = index
background: Rectangle {
radius: 22
color: parent.highlighted ? Material.accent
: (parent.hovered ? Material.listHighlightColor : "transparent")
opacity: parent.highlighted ? 0.18 : 1
}
contentItem: RowLayout {
spacing: 12
Label { text: modelData.icon; font.pixelSize: 16 }
Label {
text: modelData.label
font.weight: parent.parent.highlighted ? Font.DemiBold : Font.Normal
Layout.fillWidth: true
}
}
}
}
Item { Layout.fillHeight: true }
StatusPill {
Layout.fillWidth: true
running: controller.running
paused: controller.status === "paused"
connected: controller.connected
text: controller.connected ? (controller.running ? controller.clockText : "idle") : "offline"
accent: window.kindColor(controller.kind)
}
Label {
Layout.fillWidth: true
Layout.topMargin: 6
text: controller.completedToday + "/" + controller.goal + " today"
font.pixelSize: 12
opacity: 0.7
horizontalAlignment: Text.AlignHCenter
}
Label {
Layout.fillWidth: true
visible: controller.blockerActive
text: "🛡 blocking is on"
font.pixelSize: 12
color: "#e34948"
horizontalAlignment: Text.AlignHCenter
}
}
}
// ---- pages ----------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
Banner {
Layout.fillWidth: true
visible: !controller.connected
text: controller.connectionError !== ""
? controller.connectionError
: "Cannot reach the backend. Start it with `docker compose up`."
actionText: "Settings"
onActionClicked: window.page = 4
}
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: window.page
TimerPage {}
TasksPage {}
BlockerPage {}
ReportPage {}
SettingsPage {}
}
}
}
// ---- footer: bottom navigation on a narrow window ----------------------
footer: TabBar {
visible: !window.wide
currentIndex: window.page
onCurrentIndexChanged: window.page = currentIndex
TabButton { text: "⏱" }
TabButton { text: "✓" }
TabButton { text: "🛡" }
TabButton { text: "📊" }
TabButton { text: "⚙" }
}
Snackbar { id: snackbar }
}
+180
View File
@@ -0,0 +1,180 @@
// The day, summarised — the same payload the Telegram message is built from.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
readonly property var payload: controller.report.report ? controller.report.report : null
readonly property var metrics: payload ? payload.metrics : null
ColumnLayout {
width: page.availableWidth
spacing: 16
ColumnLayout {
Layout.fillWidth: true
Layout.margins: 20
spacing: 16
RowLayout {
Layout.fillWidth: true
Label {
text: page.payload ? "Report for " + page.payload.date : "Report"
font.pixelSize: 20
font.weight: Font.Medium
Layout.fillWidth: true
}
Button { text: "⟳"; flat: true; onClicked: controller.loadReport() }
PrimaryButton { text: "Send to Telegram"; onClicked: controller.sendReport() }
}
GridLayout {
Layout.fillWidth: true
columns: page.width > 760 ? 4 : 2
columnSpacing: 12
rowSpacing: 12
StatTile {
label: "Pomodoros"
value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—"
caption: page.metrics && page.metrics.goal_met ? "goal met 🎯" : "keep going"
accent: window.workColor
}
StatTile {
label: "Focus"
value: page.metrics ? page.metrics.focus_minutes + "m" : "—"
caption: page.metrics ? page.metrics.break_minutes + "m resting" : ""
}
StatTile {
label: "Score"
value: page.payload ? Math.round(page.payload.score) : "—"
caption: "out of 100"
}
StatTile {
label: "Streak"
value: page.payload ? page.payload.streak.current + "d" : "—"
caption: page.payload ? "best " + page.payload.streak.best : ""
}
}
Card {
title: "Last 30 days"
subtitle: "Goal line is the dashed row; darker bars cleared it."
BarRow {
Layout.fillWidth: true
days: controller.stats.range ? controller.stats.range : []
goal: controller.goal
}
}
Card {
visible: page.payload && page.payload.achievements.length > 0
title: "Achievements"
Flow {
Layout.fillWidth: true
spacing: 8
Repeater {
model: page.payload ? page.payload.achievements : []
delegate: Pane {
required property var modelData
Material.elevation: 0
Material.roundedScale: Material.SmallScale
padding: 10
background: Rectangle {
radius: 10
color: Material.accent
opacity: 0.14
}
ColumnLayout {
spacing: 1
Label {
text: modelData.emoji + " " + modelData.title
font.weight: Font.DemiBold
font.pixelSize: 13
}
Label {
text: modelData.detail
visible: modelData.detail !== ""
font.pixelSize: 11
opacity: 0.75
}
}
}
}
}
}
Card {
visible: page.payload && page.payload.fun_facts.length > 0
title: "The funny part"
Repeater {
model: page.payload ? page.payload.fun_facts : []
delegate: Label {
required property var modelData
text: modelData
wrapMode: Text.WordWrap
Layout.fillWidth: true
font.pixelSize: 13
}
}
}
Card {
visible: page.metrics && page.metrics.tasks_completed.length > 0
title: "Closed today"
Repeater {
model: page.metrics ? page.metrics.tasks_completed : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
Label { text: "✅"; font.pixelSize: 12 }
Label { text: modelData.title; Layout.fillWidth: true; elide: Text.ElideRight }
Label { text: modelData.source; font.pixelSize: 11; opacity: 0.6 }
}
}
}
Card {
title: "All time"
RowLayout {
Layout.fillWidth: true
spacing: 12
StatTile {
label: "Pomodoros"
value: controller.stats.lifetime ? controller.stats.lifetime.pomodoros : "—"
}
StatTile {
label: "Hours focused"
value: controller.stats.lifetime ? controller.stats.lifetime.focus_hours : "—"
}
StatTile {
label: "Tasks closed"
value: controller.stats.lifetime ? controller.stats.lifetime.tasks_completed : "—"
}
StatTile {
label: "Blocked"
value: controller.stats.lifetime ? controller.stats.lifetime.blocked_attempts : "—"
}
}
}
Label {
visible: page.payload && page.payload.verdict !== ""
text: page.payload ? page.payload.verdict : ""
font.italic: true
font.pixelSize: 14
opacity: 0.85
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}
Component.onCompleted: controller.loadReport()
}
@@ -0,0 +1,391 @@
// Settings, split by where they live.
//
// Anything under "This computer" is local to this machine (and saved in
// ~/.config/kadence/desktop.json). Everything else is server-side, shared with
// the web app and the Telegram bot.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
readonly property var pomodoro: controller.settings.pomodoro ? controller.settings.pomodoro : null
readonly property var telegram: controller.settings.notifications
? controller.settings.notifications.telegram : null
readonly property var report: controller.settings.notifications
? controller.settings.notifications.report : null
function patchPomodoro(key, value) {
var patch = {}; patch[key] = value
controller.patchSettings({ pomodoro: patch })
}
function patchTelegram(key, value) {
var patch = {}; patch[key] = value
controller.patchSettings({ notifications: { telegram: patch } })
}
ColumnLayout {
width: page.availableWidth
spacing: 16
ColumnLayout {
Layout.fillWidth: true
Layout.margins: 20
spacing: 16
// ---- connection --------------------------------------------------
Card {
title: "Backend"
subtitle: controller.connected ? "Connected to " + controller.config.server_url
: controller.connectionError
RowLayout {
Layout.fillWidth: true
TextField {
id: serverField
Layout.fillWidth: true
text: controller.config.server_url
placeholderText: "http://localhost:8000"
}
TextField {
id: tokenField
Layout.preferredWidth: 180
text: controller.config.api_token
placeholderText: "API token (optional)"
echoMode: TextInput.Password
}
PrimaryButton {
text: "Connect"
onClicked: {
controller.setConfig("server_url", serverField.text.trim())
controller.setConfig("api_token", tokenField.text)
}
}
}
RowLayout {
Label { text: "Poll every"; font.pixelSize: 12 }
SpinBox {
from: 1; to: 60
value: controller.config.poll_seconds
onValueModified: controller.setConfig("poll_seconds", value)
}
Label { text: "seconds"; font.pixelSize: 12 }
Item { Layout.fillWidth: true }
Button { text: "Open the web app"; flat: true; onClicked: controller.openWebUi() }
}
}
// ---- timer -------------------------------------------------------
Card {
title: "Timer"
subtitle: "Shared with the web app and the Telegram bot."
GridLayout {
Layout.fillWidth: true
columns: page.width > 700 ? 4 : 2
columnSpacing: 16
rowSpacing: 8
ColumnLayout {
Label { text: "Focus (min)"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.work_minutes : 25
onValueModified: page.patchPomodoro("work_minutes", value)
}
}
ColumnLayout {
Label { text: "Short break"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 120
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
onValueModified: page.patchPomodoro("short_break_minutes", value)
}
}
ColumnLayout {
Label { text: "Long break"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
onValueModified: page.patchPomodoro("long_break_minutes", value)
}
}
ColumnLayout {
Label { text: "Long break every"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 12
value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4
onValueModified: page.patchPomodoro("cycles_before_long_break", value)
}
}
ColumnLayout {
Label { text: "Daily goal"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 40
value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8
onValueModified: page.patchPomodoro("daily_goal_pomodoros", value)
}
}
}
Flow {
Layout.fillWidth: true
spacing: 16
Switch {
text: "Auto-start breaks"
checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true
onToggled: page.patchPomodoro("auto_start_breaks", checked)
}
Switch {
text: "Auto-start next focus"
checked: page.pomodoro ? page.pomodoro.auto_start_work : false
onToggled: page.patchPomodoro("auto_start_work", checked)
}
Switch {
text: "Allow pausing"
checked: page.pomodoro ? page.pomodoro.allow_pause : true
onToggled: page.patchPomodoro("allow_pause", checked)
}
Switch {
text: "Strict mode"
checked: page.pomodoro ? page.pomodoro.strict_mode : false
onToggled: page.patchPomodoro("strict_mode", checked)
ToolTip.visible: hovered
ToolTip.text: "Abandoning a focus interval requires a reason."
}
}
}
// ---- desktop integration ----------------------------------------
Card {
title: "This computer"
subtitle: "Native desktop features. Stored locally, not on the server."
Flow {
Layout.fillWidth: true
spacing: 16
Switch {
text: "Tray icon"
checked: controller.config.tray_icon
onToggled: controller.setConfig("tray_icon", checked)
}
Switch {
text: "Close to tray"
checked: controller.config.close_to_tray
onToggled: controller.setConfig("close_to_tray", checked)
}
Switch {
text: "Notifications"
checked: controller.config.notifications
onToggled: controller.setConfig("notifications", checked)
}
Switch {
text: "Notification buttons"
checked: controller.config.notification_actions
onToggled: controller.setConfig("notification_actions", checked)
ToolTip.visible: hovered
ToolTip.text: "“Start break” / “Skip” directly on the notification."
}
Switch {
text: "Keep the screen awake"
checked: controller.config.inhibit_screensaver
onToggled: controller.setConfig("inhibit_screensaver", checked)
}
Switch {
text: "Do Not Disturb while focusing"
checked: controller.config.do_not_disturb
onToggled: controller.setConfig("do_not_disturb", checked)
}
Switch {
text: "Pause music when focus starts"
checked: controller.config.pause_media
onToggled: controller.setConfig("pause_media", checked)
ToolTip.visible: hovered
ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers."
}
Switch {
text: "Taskbar progress"
checked: controller.config.taskbar_progress
onToggled: controller.setConfig("taskbar_progress", checked)
}
Switch {
text: "Pause when the screen locks"
checked: controller.config.pause_on_lock
onToggled: controller.setConfig("pause_on_lock", checked)
}
Switch {
text: "D-Bus control"
checked: controller.config.dbus_service
onToggled: controller.setConfig("dbus_service", checked)
ToolTip.visible: hovered
ToolTip.text: "org.kadence.Desktop — bind it to a global shortcut."
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Pause after"; font.pixelSize: 12 }
SpinBox {
from: 0; to: 120
value: controller.config.pause_on_idle_minutes
onValueModified: controller.setConfig("pause_on_idle_minutes", value)
}
Label { text: "idle minutes (0 = never)"; font.pixelSize: 12 }
Item { Layout.fillWidth: true }
}
RowLayout {
Layout.fillWidth: true
Switch {
text: "Start with the session"
checked: controller.config.autostart
onToggled: controller.setConfig("autostart", checked)
}
Switch {
text: "systemd user service"
checked: controller.config.service_enabled
onToggled: controller.setConfig("service_enabled", checked)
ToolTip.visible: hovered
ToolTip.text: "Restarts the app if it dies; logs to the journal."
}
Switch {
text: "Start minimised"
checked: controller.config.start_minimized
onToggled: controller.setConfig("start_minimized", checked)
}
}
}
// ---- telegram ----------------------------------------------------
Card {
title: "Telegram"
subtitle: "Daily report, morning briefing and the nudges that get you back to work."
RowLayout {
Layout.fillWidth: true
Switch {
text: "Enabled"
checked: page.telegram ? page.telegram.enabled : false
onToggled: page.patchTelegram("enabled", checked)
}
TextField {
Layout.fillWidth: true
placeholderText: "Bot token from @BotFather"
text: page.telegram ? page.telegram.bot_token : ""
onEditingFinished: if (text !== "••••••••") page.patchTelegram("bot_token", text)
}
TextField {
Layout.preferredWidth: 160
placeholderText: "Chat id"
text: page.telegram ? page.telegram.chat_id : ""
onEditingFinished: page.patchTelegram("chat_id", text)
}
}
Flow {
Layout.fillWidth: true
spacing: 16
Switch {
text: "Daily report"
checked: page.telegram ? page.telegram.daily_report : true
onToggled: page.patchTelegram("daily_report", checked)
}
Switch {
text: "Morning briefing"
checked: page.telegram ? page.telegram.morning_briefing : true
onToggled: page.patchTelegram("morning_briefing", checked)
}
Switch {
text: "Weekly review"
checked: page.telegram ? page.telegram.weekly_report : true
onToggled: page.patchTelegram("weekly_report", checked)
}
Switch {
text: "Nudge me when idle"
checked: page.telegram ? page.telegram.nudge_enabled : true
onToggled: page.patchTelegram("nudge_enabled", checked)
}
Switch {
text: "Chat commands"
checked: page.telegram ? page.telegram.commands_enabled : true
onToggled: page.patchTelegram("commands_enabled", checked)
}
}
RowLayout {
Layout.fillWidth: true
ColumnLayout {
Label { text: "Report at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
TextField {
text: page.telegram ? page.telegram.daily_report_time : "21:00"
onEditingFinished: page.patchTelegram("daily_report_time", text)
}
}
ColumnLayout {
Label { text: "Briefing at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
TextField {
text: page.telegram ? page.telegram.morning_briefing_time : "09:00"
onEditingFinished: page.patchTelegram("morning_briefing_time", text)
}
}
ColumnLayout {
Label { text: "Nudge after (min idle)"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 10; to: 600
value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90
onValueModified: page.patchTelegram("nudge_after_idle_minutes", value)
}
}
ColumnLayout {
Label { text: "Tone"; font.pixelSize: 11; opacity: 0.7 }
ComboBox {
model: ["friendly", "coach", "drill_sergeant", "dry"]
currentIndex: page.report ? model.indexOf(page.report.tone) : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { tone: model[index] } } })
}
}
}
ColumnLayout {
Label { text: "Language"; font.pixelSize: 11; opacity: 0.7 }
ComboBox {
model: ["en", "ru"]
currentIndex: page.report && page.report.language === "ru" ? 1 : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { language: model[index] } } })
}
}
}
}
}
Card {
title: "Shortcuts and scripting"
subtitle: "The app exposes org.kadence.Desktop on the session bus."
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
font.family: "Roboto Mono, monospace"
font.pixelSize: 11
text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n"
+ "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n"
+ "kadence-desktop --toggle # same thing from a terminal\n\n"
+ "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
}
Label {
text: "Kadence " + appVersion + " · sources, blocking rules and reports live on the backend."
font.pixelSize: 11
opacity: 0.6
}
}
}
}
Component.onCompleted: controller.loadSettings()
}
+180
View File
@@ -0,0 +1,180 @@
// Everything you owe, from every source, grouped by deadline.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
Item {
id: page
function bucketTasks(bucket) {
var out = []
for (var i = 0; i < controller.tasks.length; i++) {
var task = controller.tasks[i]
if (!task.is_done && task.bucket === bucket) out.push(task)
}
return out
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 12
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: controller.tasks.length + " open"
font.pixelSize: 13
opacity: 0.7
}
Item { Layout.fillWidth: true }
Button { text: "+ Add"; onClicked: addDialog.open() }
PrimaryButton { text: "⟳ Refresh sources"; onClicked: controller.refreshSources() }
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
contentWidth: availableWidth
clip: true
ColumnLayout {
width: parent.parent.availableWidth
spacing: 12
Repeater {
model: [
{ key: "overdue", label: "Overdue", mark: "🔴" },
{ key: "today", label: "Due today", mark: "🟡" },
{ key: "upcoming", label: "Coming up", mark: "🔵" },
{ key: "someday", label: "No deadline", mark: "⚪" }
]
delegate: Card {
required property var modelData
property var items: page.bucketTasks(modelData.key)
visible: items.length > 0
title: modelData.mark + " " + modelData.label + " (" + items.length + ")"
Repeater {
model: parent.parent.items
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 10
CheckBox {
checked: modelData.is_done
onToggled: controller.setTaskDone(modelData.id, checked)
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: modelData.title
font.pixelSize: 14
elide: Text.ElideRight
Layout.fillWidth: true
}
RowLayout {
spacing: 6
Label {
text: modelData.source_label
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: !!modelData.project
text: "· " + modelData.project
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: modelData.days_overdue > 0
text: "· " + modelData.days_overdue + "d overdue"
font.pixelSize: 10
color: "#e34948"
}
Label {
visible: !!modelData.due_date && modelData.days_overdue === 0
text: "· " + modelData.due_date
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: !!modelData.priority
text: "· " + modelData.priority
font.pixelSize: 10
opacity: 0.65
}
}
}
ToolButton {
text: "↗"
visible: !!modelData.url
onClicked: controller.openUrl(modelData.url)
ToolTip.visible: hovered
ToolTip.text: "Open in " + modelData.source_label
}
ToolButton {
text: "▶"
onClicked: { controller.attachTask(modelData.id); controller.startWork(); window.page = 0 }
ToolTip.visible: hovered
ToolTip.text: "Start a pomodoro on this"
}
}
}
}
}
Card {
visible: controller.tasks.length === 0
title: "Nothing on the list"
subtitle: "Connect Jira, Todoist or Google Tasks in Settings → Sources, "
+ "or add something here yourself."
Button {
text: "Open settings"
onClicked: window.page = 4
}
}
}
}
}
Dialog {
id: addDialog
anchors.centerIn: Overlay.overlay
width: 380
modal: true
title: "New task"
standardButtons: Dialog.Cancel
ColumnLayout {
anchors.fill: parent
spacing: 10
TextField {
id: titleField
Layout.fillWidth: true
placeholderText: "What needs doing?"
}
TextField {
id: dueField
Layout.fillWidth: true
placeholderText: "Due date (YYYY-MM-DD, optional)"
inputMask: ""
}
PrimaryButton {
text: "Add"
enabled: titleField.text.trim() !== ""
Layout.alignment: Qt.AlignRight
onClicked: {
controller.createTask(titleField.text.trim(), dueField.text.trim())
titleField.text = ""; dueField.text = ""
addDialog.close()
}
}
}
}
}
+284
View File
@@ -0,0 +1,284 @@
// The timer page: the dial, the controls, and what you are working on.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
property var openTasks: {
var list = []
for (var i = 0; i < controller.tasks.length; i++)
if (!controller.tasks[i].is_done) list.push(controller.tasks[i])
return list
}
ColumnLayout {
width: page.availableWidth
spacing: 16
anchors.margins: 20
GridLayout {
Layout.fillWidth: true
Layout.margins: 20
columns: page.width > 900 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
// ---- dial + controls --------------------------------------------
Card {
Layout.fillWidth: true
Layout.preferredWidth: 1
TimerDial {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: Math.min(300, page.availableWidth * 0.7)
Layout.preferredHeight: Layout.preferredWidth
progress: controller.progress
clockText: controller.clockText
label: controller.running ? controller.kindLabel : "Next: " + controller.nextKindLabel
sublabel: controller.running
? (controller.taskTitle !== "" ? controller.taskTitle : " ")
: "Press start when you are ready"
accent: window.kindColor(controller.kind)
paused: controller.status === "paused"
}
Flow {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 8
spacing: 8
PrimaryButton {
visible: !controller.running
text: "▶ Start focus"
onClicked: controller.startWork()
}
Button {
visible: !controller.running
text: "☕ " + controller.nextKindLabel
onClicked: controller.startBreak()
}
Button {
visible: controller.running && controller.status !== "paused"
text: "⏸ Pause"
onClicked: controller.pause()
}
PrimaryButton {
visible: controller.running && controller.status === "paused"
text: "▶ Resume"
onClicked: controller.resume()
}
PrimaryButton {
visible: controller.running
text: "✓ Done"
onClicked: controller.complete()
}
Button {
visible: controller.running
text: "+5"
onClicked: controller.extend(5)
ToolTip.visible: hovered
ToolTip.text: "Add five minutes"
}
Button {
visible: controller.running
text: "🔔"
onClicked: controller.interrupt()
ToolTip.visible: hovered
ToolTip.text: "Count an interruption without stopping"
}
Button {
visible: controller.running
text: "⏹"
flat: true
Material.foreground: "#e34948"
onClicked: abandonDialog.open()
ToolTip.visible: hovered
ToolTip.text: "Abandon this interval"
}
}
Label {
Layout.alignment: Qt.AlignHCenter
visible: controller.interruptions > 0
text: controller.interruptions + " interruption(s) this interval"
font.pixelSize: 11
opacity: 0.6
}
Label {
Layout.alignment: Qt.AlignHCenter
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
font.pixelSize: 10
opacity: 0.45
}
}
// ---- today + task picker ----------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop
spacing: 16
Card {
title: "Today"
subtitle: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
+ " before a long break"
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: controller.completedToday
font.pixelSize: 34
font.weight: Font.Light
}
Label {
text: "/ " + controller.goal + " pomodoros"
font.pixelSize: 16
opacity: 0.7
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 6
}
Item { Layout.fillWidth: true }
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: Math.max(controller.goal, 1)
value: Math.min(controller.completedToday, controller.goal)
}
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: Math.max(controller.goal, controller.completedToday)
delegate: Rectangle {
required property int index
width: 14; height: 14; radius: 7
color: index < controller.completedToday ? window.workColor : "transparent"
border.width: index < controller.completedToday ? 0 : 1
border.color: Material.hintTextColor
}
}
}
}
Card {
title: "What are you working on?"
ComboBox {
id: taskPicker
Layout.fillWidth: true
model: {
var names = ["No task"]
for (var i = 0; i < page.openTasks.length; i++)
names.push((page.openTasks[i].pinned ? "📌 " : "")
+ page.openTasks[i].title
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
return names
}
onActivated: function (index) {
controller.attachTask(index === 0 ? -1 : page.openTasks[index - 1].id)
}
}
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
font.pixelSize: 12
opacity: 0.7
text: controller.taskTitle !== ""
? "This interval is tracked against “" + controller.taskTitle + "”."
: "Pick a task so the daily report knows where the time went."
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: "🔴 " + (controller.taskCounts.overdue || 0) + " overdue"
font.pixelSize: 12
}
Label {
text: "🟡 " + (controller.taskCounts.today || 0) + " due today"
font.pixelSize: 12
}
Item { Layout.fillWidth: true }
Button { text: "Tasks"; flat: true; onClicked: window.page = 1 }
}
}
}
}
// ---- the day's numbers ------------------------------------------------
GridLayout {
Layout.fillWidth: true
Layout.leftMargin: 20
Layout.rightMargin: 20
Layout.bottomMargin: 20
columns: page.width > 760 ? 4 : 2
columnSpacing: 12
rowSpacing: 12
StatTile {
label: "Focus today"
value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—"
caption: controller.completedToday + " pomodoros"
}
StatTile {
label: "Longest stretch"
value: controller.stats.today ? controller.stats.today.longest_stretch_minutes + "m" : "—"
caption: "unbroken focus"
}
StatTile {
label: "Streak"
value: controller.stats.streak ? controller.stats.streak.current + "d" : "—"
caption: controller.stats.streak ? "best " + controller.stats.streak.best : ""
}
StatTile {
label: "Blocked"
value: controller.stats.today ? controller.stats.today.blocked_attempts : "—"
caption: "temptations turned away"
}
}
}
Dialog {
id: abandonDialog
anchors.centerIn: Overlay.overlay
width: 380
modal: true
title: "Abandon this interval?"
standardButtons: Dialog.Cancel
ColumnLayout {
anchors.fill: parent
spacing: 10
Label {
text: controller.clockText + " left. The interval is recorded as abandoned."
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
TextField {
id: reasonField
Layout.fillWidth: true
placeholderText: "What happened? (optional)"
}
Button {
text: "Abandon"
Material.background: "#e34948"
Material.foreground: "white"
Layout.alignment: Qt.AlignRight
onClicked: { controller.abandon(reasonField.text); reasonField.text = ""; abandonDialog.close() }
}
}
}
}
@@ -0,0 +1,36 @@
// An inline warning strip — used for "the backend is gone" and "the helper is
// not installed", the two states where the app cannot do its job.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Rectangle {
id: banner
property string text: ""
property string actionText: ""
signal actionClicked()
implicitHeight: visible ? row.implicitHeight + 20 : 0
color: "#e34948"
opacity: 0.92
RowLayout {
id: row
anchors.fill: parent
anchors.margins: 10
spacing: 12
Label {
text: banner.text
color: "white"
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Button {
visible: banner.actionText !== ""
text: banner.actionText
flat: true
Material.foreground: "white"
onClicked: banner.actionClicked()
}
}
}
@@ -0,0 +1,79 @@
// A tiny bar chart: magnitude over time, one hue, baseline at zero.
//
// The ramp step says "did this day clear the goal" without adding a second
// colour, and every bar carries its own tooltip, so no bar needs a label.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Item {
id: chart
property var days: [] // [{date, pomodoros, focus_minutes, goal_met}]
property int goal: 8
property int barHeight: 110
implicitHeight: barHeight + 18
readonly property int maxValue: {
var top = goal
for (var i = 0; i < days.length; i++) top = Math.max(top, days[i].pomodoros)
return Math.max(top, 1)
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight
height: 1
color: Material.hintTextColor
opacity: 0.45
visible: chart.goal > 0
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
height: chart.barHeight
spacing: 2
Repeater {
model: chart.days
delegate: Item {
required property var modelData
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: Math.max(modelData.pomodoros > 0
? (modelData.pomodoros / chart.maxValue) * chart.barHeight : 2, 2)
radius: 4
color: modelData.pomodoros === 0 ? Material.dividerColor
: (modelData.goal_met ? "#256abf" : "#6da7ec")
ToolTip.visible: hover.hovered
ToolTip.text: modelData.date + ": " + modelData.pomodoros
+ " pomodoros · " + modelData.focus_minutes + " min"
HoverHandler { id: hover }
Behavior on height { NumberAnimation { duration: 250 } }
}
}
}
}
RowLayout {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
Label {
text: chart.days.length ? chart.days[0].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6
}
Item { Layout.fillWidth: true }
Label {
text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6
}
}
}
@@ -0,0 +1,51 @@
// A Material surface with a title. Used everywhere instead of bare Panes so the
// pages share one radius, one padding and one elevation.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Pane {
id: card
property string title: ""
property string subtitle: ""
default property alias content: inner.data
Material.elevation: 1
Material.roundedScale: Material.LargeScale
padding: 18
Layout.fillWidth: true
background: Rectangle {
radius: 18
color: Material.dialogColor
border.width: 1
border.color: Material.dividerColor
}
ColumnLayout {
anchors.fill: parent
spacing: 10
ColumnLayout {
spacing: 2
visible: card.title !== ""
Layout.fillWidth: true
Label { text: card.title; font.pixelSize: 16; font.weight: Font.DemiBold }
Label {
text: card.subtitle
visible: card.subtitle !== ""
font.pixelSize: 12
opacity: 0.7
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
ColumnLayout {
id: inner
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 10
}
}
}
@@ -0,0 +1,34 @@
// The filled Material button, painted explicitly.
//
// Relying on `highlighted: true` leaves the fill to the style, and under some
// renderers that fill never appears — white text on a white surface. The
// primary action on a page is exactly the thing that must never be invisible,
// so it draws its own pill.
import QtQuick
import QtQuick.Controls.Material
Button {
id: control
Material.foreground: "white"
font.weight: Font.Medium
leftPadding: 20
rightPadding: 20
implicitHeight: 40
background: Rectangle {
radius: height / 2
color: control.enabled ? Material.accent : Material.buttonDisabledColor
opacity: control.pressed ? 0.82 : (control.hovered ? 0.92 : 1)
Behavior on opacity { NumberAnimation { duration: 120 } }
}
contentItem: Label {
text: control.text
font: control.font
color: control.enabled ? "white" : Material.hintTextColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
@@ -0,0 +1,33 @@
// Transient messages. Material's own popup, anchored to the bottom.
import QtQuick
import QtQuick.Controls.Material
Popup {
id: snackbar
property string message: ""
function show(text) {
message = text
open()
timer.restart()
}
x: Math.round((parent.width - width) / 2)
y: parent.height - height - 72
width: Math.min(parent.width - 48, Math.max(260, label.implicitWidth + 48))
padding: 16
modal: false
focus: false
closePolicy: Popup.NoAutoClose
Material.elevation: 6
Material.roundedScale: Material.SmallScale
Label {
id: label
anchors.fill: parent
text: snackbar.message
wrapMode: Text.WordWrap
}
Timer { id: timer; interval: 5000; onTriggered: snackbar.close() }
}
@@ -0,0 +1,38 @@
// One number, read at a glance. Not a chart.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Pane {
property string label: ""
property string value: "—"
property string caption: ""
property color accent: Material.foreground
Material.elevation: 1
Material.roundedScale: Material.LargeScale
padding: 16
Layout.fillWidth: true
background: Rectangle {
radius: 16
color: Material.dialogColor
border.width: 1
border.color: Material.dividerColor
}
ColumnLayout {
anchors.fill: parent
spacing: 2
Label { text: label.toUpperCase(); font.pixelSize: 10; font.letterSpacing: 1; opacity: 0.6 }
Label { text: value; font.pixelSize: 26; font.weight: Font.Light; color: accent }
Label {
text: caption
visible: caption !== ""
font.pixelSize: 11
opacity: 0.7
Layout.fillWidth: true
elide: Text.ElideRight
}
}
}
@@ -0,0 +1,29 @@
// The little "what is the timer doing" chip, shown in the rail and the toolbar.
import QtQuick
import QtQuick.Controls.Material
Rectangle {
property bool running: false
property bool paused: false
property bool connected: true
property string text: ""
property color accent: "#2a78d6"
implicitWidth: Math.max(78, label.implicitWidth + 28)
implicitHeight: 32
radius: 16
color: !connected ? "#e34948" : (running ? accent : "transparent")
opacity: paused ? 0.6 : 1
border.width: running || !connected ? 0 : 1
border.color: Material.hintTextColor
Label {
id: label
anchors.centerIn: parent
text: parent.text
font.pixelSize: 13
font.weight: Font.DemiBold
font.family: "Roboto Mono, monospace"
color: (parent.running || !parent.connected) ? "white" : Material.foreground
}
}
@@ -0,0 +1,87 @@
// The countdown ring, drawn with Canvas.
//
// A hero number inside a progress arc: the number is what you read, the arc is
// the glance. The arc colour is the only thing that says focus vs break.
import QtQuick
import QtQuick.Controls.Material
Item {
id: dial
property real progress: 0 // 0..1 elapsed
property string clockText: "00:00"
property string label: ""
property string sublabel: ""
property color accent: "#2a78d6"
property bool paused: false
implicitWidth: 260
implicitHeight: 260
onProgressChanged: canvas.requestPaint()
onAccentChanged: canvas.requestPaint()
onPausedChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var centre = Math.min(width, height) / 2
var stroke = Math.max(10, centre * 0.11)
var radius = centre - stroke
ctx.lineCap = "round"
ctx.lineWidth = stroke
ctx.beginPath()
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.18)
ctx.arc(centre, centre, radius, 0, Math.PI * 2)
ctx.stroke()
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
if (remaining > 0) {
ctx.beginPath()
ctx.globalAlpha = dial.paused ? 0.45 : 1
ctx.strokeStyle = dial.accent
ctx.arc(centre, centre, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
ctx.stroke()
}
}
}
Column {
anchors.centerIn: parent
spacing: 4
width: parent.width * 0.72
Label {
width: parent.width
text: dial.label.toUpperCase()
font.pixelSize: 11
font.letterSpacing: 1.5
opacity: 0.65
horizontalAlignment: Text.AlignHCenter
}
Label {
width: parent.width
text: dial.clockText
font.pixelSize: Math.round(dial.width * 0.2)
font.weight: Font.Light
font.family: "Roboto Mono, monospace"
opacity: dial.paused ? 0.6 : 1
horizontalAlignment: Text.AlignHCenter
}
Label {
width: parent.width
text: dial.sublabel
visible: dial.sublabel !== ""
font.pixelSize: 13
opacity: 0.75
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
}
}
+186
View File
@@ -0,0 +1,186 @@
"""The tray icon — on KDE this becomes a StatusNotifierItem, so it behaves.
The icon is painted at runtime rather than loaded from a file, because it *is*
the timer: a ring that empties as the interval runs, tinted by what is running
(focus / break / idle). With the window closed you still have a countdown, a
tooltip with the full status, and a menu that can drive everything.
"""
from __future__ import annotations
from PyQt6.QtCore import QObject, QRectF, Qt, pyqtSignal
from PyQt6.QtGui import QAction, QColor, QIcon, QPainter, QPen, QPixmap
from PyQt6.QtWidgets import QMenu, QSystemTrayIcon
# The same validated hues the charts use, so the tray matches the app.
COLOR_WORK = QColor("#2a78d6")
COLOR_SHORT = QColor("#eb6834")
COLOR_LONG = QColor("#1baf7a")
COLOR_IDLE = QColor("#8a8a8a")
COLOR_OFFLINE = QColor("#e34948")
_SIZE = 64
def _state_color(kind: str, running: bool, connected: bool) -> QColor:
if not connected:
return COLOR_OFFLINE
if not running:
return COLOR_IDLE
return {"work": COLOR_WORK, "short_break": COLOR_SHORT, "long_break": COLOR_LONG}.get(kind, COLOR_WORK)
def render_icon(progress: float, kind: str, running: bool, connected: bool, paused: bool) -> QIcon:
"""A ring that drains as the interval runs."""
pixmap = QPixmap(_SIZE, _SIZE)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = _state_color(kind, running, connected)
rect = QRectF(7, 7, _SIZE - 14, _SIZE - 14)
track = QColor(color)
track.setAlpha(60)
painter.setPen(QPen(track, 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
painter.drawArc(rect, 0, 360 * 16)
if running:
painter.setPen(QPen(color, 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
span = int(-360 * 16 * max(0.0, min(1.0, 1 - progress)))
painter.drawArc(rect, 90 * 16, span)
if paused:
# Two bars in the middle: unmistakably "on hold".
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawRect(QRectF(24, 23, 6, 18))
painter.drawRect(QRectF(34, 23, 6, 18))
else:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawEllipse(QRectF(26, 26, 12, 12))
painter.end()
return QIcon(pixmap)
class Tray(QObject):
"""Tray icon plus its menu. Every entry maps to a controller slot."""
showRequested = pyqtSignal()
quitRequested = pyqtSignal()
def __init__(self, controller, parent: QObject | None = None) -> None:
super().__init__(parent)
self._controller = controller
self._icon = QSystemTrayIcon(parent)
self._menu = QMenu()
self._build_menu()
self._icon.setContextMenu(self._menu)
self._icon.activated.connect(self._on_activated)
controller.stateChanged.connect(self.refresh)
controller.connectionChanged.connect(self.refresh)
controller.blockerChanged.connect(self.refresh)
self.refresh()
def show(self) -> None:
self._icon.show()
def hide(self) -> None:
self._icon.hide()
@property
def available(self) -> bool:
return QSystemTrayIcon.isSystemTrayAvailable()
def _build_menu(self) -> None:
controller = self._controller
self._action_status = QAction("…", self._menu)
self._action_status.setEnabled(False)
self._menu.addAction(self._action_status)
self._menu.addSeparator()
self._action_start = QAction("Start focus", self._menu)
self._action_start.triggered.connect(lambda: controller.startWork())
self._menu.addAction(self._action_start)
self._action_break = QAction("Start break", self._menu)
self._action_break.triggered.connect(controller.startBreak)
self._menu.addAction(self._action_break)
self._action_pause = QAction("Pause", self._menu)
self._action_pause.triggered.connect(controller.pause)
self._menu.addAction(self._action_pause)
self._action_resume = QAction("Resume", self._menu)
self._action_resume.triggered.connect(controller.resume)
self._menu.addAction(self._action_resume)
self._action_done = QAction("Finish now", self._menu)
self._action_done.triggered.connect(controller.complete)
self._menu.addAction(self._action_done)
self._action_extend = QAction("+5 minutes", self._menu)
self._action_extend.triggered.connect(lambda: controller.extend(5))
self._menu.addAction(self._action_extend)
self._action_abandon = QAction("Abandon", self._menu)
self._action_abandon.triggered.connect(lambda: controller.abandon("abandoned from the tray"))
self._menu.addAction(self._action_abandon)
self._menu.addSeparator()
report = QAction("Send today's report", self._menu)
report.triggered.connect(controller.sendReport)
self._menu.addAction(report)
refresh = QAction("Refresh tasks", self._menu)
refresh.triggered.connect(controller.refreshSources)
self._menu.addAction(refresh)
self._menu.addSeparator()
show = QAction("Open Kadence", self._menu)
show.triggered.connect(self.showRequested.emit)
self._menu.addAction(show)
web = QAction("Open the web app", self._menu)
web.triggered.connect(controller.openWebUi)
self._menu.addAction(web)
self._menu.addSeparator()
quit_action = QAction("Quit", self._menu)
quit_action.triggered.connect(self.quitRequested.emit)
self._menu.addAction(quit_action)
def refresh(self) -> None:
controller = self._controller
running = controller.running
paused = controller.status == "paused"
self._icon.setIcon(
render_icon(controller.progress, controller.kind, running, controller.connected, paused)
)
status = controller.statusLine()
blocker = " · 🛡 blocking" if controller.blockerActive else ""
self._icon.setToolTip(f"{status}{blocker}")
self._action_status.setText(status)
self._action_start.setVisible(not running)
self._action_break.setVisible(not running)
self._action_pause.setVisible(running and not paused)
self._action_resume.setVisible(running and paused)
self._action_done.setVisible(running)
self._action_extend.setVisible(running)
self._action_abandon.setVisible(running)
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
# Left click toggles the window; middle click toggles the timer, which is
# the fastest "start/pause" on the whole desktop.
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self.showRequested.emit()
elif reason == QSystemTrayIcon.ActivationReason.MiddleClick:
self._controller.toggle()
def message(self, title: str, body: str) -> None:
self._icon.showMessage(title, body, QSystemTrayIcon.MessageIcon.Information, 5000)
+164
View File
@@ -0,0 +1,164 @@
"""The network thread.
Every HTTP call happens here, on a worker thread, so a slow or dead backend can
never freeze the window — the UI keeps ticking its countdown from the last known
state and shows a "reconnecting" chip instead.
Commands go in through a queued slot and come back out as signals; the GUI side
never touches `Api` directly.
"""
from __future__ import annotations
import logging
from typing import Any
from PyQt6.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot
from kadence_desktop.api import Api, ApiError
log = logging.getLogger(__name__)
class ApiWorker(QObject):
stateChanged = pyqtSignal(dict)
policyChanged = pyqtSignal(dict)
tasksChanged = pyqtSignal(dict)
statsChanged = pyqtSignal(dict)
reportChanged = pyqtSignal(dict)
settingsChanged = pyqtSignal(dict)
connectionChanged = pyqtSignal(bool, str)
commandFailed = pyqtSignal(str, str)
commandDone = pyqtSignal(str, dict)
def __init__(self, api: Api, poll_seconds: int = 5) -> None:
super().__init__()
self._api = api
self._poll_seconds = poll_seconds
self._timer: QTimer | None = None
self._connected: bool | None = None
self._slow_tick = 0
# ---- lifecycle ---------------------------------------------------------
@pyqtSlot()
def start(self) -> None:
"""Called once the worker is on its own thread."""
self._timer = QTimer(self)
self._timer.timeout.connect(self.poll)
self._timer.start(max(1, self._poll_seconds) * 1000)
self.poll()
self.refresh_tasks()
self.refresh_stats()
@pyqtSlot(int)
def set_interval(self, seconds: int) -> None:
if self._timer:
self._timer.start(max(1, seconds) * 1000)
@pyqtSlot(str, str)
def configure(self, base_url: str, token: str) -> None:
self._api.configure(base_url, token)
self._connected = None
self.poll()
# ---- polling -----------------------------------------------------------
@pyqtSlot()
def poll(self) -> None:
try:
state = self._api.timer_state()
policy = self._api.blocker_policy()
except ApiError as exc:
self._set_connected(False, str(exc))
return
self._set_connected(True, "")
self.stateChanged.emit(state)
self.policyChanged.emit(policy)
# Task and stats data changes far more slowly than the clock does.
self._slow_tick += 1
if self._slow_tick % 12 == 0:
self.refresh_tasks()
self.refresh_stats()
def _set_connected(self, connected: bool, detail: str) -> None:
if connected != self._connected:
self._connected = connected
self.connectionChanged.emit(connected, detail)
@pyqtSlot()
def refresh_tasks(self) -> None:
try:
self.tasksChanged.emit(self._api.tasks())
except ApiError as exc:
log.debug("tasks: %s", exc)
@pyqtSlot()
def refresh_stats(self) -> None:
try:
self.statsChanged.emit(self._api.stats())
except ApiError as exc:
log.debug("stats: %s", exc)
@pyqtSlot()
def refresh_report(self) -> None:
try:
self.reportChanged.emit(self._api.daily_report())
except ApiError as exc:
self.commandFailed.emit("report", str(exc))
@pyqtSlot()
def refresh_settings(self) -> None:
try:
self.settingsChanged.emit(self._api.settings())
except ApiError as exc:
self.commandFailed.emit("settings", str(exc))
# ---- commands ----------------------------------------------------------
@pyqtSlot(str, dict)
def command(self, name: str, args: dict[str, Any]) -> None:
"""Run one API action by name and re-poll so the UI lands on the truth."""
handlers = {
"start": lambda: self._api.start(args.get("kind"), args.get("task_id"), args.get("minutes")),
"pause": self._api.pause,
"resume": self._api.resume,
"complete": lambda: self._api.complete(bool(args.get("auto_next", True))),
"abandon": lambda: self._api.abandon(args.get("reason")),
"extend": lambda: self._api.extend(int(args.get("minutes", 5))),
"interrupt": self._api.interrupt,
"attach_task": lambda: self._api.attach_task(args.get("task_id")),
"task_done": lambda: self._api.task_done(int(args["task_id"]), bool(args.get("done", True))),
"create_task": lambda: self._api.create_task(str(args["title"]), args.get("due_date")),
"refresh_sources": self._api.refresh_tasks,
"send_report": self._api.send_report,
"patch_settings": lambda: self._api.patch_settings(dict(args.get("patch", {}))),
"report_block": lambda: self._api.report_block(
str(args.get("kind", "app")),
str(args.get("target", "")),
str(args.get("action", "blocked")),
args.get("details"),
),
}
handler = handlers.get(name)
if handler is None:
self.commandFailed.emit(name, f"unknown command {name!r}")
return
try:
result = handler()
except ApiError as exc:
self.commandFailed.emit(name, str(exc))
return
except Exception as exc: # pragma: no cover
log.exception("command %s blew up", name)
self.commandFailed.emit(name, str(exc))
return
self.commandDone.emit(name, result if isinstance(result, dict) else {})
if name in ("refresh_sources", "task_done", "create_task"):
self.refresh_tasks()
if name == "patch_settings":
self.refresh_settings()
if name != "report_block":
self.poll()