desktop: an agent and a mini timer, not an application window
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>
This commit is contained in:
26 files changed
+752
-2690
No files matched your search
@@ -1,8 +1,24 @@
|
||||
"""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; the window is a native Wayland
|
||||
surface with the session's own fonts, scaling and colour scheme.
|
||||
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
|
||||
@@ -20,8 +36,7 @@ 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.QtQml import QQmlComponent, QQmlEngine
|
||||
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon
|
||||
|
||||
from kadence_desktop import APP_ID, __version__
|
||||
@@ -42,7 +57,8 @@ class KadenceApp(QObject):
|
||||
) -> 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.
|
||||
# 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")
|
||||
@@ -52,6 +68,8 @@ class KadenceApp(QObject):
|
||||
# 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()
|
||||
@@ -59,25 +77,33 @@ class KadenceApp(QObject):
|
||||
self._config.view_only = True
|
||||
self._controller = Controller(self._config)
|
||||
|
||||
self._engine = QQmlApplicationEngine()
|
||||
# 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._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._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.
|
||||
@@ -85,16 +111,16 @@ class KadenceApp(QObject):
|
||||
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:
|
||||
# `--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
|
||||
@@ -104,34 +130,36 @@ class KadenceApp(QObject):
|
||||
self._wakeup.start(500)
|
||||
self._wakeup.timeout.connect(lambda: None)
|
||||
|
||||
# ---- the floating mini timer --------------------------------------------
|
||||
# ---- the windows --------------------------------------------------------
|
||||
|
||||
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")))
|
||||
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("mini timer: %s", error.toString())
|
||||
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 on
|
||||
# `self._mini` is what keeps it alive.
|
||||
# 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", "miniTimer")
|
||||
window.setProperty("objectName", object_name)
|
||||
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
|
||||
"""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._window.isVisible()
|
||||
and not self._controller.mini_dismissed
|
||||
)
|
||||
)
|
||||
@@ -143,8 +171,8 @@ class KadenceApp(QObject):
|
||||
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()
|
||||
"""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())
|
||||
|
||||
@@ -155,8 +183,6 @@ class KadenceApp(QObject):
|
||||
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)
|
||||
@@ -166,7 +192,7 @@ class KadenceApp(QObject):
|
||||
return
|
||||
|
||||
size = (
|
||||
int(self._mini.property("width") or 252),
|
||||
int(self._mini.property("width") or 268),
|
||||
int(self._mini.property("height") or 152),
|
||||
)
|
||||
point = point_for(
|
||||
@@ -176,18 +202,24 @@ class KadenceApp(QObject):
|
||||
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()
|
||||
"""Bring the countdown up — the tray's "Open Kadence", and `--mini`."""
|
||||
self._controller.setMiniVisible(True)
|
||||
|
||||
def toggle_window(self) -> None:
|
||||
if self._window.isVisible() and self._window.isActive():
|
||||
self._window.hide()
|
||||
else:
|
||||
self.show_window()
|
||||
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 -------------------------------------------------------------
|
||||
|
||||
@@ -206,7 +238,10 @@ class KadenceApp(QObject):
|
||||
"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):
|
||||
@@ -215,8 +250,9 @@ class KadenceApp(QObject):
|
||||
# ---- lifecycle ---------------------------------------------------------
|
||||
|
||||
def quit(self) -> None:
|
||||
if self._mini is not None:
|
||||
self._mini.setProperty("visible", False)
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user