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:
timka123andClaude Opus 5 committed 2026-10-22 18:00:52 +03:00
1 parent 81032aca51
commit 8e659cd315
26 files changed
+751 -2689

No files matched your search

+16 -10
View File
@@ -10,7 +10,7 @@ Four pieces, one backend:
| --- | --- | --- | | --- | --- | --- |
| **Backend** | FastAPI + Postgres: the clock, the task mirror, the blocking policy, the reports, the Telegram bot | Docker | | **Backend** | FastAPI + Postgres: the clock, the task mirror, the blocking policy, the reports, the Telegram bot | Docker |
| **Web app** | React + Material UI — the whole thing in a browser, on any device | Docker (same container) | | **Web app** | React + Material UI — the whole thing in a browser, on any device | Docker (same container) |
| **Desktop app** | A **native Qt/QML** app: tray, D-Bus, real notifications, and the part that actually blocks websites and apps | Your machine | | **Desktop agent** | A **native Qt/QML** tray agent: D-Bus, real notifications, a floating mini timer, and the part that actually blocks websites and apps | Your machine |
| **Android app** | A native Compose client for the Nothing Phone (3): the interval as an Android 16 Live Update, and the countdown on the rear Glyph Matrix | Your phone | | **Android app** | A native Compose client for the Nothing Phone (3): the interval as an Android 16 Live Update, and the countdown on the rear Glyph Matrix | Your phone |
The server owns the clock. Start a pomodoro on your phone's browser, watch it The server owns the clock. Start a pomodoro on your phone's browser, watch it
@@ -54,8 +54,8 @@ list is mirrored into the database on every refresh, so the UI is instant and
the daily report still works when a source is down. the daily report still works when a source is down.
### A focus guard that actually blocks ### A focus guard that actually blocks
Rules live on the server (edit them in the web app or the desktop app); the Rules live on the server and are edited in the web app; the native desktop
native desktop app enforces them on your machine: agent enforces them on your machine:
* **Websites** — `/etc/hosts` is rewritten through a tiny privileged helper under * **Websites** — `/etc/hosts` is rewritten through a tiny privileged helper under
polkit, and the masks a hosts file cannot express are answered by a local DNS polkit, and the masks a hosts file cannot express are answered by a local DNS
@@ -140,7 +140,7 @@ cp .env.example .env # fill in Jira / Todoist / Telegram (optional)
docker compose up -d --build # → http://localhost:8000 docker compose up -d --build # → http://localhost:8000
``` ```
That is the backend and the web app. Then the native desktop app: That is the backend and the web app. Then the desktop agent:
```bash ```bash
# Arch: the only dependency is PyQt6, which is a system package # Arch: the only dependency is PyQt6, which is a system package
@@ -152,8 +152,10 @@ sudo ./desktop/helper/install.sh # the privileged helper, for website bl
kadence-desktop kadence-desktop
``` ```
Point it at the backend in **Settings → Backend** (it defaults to It has no application window: it sits in the tray, and the countdown is a
`http://localhost:8000`). floating pill you can put in a corner. Point it at the backend from the tray's
**Settings…** (or `kadence-desktop --settings`); it defaults to
`http://localhost:8000`.
### Without Docker ### Without Docker
@@ -201,7 +203,7 @@ and you can fill everything in from **Settings**.
└──────────────┘ │ • task mirror + write-back │ │ Google Tasks │ └──────────────┘ │ • task mirror + write-back │ │ Google Tasks │
│ • blocking policy │ │ Laundry │ │ • blocking policy │ │ Laundry │
┌──────────────┐ │ • reports + achievements │ └───────────────┘ ┌──────────────┐ │ • reports + achievements │ └───────────────┘
│ Desktop app │──/api──│ • scheduler (briefing, │ │ Desktop agent│──/api──│ • scheduler (briefing, │
│ Qt/QML, D-Bus│ │ report, nudges) │───────▶ Telegram ⇄ you │ Qt/QML, D-Bus│ │ report, nudges) │───────▶ Telegram ⇄ you
│ hosts+DNS+ │ └───────────────────────────────┘ │ hosts+DNS+ │ └───────────────────────────────┘
│ /proc │ │ │ /proc │ │
@@ -216,11 +218,15 @@ and you can fill everything in from **Settings**.
served from the same origin (so there is no CORS to configure). served from the same origin (so there is no CORS to configure).
* **The look** — warm paper, ink type, hairline rules instead of boxes and * **The look** — warm paper, ink type, hairline rules instead of boxes and
shadows, Fraunces for figures and headings, Inter for everything else, one shadows, Fraunces for figures and headings, Inter for everything else, one
ember accent. The desktop app's `Theme` singleton carries the same tokens as ember accent. The desktop agent's `Theme` singleton carries the same tokens as
the web app's `tokens.ts`, so the two clients are one product. Chart colours the web app's `tokens.ts`, so the clients are one product. Fraunces has no
tabular figures, so every countdown set in it — the desktop pill, the phone's
dial — lays each glyph on a fixed advance rather than letting the clock jitter. Chart colours
come from a separate, validated data palette: the brand is warm, but a chart come from a separate, validated data palette: the brand is warm, but a chart
has to stay readable for colour-blind readers in both themes. has to stay readable for colour-blind readers in both themes.
* `desktop/` — the native app. See [`desktop/README.md`](desktop/README.md). * `desktop/` — the native agent: tray, mini timer, one settings dialog, and the
enforcement. There is deliberately no window with pages in it — lists, rules
and reports are the web app's job. See [`desktop/README.md`](desktop/README.md).
* `android/` — the phone client. Everything with interesting behaviour lives in a * `android/` — the phone client. Everything with interesting behaviour lives in a
plain-JVM `:core` module, so 234 tests run on a laptop with nothing but a JDK. See plain-JVM `:core` module, so 234 tests run on a laptop with nothing but a JDK. See
[`android/README.md`](android/README.md). [`android/README.md`](android/README.md).
+45 -23
View File
@@ -1,15 +1,27 @@
# Kadence — the native desktop app # Kadence — the desktop agent
Not Electron. **PyQt6 + QML with Qt Quick Controls' Material style**: the window Not Electron, and **not an application window either**. This piece is an agent:
is a native Wayland/X11 surface drawn by Qt, using the session's own fonts, it lives in the tray, enforces the server's blocking policy on this machine,
scaling and colour scheme. The only dependency is PyQt6, which on Arch is the pauses your music, holds the screen awake, answers D-Bus, posts notifications
system package `python-pyqt6`; everything else — HTTP, config, process scanning — with buttons — and shows a floating countdown. **PyQt6 + QML with Qt Quick
is the Python standard library. Controls' Material style**, so what it does draw is a native Wayland/X11 surface
with the session's own fonts, scaling and colour scheme. The only dependency is
PyQt6, which on Arch is the system package `python-pyqt6`; everything else —
HTTP, config, process scanning, DNS — is the Python standard library.
It is also the piece that *enforces* things, because it is the only part running There used to be a five-page window here (Timer, Tasks, Blocker, Report,
on your machine. The interface is the same warm, hairline-ruled design as the Settings) and it is gone. Every one of those pages was a second view of the
web app — the `Theme` singleton in `qml/` holds the same tokens as the web *server's* data, which made it a second place to be wrong and a second place to
app's `tokens.ts`. style — and the styling is what it never earned. The web app does lists, rules
and reports properly, in a browser, on any device. What is left on the desktop is
the part only a program on your machine can do, plus the two surfaces worth
having in front of you:
| | |
| --- | --- |
| **the tray menu** | the whole timer: start, pause, finish, +5, abandon, report, view-only, mini timer, settings |
| **the mini timer** | a floating pill with the countdown, the task, and the controls |
| **the settings dialog** | the handful of settings that belong to *this machine* |
```bash ```bash
sudo pacman -S --needed python-pyqt6 qt6-declarative # Arch sudo pacman -S --needed python-pyqt6 qt6-declarative # Arch
@@ -18,7 +30,12 @@ sudo ./helper/install.sh # privileged helper (w
kadence-desktop kadence-desktop
``` ```
Run it straight from the checkout instead: `python3 -m kadence_desktop`. Run it straight from the checkout instead: `python3 -m kadence_desktop`. Point it
at the backend from the tray's **Settings…**, or `kadence-desktop --settings`.
The usual way to run it is with no window at all — `--minimized`, which is what
the autostart entry and the systemd user unit use. The mini timer then appears by
itself when an interval starts.
--- ---
@@ -26,20 +43,20 @@ Run it straight from the checkout instead: `python3 -m kadence_desktop`.
| Feature | How | | Feature | How |
| --- | --- | | --- | --- |
| **A floating mini timer** | A small pill with the countdown, the task you are on, and the controls — *Pause / Resume / Finish / +5*, or *Start focus / Break* when idle — all on their own rows and all visible without hovering. Open it with **Ctrl+M**, the tray menu, or `kadence-desktop --mini`; it also appears by itself while an interval runs and the main window is away. Dragging moves it (through the compositor, so it works on Wayland) and double-clicking opens the main window. It is **not** a `Qt.Tool` window — Qt hides those whenever the application is deactivated, which made it vanish on every click in another app — and it refuses keyboard focus instead. | | **A floating mini timer** | A small pill with the countdown, the task you are on, and the controls — *Pause / Resume / Finish / +5*, or *Start focus / Break* when idle — all on their own rows and all visible without hovering. Open it from the tray menu or with `kadence-desktop --mini`; it also appears by itself while an interval runs. Dragging moves it (through the compositor, so it works on Wayland) and double-clicking opens the web app. It is **not** a `Qt.Tool` window — Qt hides those whenever the application is deactivated, which made it vanish on every click in another app — and it refuses keyboard focus instead. The countdown is set in the display serif on a **fixed advance**: Fraunces has no tabular figures and ships no `tnum`, so each glyph is laid in a box as wide as the widest digit, or the clock jitters sideways on every tick. |
| **Where it appears** | Settings → *Mini timer appears*: any of the four corners, the top/bottom edge centres, the centre of the screen, or *where I last dragged it*, with a configurable gap from the edges. It is placed on the screen the app is on, inside the usable area (panels excluded), and it returns to that spot every time it comes up. On X11 the app positions the window; on Wayland — where a client may not — the point is written into the same KWin rule. | | **Where it appears** | Settings → *Mini timer appears*: any of the four corners, the top/bottom edge centres, the centre of the screen, or *where I last dragged it*, with a configurable gap from the edges. It is placed on the screen the app is on, inside the usable area (panels excluded), and it returns to that spot every time it comes up. On X11 the app positions the window; on Wayland — where a client may not — the point is written into the same KWin rule. |
| **Above everything, on Wayland** | Wayland gives an application no way to ask for "always on top", so on KDE the app installs a **KWin window rule** (keep above, skip task bar, skip switcher, skip pager) and asks KWin to re-read its config over D-Bus. The rule matches the window title as a **substring**: Qt appends the application's display name, so KWin sees `Kadence mini — Kadence`, and an exact match silently never fires. The app backs the file up first, leaves your other rules alone, re-writes the rule when an upgrade changes it, and *Settings → Keep the mini timer above everything* removes it. Verify with `busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting loadScript …` or simply: the mini timer should stay on top of a maximised window. | | **Above everything, on Wayland** | Wayland gives an application no way to ask for "always on top", so on KDE the app installs a **KWin window rule** (keep above, skip task bar, skip switcher, skip pager) and asks KWin to re-read its config over D-Bus. The rule matches the window title as a **substring**: Qt appends the application's display name, so KWin sees `Kadence mini — Kadence`, and an exact match silently never fires. The app backs the file up first, leaves your other rules alone, re-writes the rule when an upgrade changes it, and *Settings → Keep the mini timer above everything* removes it. Verify with `busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting loadScript …` or simply: the mini timer should stay on top of a maximised window. |
| **Tray icon that *is* the timer** | `QSystemTrayIcon` (a StatusNotifierItem on KDE). The icon is painted at runtime: a ring that drains as the interval runs, tinted by focus / break / idle, with a pause glyph when held. Middle-click toggles the timer. | | **Tray icon that *is* the timer** | `QSystemTrayIcon` (a StatusNotifierItem on KDE). The icon is painted at runtime: a ring that drains as the interval runs, tinted by focus / break / idle, with a pause glyph when held. Middle-click toggles the timer. |
| **Real notifications with buttons** | `org.freedesktop.Notifications` over D-Bus, with actions: *Start break* · *Another pomodoro* · *+5 min*. Replaced in place, so a day of pomodoros never stacks a column of toasts. | | **Real notifications with buttons** | `org.freedesktop.Notifications` over D-Bus, with actions: *Start break* · *Another pomodoro* · *+5 min*. Replaced in place, so a day of pomodoros never stacks a column of toasts. |
| **Scriptable over D-Bus** | `org.kadence.Desktop` exports `StartWork`, `Toggle`, `Pause`, `Complete`, `Extend`, `Interrupt`, `Status`, `SendReport`, `Raise`. Bind any of them to a global shortcut in System Settings — no key-grabbing hacks. | | **Scriptable over D-Bus** | `org.kadence.Desktop` exports `StartWork`, `Toggle`, `Pause`, `Complete`, `Extend`, `Interrupt`, `Status`, `SendReport`, `ToggleMini`, `Settings`, `Raise`. Bind any of them to a global shortcut in System Settings — no key-grabbing hacks. |
| **Screen stays awake** | `org.freedesktop.ScreenSaver.Inhibit` while you focus, released the moment the interval ends. | | **Screen stays awake** | `org.freedesktop.ScreenSaver.Inhibit` while you focus, released the moment the interval ends. |
| **Do Not Disturb** | Other apps' banners are suppressed during focus (Plasma's `Notifications.Inhibit`; on GNOME the `show-banners` gsetting, put back afterwards). | | **Do Not Disturb** | Other apps' banners are suppressed during focus (Plasma's `Notifications.Inhibit`; on GNOME the `show-banners` gsetting, put back afterwards). |
| **Your music pauses** | Any MPRIS player (Spotify, VLC, mpv, browsers) is paused when focus starts, and *only the ones we paused* resume on the break. | | **Your music pauses** | Any MPRIS player (Spotify, VLC, mpv, browsers) is paused when focus starts, and *only the ones we paused* resume on the break. |
| **Taskbar progress** | `com.canonical.Unity.LauncherEntry` — a progress bar under the dock icon and a badge with today's count. | | **Taskbar progress** | `com.canonical.Unity.LauncherEntry` — a progress bar under the dock icon and a badge with today's count. |
| **Knows when you walked away** | logind `Lock`/`Unlock` and `PrepareForSleep` pause and resume the timer; optional idle-pause uses KDE's or Mutter's idle monitor. | | **Knows when you walked away** | logind `Lock`/`Unlock` and `PrepareForSleep` pause and resume the timer; optional idle-pause uses KDE's or Mutter's idle monitor. |
| **Starts with the session** | XDG autostart entry, or a **systemd user unit** that restarts on failure and logs to the journal. | | **Starts with the session** | XDG autostart entry, or a **systemd user unit** that restarts on failure and logs to the journal. |
| **Single instance** | Owning the D-Bus name is the lock; a second launch raises the first window and exits. | | **Single instance** | Owning the D-Bus name is the lock; a second launch brings the first one's countdown up and exits. |
| **Launcher actions** | Right-click the icon: *Start a pomodoro*, *Start a break*, *Start / pause*, *Send today's report*. | | **Launcher actions** | Right-click the icon: *Start a pomodoro*, *Start a break*, *Start / pause*, *Send today's report*, *Settings*. |
Every one of these degrades to a no-op where the desktop does not provide it, so Every one of these degrades to a no-op where the desktop does not provide it, so
the same build runs on KDE, GNOME or a bare window manager. the same build runs on KDE, GNOME or a bare window manager.
@@ -188,9 +205,9 @@ export a plain `QObject` instead and Qt names the interface after the class
(`local.kadence.TimerDBusService`), so every documented command fails with (`local.kadence.TimerDBusService`), so every documented command fails with
*"No such interface"*. *"No such interface"*.
In-window shortcuts: **Space** start/pause · **Ctrl+Enter** finish · There are no in-window shortcuts any more — there is no window to be focused in.
**Ctrl+E** +5 min · **Ctrl+R** refresh sources · **Ctrl+M** mini timer · Bind the D-Bus verbs (or `kadence-desktop --toggle`) to real global shortcuts
**Ctrl+1…5** pages · **Ctrl+W** hide to tray. instead, which is what they were always better as.
> **Wayland note.** The mini timer's placement and "always on top" are a KWin rule written to > **Wayland note.** The mini timer's placement and "always on top" are a KWin rule written to
> `~/.config/kwinrulesrc` (backed up once as `kwinrulesrc.kadence-backup`); you > `~/.config/kwinrulesrc` (backed up once as `kwinrulesrc.kadence-backup`); you
@@ -204,9 +221,14 @@ In-window shortcuts: **Space** start/pause · **Ctrl+Enter** finish ·
## Configuration ## Configuration
`~/.config/kadence/desktop.json` holds only what is about *this machine* — the `~/.config/kadence/desktop.json` holds only what is about *this machine* — the
backend URL and token, and which integrations this box may use. Timer lengths, backend URL and token, where the mini timer sits, and which integrations this box
task sources, block lists and Telegram all live on the server, so they are the may use. Timer lengths, task sources, block lists and Telegram all live on the
same everywhere. The **Settings** page edits both, clearly separated. server, so they are the same everywhere and are edited in the web app.
The **Settings…** dialog (tray menu, or `kadence-desktop --settings`) edits that
file and nothing else, with one exception that has to be server-side: *view-only
mode* writes `pomodoro.timer_enabled`, because the other clients have to honour
it too.
## Layout ## Layout
@@ -220,7 +242,7 @@ kadence_desktop/
tray.py # the painted countdown icon and its menu tray.py # the painted countdown icon and its menu
integrations/ # notifications, inhibit/DND, MPRIS, launcher, logind, D-Bus, autostart integrations/ # notifications, inhibit/DND, MPRIS, launcher, logind, D-Bus, autostart
blocking/ # hosts region, DNS stub + resolved route, /proc scanning, the manager that syncs policy blocking/ # hosts region, DNS stub + resolved route, /proc scanning, the manager that syncs policy
qml/ # Material UI: Timer, Tasks, Blocker, Report, Settings qml/ # MiniTimer, SettingsDialog, the Theme singleton, five components
helper/ # the privileged helper + polkit action + installer helper/ # the privileged helper + polkit action + installer
packaging/ # .desktop entry, icon, user installer packaging/ # .desktop entry, icon, systemd user unit, user installer
``` ```
+9 -2
View File
@@ -7,6 +7,12 @@ is what makes them usable as global shortcuts:
kadence-desktop --toggle # bind this to a key in System Settings kadence-desktop --toggle # bind this to a key in System Settings
kadence-desktop --mini # show/hide the floating mini timer kadence-desktop --mini # show/hide the floating mini timer
kadence-desktop --status # prints "Focus 18:42 · Write the docs · 3/8 today" kadence-desktop --status # prints "Focus 18:42 · Write the docs · 3/8 today"
kadence-desktop --settings # open the one window with fields in it
There is no main window: Kadence on the desktop is a tray agent that enforces
the blocking policy and shows a floating countdown. Started with no arguments it
comes up with the mini timer; `--minimized` starts it with nothing on screen,
which is what the autostart entry and the systemd user unit do.
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,6 +36,7 @@ REMOTE_VERBS = {
"report": "SendReport", "report": "SendReport",
"mini": "ToggleMini", "mini": "ToggleMini",
"show": "Raise", "show": "Raise",
"settings": "Settings",
} }
@@ -77,9 +84,9 @@ def main() -> int:
from kadence_desktop.app import KadenceApp from kadence_desktop.app import KadenceApp
from kadence_desktop.integrations.dbus_service import call_existing from kadence_desktop.integrations.dbus_service import call_existing
# Single instance: if somebody already owns the name, just raise their window. # Single instance: if somebody already owns the name, show their countdown.
if call_existing("Raise"): if call_existing("Raise"):
print("Kadence is already running — raised its window.") print("Kadence is already running — brought its timer up.")
return 0 return 0
return KadenceApp(sys.argv, minimized=args.minimized, view_only=args.view_only).run() return KadenceApp(sys.argv, minimized=args.minimized, view_only=args.view_only).run()
+6 -22
View File
@@ -99,28 +99,21 @@ class Api:
def interrupt(self) -> dict: def interrupt(self) -> dict:
return self._request("POST", "/api/timer/interrupt", {}) return self._request("POST", "/api/timer/interrupt", {})
def attach_task(self, task_id: int | None) -> dict:
return self._request("POST", "/api/timer/task", {"task_id": task_id})
def tasks(self) -> dict: def tasks(self) -> dict:
"""The list, read for one fact: which task is pinned.
There is no Tasks page here any more — the list is the web app's job.
`Controller._on_tasks` keeps the pinned id so that starting an interval
from the tray attaches what you pinned this morning.
"""
return self._request("GET", "/api/tasks") return self._request("GET", "/api/tasks")
def refresh_tasks(self) -> dict: def refresh_tasks(self) -> dict:
return self._request("POST", "/api/tasks/refresh", {}) return self._request("POST", "/api/tasks/refresh", {})
def patch_task(self, task_id: int, patch: dict) -> dict:
"""Edit a task's local fields (pinning, estimate). Completion is not ours."""
return self._request("PATCH", f"/api/tasks/{task_id}", patch)
def create_task(self, title: str, due_date: str | None = None) -> dict:
return self._request("POST", "/api/tasks", {"title": title, "due_date": due_date})
def blocker_policy(self) -> dict: def blocker_policy(self) -> dict:
return self._request("GET", "/api/blocker/policy") return self._request("GET", "/api/blocker/policy")
def blocker_events(self) -> dict:
return self._request("GET", "/api/blocker/events")
def report_block(self, kind: str, target: str, action: str = "blocked", details: str | None = None) -> dict: def report_block(self, kind: str, target: str, action: str = "blocked", details: str | None = None) -> dict:
return self._request( return self._request(
"POST", "POST",
@@ -128,18 +121,9 @@ class Api:
{"kind": kind, "target": target, "action": action, "details": details}, {"kind": kind, "target": target, "action": action, "details": details},
) )
def daily_report(self) -> dict:
return self._request("GET", "/api/reports/daily")
def send_report(self) -> dict: def send_report(self) -> dict:
return self._request("POST", "/api/reports/daily/send", {"force": True}) return self._request("POST", "/api/reports/daily/send", {"force": True})
def stats(self) -> dict:
return self._request("GET", "/api/stats/summary?days=30")
def settings(self) -> dict:
return self._request("GET", "/api/settings")
def patch_settings(self, patch: dict) -> dict: def patch_settings(self, patch: dict) -> dict:
return self._request("PUT", "/api/settings", patch) return self._request("PUT", "/api/settings", patch)
+78 -42
View File
@@ -1,8 +1,24 @@
"""Application wiring: Qt, QML, the tray, and the D-Bus service. """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 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 Qt itself — no web view, no bundled browser; both windows are native Wayland
surface with the session's own fonts, scaling and colour scheme. surfaces with the session's own fonts, scaling and colour scheme.
""" """
from __future__ import annotations 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.QtCore import QObject, QTimer, QUrl
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from PyQt6.QtQml import QQmlApplicationEngine, QQmlComponent from PyQt6.QtQml import QQmlComponent, QQmlEngine
from PyQt6.QtQuick import QQuickWindow
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon from PyQt6.QtWidgets import QApplication, QSystemTrayIcon
from kadence_desktop import APP_ID, __version__ from kadence_desktop import APP_ID, __version__
@@ -42,7 +57,8 @@ class KadenceApp(QObject):
) -> None: ) -> None:
super().__init__() super().__init__()
# QApplication (not QGuiApplication): the tray icon and its menu are # 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 = QApplication(argv)
self._app.setApplicationName("Kadence") self._app.setApplicationName("Kadence")
self._app.setApplicationDisplayName("Kadence") self._app.setApplicationDisplayName("Kadence")
@@ -52,6 +68,8 @@ class KadenceApp(QObject):
# has no icon and the task manager cannot group it. # has no icon and the task manager cannot group it.
self._app.setDesktopFileName("kadence") self._app.setDesktopFileName("kadence")
self._app.setWindowIcon(_load_icon()) 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._app.setQuitOnLastWindowClosed(False)
self._config = DesktopConfig.load() self._config = DesktopConfig.load()
@@ -59,25 +77,33 @@ class KadenceApp(QObject):
self._config.view_only = True self._config.view_only = True
self._controller = Controller(self._config) 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("controller", self._controller)
self._engine.rootContext().setContextProperty("appVersion", __version__) self._engine.rootContext().setContextProperty("appVersion", __version__)
self._engine.addImportPath(str(QML_DIR)) 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 self._tray: Tray | None = None
if self._config.tray_icon and QSystemTrayIcon.isSystemTrayAvailable(): if self._config.tray_icon and QSystemTrayIcon.isSystemTrayAvailable():
self._tray = Tray(self._controller, self) self._tray = Tray(self._controller, self)
self._tray.showRequested.connect(self.toggle_window) self._tray.showRequested.connect(self.toggle_window)
self._tray.settingsRequested.connect(self.show_settings)
self._tray.quitRequested.connect(self.quit) 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._tray.show()
self._controller.showWindowRequested.connect(self.show_window) self._controller.showWindowRequested.connect(self.show_window)
self._controller.settingsRequested.connect(self.show_settings)
self._app.aboutToQuit.connect(self._controller.shutdown) self._app.aboutToQuit.connect(self._controller.shutdown)
# Anything that can change "should the mini timer be up?" lands here. # 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.miniPlacementChanged.connect(self._place_mini)
self._controller.stateChanged.connect(self._update_mini) self._controller.stateChanged.connect(self._update_mini)
self._controller.configChanged.connect(self._update_mini) self._controller.configChanged.connect(self._update_mini)
self._window.visibleChanged.connect(self._update_mini)
self._update_mini() self._update_mini()
self._place_mini() self._place_mini()
if self._config.dbus_service: if self._config.dbus_service:
self._register_dbus() self._register_dbus()
if minimized or self._config.start_minimized: # `--minimized` and "start minimised" mean the same thing they always
self._window.hide() # did: come up with no window. The difference is that it is now the
else: # normal way to run — the agent needs no window to do its job.
if not (minimized or self._config.start_minimized):
self.show_window() self.show_window()
# Let Ctrl+C in a terminal actually kill the app (Qt swallows SIGINT # 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.start(500)
self._wakeup.timeout.connect(lambda: None) self._wakeup.timeout.connect(lambda: None)
# ---- the floating mini timer -------------------------------------------- # ---- the windows --------------------------------------------------------
def _create_mini_window(self) -> QObject | None: def _create_window(self, file_name: str, object_name: str) -> QObject | None:
"""Build MiniTimer.qml as a top-level window of its own.""" """Build one QML window as a top-level of its own."""
component = QQmlComponent(self._engine, QUrl.fromLocalFile(str(QML_DIR / "MiniTimer.qml"))) component = QQmlComponent(self._engine, QUrl.fromLocalFile(str(QML_DIR / file_name)))
if component.isError(): # pragma: no cover - a QML typo, caught in dev if component.isError(): # pragma: no cover - a QML typo, caught in dev
for error in component.errors(): for error in component.errors():
log.error("mini timer: %s", error.toString()) log.error("%s: %s", file_name, error.toString())
return None return None
# No parent on purpose: a parented QML Window becomes transient and # No parent on purpose: a parented QML Window becomes transient and
# cannot outlive — or out-show — its parent. The Python reference on # cannot outlive — or out-show — its parent. The Python reference is
# `self._mini` is what keeps it alive. # what keeps it alive.
window = component.create() window = component.create()
if window is not None: if window is not None:
window.setProperty("objectName", "miniTimer") window.setProperty("objectName", object_name)
return window return window
def _update_mini(self) -> None: def _update_mini(self) -> None:
"""Show it when pinned, or when an interval runs with the window away.""" """Show it when pinned, or by itself while an interval runs.
if self._mini is None:
return 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 config = self._config
wanted = bool(config.mini_timer) and ( wanted = bool(config.mini_timer) and (
self._controller.miniVisible self._controller.miniVisible
or ( or (
config.mini_timer_auto config.mini_timer_auto
and self._controller.running and self._controller.running
and not self._window.isVisible()
and not self._controller.mini_dismissed and not self._controller.mini_dismissed
) )
) )
@@ -143,8 +171,8 @@ class KadenceApp(QObject):
self._mini.setProperty("visible", wanted) self._mini.setProperty("visible", wanted)
def _mini_screen_rect(self) -> Rect: def _mini_screen_rect(self) -> Rect:
"""The usable area of the screen the app is on — panels excluded.""" """The usable area of the screen it is on — panels excluded."""
screen = self._window.screen() or self._app.primaryScreen() screen = self._mini.screen() or self._app.primaryScreen()
area = screen.availableGeometry() area = screen.availableGeometry()
return Rect(area.x(), area.y(), area.width(), area.height()) 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 place itself, so the same point goes into the KWin rule, which is
honoured when the window is mapped. honoured when the window is mapped.
""" """
if self._mini is None:
return
config = self._config config = self._config
if config.mini_timer_corner == "remember": if config.mini_timer_corner == "remember":
self._controller.ensure_keep_above(None) self._controller.ensure_keep_above(None)
@@ -166,7 +192,7 @@ class KadenceApp(QObject):
return return
size = ( size = (
int(self._mini.property("width") or 252), int(self._mini.property("width") or 268),
int(self._mini.property("height") or 152), int(self._mini.property("height") or 152),
) )
point = point_for( point = point_for(
@@ -176,18 +202,24 @@ class KadenceApp(QObject):
self._mini.setProperty("y", point[1]) self._mini.setProperty("y", point[1])
self._controller.ensure_keep_above(point) self._controller.ensure_keep_above(point)
# ---- window ------------------------------------------------------------
def show_window(self) -> None: def show_window(self) -> None:
self._window.show() """Bring the countdown up — the tray's "Open Kadence", and `--mini`."""
self._window.raise_() self._controller.setMiniVisible(True)
self._window.requestActivate()
def toggle_window(self) -> None: def toggle_window(self) -> None:
if self._window.isVisible() and self._window.isActive(): self._controller.toggleMini()
self._window.hide()
else: def show_settings(self) -> None:
self.show_window() """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 ------------------------------------------------------------- # ---- D-Bus -------------------------------------------------------------
@@ -206,7 +238,10 @@ class KadenceApp(QObject):
"status": controller.statusLine, "status": controller.statusLine,
"send_report": lambda: (controller.sendReport(), "sending")[1], "send_report": lambda: (controller.sendReport(), "sending")[1],
"toggle_mini": controller.toggleMini, "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], "raise_window": lambda: (self.show_window(), "shown")[1],
"settings": lambda: (self.show_settings(), "settings opened")[1],
} }
self._dbus = dbus_service.TimerDBusService(actions, self) self._dbus = dbus_service.TimerDBusService(actions, self)
if not dbus_service.register(self._dbus): if not dbus_service.register(self._dbus):
@@ -215,8 +250,9 @@ class KadenceApp(QObject):
# ---- lifecycle --------------------------------------------------------- # ---- lifecycle ---------------------------------------------------------
def quit(self) -> None: 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() self._controller.shutdown()
if self._tray: if self._tray:
self._tray.hide() self._tray.hide()
+59 -149
View File
@@ -25,7 +25,7 @@ from PyQt6.QtGui import QDesktopServices
from kadence_desktop.api import Api from kadence_desktop.api import Api
from kadence_desktop.blocking.manager import BlockerManager from kadence_desktop.blocking.manager import BlockerManager
from kadence_desktop.config import DesktopConfig from kadence_desktop.config import DesktopConfig
from kadence_desktop.integrations import autostart, keep_above from kadence_desktop.integrations import autostart, keep_above, placement
from kadence_desktop.integrations.inhibit import DoNotDisturb, ScreensaverInhibitor from kadence_desktop.integrations.inhibit import DoNotDisturb, ScreensaverInhibitor
from kadence_desktop.integrations.launcher import LauncherEntry from kadence_desktop.integrations.launcher import LauncherEntry
from kadence_desktop.integrations.mpris import MediaController from kadence_desktop.integrations.mpris import MediaController
@@ -46,17 +46,15 @@ def clock(seconds: int) -> str:
class Controller(QObject): class Controller(QObject):
# ---- notifications to QML --------------------------------------------- # ---- notifications to QML ---------------------------------------------
stateChanged = pyqtSignal() stateChanged = pyqtSignal()
tasksChanged = pyqtSignal()
statsChanged = pyqtSignal()
reportChanged = pyqtSignal()
settingsChanged = pyqtSignal()
blockerChanged = pyqtSignal() blockerChanged = pyqtSignal()
tasksChanged = pyqtSignal()
connectionChanged = pyqtSignal() connectionChanged = pyqtSignal()
configChanged = pyqtSignal() configChanged = pyqtSignal()
miniChanged = pyqtSignal() miniChanged = pyqtSignal()
miniPlacementChanged = pyqtSignal() miniPlacementChanged = pyqtSignal()
toast = pyqtSignal(str) toast = pyqtSignal(str)
showWindowRequested = pyqtSignal() showWindowRequested = pyqtSignal()
settingsRequested = pyqtSignal()
def __init__(self, config: DesktopConfig, parent: QObject | None = None) -> None: def __init__(self, config: DesktopConfig, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -66,11 +64,14 @@ class Controller(QObject):
# ---- state mirrors -------------------------------------------------- # ---- state mirrors --------------------------------------------------
self._state: dict[str, Any] = {} self._state: dict[str, Any] = {}
self._policy: dict[str, Any] = {} self._policy: dict[str, Any] = {}
self._tasks: list[dict[str, Any]] = [] self._pinned_task_id: int | None = None
self._task_counts: dict[str, Any] = {} """The task pinned in the web app, so "Start focus" can attach it.
self._stats: dict[str, Any] = {}
self._report: dict[str, Any] = {} All that is left of this app's interest in the task list: there is no
self._settings: dict[str, Any] = {} Tasks page any more, but starting an interval from the tray and having
it land on the thing you pinned this morning is worth one request a
minute. The list itself is never rendered here.
"""
self._connected = False self._connected = False
self._connection_error = "" self._connection_error = ""
self._remaining = 0 self._remaining = 0
@@ -116,9 +117,6 @@ class Controller(QObject):
self._worker.stateChanged.connect(self._on_state) self._worker.stateChanged.connect(self._on_state)
self._worker.policyChanged.connect(self._on_policy) self._worker.policyChanged.connect(self._on_policy)
self._worker.tasksChanged.connect(self._on_tasks) self._worker.tasksChanged.connect(self._on_tasks)
self._worker.statsChanged.connect(self._on_stats)
self._worker.reportChanged.connect(self._on_report)
self._worker.settingsChanged.connect(self._on_settings)
self._worker.connectionChanged.connect(self._on_connection) self._worker.connectionChanged.connect(self._on_connection)
self._worker.commandFailed.connect(self._on_command_failed) self._worker.commandFailed.connect(self._on_command_failed)
self._worker.commandDone.connect(self._on_command_done) self._worker.commandDone.connect(self._on_command_done)
@@ -208,20 +206,6 @@ class Controller(QObject):
task = current.get("task") or {} task = current.get("task") or {}
return str(task.get("title", "")) return str(task.get("title", ""))
@pyqtProperty(int, notify=stateChanged)
def taskId(self) -> int:
for task in self._tasks:
current = self._state.get("current") or {}
attached = current.get("task") or {}
if attached and task.get("external_id") == attached.get("external_id"):
return int(task.get("id", -1))
return -1
@pyqtProperty(int, notify=stateChanged)
def interruptions(self) -> int:
current = self._state.get("current") or {}
return int(current.get("interruptions", 0))
@pyqtProperty(int, notify=stateChanged) @pyqtProperty(int, notify=stateChanged)
def completedToday(self) -> int: def completedToday(self) -> int:
return int(self._state.get("completed_today", 0)) return int(self._state.get("completed_today", 0))
@@ -230,63 +214,18 @@ class Controller(QObject):
def goal(self) -> int: def goal(self) -> int:
return int(self._state.get("goal", 8)) return int(self._state.get("goal", 8))
@pyqtProperty(int, notify=stateChanged) @pyqtProperty(bool, notify=stateChanged)
def cyclePosition(self) -> int:
return int(self._state.get("cycle_position", 1))
@pyqtProperty(int, notify=stateChanged)
def cycleLength(self) -> int:
return int(self._state.get("cycles_before_long_break", 4))
@pyqtProperty(str, notify=stateChanged)
def nextKindLabel(self) -> str:
return KIND_LABELS.get(str(self._state.get("next_kind", "work")), "Focus")
@pyqtProperty(bool, notify=settingsChanged)
def timerEnabled(self) -> bool: def timerEnabled(self) -> bool:
"""When False the app is in view-only mode — no timer controls.""" """When False the app is in view-only mode — no timer controls.
Read from the timer *state*, which carries `timer_enabled`, rather than
from a second fetch of the settings document: this app no longer has a
page to edit that document in, and one less endpoint to poll is one less
thing to be out of date.
"""
if self._config.view_only: if self._config.view_only:
return False return False
pomodoro = self._settings.get("pomodoro", {}) return bool(self._state.get("timer_enabled", True))
return bool(pomodoro.get("timer_enabled", True))
@pyqtProperty("QVariantList", notify=tasksChanged)
def tasks(self) -> list[dict[str, Any]]:
return self._tasks
@pyqtProperty("QVariantMap", notify=tasksChanged)
def taskCounts(self) -> dict[str, Any]:
return self._task_counts
@pyqtProperty("QVariantMap", notify=statsChanged)
def stats(self) -> dict[str, Any]:
return self._stats
@pyqtProperty("QVariantMap", notify=reportChanged)
def report(self) -> dict[str, Any]:
return self._report
@pyqtProperty("QVariantMap", notify=settingsChanged)
def settings(self) -> dict[str, Any]:
return self._settings
@pyqtProperty(int, notify=settingsChanged)
def activePresetIndex(self) -> int:
"""Which saved preset the live Focus/Short/Long minutes match, or -1.
One source of truth for SettingsPage (the full switcher, with the
numbers editable below it) and TimerPage (a quick switcher next to
the dial) so the two never compute "which preset is this" differently.
"""
pomodoro = self._settings.get("pomodoro", {})
for index, preset in enumerate(pomodoro.get("presets", [])):
if (
preset.get("work_minutes") == pomodoro.get("work_minutes")
and preset.get("short_break_minutes") == pomodoro.get("short_break_minutes")
and preset.get("long_break_minutes") == pomodoro.get("long_break_minutes")
):
return index
return -1
@pyqtProperty(bool, notify=blockerChanged) @pyqtProperty(bool, notify=blockerChanged)
def blockerActive(self) -> bool: def blockerActive(self) -> bool:
@@ -345,6 +284,30 @@ class Controller(QObject):
def blockerError(self) -> str: def blockerError(self) -> str:
return self._blocker.error or "" return self._blocker.error or ""
@pyqtProperty("QVariantList", notify=configChanged)
def miniCornerLabels(self) -> list[str]:
"""Where the mini timer may be put, as the settings dialog lists them.
From `placement.CORNER_LABELS` rather than written out again in QML:
the old settings page kept its own copy of this list, which is how a
corner gets added in one place and silently missing in the other.
"""
return list(placement.CORNER_LABELS.values())
@pyqtProperty(int, notify=configChanged)
def miniCornerIndex(self) -> int:
keys = list(placement.CORNER_LABELS)
try:
return keys.index(self._config.mini_timer_corner)
except ValueError:
return keys.index("bottom-right")
@pyqtSlot(int)
def setMiniCorner(self, index: int) -> None:
keys = list(placement.CORNER_LABELS)
if 0 <= index < len(keys):
self.setConfig("mini_timer_corner", keys[index])
@pyqtProperty(bool, notify=miniChanged) @pyqtProperty(bool, notify=miniChanged)
def miniVisible(self) -> bool: def miniVisible(self) -> bool:
"""Whether the floating mini timer is pinned open by the user.""" """Whether the floating mini timer is pinned open by the user."""
@@ -389,10 +352,8 @@ class Controller(QObject):
if minutes: if minutes:
args["minutes"] = minutes args["minutes"] = minutes
current_task = self._state.get("current") or {} current_task = self._state.get("current") or {}
if not current_task.get("task"): if not current_task.get("task") and self._pinned_task_id is not None:
pinned = next((task for task in self._tasks if task.get("pinned")), None) args["task_id"] = self._pinned_task_id
if pinned:
args["task_id"] = pinned["id"]
self._send("start", args) self._send("start", args)
@pyqtSlot() @pyqtSlot()
@@ -419,8 +380,8 @@ class Controller(QObject):
"""Toggle view-only mode on or off.""" """Toggle view-only mode on or off."""
self._config.update(view_only=not enabled) self._config.update(view_only=not enabled)
self.configChanged.emit() self.configChanged.emit()
self.settingsChanged.emit() self.stateChanged.emit()
self.patchSettings({"pomodoro": {"timer_enabled": enabled}}) self._patch_settings({"pomodoro": {"timer_enabled": enabled}})
@pyqtSlot() @pyqtSlot()
def pause(self) -> None: def pause(self) -> None:
@@ -448,19 +409,6 @@ class Controller(QObject):
def interrupt(self) -> None: def interrupt(self) -> None:
self._send("interrupt") self._send("interrupt")
@pyqtSlot(int)
def attachTask(self, task_id: int) -> None:
self._send("attach_task", {"task_id": task_id if task_id >= 0 else None})
@pyqtSlot(int, bool)
def setTaskPinned(self, task_id: int, pinned: bool) -> None:
"""Pinning is the only task edit this app makes — closing one is not."""
self._send("patch_task", {"task_id": task_id, "patch": {"pinned": pinned}})
@pyqtSlot(str, str)
def createTask(self, title: str, due_date: str) -> None:
self._send("create_task", {"title": title, "due_date": due_date or None})
@pyqtSlot() @pyqtSlot()
def refreshSources(self) -> None: def refreshSources(self) -> None:
self.toast.emit("Pulling Jira, Todoist, Google Tasks…") self.toast.emit("Pulling Jira, Todoist, Google Tasks…")
@@ -470,43 +418,14 @@ class Controller(QObject):
def sendReport(self) -> None: def sendReport(self) -> None:
self._send("send_report") self._send("send_report")
@pyqtSlot() def _patch_settings(self, patch: dict[str, Any]) -> None:
def loadReport(self) -> None: """The one write this app still makes to the server's settings document.
from PyQt6.QtCore import QMetaObject, Qt
QMetaObject.invokeMethod(self._worker, "refresh_report", Qt.ConnectionType.QueuedConnection) Only [setTimerEnabled] uses it — view-only mode is a server-side flag,
because the other clients have to honour it too. Everything else in that
@pyqtSlot() document is edited in the web app.
def loadSettings(self) -> None:
from PyQt6.QtCore import QMetaObject, Qt
QMetaObject.invokeMethod(self._worker, "refresh_settings", Qt.ConnectionType.QueuedConnection)
@pyqtSlot("QVariantMap")
def patchSettings(self, patch: dict[str, Any]) -> None:
self._send("patch_settings", {"patch": patch})
@pyqtSlot(int)
def applyPreset(self, index: int) -> None:
"""Copy a saved preset's minutes onto the live timer settings.
The backend resizes whatever interval is already running or paused
to match, so this takes effect at once — not just on the next one.
""" """
presets = self._settings.get("pomodoro", {}).get("presets", []) self._send("patch_settings", {"patch": patch})
if not (0 <= index < len(presets)):
return
preset = presets[index]
self.patchSettings(
{
"pomodoro": {
"work_minutes": preset["work_minutes"],
"short_break_minutes": preset["short_break_minutes"],
"long_break_minutes": preset["long_break_minutes"],
"cycles_before_long_break": preset["cycles_before_long_break"],
}
}
)
@pyqtSlot(str, "QVariant") @pyqtSlot(str, "QVariant")
def setConfig(self, key: str, value: Any) -> None: def setConfig(self, key: str, value: Any) -> None:
@@ -675,22 +594,13 @@ class Controller(QObject):
return max(1, min(30, seconds)) * 1000 return max(1, min(30, seconds)) * 1000
def _on_tasks(self, payload: dict[str, Any]) -> None: def _on_tasks(self, payload: dict[str, Any]) -> None:
self._tasks = list(payload.get("tasks", [])) pinned = next(
self._task_counts = dict(payload.get("counts", {})) (task for task in payload.get("tasks", []) if task.get("pinned")),
None,
)
self._pinned_task_id = int(pinned["id"]) if pinned else None
self.tasksChanged.emit() self.tasksChanged.emit()
def _on_stats(self, payload: dict[str, Any]) -> None:
self._stats = payload
self.statsChanged.emit()
def _on_report(self, payload: dict[str, Any]) -> None:
self._report = payload
self.reportChanged.emit()
def _on_settings(self, payload: dict[str, Any]) -> None:
self._settings = payload.get("settings", payload)
self.settingsChanged.emit()
def _on_connection(self, connected: bool, detail: str) -> None: def _on_connection(self, connected: bool, detail: str) -> None:
self._connected = connected self._connected = connected
self._connection_error = detail self._connection_error = detail
@@ -69,6 +69,7 @@ VERBS = (
"SendReport", "SendReport",
"ToggleMini", "ToggleMini",
"Raise", "Raise",
"Settings",
) )
INTROSPECTION = "\n".join( INTROSPECTION = "\n".join(
@@ -146,9 +147,18 @@ class TimerAdaptor(QDBusAbstractAdaptor):
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Raise(self) -> str: def Raise(self) -> str:
"""Show the main window (used by a second instance).""" """Show the countdown (used by a second instance, and by `--show`).
There is no main window to raise any more — this app is an agent, and
the mini timer is the whole of its own interface.
"""
return self._service.run("raise_window") return self._service.run("raise_window")
@pyqtSlot(result=str)
def Settings(self) -> str:
"""Open the settings dialog."""
return self._service.run("settings")
def register(service: TimerDBusService) -> bool: def register(service: TimerDBusService) -> bool:
"""Own the name and export the adaptor. False means another instance has it.""" """Own the name and export the adaptor. False means another instance has it."""
-360
View File
@@ -1,360 +0,0 @@
// The focus guard: what is blocked, when, and what it caught.
//
// Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is
// expanded server-side into real hostnames, and the patterns that leaves over
// are answered by the local DNS stub for the zones it can route. The stats show
// what is *really* in place on this computer — the hosts region and the routed
// zones — and every banner says plainly what could not be enforced here.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
ColumnLayout {
width: page.availableWidth - 56
x: 28
y: 26
spacing: 22
PageHeader {
eyebrow: controller.blockerReason
title: controller.blockerActive ? "Blocking is on" : "Blocking is off"
caption: "Rules are shared with the web app; this machine enforces them."
Switch {
text: "Enabled"
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
}
}
Banner {
visible: !controller.helperInstalled
text: "The privileged helper is not installed, so websites cannot be blocked. "
+ "Run: sudo desktop/helper/install.sh"
}
Banner {
visible: controller.blockerError !== ""
text: controller.blockerError
}
Banner {
visible: controller.invalidExpansions.length > 0
text: controller.invalidExpansions.join(", ")
+ " is not a subdomain label, so it was ignored. Each entry is one "
+ "label — web, m, mobile. A bare `*` is kept and means every subdomain."
}
Banner {
visible: controller.blockerActive && controller.unroutableDomains.length > 0
text: controller.unroutableDomains.join(", ")
+ " cannot be blocked on this computer: no DNS zone can be routed for "
+ "a mask with nothing after its last `*`. The phone still matches it "
+ "against the URLs you open."
}
Banner {
visible: controller.blockerActive && controller.blockedDomains.length > 0
&& controller.appliedDomains.length === 0
text: "Blocking is on, but this machine's hosts file has none of the "
+ controller.blockedDomains.length + " hostnames in it yet. Either the "
+ "write has not run, or it was refused — a message above would say which."
}
Panel {
padding: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: [
{ label: "Hostnames",
value: "" + controller.appliedDomains.length,
caption: controller.config.enforce_blocking && controller.config.block_hosts_file
? "in /etc/hosts"
: "not written (enforcement off)" },
{ label: "Zones",
value: "" + controller.routedZones.length,
caption: "routed to the stub" },
{ label: "Apps",
value: "" + controller.blockedApps.length,
caption: !controller.config.enforce_blocking || !controller.config.block_apps
? "not enforced"
: (controller.settings.blocker
&& controller.settings.blocker.kill_blocked_apps
? "closed on sight" : "warned only") },
{ label: "Attempts today",
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "0",
caption: "turned away" },
{ label: "Enforcing",
value: controller.config.enforce_blocking ? "Yes" : "No",
caption: "on this computer" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.preferredHeight: 70
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 18
label: modelData.label
value: modelData.value
caption: modelData.caption
}
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "When" }
RowLayout {
Layout.fillWidth: true
spacing: 16
ComboBox {
Layout.preferredWidth: 240
font.pixelSize: 13
model: ["While a focus interval runs", "Always", "On a schedule"]
currentIndex: {
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
}
onActivated: function (index) {
var modes = ["during_work", "always", "scheduled"]
controller.patchSettings({ blocker: { mode: modes[index] } })
}
}
Switch {
text: "Through breaks"
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
}
Switch {
text: "Close blocked apps"
checked: controller.settings.blocker ? controller.settings.blocker.kill_blocked_apps : true
onToggled: controller.patchSettings({ blocker: { kill_blocked_apps: checked } })
}
Item { Layout.fillWidth: true }
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Masks" }
Panel {
spacing: 0
Repeater {
model: [
{ pattern: "youtube.com", meaning: "the site itself, plus www. and m." },
{ pattern: "*.google.com", meaning: "the bare domain plus every subdomain in the expansion list" },
{ pattern: "*gram*", meaning: "matches apps, and URLs on the phone; no DNS zone can be routed for it" },
{ pattern: "* in the list below", meaning: "makes *.site cover every subdomain — answered by the DNS stub" }
]
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 16
Label {
text: modelData.pattern
color: Theme.ink
font.pixelSize: 12
font.family: Theme.monoFamily
Layout.preferredWidth: 140
}
Label {
text: modelData.meaning
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
Layout.fillWidth: true
}
}
}
}
}
GridLayout {
Layout.fillWidth: true
columns: page.width > 900 ? 3 : 1
columnSpacing: 18
rowSpacing: 18
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Blocked websites" }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 160
radius: 10
color: Theme.surface
border.width: 1
border.color: domainsArea.activeFocus ? Theme.ember : Theme.rule
ScrollView {
anchors.fill: parent
anchors.margins: 8
clip: true
TextArea {
id: domainsArea
background: null
wrapMode: TextArea.NoWrap
font.family: Theme.monoFamily
font.pixelSize: 12
color: Theme.ink
placeholderText: "youtube.com\n*.reddit.com"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("
")
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Exceptions" }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 160
radius: 10
color: Theme.surface
border.width: 1
border.color: allowArea.activeFocus ? Theme.ember : Theme.rule
ScrollView {
anchors.fill: parent
anchors.margins: 8
clip: true
TextArea {
id: allowArea
background: null
wrapMode: TextArea.NoWrap
font.family: Theme.monoFamily
font.pixelSize: 12
color: Theme.ink
placeholderText: "music.youtube.com\n*.docs.google.com"
text: (controller.settings.blocker ? controller.settings.blocker.allowed_domains : []).join("
")
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Blocked applications" }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 160
radius: 10
color: Theme.surface
border.width: 1
border.color: appsArea.activeFocus ? Theme.ember : Theme.rule
ScrollView {
anchors.fill: parent
anchors.margins: 8
clip: true
TextArea {
id: appsArea
background: null
wrapMode: TextArea.NoWrap
font.family: Theme.monoFamily
font.pixelSize: 12
color: Theme.ink
placeholderText: "steam*\n*discord*"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("
")
}
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 12
TextField {
id: subdomainField
Layout.preferredWidth: 420
placeholderText: "*.site expands to: www, m, web… (`*` = every subdomain)"
text: (controller.settings.blocker && controller.settings.blocker.subdomain_expansions
? controller.settings.blocker.subdomain_expansions : []).join(", ")
}
Item { Layout.fillWidth: true }
PrimaryButton {
text: "Save lists"
onClicked: {
function split(text, sep) {
return text.split(sep).map(function (s) { return s.trim() })
.filter(function (s) { return s.length > 0 })
}
controller.patchSettings({
blocker: {
blocked_domains: split(domainsArea.text, "\n"),
allowed_domains: split(allowArea.text, "\n"),
blocked_apps: split(appsArea.text, "\n"),
subdomain_expansions: split(subdomainField.text, ",")
}
})
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Today's temptations" }
Repeater {
model: controller.stats.today ? controller.stats.today.blocked_top : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
Label {
text: modelData.target
color: Theme.ink
font.pixelSize: 12
font.family: Theme.monoFamily
Layout.fillWidth: true
}
Label {
text: "× " + modelData.count
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
}
}
}
Label {
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
text: "Nothing yet — either the blocker is off, or you are behaving."
color: Theme.faint
font.pixelSize: 12
font.family: Theme.sansFamily
}
}
Item { Layout.preferredHeight: 10 }
}
Component.onCompleted: controller.loadSettings()
}
-260
View File
@@ -1,260 +0,0 @@
// The window.
//
// A quiet left column — wordmark, navigation as words, and the two things worth
// knowing from anywhere (what the timer is doing, whether the blocker is on) —
// with the pages on the right. Qt Quick Controls supply the behaviour; the
// chrome comes from `Theme`, so the desktop app and the web app are the same
// product rather than two takes on it.
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
import QtQuick.Layouts
import "."
import "components"
ApplicationWindow {
id: window
width: 1180
height: 820
minimumWidth: 460
minimumHeight: 600
visible: false
color: Theme.paper
title: controller.running
? controller.clockText + " · " + controller.kindLabel + " — Kadence"
: "Kadence"
Material.theme: Theme.dark ? Material.Dark : Material.Light
Material.primary: Theme.ember
Material.accent: Theme.ember
Material.background: Theme.paper
Material.foreground: Theme.ink
readonly property bool wide: width >= 820
property int page: 0
onClosing: function (close) {
if (controller.config.close_to_tray && controller.config.tray_icon) {
close.accepted = false
window.hide()
}
}
Connections {
target: controller
function onToast(message) { snackbar.show(message) }
function onShowWindowRequested() { window.show(); window.raise(); window.requestActivate() }
}
Shortcut { sequence: "Space"; onActivated: if (controller.timerEnabled) controller.toggle() }
Shortcut { sequence: "Ctrl+Return"; onActivated: if (controller.timerEnabled) controller.complete() }
Shortcut { sequence: "Ctrl+E"; onActivated: if (controller.timerEnabled) controller.extend(5) }
Shortcut { sequence: "Ctrl+R"; onActivated: controller.refreshSources() }
Shortcut { sequence: "Ctrl+1"; onActivated: window.page = 0 }
Shortcut { sequence: "Ctrl+2"; onActivated: window.page = 1 }
Shortcut { sequence: "Ctrl+3"; onActivated: window.page = 2 }
Shortcut { sequence: "Ctrl+4"; onActivated: window.page = 3 }
Shortcut { sequence: "Ctrl+5"; onActivated: window.page = 4 }
Shortcut { sequence: "Ctrl+W"; onActivated: window.hide() }
Shortcut { sequence: "Ctrl+M"; onActivated: controller.toggleMini() }
RowLayout {
anchors.fill: parent
spacing: 0
// ---- the rail --------------------------------------------------------
Item {
visible: window.wide
Layout.fillHeight: true
Layout.preferredWidth: 212
Rectangle {
anchors.right: parent.right
width: 1
height: parent.height
color: Theme.rule
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 22
spacing: 2
ColumnLayout {
Layout.bottomMargin: 26
spacing: 0
Label {
text: "Kadence"
color: Theme.ink
font.pixelSize: 23
font.family: Theme.displayFamily
font.letterSpacing: -0.5
}
Label {
text: "find your rhythm"
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
Repeater {
model: ["Timer", "Tasks", "Blocker", "Report", "Settings"]
delegate: Item {
required property int index
required property string modelData
Layout.fillWidth: true
implicitHeight: 34
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 2
height: 16
radius: 1
color: Theme.ember
visible: window.page === index
}
Label {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: modelData
color: window.page === index ? Theme.ink : (mouse.containsMouse ? Theme.ink : Theme.muted)
font.pixelSize: 14
font.weight: window.page === index ? Font.DemiBold : Font.Normal
font.family: Theme.sansFamily
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: window.page = index
}
}
}
Item { Layout.fillHeight: true }
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule; Layout.bottomMargin: 10 }
RowLayout {
Layout.fillWidth: true
spacing: 8
Rectangle {
width: 7; height: 7; radius: 3.5
color: controller.running ? Theme.kindColor(controller.kind) : Theme.faint
SequentialAnimation on opacity {
running: controller.running && controller.status === "running"
loops: Animation.Infinite
NumberAnimation { to: 0.35; duration: 900 }
NumberAnimation { to: 1.0; duration: 900 }
}
}
Label {
text: controller.connected
? (controller.timerEnabled
? (controller.running ? controller.clockText : "Idle")
: "View mode")
: "Offline"
color: Theme.ink
font.pixelSize: 13
font.weight: Font.Medium
font.family: Theme.monoFamily
}
Item { Layout.fillWidth: true }
Label {
text: controller.completedToday + "/" + controller.goal
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
}
}
Label {
visible: controller.blockerActive
text: "● BLOCKING"
color: Theme.clay
font.pixelSize: 10
font.letterSpacing: 1.1
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
}
}
// ---- pages -----------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
ToolBar {
visible: !window.wide
Layout.fillWidth: true
background: Rectangle {
color: Theme.paper
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: Theme.rule }
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 16
anchors.rightMargin: 16
Label {
text: "Kadence"
color: Theme.ink
font.pixelSize: 18
font.family: Theme.displayFamily
Layout.fillWidth: true
}
Label {
text: controller.connected
? (controller.timerEnabled
? (controller.running ? controller.clockText : "Idle")
: "View mode")
: "Offline"
color: Theme.ink
font.pixelSize: 13
font.family: Theme.monoFamily
}
}
}
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: window.page
TimerPage {}
TasksPage {}
BlockerPage {}
ReportPage {}
SettingsPage {}
}
TabBar {
visible: !window.wide
Layout.fillWidth: true
currentIndex: window.page
onCurrentIndexChanged: window.page = currentIndex
background: Rectangle {
color: Theme.paper
Rectangle { width: parent.width; height: 1; color: Theme.rule }
}
TabButton { text: "Timer" }
TabButton { text: "Tasks" }
TabButton { text: "Blocker" }
TabButton { text: "Report" }
TabButton { text: "Settings" }
}
}
}
Snackbar { id: snackbar }
// The floating mini timer is created by app.py as its own top-level window:
// nested inside this one it would be transient, and a transient window
// cannot be shown while its parent is hidden — which is precisely when the
// mini timer earns its keep.
}
+52 -26
View File
@@ -1,9 +1,9 @@
// The floating mini timer. // The mini timer — which is now the whole of Kadence's own window.
// //
// A small pill you keep in a corner while you work: the countdown, what you are // A small pill you keep in a corner while you work: the countdown, what you are
// working on, and the three controls you actually reach for. It appears on // working on, and the controls you actually reach for. It appears on demand
// demand (tray, Ctrl+M, the D-Bus verb) and — if you let it — by itself // (tray, Ctrl+M, the D-Bus verb) and — if you let it — by itself whenever an
// whenever an interval is running and the main window is out of the way. // interval is running.
// //
// Everything has its own row. An earlier version saved space by swapping the // Everything has its own row. An earlier version saved space by swapping the
// task line for the controls on hover and by drawing the progress bar over the // task line for the controls on hover and by drawing the progress bar over the
@@ -14,22 +14,27 @@
// that works on Wayland, and the window refuses keyboard focus rather than // that works on Wayland, and the window refuses keyboard focus rather than
// being a `Qt.Tool` — Qt hides tool windows whenever the application is // being a `Qt.Tool` — Qt hides tool windows whenever the application is
// deactivated, which made this one vanish on every click in another app. // deactivated, which made this one vanish on every click in another app.
//
// The design is the house one, and now it carries it alone: warm surface, a
// hairline rather than a shadow, a letterspaced eyebrow, the figures in the
// display serif on a fixed advance, and exactly one filled button — whichever
// action is the obvious next one.
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Window import QtQuick.Window
import "." import "."
import "components"
Window { Window {
id: mini id: mini
width: 252 width: 268
height: card.implicitHeight + 12 height: card.implicitHeight + 12
color: "transparent" color: "transparent"
// KWin's rule matches a substring of this, so it must stay distinct from // KWin's rule matches a substring of this. Qt appends " — Kadence" to it.
// the main window's caption. Qt appends " — Kadence" to it.
title: "Kadence mini" title: "Kadence mini"
flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
@@ -48,20 +53,19 @@ Window {
onTriggered: if (mini.visible) controller.saveMiniPosition(mini.x, mini.y) onTriggered: if (mini.visible) controller.saveMiniPosition(mini.x, mini.y)
} }
// A small pill button. `filled` marks the one action you want by default — // A small pill button. `filled` marks the one action you want by default.
// Resume when paused, Start when idle.
component MiniButton: Rectangle { component MiniButton: Rectangle {
id: button id: button
property string label: "" property string label: ""
property bool filled: false property bool filled: false
signal clicked() signal clicked()
implicitWidth: buttonText.implicitWidth + 18 implicitWidth: buttonText.implicitWidth + 20
implicitHeight: 24 implicitHeight: 25
radius: 7 radius: 7
color: filled color: filled
? (buttonHover.hovered ? Qt.lighter(mini.accent, 1.08) : mini.accent) ? (buttonHover.hovered ? Qt.lighter(mini.accent, 1.08) : mini.accent)
: (buttonHover.hovered ? Qt.alpha(mini.accent, 0.12) : "transparent") : (buttonHover.hovered ? Qt.alpha(mini.accent, 0.10) : "transparent")
border.width: filled ? 0 : 1 border.width: filled ? 0 : 1
border.color: buttonHover.hovered ? mini.accent : Theme.rule border.color: buttonHover.hovered ? mini.accent : Theme.rule
Behavior on color { ColorAnimation { duration: 110 } } Behavior on color { ColorAnimation { duration: 110 } }
@@ -104,7 +108,9 @@ Window {
TapHandler { TapHandler {
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
onDoubleTapped: controller.showWindowRequested() // There is no main window to open any more; the rules, the lists and
// the report live in the web app, so that is where a double-click goes.
onDoubleTapped: controller.openWebUi()
} }
ColumnLayout { ColumnLayout {
@@ -112,10 +118,10 @@ Window {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
anchors.margins: 12 anchors.margins: 13
spacing: 3 spacing: 4
// ---- state ------------------------------------------------------- // ---- what is happening -------------------------------------------
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 7 spacing: 7
@@ -135,17 +141,30 @@ Window {
+ (mini.paused ? " · PAUSED" : "") + (mini.paused ? " · PAUSED" : "")
color: mini.paused ? Theme.amber : (controller.running ? mini.accent : Theme.muted) color: mini.paused ? Theme.amber : (controller.running ? mini.accent : Theme.muted)
font.pixelSize: 9 font.pixelSize: 9
font.letterSpacing: 1.2 font.letterSpacing: 1.3
font.weight: Font.DemiBold font.weight: Font.DemiBold
font.family: Theme.sansFamily font.family: Theme.sansFamily
Layout.fillWidth: true Layout.fillWidth: true
elide: Text.ElideRight elide: Text.ElideRight
} }
// The backend being away is worth saying: the count and the clock
// are both the server's, and this is the only window left to say it in.
Label { Label {
visible: !controller.connected
text: "OFFLINE"
color: Theme.amber
font.pixelSize: 9
font.letterSpacing: 1.1
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
visible: controller.connected
text: controller.completedToday + "/" + controller.goal text: controller.completedToday + "/" + controller.goal
color: Theme.faint color: Theme.faint
font.pixelSize: 10 font.pixelSize: 10
font.family: Theme.sansFamily font.family: Theme.sansFamily
font.features: ({ "tnum": 1 })
} }
Label { Label {
text: "✕" text: "✕"
@@ -166,23 +185,22 @@ Window {
} }
// ---- the time ---------------------------------------------------- // ---- the time ----------------------------------------------------
Label { ClockFigures {
text: controller.clockText text: controller.clockText
color: Theme.ink pixelSize: 34
ink: Theme.ink
opacity: mini.paused ? 0.55 : 1 opacity: mini.paused ? 0.55 : 1
font.pixelSize: 32 Layout.topMargin: -1
font.family: Theme.displayFamily
font.letterSpacing: -1
Layout.topMargin: -2
Behavior on opacity { NumberAnimation { duration: 150 } } Behavior on opacity { NumberAnimation { duration: 150 } }
} }
// ---- what you are on --------------------------------------------- // ---- what you are on ---------------------------------------------
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: 1
text: controller.taskTitle !== "" text: controller.taskTitle !== ""
? controller.taskTitle ? controller.taskTitle
: (controller.running ? "no task attached" : "double-click to open Kadence") : (controller.running ? "no task attached" : "double-click for the web app")
color: Theme.muted color: Theme.muted
font.pixelSize: 11 font.pixelSize: 11
font.family: Theme.sansFamily font.family: Theme.sansFamily
@@ -192,12 +210,15 @@ Window {
// ---- controls, always visible -------------------------------------- // ---- controls, always visible --------------------------------------
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: 4 Layout.topMargin: 6
spacing: 6 spacing: 6
// One filled button, and it is whichever action is the obvious
// next one: pause what is running, resume what is held, start
// when there is nothing.
MiniButton { MiniButton {
label: mini.paused ? "Resume" : "Pause" label: mini.paused ? "Resume" : "Pause"
filled: mini.paused filled: controller.running
visible: controller.running visible: controller.running
onClicked: mini.paused ? controller.resume() : controller.pause() onClicked: mini.paused ? controller.resume() : controller.pause()
} }
@@ -239,6 +260,11 @@ Window {
height: 3 height: 3
clip: true clip: true
Rectangle {
anchors.fill: parent
color: Theme.rule
opacity: 0.6
}
Rectangle { Rectangle {
width: parent.width * Math.max(0, Math.min(1, controller.progress)) width: parent.width * Math.max(0, Math.min(1, controller.progress))
height: parent.height height: parent.height
-229
View File
@@ -1,229 +0,0 @@
// The day in numbers — the same payload the Telegram message is built from.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
readonly property var payload: controller.report.report ? controller.report.report : null
readonly property var metrics: payload ? payload.metrics : null
ColumnLayout {
width: page.availableWidth - 56
x: 28
y: 26
spacing: 22
PageHeader {
eyebrow: controller.report.sent_at ? "sent to Telegram" : "not sent yet"
title: page.payload ? "Report · " + page.payload.date : "Report"
caption: page.payload ? page.payload.verdict : ""
GhostButton { text: "Reload"; onClicked: controller.loadReport() }
PrimaryButton { text: "Send to Telegram"; onClicked: controller.sendReport() }
}
Panel {
padding: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: [
{ label: "Pomodoros",
value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—",
caption: page.metrics && page.metrics.goal_met ? "goal met" : "goal not met yet",
accent: true },
{ label: "Focus",
value: page.metrics ? Theme.humanMinutes(page.metrics.focus_minutes) : "—",
caption: page.metrics ? Theme.humanMinutes(page.metrics.break_minutes) + " resting" : "" },
{ label: "Score",
value: page.payload ? "" + Math.round(page.payload.score) : "—",
caption: "out of 100" },
{ label: "Streak",
value: page.payload ? page.payload.streak.current + "d" : "—",
caption: page.payload ? "best " + page.payload.streak.best : "" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.preferredHeight: 84
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 20
label: modelData.label
value: modelData.value
caption: modelData.caption
size: 38
accent: modelData.accent ? Theme.work : Theme.ink
}
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 10
SectionTitle { text: "Last 30 days"; trailing: "goal line dashed" }
Panel {
BarRow {
Layout.fillWidth: true
days: controller.stats.range ? controller.stats.range : []
goal: controller.goal
}
}
}
ColumnLayout {
Layout.fillWidth: true
visible: page.payload && page.payload.achievements.length > 0
spacing: 10
SectionTitle { text: "Achievements" }
Flow {
Layout.fillWidth: true
spacing: 8
Repeater {
model: page.payload ? page.payload.achievements : []
delegate: Rectangle {
required property var modelData
implicitWidth: badge.implicitWidth + 24
implicitHeight: badge.implicitHeight + 18
radius: 10
color: Qt.alpha(Theme.ember, 0.08)
border.width: 1
border.color: Qt.alpha(Theme.ember, 0.22)
ColumnLayout {
id: badge
anchors.centerIn: parent
spacing: 1
Label {
text: modelData.emoji + " " + modelData.title
color: Theme.ink
font.pixelSize: 13
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
visible: modelData.detail !== ""
text: modelData.detail
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
visible: page.payload && page.payload.fun_facts.length > 0
spacing: 8
SectionTitle { text: "The funny part" }
Repeater {
model: page.payload ? page.payload.fun_facts : []
delegate: Label {
required property var modelData
text: modelData
color: Theme.ink
font.pixelSize: 13
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}
ColumnLayout {
Layout.fillWidth: true
visible: page.metrics && page.metrics.tasks_completed.length > 0
spacing: 6
SectionTitle { text: "Closed today" }
Repeater {
model: page.metrics ? page.metrics.tasks_completed : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 12
Label {
text: modelData.title
color: Theme.ink
font.pixelSize: 13
font.family: Theme.sansFamily
elide: Text.ElideRight
Layout.fillWidth: true
}
Label {
text: modelData.source
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 10
SectionTitle { text: "All time" }
Panel {
padding: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: [
{ label: "Pomodoros",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.pomodoros : "—" },
{ label: "Hours focused",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.focus_hours : "—" },
{ label: "Tasks closed",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.tasks_completed : "—" },
{ label: "Blocked",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.blocked_attempts : "—" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.preferredHeight: 64
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 18
label: modelData.label
value: modelData.value
}
}
}
}
}
}
Item { Layout.preferredHeight: 10 }
}
Component.onCompleted: controller.loadReport()
}
@@ -0,0 +1,364 @@
// The one window with fields in it.
//
// Kadence on the desktop is an agent and a countdown: the tray menu drives the
// timer, the mini timer shows it, and the blocking happens with no window open
// at all. The rules, the task sources, the report and the lengths are the
// *server's*, edited in the web app, so a second place to edit them was a second
// place to be wrong — the five-page window that used to be here has gone.
//
// What is left genuinely belongs to this machine, and a fresh install has to be
// able to set it somewhere: which backend, and how this box behaves. One column,
// hairline sections, exactly one filled button.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import "."
import "components"
Window {
id: dialog
width: 460
height: Math.min(720, content.implicitHeight + 76)
minimumWidth: 420
minimumHeight: 420
title: "Kadence settings"
color: Theme.paper
flags: Qt.Dialog
// The live config, re-read whenever it changes underneath us (the tray can
// change view-only mode, and a toggle that fails is reverted in Python).
readonly property var config: controller.config
// Typed into, not bound: a text field that re-binds on every keystroke
// fights the cursor. These are committed on editing finished, or by Save.
property string draftUrl: ""
property string draftToken: ""
function reload() {
draftUrl = config.server_url
draftToken = config.api_token
}
onVisibleChanged: if (visible) reload()
Component.onCompleted: reload()
function commitBackend() {
if (draftUrl !== config.server_url) controller.setConfig("server_url", draftUrl)
if (draftToken !== config.api_token) controller.setConfig("api_token", draftToken)
}
// ---- the furniture -----------------------------------------------------
component FieldLabel: Label {
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
component Field: TextField {
Layout.fillWidth: true
color: Theme.ink
font.pixelSize: 13
font.family: Theme.sansFamily
background: Rectangle {
radius: 8
color: Theme.surface
border.width: 1
border.color: parent.activeFocus ? Theme.ember : Theme.rule
}
}
// A labelled switch with its own explanation underneath, because every one
// of these turns a real desktop facility on or off and "Inhibit" alone does
// not say what it will do to your screen.
component Toggle: RowLayout {
id: toggleRow
property string label: ""
property string detail: ""
property bool on: false
property bool available: true
signal toggledTo(bool value)
Layout.fillWidth: true
spacing: 12
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Label {
text: toggleRow.label
color: toggleRow.available ? Theme.ink : Theme.faint
font.pixelSize: 13
font.family: Theme.sansFamily
}
Label {
visible: toggleRow.detail !== ""
text: toggleRow.detail
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
Switch {
checked: toggleRow.on
enabled: toggleRow.available
onToggled: toggleRow.toggledTo(checked)
}
}
ScrollView {
anchors.fill: parent
contentWidth: availableWidth
clip: true
ColumnLayout {
id: content
width: dialog.width
spacing: 18
Item { Layout.fillWidth: true; implicitHeight: 4 }
ColumnLayout {
Layout.leftMargin: 22
Layout.rightMargin: 22
spacing: 2
Label {
text: "KADENCE"
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.4
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
text: "This machine"
color: Theme.ink
font.pixelSize: 26
font.family: Theme.displayFamily
}
Label {
text: "Timer lengths, the block lists, the task sources and the "
+ "report all live on the server — edit those in the web app."
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
Layout.fillWidth: true
Layout.topMargin: 6
}
}
// ---- backend -------------------------------------------------------
ColumnLayout {
Layout.leftMargin: 22
Layout.rightMargin: 22
spacing: 10
SectionTitle {
text: "Backend"
trailing: controller.connected
? "reachable"
: (controller.connectionError !== "" ? "not answering" : "…")
}
Banner {
visible: !controller.connected && controller.connectionError !== ""
text: controller.connectionError
}
FieldLabel { text: "Address" }
Field {
text: dialog.draftUrl
placeholderText: "http://localhost:8000"
onTextChanged: dialog.draftUrl = text
onEditingFinished: dialog.commitBackend()
}
FieldLabel { text: "API token — API_TOKEN from the backend's .env, if it has one" }
Field {
text: dialog.draftToken
echoMode: TextInput.Password
placeholderText: "none"
onTextChanged: dialog.draftToken = text
onEditingFinished: dialog.commitBackend()
}
}
// ---- the mini timer ------------------------------------------------
ColumnLayout {
Layout.leftMargin: 22
Layout.rightMargin: 22
spacing: 10
SectionTitle { text: "Mini timer" }
Toggle {
label: "Show it while an interval runs"
detail: "Otherwise it appears only when you ask for it — tray, Ctrl+M, or --mini."
on: dialog.config.mini_timer_auto
onToggledTo: (value) => controller.setConfig("mini_timer_auto", value)
}
Toggle {
label: "Keep it above everything"
detail: dialog.config.keep_above_supported
? "A KWin window rule, because Wayland gives an app no way to ask."
: "Not available on this desktop — your compositor decides."
on: dialog.config.mini_timer_keep_above
available: dialog.config.keep_above_supported
onToggledTo: (value) => controller.setConfig("mini_timer_keep_above", value)
}
RowLayout {
Layout.fillWidth: true
spacing: 12
ColumnLayout {
Layout.fillWidth: true
spacing: 4
FieldLabel { text: "Where it appears" }
ComboBox {
Layout.fillWidth: true
model: controller.miniCornerLabels
currentIndex: controller.miniCornerIndex
font.family: Theme.sansFamily
onActivated: controller.setMiniCorner(currentIndex)
}
}
ColumnLayout {
spacing: 4
FieldLabel { text: "Edge gap" }
SpinBox {
from: 0
to: 400
stepSize: 4
value: dialog.config.mini_timer_margin
onValueModified: controller.setConfig("mini_timer_margin", value)
}
}
}
}
// ---- enforcement ---------------------------------------------------
ColumnLayout {
Layout.leftMargin: 22
Layout.rightMargin: 22
spacing: 10
SectionTitle {
text: "Focus guard"
trailing: controller.blockerActive ? "enforcing" : controller.blockerReason
}
Banner {
visible: dialog.config.enforce_blocking && !controller.helperInstalled
text: "The privileged helper is not installed, so websites cannot be "
+ "blocked on this machine. Run: sudo ./desktop/helper/install.sh"
}
Banner {
visible: controller.blockerError !== ""
text: controller.blockerError
}
Toggle {
label: "Enforce the server's rules here"
detail: "Off, this machine is a timer and nothing else."
on: dialog.config.enforce_blocking
onToggledTo: (value) => controller.setConfig("enforce_blocking", value)
}
Toggle {
label: "Block websites"
detail: "The managed region of /etc/hosts, plus a local DNS stub for the masks it cannot express."
on: dialog.config.block_hosts_file
available: dialog.config.enforce_blocking
onToggledTo: (value) => controller.setConfig("block_hosts_file", value)
}
Toggle {
label: "Close blocked applications"
detail: "/proc is scanned every few seconds and matches are asked to quit, then made to."
on: dialog.config.block_apps
available: dialog.config.enforce_blocking
onToggledTo: (value) => controller.setConfig("block_apps", value)
}
}
// ---- the session ---------------------------------------------------
ColumnLayout {
Layout.leftMargin: 22
Layout.rightMargin: 22
spacing: 10
SectionTitle { text: "This session" }
Toggle {
label: "Start with the session"
detail: "An XDG autostart entry, minimised to the tray."
on: dialog.config.autostart
onToggledTo: (value) => controller.setConfig("autostart", value)
}
Toggle {
label: "Run as a systemd user service"
detail: "Restarts on failure and logs to the journal: journalctl --user -u kadence-desktop."
on: dialog.config.service_enabled
onToggledTo: (value) => controller.setConfig("service_enabled", value)
}
Toggle {
label: "Pause when the session locks"
detail: "logind says when you walked away; the interval resumes when you come back."
on: dialog.config.pause_on_lock
onToggledTo: (value) => controller.setConfig("pause_on_lock", value)
}
Toggle {
label: "Pause other people's music"
detail: "Any MPRIS player is paused when focus starts; only the ones we paused resume."
on: dialog.config.pause_media
onToggledTo: (value) => controller.setConfig("pause_media", value)
}
Toggle {
label: "Silence other apps' notifications"
detail: "Do Not Disturb for the length of a focus interval, put back afterwards."
on: dialog.config.do_not_disturb
onToggledTo: (value) => controller.setConfig("do_not_disturb", value)
}
Toggle {
label: "Keep the screen awake while focusing"
on: dialog.config.inhibit_screensaver
onToggledTo: (value) => controller.setConfig("inhibit_screensaver", value)
}
}
// ---- out -----------------------------------------------------------
RowLayout {
Layout.leftMargin: 22
Layout.rightMargin: 22
Layout.bottomMargin: 20
Layout.topMargin: 4
spacing: 10
GhostButton {
text: "Open the web app"
onClicked: controller.openWebUi()
}
Item { Layout.fillWidth: true }
PrimaryButton {
text: "Done"
onClicked: {
dialog.commitBackend()
dialog.close()
}
}
}
}
}
// Esc closes it, like any dialog. Enter commits what was typed.
Shortcut {
sequence: StandardKey.Cancel
onActivated: dialog.close()
}
}
@@ -1,558 +0,0 @@
// Settings, split by where they live.
//
// "This computer" is local to this machine (~/.config/kadence/desktop.json).
// Everything else is server-side, shared with the web app and the Telegram bot.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
readonly property var pomodoro: controller.settings.pomodoro ? controller.settings.pomodoro : null
readonly property var telegram: controller.settings.notifications
? controller.settings.notifications.telegram : null
readonly property var report: controller.settings.notifications
? controller.settings.notifications.report : null
function patchPomodoro(key, value) {
var patch = {}; patch[key] = value
controller.patchSettings({ pomodoro: patch })
}
function patchTelegram(key, value) {
var patch = {}; patch[key] = value
controller.patchSettings({ notifications: { telegram: patch } })
}
// The Focus/Short break/Long break fields always show and edit the live
// settings. When they still match a saved preset, the edit also rewrites
// that preset's own numbers, so picking it again later gets you back to
// where you left off rather than to some older saved value.
function patchDuration(key, value) {
if (!page.pomodoro) return
var patch = {}; patch[key] = value
var idx = controller.activePresetIndex
if (idx >= 0 && page.pomodoro.presets) {
var presets = page.pomodoro.presets.map(function (p) { return Object.assign({}, p) })
presets[idx] = Object.assign(presets[idx], patch)
controller.patchSettings({ pomodoro: Object.assign({ presets: presets }, patch) })
} else {
controller.patchSettings({ pomodoro: patch })
}
}
component Field: ColumnLayout {
property string label: ""
default property alias content: holder.data
spacing: 4
Label {
text: parent.label
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
ColumnLayout { id: holder; spacing: 0 }
}
ColumnLayout {
width: page.availableWidth - 56
x: 28
y: 26
spacing: 26
PageHeader {
eyebrow: "Shared with the web app and the Telegram bot"
title: "Settings"
caption: "Secrets are masked once stored — leave the dots alone to keep the saved value."
GhostButton { text: "Open the web app"; onClicked: controller.openWebUi() }
}
// ---- backend ----------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle {
text: "Backend"
trailing: controller.connected ? "connected" : "offline"
}
RowLayout {
Layout.fillWidth: true
spacing: 10
TextField {
id: serverField
Layout.fillWidth: true
text: controller.config.server_url
placeholderText: "http://localhost:8000"
}
TextField {
id: tokenField
Layout.preferredWidth: 180
text: controller.config.api_token
placeholderText: "API token (optional)"
echoMode: TextInput.Password
}
PrimaryButton {
text: "Connect"
onClicked: {
controller.setConfig("server_url", serverField.text.trim())
controller.setConfig("api_token", tokenField.text)
}
}
}
Label {
visible: !controller.connected && controller.connectionError !== ""
text: controller.connectionError
color: Theme.clay
font.pixelSize: 12
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
// ---- timer -------------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Timer" }
// The preset switcher: pick one and it applies at once. The fields
// below always belong to whichever one is lit — there is nothing
// to show for the others until you switch to them.
RowLayout {
Layout.fillWidth: true
spacing: 8
Repeater {
model: page.pomodoro && page.pomodoro.presets ? page.pomodoro.presets : []
delegate: PresetChip {
required property var modelData
required property int index
text: modelData.name
active: controller.activePresetIndex === index
onClicked: controller.applyPreset(index)
}
}
Label {
visible: controller.activePresetIndex === -1
text: "Custom"
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
leftPadding: 6
}
Item { Layout.fillWidth: true }
}
Flow {
Layout.fillWidth: true
spacing: 20
Field {
label: "Focus (min)"
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.work_minutes : 25
onValueModified: page.patchDuration("work_minutes", value)
}
}
Field {
label: "Short break"
SpinBox {
from: 1; to: 120
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
onValueModified: page.patchDuration("short_break_minutes", value)
}
}
Field {
label: "Long break"
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
onValueModified: page.patchDuration("long_break_minutes", value)
}
}
Field {
label: "Long break every"
SpinBox {
from: 1; to: 12
value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4
onValueModified: page.patchPomodoro("cycles_before_long_break", value)
}
}
Field {
label: "Daily goal"
SpinBox {
from: 1; to: 40
value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8
onValueModified: page.patchPomodoro("daily_goal_pomodoros", value)
}
}
}
Flow {
Layout.fillWidth: true
spacing: 18
Switch {
text: "Timer enabled (off = view-only mode)"
checked: controller.timerEnabled
onToggled: controller.setTimerEnabled(checked)
}
Switch {
text: "Auto-start breaks"
checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true
onToggled: page.patchPomodoro("auto_start_breaks", checked)
}
Switch {
text: "Auto-start next focus"
checked: page.pomodoro ? page.pomodoro.auto_start_work : false
onToggled: page.patchPomodoro("auto_start_work", checked)
}
Switch {
text: "Allow pausing"
checked: page.pomodoro ? page.pomodoro.allow_pause : true
onToggled: page.patchPomodoro("allow_pause", checked)
}
Switch {
text: "Strict mode"
checked: page.pomodoro ? page.pomodoro.strict_mode : false
onToggled: page.patchPomodoro("strict_mode", checked)
ToolTip.visible: hovered
ToolTip.text: "Abandoning a focus interval requires a reason."
}
}
}
// ---- this computer ------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "This computer"; trailing: "native desktop features" }
Flow {
Layout.fillWidth: true
spacing: 18
Switch {
text: "Start in view-only mode"
checked: controller.config.view_only
onToggled: controller.setConfig("view_only", checked)
}
Switch {
text: "Tray icon"
checked: controller.config.tray_icon
onToggled: controller.setConfig("tray_icon", checked)
}
Switch {
text: "Close to tray"
checked: controller.config.close_to_tray
onToggled: controller.setConfig("close_to_tray", checked)
}
Switch {
text: "Notifications"
checked: controller.config.notifications
onToggled: controller.setConfig("notifications", checked)
}
Switch {
text: "Notification buttons"
checked: controller.config.notification_actions
onToggled: controller.setConfig("notification_actions", checked)
}
Switch {
text: "Keep the screen awake"
checked: controller.config.inhibit_screensaver
onToggled: controller.setConfig("inhibit_screensaver", checked)
}
Switch {
text: "Do Not Disturb while focusing"
checked: controller.config.do_not_disturb
onToggled: controller.setConfig("do_not_disturb", checked)
}
Switch {
text: "Pause music on focus"
checked: controller.config.pause_media
onToggled: controller.setConfig("pause_media", checked)
ToolTip.visible: hovered
ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers."
}
Switch {
text: "Mini timer"
checked: controller.config.mini_timer
onToggled: controller.setConfig("mini_timer", checked)
ToolTip.visible: hovered
ToolTip.text: "A small always-on-top countdown. Ctrl+M, or the tray menu."
}
Switch {
text: "Mini timer follows the window"
checked: controller.config.mini_timer_auto
onToggled: controller.setConfig("mini_timer_auto", checked)
ToolTip.visible: hovered
ToolTip.text: "Show it while an interval runs and the main window is hidden."
}
Switch {
text: "Keep the mini timer above everything"
enabled: controller.config.keep_above_supported
checked: controller.config.mini_timer_keep_above
onToggled: controller.setConfig("mini_timer_keep_above", checked)
ToolTip.visible: hovered
ToolTip.text: controller.config.keep_above_supported
? "Installs a KWin window rule — the only way to float above "
+ "other apps on Wayland. Switching this off removes it again."
: "Needs KWin (KDE). Elsewhere the Qt window flag is used."
}
Switch {
text: "Taskbar progress"
checked: controller.config.taskbar_progress
onToggled: controller.setConfig("taskbar_progress", checked)
}
Switch {
text: "Pause when the screen locks"
checked: controller.config.pause_on_lock
onToggled: controller.setConfig("pause_on_lock", checked)
}
Switch {
text: "D-Bus control"
checked: controller.config.dbus_service
onToggled: controller.setConfig("dbus_service", checked)
ToolTip.visible: hovered
ToolTip.text: "org.kadence.Desktop — bind it to a global shortcut."
}
}
RowLayout {
Layout.fillWidth: true
spacing: 20
Field {
label: "Mini timer appears"
ComboBox {
implicitWidth: 210
textRole: "label"
valueRole: "key"
model: [
{ key: "top-left", label: "Top left" },
{ key: "top-centre", label: "Top centre" },
{ key: "top-right", label: "Top right" },
{ key: "bottom-left", label: "Bottom left" },
{ key: "bottom-centre", label: "Bottom centre" },
{ key: "bottom-right", label: "Bottom right" },
{ key: "centre", label: "Centre of the screen" },
{ key: "remember", label: "Where I last dragged it" }
]
currentIndex: {
var corner = controller.config.mini_timer_corner
for (var i = 0; i < model.length; i++)
if (model[i].key === corner) return i
return 5
}
onActivated: function (index) {
controller.setConfig("mini_timer_corner", model[index].key)
}
}
}
Field {
label: "Gap from the edges (px)"
SpinBox {
from: 0; to: 400; stepSize: 4
value: controller.config.mini_timer_margin
onValueModified: controller.setConfig("mini_timer_margin", value)
}
}
Item { Layout.fillWidth: true }
}
RowLayout {
Layout.fillWidth: true
spacing: 20
Field {
label: "Pause after idle (min, 0 = never)"
SpinBox {
from: 0; to: 120
value: controller.config.pause_on_idle_minutes
onValueModified: controller.setConfig("pause_on_idle_minutes", value)
}
}
Field {
label: "Poll the backend every (s)"
SpinBox {
from: 1; to: 60
value: controller.config.poll_seconds
onValueModified: controller.setConfig("poll_seconds", value)
}
}
Item { Layout.fillWidth: true }
}
Flow {
Layout.fillWidth: true
spacing: 18
Switch {
text: "Start with the session"
checked: controller.config.autostart
onToggled: controller.setConfig("autostart", checked)
}
Switch {
text: "systemd user service"
checked: controller.config.service_enabled
onToggled: controller.setConfig("service_enabled", checked)
ToolTip.visible: hovered
ToolTip.text: "Restarts the app if it dies; logs to the journal."
}
Switch {
text: "Start minimised"
checked: controller.config.start_minimized
onToggled: controller.setConfig("start_minimized", checked)
}
}
}
// ---- telegram -------------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Telegram"; trailing: "reports, briefings and nudges" }
RowLayout {
Layout.fillWidth: true
spacing: 10
Switch {
text: "Enabled"
checked: page.telegram ? page.telegram.enabled : false
onToggled: page.patchTelegram("enabled", checked)
}
TextField {
Layout.fillWidth: true
placeholderText: "Bot token from @BotFather"
text: page.telegram ? page.telegram.bot_token : ""
onEditingFinished: if (text !== "••••••••") page.patchTelegram("bot_token", text)
}
TextField {
Layout.preferredWidth: 160
placeholderText: "Chat id"
text: page.telegram ? page.telegram.chat_id : ""
onEditingFinished: page.patchTelegram("chat_id", text)
}
}
Flow {
Layout.fillWidth: true
spacing: 18
Switch {
text: "Daily report"
checked: page.telegram ? page.telegram.daily_report : true
onToggled: page.patchTelegram("daily_report", checked)
}
Switch {
text: "Morning briefing"
checked: page.telegram ? page.telegram.morning_briefing : true
onToggled: page.patchTelegram("morning_briefing", checked)
}
Switch {
text: "Weekly review"
checked: page.telegram ? page.telegram.weekly_report : true
onToggled: page.patchTelegram("weekly_report", checked)
}
Switch {
text: "Nudge me when idle"
checked: page.telegram ? page.telegram.nudge_enabled : true
onToggled: page.patchTelegram("nudge_enabled", checked)
}
Switch {
text: "Chat commands"
checked: page.telegram ? page.telegram.commands_enabled : true
onToggled: page.patchTelegram("commands_enabled", checked)
}
}
Flow {
Layout.fillWidth: true
spacing: 20
Field {
label: "Report at (HH:MM)"
TextField {
implicitWidth: 110
text: page.telegram ? page.telegram.daily_report_time : "21:00"
onEditingFinished: page.patchTelegram("daily_report_time", text)
}
}
Field {
label: "Briefing at (HH:MM)"
TextField {
implicitWidth: 110
text: page.telegram ? page.telegram.morning_briefing_time : "09:00"
onEditingFinished: page.patchTelegram("morning_briefing_time", text)
}
}
Field {
label: "Nudge after (min idle)"
SpinBox {
from: 10; to: 600
value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90
onValueModified: page.patchTelegram("nudge_after_idle_minutes", value)
}
}
Field {
label: "Tone"
ComboBox {
implicitWidth: 170
model: ["friendly", "coach", "drill_sergeant", "dry"]
currentIndex: page.report ? model.indexOf(page.report.tone) : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { tone: model[index] } } })
}
}
}
Field {
label: "Language"
ComboBox {
implicitWidth: 110
model: ["en", "ru"]
currentIndex: page.report && page.report.language === "ru" ? 1 : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { language: model[index] } } })
}
}
}
}
}
// ---- scripting -------------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Shortcuts and scripting" }
Panel {
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Theme.muted
font.pixelSize: 11
font.family: Theme.monoFamily
text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n"
+ "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n"
+ "kadence-desktop --toggle"
}
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
text: "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
}
}
Label {
text: "Kadence " + appVersion
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
Item { Layout.preferredHeight: 10 }
}
Component.onCompleted: controller.loadSettings()
}
-234
View File
@@ -1,234 +0,0 @@
// Everything you owe, grouped by deadline.
//
// There is no checkbox: a task is closed in Jira, Todoist or Google Tasks, and
// the next refresh notices. Two places to tick something off is two places to
// be wrong.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
function bucketTasks(bucket) {
var out = []
for (var i = 0; i < controller.tasks.length; i++) {
var task = controller.tasks[i]
if (!task.is_done && task.bucket === bucket) out.push(task)
}
return out
}
function bucketColor(bucket) {
if (bucket === "overdue") return Theme.clay
if (bucket === "today") return Theme.amber
if (bucket === "active") return Theme.pine
if (bucket === "upcoming") return Theme.faint
return Theme.rule
}
ColumnLayout {
width: page.availableWidth - 56
x: 28
y: 26
spacing: 22
PageHeader {
eyebrow: "From Jira, Todoist, Google Tasks and your own list"
title: "Tasks"
caption: "Close them where they live — the next refresh credits them to today."
GhostButton { text: "Add"; onClicked: addDialog.open() }
PrimaryButton { text: "Refresh sources"; onClicked: controller.refreshSources() }
}
Repeater {
model: [
{ key: "overdue", label: "Overdue" },
{ key: "today", label: "Due today" },
{ key: "active", label: "In progress" },
{ key: "upcoming", label: "Coming up" },
{ key: "someday", label: "No deadline" }
]
delegate: ColumnLayout {
required property var modelData
property var items: page.bucketTasks(modelData.key)
visible: items.length > 0
Layout.fillWidth: true
spacing: 2
SectionTitle { text: modelData.label; trailing: "" + parent.items.length }
Repeater {
model: parent.items
delegate: Item {
required property var modelData
Layout.fillWidth: true
implicitHeight: row.implicitHeight + 20
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: Theme.rule
}
RowLayout {
id: row
anchors.fill: parent
anchors.topMargin: 10
anchors.bottomMargin: 10
spacing: 14
Rectangle {
width: 2
Layout.fillHeight: true
radius: 1
color: page.bucketColor(modelData.bucket)
}
ColumnLayout {
Layout.fillWidth: true
spacing: 3
Label {
text: modelData.title
color: Theme.ink
font.pixelSize: 14
font.weight: modelData.pinned ? Font.DemiBold : Font.Normal
font.family: Theme.sansFamily
elide: Text.ElideRight
Layout.fillWidth: true
}
RowLayout {
spacing: 12
Label {
text: modelData.source_label
color: Theme.faint; font.pixelSize: 11
font.family: Theme.sansFamily
}
Label {
visible: !!modelData.project && modelData.project !== modelData.source_label
text: modelData.project || ""
color: Theme.faint; font.pixelSize: 11
font.family: Theme.sansFamily
}
Label {
visible: !!modelData.status
text: modelData.status || ""
color: Theme.faint; font.pixelSize: 11
font.family: Theme.sansFamily
}
Label {
visible: modelData.days_overdue > 0
text: modelData.days_overdue + "d overdue"
color: Theme.clay; font.pixelSize: 11
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
visible: !!modelData.due_date && modelData.days_overdue === 0
text: "due " + (modelData.due_date || "")
color: Theme.faint; font.pixelSize: 11
font.family: Theme.sansFamily
}
}
}
GhostButton {
text: modelData.pinned ? "Unpin" : "Pin"
implicitHeight: 28
onClicked: controller.setTaskPinned(modelData.id, !modelData.pinned)
}
GhostButton {
visible: !!modelData.url
text: "Open"
implicitHeight: 28
onClicked: controller.openUrl(modelData.url)
}
GhostButton {
text: "Focus"
implicitHeight: 28
onClicked: {
controller.attachTask(modelData.id)
controller.startWork()
window.page = 0
}
}
}
}
}
}
}
Panel {
visible: controller.tasks.length === 0
ColumnLayout {
spacing: 8
Label {
text: "Nothing on the list"
color: Theme.ink
font.pixelSize: 17
font.family: Theme.displayFamily
}
Label {
text: "Connect Jira, Todoist or Google Tasks in Settings, or add something yourself."
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
GhostButton { text: "Open settings"; onClicked: window.page = 4 }
}
}
Item { Layout.preferredHeight: 10 }
}
Dialog {
id: addDialog
anchors.centerIn: Overlay.overlay
width: 380
modal: true
title: "New task"
standardButtons: Dialog.Cancel
ColumnLayout {
anchors.fill: parent
spacing: 12
TextField {
id: titleField
Layout.fillWidth: true
placeholderText: "What needs doing?"
}
TextField {
id: dueField
Layout.fillWidth: true
placeholderText: "Due date — YYYY-MM-DD (optional)"
}
Label {
text: "Local tasks live only here, so they are the one kind you can delete."
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
PrimaryButton {
text: "Add"
enabled: titleField.text.trim() !== ""
Layout.alignment: Qt.AlignRight
onClicked: {
controller.createTask(titleField.text.trim(), dueField.text.trim())
titleField.text = ""; dueField.text = ""
addDialog.close()
}
}
}
}
}
-354
View File
@@ -1,354 +0,0 @@
// The timer: the dial, the controls, and what you are working on.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "."
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
readonly property var openTasks: {
var list = []
for (var i = 0; i < controller.tasks.length; i++)
if (!controller.tasks[i].is_done) list.push(controller.tasks[i])
return list
}
function suggestion() {
var buckets = ["overdue", "today", "upcoming", "someday"]
for (var b = 0; b < buckets.length; b++)
for (var i = 0; i < openTasks.length; i++)
if (openTasks[i].pinned || openTasks[i].bucket === buckets[b]) return openTasks[i]
return null
}
ColumnLayout {
width: page.availableWidth - 56
x: 28
y: 26
spacing: 22
PageHeader {
eyebrow: Qt.formatDate(new Date(), "dddd, d MMMM")
title: !controller.timerEnabled ? "View mode" : (controller.running ? controller.kindLabel : "Ready when you are")
caption: !controller.timerEnabled
? "Timer is disabled · view-only mode"
: (controller.taskTitle !== ""
? "Tracking “" + controller.taskTitle + "”"
: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
+ " before a long break")
GhostButton {
visible: controller.timerEnabled && controller.running && controller.status !== "paused"
text: "Pause"
onClicked: controller.pause()
}
GhostButton {
visible: controller.timerEnabled && controller.running && controller.status === "paused"
text: "Resume"
onClicked: controller.resume()
}
PrimaryButton {
visible: controller.timerEnabled && controller.running
text: "Finish"
onClicked: controller.complete()
}
PrimaryButton {
visible: controller.timerEnabled && !controller.running
text: "Start focus"
onClicked: controller.startWork()
}
GhostButton {
visible: controller.timerEnabled && !controller.running
text: controller.nextKindLabel
onClicked: controller.startBreak()
}
GhostButton {
text: controller.timerEnabled ? "Turn off timer" : "Turn on timer"
onClicked: controller.setTimerEnabled(!controller.timerEnabled)
}
}
GridLayout {
Layout.fillWidth: true
columns: page.width > 940 ? 2 : 1
columnSpacing: 22
rowSpacing: 22
Panel {
Layout.fillWidth: true
Layout.preferredWidth: 1
padding: 28
TimerDial {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: Math.min(290, page.availableWidth * 0.62)
Layout.preferredHeight: Layout.preferredWidth
progress: controller.timerEnabled ? controller.progress : 0
clockText: controller.timerEnabled ? controller.clockText : "—"
minutes: Math.max(1, Math.round(controller.plannedSeconds / 60))
label: !controller.timerEnabled ? "View mode" : (controller.running ? controller.kindLabel : "Next · " + controller.nextKindLabel)
sublabel: !controller.timerEnabled
? "Timer disabled"
: (controller.taskTitle !== ""
? controller.taskTitle
: (controller.running ? "" : "Pick something below, or just begin"))
accent: controller.timerEnabled ? Theme.kindColor(controller.kind) : Theme.muted
paused: controller.status === "paused"
idle: !controller.timerEnabled || !controller.running
}
// Switching here takes effect at once — it resizes whatever
// is already running or paused too, not just the next one.
RowLayout {
visible: controller.timerEnabled
&& controller.settings.pomodoro
&& controller.settings.pomodoro.presets
&& controller.settings.pomodoro.presets.length > 0
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 14
spacing: 8
Repeater {
model: controller.settings.pomodoro ? controller.settings.pomodoro.presets : []
delegate: PresetChip {
required property var modelData
required property int index
text: modelData.name
active: controller.activePresetIndex === index
onClicked: controller.applyPreset(index)
}
}
}
RowLayout {
visible: controller.timerEnabled && controller.running
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 14
spacing: 6
GhostButton {
text: "+5 min"
onClicked: controller.extend(5)
}
GhostButton {
text: "Interruption"
onClicked: controller.interrupt()
ToolTip.visible: hovered
ToolTip.text: "Count a distraction without stopping the clock"
}
GhostButton {
text: "Abandon"
onClicked: abandonDialog.open()
}
}
Label {
Layout.alignment: Qt.AlignHCenter
visible: controller.timerEnabled && controller.interruptions > 0
text: controller.interruptions + " interruption(s) so far"
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
}
Label {
Layout.alignment: Qt.AlignHCenter
visible: controller.timerEnabled
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
color: Theme.faint
font.pixelSize: 10
font.family: Theme.sansFamily
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop
spacing: 20
ColumnLayout {
Layout.fillWidth: true
spacing: 10
SectionTitle { text: "Today" }
RowLayout {
spacing: 8
Label {
text: controller.completedToday
color: Theme.ink
font.pixelSize: 40
font.family: Theme.displayFamily
font.letterSpacing: -1
}
Label {
text: "of " + controller.goal + " pomodoros"
color: Theme.muted
font.pixelSize: 14
font.family: Theme.sansFamily
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 7
}
}
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: Math.max(controller.goal, controller.completedToday)
delegate: Rectangle {
required property int index
width: 11; height: 11; radius: 5.5
color: index < controller.completedToday ? Theme.work : "transparent"
border.width: index < controller.completedToday ? 0 : 1
border.color: Theme.rule
}
}
}
Label {
Layout.fillWidth: true
text: controller.stats.today
? Theme.humanMinutes(controller.stats.today.focus_minutes) + " focused · "
+ Theme.humanMinutes(controller.stats.today.break_minutes) + " rested"
+ (controller.stats.today.longest_stretch_minutes > 0
? " · longest run " + Theme.humanMinutes(controller.stats.today.longest_stretch_minutes) : "")
: ""
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 10
SectionTitle { text: "Working on" }
ComboBox {
id: taskPicker
Layout.fillWidth: true
font.pixelSize: 13
font.family: Theme.sansFamily
model: {
var names = ["Nothing in particular"]
for (var i = 0; i < page.openTasks.length; i++)
names.push((page.openTasks[i].pinned ? "★ " : "")
+ page.openTasks[i].title
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
return names
}
onActivated: function (index) {
controller.attachTask(index === 0 ? -1 : page.openTasks[index - 1].id)
}
}
RowLayout {
Layout.fillWidth: true
spacing: 14
Label {
text: (controller.taskCounts.overdue || 0) + " overdue"
color: (controller.taskCounts.overdue || 0) > 0 ? Theme.clay : Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
Label {
text: (controller.taskCounts.today || 0) + " due today"
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
Item { Layout.fillWidth: true }
GhostButton { text: "All tasks"; onClicked: window.page = 1 }
}
}
}
}
// ---- the day's numbers -------------------------------------------------
Panel {
padding: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: [
{ label: "Focus today",
value: controller.stats.today ? Theme.humanMinutes(controller.stats.today.focus_minutes) : "—",
caption: controller.completedToday + " intervals" },
{ label: "Interruptions",
value: controller.stats.today ? "" + controller.stats.today.interruptions : "—",
caption: controller.stats.today && controller.stats.today.abandoned > 0
? controller.stats.today.abandoned + " abandoned" : "none dropped" },
{ label: "Closed today",
value: controller.stats.today ? "" + controller.stats.today.tasks_completed_count : "—",
caption: "in your own tools" },
{ label: "Blocked",
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "—",
caption: "temptations turned away" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.fillHeight: true
Layout.preferredHeight: 70
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 18
label: modelData.label
value: modelData.value
caption: modelData.caption
}
}
}
}
}
Item { Layout.preferredHeight: 10 }
}
Dialog {
id: abandonDialog
anchors.centerIn: Overlay.overlay
width: 380
modal: true
title: "Abandon this interval?"
standardButtons: Dialog.Cancel
ColumnLayout {
anchors.fill: parent
spacing: 12
Label {
text: controller.clockText + " left. It will be recorded as abandoned."
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
TextField {
id: reasonField
Layout.fillWidth: true
placeholderText: "What happened? (optional)"
}
PrimaryButton {
text: "Abandon"
Layout.alignment: Qt.AlignRight
onClicked: { controller.abandon(reasonField.text); reasonField.text = ""; abandonDialog.close() }
}
}
}
}
@@ -1,82 +0,0 @@
// Pomodoros per day: magnitude over time, so bars, one hue, baseline at zero.
// The goal line is the only reference mark; days that cleared it take a darker
// step of the same ramp, which keeps "did I hit it" readable without a second
// colour.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
Item {
id: chart
property var days: []
property int goal: 8
property int barHeight: 108
Layout.fillWidth: true
implicitHeight: barHeight + 16
readonly property int maxValue: {
var top = goal
for (var i = 0; i < days.length; i++) top = Math.max(top, days[i].pomodoros)
return Math.max(top, 1)
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight
height: 1
color: Theme.rule
visible: chart.goal > 0
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
height: chart.barHeight
spacing: 2
Repeater {
model: chart.days
delegate: Item {
required property var modelData
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: Math.max(modelData.pomodoros > 0
? (modelData.pomodoros / chart.maxValue) * chart.barHeight : 2, 2)
radius: 3
color: modelData.pomodoros === 0
? Theme.rule
: (modelData.goal_met ? Theme.sequential[4] : Theme.sequential[2])
ToolTip.visible: hover.hovered
ToolTip.text: modelData.date + " · " + modelData.pomodoros
+ " pomodoros · " + modelData.focus_minutes + " min"
HoverHandler { id: hover }
Behavior on height { NumberAnimation { duration: 220 } }
}
}
}
}
RowLayout {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
Label {
text: chart.days.length ? chart.days[0].date.substring(5) : ""
font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
}
Item { Layout.fillWidth: true }
Label {
text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : ""
font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
}
}
}
@@ -0,0 +1,58 @@
// The countdown, in the display serif, on a fixed advance.
//
// Fraunces has **no tabular figures**: its digits are proportional (`1` is 1024
// units against `0` at 1461) and the font ships no `tnum` feature, so a clock
// set in it jitters sideways on every tick — the one thing a clock must not do.
// `font.features` cannot fix what the font does not contain, so each glyph is
// laid in a box as wide as the widest digit instead. The separators keep their
// natural width, because a colon in a digit-wide box reads as a gap.
//
// The Android client's `TabularFigures` solves the same problem the same way;
// Inter *does* ship `tnum`, which is why the sans numbers elsewhere only need a
// font feature.
import QtQuick
import ".."
Row {
id: figures
property string text: ""
property int pixelSize: 34
property color ink: Theme.ink
readonly property font figureFont: Qt.font({
family: Theme.displayFamily,
pixelSize: figures.pixelSize
})
// Measured rather than assumed: with Fraunces absent the fallback serif may
// already be tabular, in which case this is simply the digit width.
readonly property real cell: {
var widest = 0
for (var digit = 0; digit < 10; digit++)
widest = Math.max(widest, metrics.advanceWidth(String(digit)))
return widest
}
FontMetrics { id: metrics; font: figures.figureFont }
Repeater {
model: figures.text.length
delegate: Item {
required property int index
readonly property string glyph: figures.text.charAt(index)
readonly property bool isDigit: glyph >= "0" && glyph <= "9"
width: isDigit ? figures.cell : metrics.advanceWidth(glyph)
height: metrics.height
Text {
anchors.centerIn: parent
text: parent.glyph
font: figures.figureFont
color: figures.ink
}
}
}
}
@@ -1,55 +0,0 @@
// Eyebrow, title, actions, rule — the same opening every page uses.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
ColumnLayout {
property string eyebrow: ""
property string title: ""
property string caption: ""
default property alias actions: actionRow.data
Layout.fillWidth: true
spacing: 10
RowLayout {
Layout.fillWidth: true
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
visible: eyebrow !== ""
text: eyebrow.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.3
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
text: title
color: Theme.ink
font.pixelSize: 27
font.family: Theme.displayFamily
font.letterSpacing: -0.6
}
Label {
visible: caption !== ""
text: caption
color: Theme.muted
font.pixelSize: 13
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
RowLayout { id: actionRow; spacing: 8; Layout.alignment: Qt.AlignRight | Qt.AlignVCenter }
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule }
}
@@ -1,39 +0,0 @@
// A pill in a preset switcher: filled ember when it is the one the live
// settings currently match, outlined otherwise. Clicking any pill applies it
// at once — there is no separate "Apply" step. Shared by SettingsPage (the
// full switcher, with the numbers editable below it) and TimerPage (a quick
// way to change the length of the *next* interval without leaving the dial).
import QtQuick
import QtQuick.Controls
import ".."
Rectangle {
id: chip
property string text: ""
property bool active: false
signal clicked()
implicitWidth: label.implicitWidth + 28
implicitHeight: 32
radius: 16
color: chip.active ? Theme.ember : (chipHover.hovered ? Qt.alpha(Theme.ember, 0.10) : "transparent")
border.width: chip.active ? 0 : 1
border.color: chipHover.hovered ? Theme.ember : Theme.rule
Behavior on color { ColorAnimation { duration: 110 } }
Label {
id: label
anchors.centerIn: parent
text: chip.text
color: chip.active ? "white" : Theme.ink
font.pixelSize: 12
font.weight: chip.active ? Font.DemiBold : Font.Medium
font.family: Theme.sansFamily
}
HoverHandler { id: chipHover; cursorShape: Qt.PointingHandCursor }
TapHandler {
grabPermissions: PointerHandler.TakeOverForbidden
onTapped: chip.clicked()
}
}
@@ -1,39 +0,0 @@
// Transient messages, bottom centre, on an ink slab.
import QtQuick
import QtQuick.Controls
import ".."
Popup {
id: snackbar
property string message: ""
function show(text) {
message = text
open()
timer.restart()
}
x: Math.round((parent.width - width) / 2)
y: parent.height - height - 28
width: Math.min(parent.width - 48, Math.max(240, label.implicitWidth + 36))
padding: 14
modal: false
focus: false
closePolicy: Popup.NoAutoClose
background: Rectangle {
color: Theme.ink
radius: 10
}
contentItem: Label {
id: label
text: snackbar.message
color: Theme.paper
font.pixelSize: 13
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
}
Timer { id: timer; interval: 5000; onTriggered: snackbar.close() }
}
@@ -1,44 +0,0 @@
// A number and its name. The figure is set in the display serif, which is what
// makes a screen of statistics read like a page rather than a readout.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
ColumnLayout {
property string label: ""
property string value: "—"
property string caption: ""
property color accent: Theme.ink
property int size: 30
spacing: 2
Layout.fillWidth: true
Label {
text: label.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.2
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
text: value
color: accent
font.pixelSize: size
font.family: Theme.displayFamily
font.letterSpacing: -0.5
}
Label {
visible: caption !== ""
text: caption
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
Layout.fillWidth: true
elide: Text.ElideRight
wrapMode: Text.WordWrap
}
}
@@ -1,120 +0,0 @@
// The dial.
//
// A hairline ring with one tick per minute inside it: the ticks light up as the
// minutes are spent, so the dial reads as "how much is left" at a glance and as
// "how long was this?" on a second look. The time is the hero, set in the
// display serif.
import QtQuick
import QtQuick.Controls
import ".."
Item {
id: dial
property real progress: 0 // 0..1 elapsed
property string clockText: "00:00"
property string label: ""
property string sublabel: ""
property color accent: Theme.work
property bool paused: false
property bool idle: false
property int minutes: 25
implicitWidth: 280
implicitHeight: 280
onProgressChanged: canvas.requestPaint()
onAccentChanged: canvas.requestPaint()
onPausedChanged: canvas.requestPaint()
onIdleChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var c = Math.min(width, height) / 2
var radius = c - 26
// The ring.
ctx.lineWidth = 1.5
ctx.strokeStyle = Theme.rule
ctx.beginPath()
ctx.arc(c, c, radius, 0, Math.PI * 2)
ctx.stroke()
// Minute ticks, inside the ring.
var count = dial.minutes <= 60 ? Math.max(1, dial.minutes) : Math.round(dial.minutes / 5)
var spent = Math.floor(count * Math.max(0, Math.min(1, dial.progress)))
for (var i = 0; i < count; i++) {
var angle = (i / count) * Math.PI * 2 - Math.PI / 2
var major = i % 5 === 0
var outer = radius - 7
var inner = outer - (major ? 9 : 5)
ctx.beginPath()
ctx.lineWidth = major ? 1.4 : 1
ctx.strokeStyle = (i < spent && !dial.idle) ? dial.accent : Theme.rule
ctx.moveTo(c + inner * Math.cos(angle), c + inner * Math.sin(angle))
ctx.lineTo(c + outer * Math.cos(angle), c + outer * Math.sin(angle))
ctx.stroke()
}
// The remaining arc.
if (!dial.idle) {
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
if (remaining > 0) {
ctx.beginPath()
ctx.lineWidth = 2.5
ctx.lineCap = "round"
ctx.globalAlpha = dial.paused ? 0.4 : 1
ctx.strokeStyle = dial.accent
ctx.arc(c, c, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
ctx.stroke()
}
}
}
}
Column {
anchors.centerIn: parent
spacing: 6
width: parent.width * 0.7
Label {
width: parent.width
text: dial.label.toUpperCase()
color: dial.idle ? Theme.muted : dial.accent
font.pixelSize: 10
font.letterSpacing: 1.4
font.weight: Font.DemiBold
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
}
Label {
width: parent.width
text: dial.clockText
color: Theme.ink
opacity: dial.paused ? 0.55 : 1
font.pixelSize: Math.round(dial.width * 0.21)
font.family: Theme.displayFamily
font.letterSpacing: -1.5
horizontalAlignment: Text.AlignHCenter
Behavior on opacity { NumberAnimation { duration: 180 } }
}
Label {
width: parent.width
visible: dial.sublabel !== ""
text: dial.sublabel
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.WordWrap
}
}
}
+10 -2
View File
@@ -68,6 +68,7 @@ class Tray(QObject):
"""Tray icon plus its menu. Every entry maps to a controller slot.""" """Tray icon plus its menu. Every entry maps to a controller slot."""
showRequested = pyqtSignal() showRequested = pyqtSignal()
settingsRequested = pyqtSignal()
quitRequested = pyqtSignal() quitRequested = pyqtSignal()
def __init__(self, controller, parent: QObject | None = None) -> None: def __init__(self, controller, parent: QObject | None = None) -> None:
@@ -155,10 +156,17 @@ class Tray(QObject):
) )
self._menu.addAction(self._action_view_mode) self._menu.addAction(self._action_view_mode)
# The menu is unchanged, and "Open Kadence" still means "show me the
# timer" — there is simply no five-page window behind it any more: it
# raises the mini timer, which is the whole of this app's own interface.
show = QAction("Open Kadence", self._menu) show = QAction("Open Kadence", self._menu)
show.triggered.connect(self.showRequested.emit) show.triggered.connect(self.showRequested.emit)
self._menu.addAction(show) self._menu.addAction(show)
settings = QAction("Settings…", self._menu)
settings.triggered.connect(self.settingsRequested.emit)
self._menu.addAction(settings)
web = QAction("Open the web app", self._menu) web = QAction("Open the web app", self._menu)
web.triggered.connect(controller.openWebUi) web.triggered.connect(controller.openWebUi)
self._menu.addAction(web) self._menu.addAction(web)
@@ -200,8 +208,8 @@ class Tray(QObject):
self._action_view_mode.setChecked(not timer_enabled) self._action_view_mode.setChecked(not timer_enabled)
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
# Left click toggles the window; middle click toggles the timer, which is # Left click shows or hides the countdown; middle click toggles the timer,
# the fastest "start/pause" on the whole desktop. # which is the fastest "start/pause" on the whole desktop.
if reason == QSystemTrayIcon.ActivationReason.Trigger: if reason == QSystemTrayIcon.ActivationReason.Trigger:
self.showRequested.emit() self.showRequested.emit()
elif reason == QSystemTrayIcon.ActivationReason.MiddleClick: elif reason == QSystemTrayIcon.ActivationReason.MiddleClick:
+3 -33
View File
@@ -24,9 +24,6 @@ class ApiWorker(QObject):
stateChanged = pyqtSignal(dict) stateChanged = pyqtSignal(dict)
policyChanged = pyqtSignal(dict) policyChanged = pyqtSignal(dict)
tasksChanged = pyqtSignal(dict) tasksChanged = pyqtSignal(dict)
statsChanged = pyqtSignal(dict)
reportChanged = pyqtSignal(dict)
settingsChanged = pyqtSignal(dict)
connectionChanged = pyqtSignal(bool, str) connectionChanged = pyqtSignal(bool, str)
commandFailed = pyqtSignal(str, str) commandFailed = pyqtSignal(str, str)
commandDone = pyqtSignal(str, dict) commandDone = pyqtSignal(str, dict)
@@ -49,7 +46,6 @@ class ApiWorker(QObject):
self._timer.start(max(1, self._poll_seconds) * 1000) self._timer.start(max(1, self._poll_seconds) * 1000)
self.poll() self.poll()
self.refresh_tasks() self.refresh_tasks()
self.refresh_stats()
@pyqtSlot(int) @pyqtSlot(int)
def set_interval(self, seconds: int) -> None: def set_interval(self, seconds: int) -> None:
@@ -76,11 +72,11 @@ class ApiWorker(QObject):
self.stateChanged.emit(state) self.stateChanged.emit(state)
self.policyChanged.emit(policy) self.policyChanged.emit(policy)
# Task and stats data changes far more slowly than the clock does. # The task list changes far more slowly than the clock does, and all the
# app wants from it now is which one is pinned.
self._slow_tick += 1 self._slow_tick += 1
if self._slow_tick % 12 == 0: if self._slow_tick % 12 == 0:
self.refresh_tasks() self.refresh_tasks()
self.refresh_stats()
def _set_connected(self, connected: bool, detail: str) -> None: def _set_connected(self, connected: bool, detail: str) -> None:
if connected != self._connected: if connected != self._connected:
@@ -94,27 +90,6 @@ class ApiWorker(QObject):
except ApiError as exc: except ApiError as exc:
log.debug("tasks: %s", exc) log.debug("tasks: %s", exc)
@pyqtSlot()
def refresh_stats(self) -> None:
try:
self.statsChanged.emit(self._api.stats())
except ApiError as exc:
log.debug("stats: %s", exc)
@pyqtSlot()
def refresh_report(self) -> None:
try:
self.reportChanged.emit(self._api.daily_report())
except ApiError as exc:
self.commandFailed.emit("report", str(exc))
@pyqtSlot()
def refresh_settings(self) -> None:
try:
self.settingsChanged.emit(self._api.settings())
except ApiError as exc:
self.commandFailed.emit("settings", str(exc))
# ---- commands ---------------------------------------------------------- # ---- commands ----------------------------------------------------------
@pyqtSlot(str, dict) @pyqtSlot(str, dict)
@@ -128,9 +103,6 @@ class ApiWorker(QObject):
"abandon": lambda: self._api.abandon(args.get("reason")), "abandon": lambda: self._api.abandon(args.get("reason")),
"extend": lambda: self._api.extend(int(args.get("minutes", 5))), "extend": lambda: self._api.extend(int(args.get("minutes", 5))),
"interrupt": self._api.interrupt, "interrupt": self._api.interrupt,
"attach_task": lambda: self._api.attach_task(args.get("task_id")),
"patch_task": lambda: self._api.patch_task(int(args["task_id"]), dict(args.get("patch", {}))),
"create_task": lambda: self._api.create_task(str(args["title"]), args.get("due_date")),
"refresh_sources": self._api.refresh_tasks, "refresh_sources": self._api.refresh_tasks,
"send_report": self._api.send_report, "send_report": self._api.send_report,
"patch_settings": lambda: self._api.patch_settings(dict(args.get("patch", {}))), "patch_settings": lambda: self._api.patch_settings(dict(args.get("patch", {}))),
@@ -156,9 +128,7 @@ class ApiWorker(QObject):
return return
self.commandDone.emit(name, result if isinstance(result, dict) else {}) self.commandDone.emit(name, result if isinstance(result, dict) else {})
if name in ("refresh_sources", "patch_task", "create_task"): if name == "refresh_sources":
self.refresh_tasks() self.refresh_tasks()
if name == "patch_settings":
self.refresh_settings()
if name != "report_block": if name != "report_block":
self.poll() self.poll()
+32
View File
@@ -0,0 +1,32 @@
# The agent, as a systemd user service.
#
# Kadence on the desktop is a tray agent: it enforces the server's blocking
# policy on this machine, posts notifications with buttons, answers D-Bus and
# shows a floating countdown. None of that needs a window, so running it from
# login is the normal arrangement — `--minimized` starts it with nothing on
# screen and the mini timer appears by itself when an interval starts.
#
# Settings -> "Run as a systemd user service" writes this same unit with an
# absolute ExecStart; this copy is for installing it by hand:
#
# mkdir -p ~/.config/systemd/user
# cp desktop/packaging/kadence-desktop.service ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now kadence-desktop
# journalctl --user -u kadence-desktop -f
#
# `PartOf`/`WantedBy=graphical-session.target` because the tray, the
# notifications and the KWin rule all need a session to be in.
[Unit]
Description=Kadence desktop client
PartOf=graphical-session.target
After=graphical-session.target
[Service]
Type=simple
ExecStart=%h/.local/bin/kadence-desktop --minimized
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target
+8 -5
View File
@@ -2,20 +2,19 @@
Type=Application Type=Application
Name=Kadence Name=Kadence
GenericName=Pomodoro timer GenericName=Pomodoro timer
Comment=Pomodoro timer, task list from Jira/Todoist/Google Tasks, and a focus guard Comment=Pomodoro timer and focus guard — a tray agent with a floating countdown
Exec=kadence-desktop Exec=kadence-desktop --mini
TryExec=kadence-desktop TryExec=kadence-desktop
Icon=kadence Icon=kadence
Terminal=false Terminal=false
Categories=Office;ProjectManagement; Categories=Office;ProjectManagement;
Keywords=pomodoro;timer;focus;tasks;productivity; Keywords=pomodoro;timer;focus;tasks;productivity;
StartupNotify=true StartupNotify=false
StartupWMClass=kadence StartupWMClass=kadence
SingleMainWindow=true
X-GNOME-UsesNotifications=true X-GNOME-UsesNotifications=true
# Right-click the icon in the launcher or the task manager for these. # Right-click the icon in the launcher or the task manager for these.
Actions=StartFocus;StartBreak;Toggle;Report; Actions=StartFocus;StartBreak;Toggle;Report;Settings;
[Desktop Action StartFocus] [Desktop Action StartFocus]
Name=Start a pomodoro Name=Start a pomodoro
@@ -32,3 +31,7 @@ Exec=kadence-desktop --toggle
[Desktop Action Report] [Desktop Action Report]
Name=Send today's report Name=Send today's report
Exec=kadence-desktop --report Exec=kadence-desktop --report
[Desktop Action Settings]
Name=Settings
Exec=kadence-desktop --settings