"""Application wiring: Qt, QML, the tray, and the D-Bus service. Kadence on the desktop is an **agent**, not a window. It sits in the tray, enforces the server's blocking policy on this machine, answers D-Bus, posts notifications with buttons, and shows a floating countdown. There is no main window: the rules, the task lists, the report and the interval lengths are the server's, edited in the web app, and a second place to edit them was a second place to be wrong. So two surfaces, both created here: * **`MiniTimer.qml`** — the countdown, and the only interface this app keeps. It is the QML root in spirit, though not as a `QQmlApplicationEngine` root: that would tie its lifetime to the engine's, and the window has to be able to come and go all day. * **`SettingsDialog.qml`** — built on demand, for the handful of settings that really do belong to this machine (which backend, and how this box behaves). Qt Quick Controls' **Material** style gives real Material 3 widgets rendered by Qt itself — no web view, no bundled browser; both windows are native Wayland surfaces 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 QQmlComponent, QQmlEngine 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.integrations.placement import Rect, point_for 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, view_only: 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 — # which, for an agent, is most of the time. 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()) # Nothing is tied to a window's lifetime: closing the mini timer must # not take the blocker and the tray down with it. self._app.setQuitOnLastWindowClosed(False) self._config = DesktopConfig.load() if view_only: self._config.view_only = True self._controller = Controller(self._config) # A plain QQmlEngine, not QQmlApplicationEngine: there is no root # document to load. Both windows are components created from it. self._engine = QQmlEngine(self) self._engine.rootContext().setContextProperty("controller", self._controller) self._engine.rootContext().setContextProperty("appVersion", __version__) self._engine.addImportPath(str(QML_DIR)) self._mini = self._create_window("MiniTimer.qml", "miniTimer") if self._mini is None: raise SystemExit("Could not load the mini timer.") self._settings: QObject | None = None 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.settingsRequested.connect(self.show_settings) self._tray.quitRequested.connect(self.quit) # The main window's snackbar used to catch these. An agent's # equivalent is the tray's own balloon — and these are worth seeing: # "the KWin rule was written", "Pulling Jira, Todoist…", or the # reason a toggle refused. self._controller.toast.connect(lambda text: self._tray.message("Kadence", text)) self._tray.show() self._controller.showWindowRequested.connect(self.show_window) self._controller.settingsRequested.connect(self.show_settings) self._app.aboutToQuit.connect(self._controller.shutdown) # Anything that can change "should the mini timer be up?" lands here. self._controller.miniChanged.connect(self._update_mini) self._controller.miniPlacementChanged.connect(self._place_mini) self._controller.stateChanged.connect(self._update_mini) self._controller.configChanged.connect(self._update_mini) self._update_mini() self._place_mini() if self._config.dbus_service: self._register_dbus() # `--minimized` and "start minimised" mean the same thing they always # did: come up with no window. The difference is that it is now the # normal way to run — the agent needs no window to do its job. if not (minimized or self._config.start_minimized): 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) # ---- the windows -------------------------------------------------------- def _create_window(self, file_name: str, object_name: str) -> QObject | None: """Build one QML window as a top-level of its own.""" component = QQmlComponent(self._engine, QUrl.fromLocalFile(str(QML_DIR / file_name))) if component.isError(): # pragma: no cover - a QML typo, caught in dev for error in component.errors(): log.error("%s: %s", file_name, error.toString()) return None # No parent on purpose: a parented QML Window becomes transient and # cannot outlive — or out-show — its parent. The Python reference is # what keeps it alive. window = component.create() if window is not None: window.setProperty("objectName", object_name) return window def _update_mini(self) -> None: """Show it when pinned, or by itself while an interval runs. The second clause used to also require the main window to be hidden. With no main window the rule is simply "an interval is running", which is what the setting says on the tin. """ config = self._config wanted = bool(config.mini_timer) and ( self._controller.miniVisible or ( config.mini_timer_auto and self._controller.running and not self._controller.mini_dismissed ) ) if bool(self._mini.property("visible")) != wanted: if wanted: # Each time it comes up it lands where you asked for, even if it # was dragged elsewhere during the last interval. self._place_mini() self._mini.setProperty("visible", wanted) def _mini_screen_rect(self) -> Rect: """The usable area of the screen it is on — panels excluded.""" screen = self._mini.screen() or self._app.primaryScreen() area = screen.availableGeometry() return Rect(area.x(), area.y(), area.width(), area.height()) def _place_mini(self) -> None: """Put the mini timer in the configured corner. Setting `x`/`y` is what works on X11; on Wayland the client may not place itself, so the same point goes into the KWin rule, which is honoured when the window is mapped. """ config = self._config if config.mini_timer_corner == "remember": self._controller.ensure_keep_above(None) if config.mini_timer_x >= 0 and config.mini_timer_y >= 0: self._mini.setProperty("x", config.mini_timer_x) self._mini.setProperty("y", config.mini_timer_y) return size = ( int(self._mini.property("width") or 268), int(self._mini.property("height") or 152), ) point = point_for( config.mini_timer_corner, self._mini_screen_rect(), size, config.mini_timer_margin ) self._mini.setProperty("x", point[0]) self._mini.setProperty("y", point[1]) self._controller.ensure_keep_above(point) def show_window(self) -> None: """Bring the countdown up — the tray's "Open Kadence", and `--mini`.""" self._controller.setMiniVisible(True) def toggle_window(self) -> None: self._controller.toggleMini() def show_settings(self) -> None: """Open the settings dialog, building it the first time it is asked for.""" if self._settings is None: self._settings = self._create_window("SettingsDialog.qml", "settingsDialog") if self._settings is None: # pragma: no cover - a QML typo return self._settings.setProperty("visible", True) for call in ("raise", "requestActivate"): method = getattr(self._settings, call, None) if callable(method): method() # ---- 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], "toggle_mini": controller.toggleMini, # `--raise` and a second launch both land here. There is one window # to raise now, and it is the countdown. "raise_window": lambda: (self.show_window(), "shown")[1], "settings": lambda: (self.show_settings(), "settings opened")[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._mini.setProperty("visible", False) if self._settings is not None: self._settings.setProperty("visible", False) 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)