Files
kadence/desktop/kadence_desktop/app.py
T
2026-10-19 09:19:47 +03:00

238 lines
9.8 KiB
Python

"""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)