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