"""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, QQmlComponent 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.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. 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() if view_only: self._config.view_only = True 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._mini = self._create_mini_window() 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) # 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._window.visibleChanged.connect(self._update_mini) self._update_mini() self._place_mini() 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) # ---- the floating mini timer -------------------------------------------- def _create_mini_window(self) -> QObject | None: """Build MiniTimer.qml as a top-level window of its own.""" component = QQmlComponent(self._engine, QUrl.fromLocalFile(str(QML_DIR / "MiniTimer.qml"))) if component.isError(): # pragma: no cover - a QML typo, caught in dev for error in component.errors(): log.error("mini timer: %s", 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 on # `self._mini` is what keeps it alive. window = component.create() if window is not None: window.setProperty("objectName", "miniTimer") return window def _update_mini(self) -> None: """Show it when pinned, or when an interval runs with the window away.""" if self._mini is None: return config = self._config wanted = bool(config.mini_timer) and ( self._controller.miniVisible or ( config.mini_timer_auto and self._controller.running and not self._window.isVisible() 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 the app is on — panels excluded.""" screen = self._window.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. """ if self._mini is None: return 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 252), 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) # ---- 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], "toggle_mini": controller.toggleMini, "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: if self._mini is not None: self._mini.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)