The design of the full desktop app was still bad, and the reason it stayed bad is that it had no business existing. Five pages — Timer, Tasks, Blocker, Report, Settings — each a second view of the *server's* data: a second place to be wrong, a second place to style, and a second place to keep in step with a web app that already does lists, rules and reports properly in a browser, on any device. So it is gone, along with 2,100 lines of QML and the controller surface that fed it. What is left is what only a program on your machine can do, plus the two surfaces worth having in front of you: * **the tray menu — unchanged**, as asked. All fourteen entries, plus "Settings…". "Open Kadence" now brings the countdown up, because that is what it meant all along. * **the mini timer**, which is now the whole of this app's own interface and so finally carries the house style on its own: warm surface, hairline instead of shadow, a letterspaced eyebrow, the progress lane as a rule, and exactly one filled button — whichever action is the obvious next one. The countdown is set in the display serif on a **fixed advance**, because Fraunces has no tabular figures and ships no `tnum`: each glyph now sits in a box as wide as the widest digit, instead of the clock shifting sideways on every tick. The phone client documents the same trap; the desktop had it too and nobody had looked. It also says OFFLINE when the backend is away — the count and the clock are both the server's, and this is the only window left to say it in. * **a settings dialog**, because a fresh install has to be pointed at a backend somewhere. One column, hairline sections: the backend and its token, where the mini timer sits, what this machine enforces, and what it does to the session. It edits `desktop.json` and nothing else — with one honest exception, view-only mode, which is a server-side flag the other clients have to honour too. Structurally, `app.py` no longer loads a QML root at all: a plain `QQmlEngine` with the mini timer as a component, and the dialog built on demand. Everything that used to ask "is the main window visible?" is simpler for not having one — the mini timer's auto-show rule is now just "an interval is running", which is what the setting always claimed. The window-only controller surface goes with the pages: tasks and task writes, stats, the daily report, the settings document, the presets, the cycle and interruption counters, and their worker jobs and API calls. Two things were kept deliberately: the task list is still read once a minute for one fact — which task is pinned, so "Start focus" from the tray attaches what you pinned this morning — and `timerEnabled` now comes from the timer state, which already carries `timer_enabled`, rather than from a second fetch of a document this app can no longer edit. Also: `--settings` and a `Settings` D-Bus verb; toasts go to the tray's balloon now that the snackbar is gone; the launcher entry starts the agent and grew a Settings action; a systemd user unit is shipped rather than only written at runtime; and both READMEs say what this piece is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
12 KiB
Python
274 lines
12 KiB
Python
"""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)
|