init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,4 @@
|
||||
"""Native desktop client for Kadence (PyQt6 + QML, Material style)."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
APP_ID = "org.kadence.Desktop"
|
||||
@@ -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())
|
||||
@@ -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}"
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user