desktop: an agent and a mini timer, not an application window
The design of the full desktop app was still bad, and the reason it stayed bad is that it had no business existing. Five pages — Timer, Tasks, Blocker, Report, Settings — each a second view of the *server's* data: a second place to be wrong, a second place to style, and a second place to keep in step with a web app that already does lists, rules and reports properly in a browser, on any device. So it is gone, along with 2,100 lines of QML and the controller surface that fed it. What is left is what only a program on your machine can do, plus the two surfaces worth having in front of you: * **the tray menu — unchanged**, as asked. All fourteen entries, plus "Settings…". "Open Kadence" now brings the countdown up, because that is what it meant all along. * **the mini timer**, which is now the whole of this app's own interface and so finally carries the house style on its own: warm surface, hairline instead of shadow, a letterspaced eyebrow, the progress lane as a rule, and exactly one filled button — whichever action is the obvious next one. The countdown is set in the display serif on a **fixed advance**, because Fraunces has no tabular figures and ships no `tnum`: each glyph now sits in a box as wide as the widest digit, instead of the clock shifting sideways on every tick. The phone client documents the same trap; the desktop had it too and nobody had looked. It also says OFFLINE when the backend is away — the count and the clock are both the server's, and this is the only window left to say it in. * **a settings dialog**, because a fresh install has to be pointed at a backend somewhere. One column, hairline sections: the backend and its token, where the mini timer sits, what this machine enforces, and what it does to the session. It edits `desktop.json` and nothing else — with one honest exception, view-only mode, which is a server-side flag the other clients have to honour too. Structurally, `app.py` no longer loads a QML root at all: a plain `QQmlEngine` with the mini timer as a component, and the dialog built on demand. Everything that used to ask "is the main window visible?" is simpler for not having one — the mini timer's auto-show rule is now just "an interval is running", which is what the setting always claimed. The window-only controller surface goes with the pages: tasks and task writes, stats, the daily report, the settings document, the presets, the cycle and interruption counters, and their worker jobs and API calls. Two things were kept deliberately: the task list is still read once a minute for one fact — which task is pinned, so "Start focus" from the tray attaches what you pinned this morning — and `timerEnabled` now comes from the timer state, which already carries `timer_enabled`, rather than from a second fetch of a document this app can no longer edit. Also: `--settings` and a `Settings` D-Bus verb; toasts go to the tray's balloon now that the snackbar is gone; the launcher entry starts the agent and grew a Settings action; a systemd user unit is shipped rather than only written at runtime; and both READMEs say what this piece is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
26 files changed
+752
-2690
No files matched your search
+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
|
||||
is a native Wayland/X11 surface drawn by Qt, using 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 —
|
||||
is the Python standard library.
|
||||
Not Electron, and **not an application window either**. This piece is an agent:
|
||||
it lives in the tray, enforces the server's blocking policy on this machine,
|
||||
pauses your music, holds the screen awake, answers D-Bus, posts notifications
|
||||
with buttons — and shows a floating countdown. **PyQt6 + QML with Qt Quick
|
||||
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
|
||||
on your machine. The interface is the same warm, hairline-ruled design as the
|
||||
web app — the `Theme` singleton in `qml/` holds the same tokens as the web
|
||||
app's `tokens.ts`.
|
||||
There used to be a five-page window here (Timer, Tasks, Blocker, Report,
|
||||
Settings) and it is gone. Every one of those pages was a second view of the
|
||||
*server's* data, which made it a second place to be wrong and a second place to
|
||||
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
|
||||
sudo pacman -S --needed python-pyqt6 qt6-declarative # Arch
|
||||
@@ -18,7 +30,12 @@ sudo ./helper/install.sh # privileged helper (w
|
||||
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 |
|
||||
| --- | --- |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **Launcher actions** | Right-click the icon: *Start a pomodoro*, *Start a break*, *Start / pause*, *Send today's report*. |
|
||||
| **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*, *Settings*. |
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
*"No such interface"*.
|
||||
|
||||
In-window shortcuts: **Space** start/pause · **Ctrl+Enter** finish ·
|
||||
**Ctrl+E** +5 min · **Ctrl+R** refresh sources · **Ctrl+M** mini timer ·
|
||||
**Ctrl+1…5** pages · **Ctrl+W** hide to tray.
|
||||
There are no in-window shortcuts any more — there is no window to be focused in.
|
||||
Bind the D-Bus verbs (or `kadence-desktop --toggle`) to real global shortcuts
|
||||
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
|
||||
> `~/.config/kwinrulesrc` (backed up once as `kwinrulesrc.kadence-backup`); you
|
||||
@@ -204,9 +221,14 @@ In-window shortcuts: **Space** start/pause · **Ctrl+Enter** finish ·
|
||||
## Configuration
|
||||
|
||||
`~/.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,
|
||||
task sources, block lists and Telegram all live on the server, so they are the
|
||||
same everywhere. The **Settings** page edits both, clearly separated.
|
||||
backend URL and token, where the mini timer sits, and which integrations this box
|
||||
may use. Timer lengths, task sources, block lists and Telegram all live on the
|
||||
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
|
||||
|
||||
@@ -220,7 +242,7 @@ kadence_desktop/
|
||||
tray.py # the painted countdown icon and its menu
|
||||
integrations/ # notifications, inhibit/DND, MPRIS, launcher, logind, D-Bus, autostart
|
||||
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
|
||||
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 --mini # show/hide the floating mini timer
|
||||
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
|
||||
@@ -30,6 +36,7 @@ REMOTE_VERBS = {
|
||||
"report": "SendReport",
|
||||
"mini": "ToggleMini",
|
||||
"show": "Raise",
|
||||
"settings": "Settings",
|
||||
}
|
||||
|
||||
|
||||
@@ -77,9 +84,9 @@ def main() -> int:
|
||||
from kadence_desktop.app import KadenceApp
|
||||
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"):
|
||||
print("Kadence is already running — raised its window.")
|
||||
print("Kadence is already running — brought its timer up.")
|
||||
return 0
|
||||
|
||||
return KadenceApp(sys.argv, minimized=args.minimized, view_only=args.view_only).run()
|
||||
|
||||
@@ -99,28 +99,21 @@ class Api:
|
||||
def interrupt(self) -> dict:
|
||||
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:
|
||||
"""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")
|
||||
|
||||
def refresh_tasks(self) -> dict:
|
||||
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:
|
||||
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:
|
||||
return self._request(
|
||||
"POST",
|
||||
@@ -128,18 +121,9 @@ class Api:
|
||||
{"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:
|
||||
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:
|
||||
return self._request("PUT", "/api/settings", patch)
|
||||
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
"""Application wiring: Qt, QML, the tray, and the D-Bus service.
|
||||
|
||||
Kadence on the desktop is an **agent**, not a window. It sits in the tray,
|
||||
enforces the server's blocking policy on this machine, answers D-Bus, posts
|
||||
notifications with buttons, and shows a floating countdown. There is no main
|
||||
window: the rules, the task lists, the report and the interval lengths are the
|
||||
server's, edited in the web app, and a second place to edit them was a second
|
||||
place to be wrong.
|
||||
|
||||
So two surfaces, both created here:
|
||||
|
||||
* **`MiniTimer.qml`** — the countdown, and the only interface this app keeps.
|
||||
It is the QML root in spirit, though not as a `QQmlApplicationEngine` root:
|
||||
that would tie its lifetime to the engine's, and the window has to be able to
|
||||
come and go all day.
|
||||
* **`SettingsDialog.qml`** — built on demand, for the handful of settings that
|
||||
really do belong to this machine (which backend, and how this box behaves).
|
||||
|
||||
Qt Quick Controls' **Material** style gives real Material 3 widgets rendered by
|
||||
Qt itself — no web view, no bundled browser; the window is a native Wayland
|
||||
surface with the session's own fonts, scaling and colour scheme.
|
||||
Qt itself — no web view, no bundled browser; both windows are native Wayland
|
||||
surfaces with the session's own fonts, scaling and colour scheme.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,8 +36,7 @@ os.environ.setdefault("QT_QUICK_CONTROLS_MATERIAL_VARIANT", "Normal")
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer, QUrl
|
||||
from PyQt6.QtGui import QIcon
|
||||
from PyQt6.QtQml import QQmlApplicationEngine, QQmlComponent
|
||||
from PyQt6.QtQuick import QQuickWindow
|
||||
from PyQt6.QtQml import QQmlComponent, QQmlEngine
|
||||
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon
|
||||
|
||||
from kadence_desktop import APP_ID, __version__
|
||||
@@ -42,7 +57,8 @@ class KadenceApp(QObject):
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# QApplication (not QGuiApplication): the tray icon and its menu are
|
||||
# widgets, and they are what makes the app usable with no window open.
|
||||
# widgets, and they are what makes the app usable with no window open —
|
||||
# which, for an agent, is most of the time.
|
||||
self._app = QApplication(argv)
|
||||
self._app.setApplicationName("Kadence")
|
||||
self._app.setApplicationDisplayName("Kadence")
|
||||
@@ -52,6 +68,8 @@ class KadenceApp(QObject):
|
||||
# has no icon and the task manager cannot group it.
|
||||
self._app.setDesktopFileName("kadence")
|
||||
self._app.setWindowIcon(_load_icon())
|
||||
# Nothing is tied to a window's lifetime: closing the mini timer must
|
||||
# not take the blocker and the tray down with it.
|
||||
self._app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
self._config = DesktopConfig.load()
|
||||
@@ -59,25 +77,33 @@ class KadenceApp(QObject):
|
||||
self._config.view_only = True
|
||||
self._controller = Controller(self._config)
|
||||
|
||||
self._engine = QQmlApplicationEngine()
|
||||
# A plain QQmlEngine, not QQmlApplicationEngine: there is no root
|
||||
# document to load. Both windows are components created from it.
|
||||
self._engine = QQmlEngine(self)
|
||||
self._engine.rootContext().setContextProperty("controller", self._controller)
|
||||
self._engine.rootContext().setContextProperty("appVersion", __version__)
|
||||
self._engine.addImportPath(str(QML_DIR))
|
||||
self._engine.load(QUrl.fromLocalFile(str(QML_DIR / "Main.qml")))
|
||||
if not self._engine.rootObjects():
|
||||
raise SystemExit("Could not load the QML interface.")
|
||||
self._window: QQuickWindow = self._engine.rootObjects()[0]
|
||||
|
||||
self._mini = self._create_mini_window()
|
||||
self._mini = self._create_window("MiniTimer.qml", "miniTimer")
|
||||
if self._mini is None:
|
||||
raise SystemExit("Could not load the mini timer.")
|
||||
self._settings: QObject | None = None
|
||||
|
||||
self._tray: Tray | None = None
|
||||
if self._config.tray_icon and QSystemTrayIcon.isSystemTrayAvailable():
|
||||
self._tray = Tray(self._controller, self)
|
||||
self._tray.showRequested.connect(self.toggle_window)
|
||||
self._tray.settingsRequested.connect(self.show_settings)
|
||||
self._tray.quitRequested.connect(self.quit)
|
||||
# The main window's snackbar used to catch these. An agent's
|
||||
# equivalent is the tray's own balloon — and these are worth seeing:
|
||||
# "the KWin rule was written", "Pulling Jira, Todoist…", or the
|
||||
# reason a toggle refused.
|
||||
self._controller.toast.connect(lambda text: self._tray.message("Kadence", text))
|
||||
self._tray.show()
|
||||
|
||||
self._controller.showWindowRequested.connect(self.show_window)
|
||||
self._controller.settingsRequested.connect(self.show_settings)
|
||||
self._app.aboutToQuit.connect(self._controller.shutdown)
|
||||
|
||||
# Anything that can change "should the mini timer be up?" lands here.
|
||||
@@ -85,16 +111,16 @@ class KadenceApp(QObject):
|
||||
self._controller.miniPlacementChanged.connect(self._place_mini)
|
||||
self._controller.stateChanged.connect(self._update_mini)
|
||||
self._controller.configChanged.connect(self._update_mini)
|
||||
self._window.visibleChanged.connect(self._update_mini)
|
||||
self._update_mini()
|
||||
self._place_mini()
|
||||
|
||||
if self._config.dbus_service:
|
||||
self._register_dbus()
|
||||
|
||||
if minimized or self._config.start_minimized:
|
||||
self._window.hide()
|
||||
else:
|
||||
# `--minimized` and "start minimised" mean the same thing they always
|
||||
# did: come up with no window. The difference is that it is now the
|
||||
# normal way to run — the agent needs no window to do its job.
|
||||
if not (minimized or self._config.start_minimized):
|
||||
self.show_window()
|
||||
|
||||
# Let Ctrl+C in a terminal actually kill the app (Qt swallows SIGINT
|
||||
@@ -104,34 +130,36 @@ class KadenceApp(QObject):
|
||||
self._wakeup.start(500)
|
||||
self._wakeup.timeout.connect(lambda: None)
|
||||
|
||||
# ---- the floating mini timer --------------------------------------------
|
||||
# ---- the windows --------------------------------------------------------
|
||||
|
||||
def _create_mini_window(self) -> QObject | None:
|
||||
"""Build MiniTimer.qml as a top-level window of its own."""
|
||||
component = QQmlComponent(self._engine, QUrl.fromLocalFile(str(QML_DIR / "MiniTimer.qml")))
|
||||
def _create_window(self, file_name: str, object_name: str) -> QObject | None:
|
||||
"""Build one QML window as a top-level of its own."""
|
||||
component = QQmlComponent(self._engine, QUrl.fromLocalFile(str(QML_DIR / file_name)))
|
||||
if component.isError(): # pragma: no cover - a QML typo, caught in dev
|
||||
for error in component.errors():
|
||||
log.error("mini timer: %s", error.toString())
|
||||
log.error("%s: %s", file_name, error.toString())
|
||||
return None
|
||||
# No parent on purpose: a parented QML Window becomes transient and
|
||||
# cannot outlive — or out-show — its parent. The Python reference on
|
||||
# `self._mini` is what keeps it alive.
|
||||
# cannot outlive — or out-show — its parent. The Python reference is
|
||||
# what keeps it alive.
|
||||
window = component.create()
|
||||
if window is not None:
|
||||
window.setProperty("objectName", "miniTimer")
|
||||
window.setProperty("objectName", object_name)
|
||||
return window
|
||||
|
||||
def _update_mini(self) -> None:
|
||||
"""Show it when pinned, or when an interval runs with the window away."""
|
||||
if self._mini is None:
|
||||
return
|
||||
"""Show it when pinned, or by itself while an interval runs.
|
||||
|
||||
The second clause used to also require the main window to be hidden.
|
||||
With no main window the rule is simply "an interval is running", which
|
||||
is what the setting says on the tin.
|
||||
"""
|
||||
config = self._config
|
||||
wanted = bool(config.mini_timer) and (
|
||||
self._controller.miniVisible
|
||||
or (
|
||||
config.mini_timer_auto
|
||||
and self._controller.running
|
||||
and not self._window.isVisible()
|
||||
and not self._controller.mini_dismissed
|
||||
)
|
||||
)
|
||||
@@ -143,8 +171,8 @@ class KadenceApp(QObject):
|
||||
self._mini.setProperty("visible", wanted)
|
||||
|
||||
def _mini_screen_rect(self) -> Rect:
|
||||
"""The usable area of the screen the app is on — panels excluded."""
|
||||
screen = self._window.screen() or self._app.primaryScreen()
|
||||
"""The usable area of the screen it is on — panels excluded."""
|
||||
screen = self._mini.screen() or self._app.primaryScreen()
|
||||
area = screen.availableGeometry()
|
||||
return Rect(area.x(), area.y(), area.width(), area.height())
|
||||
|
||||
@@ -155,8 +183,6 @@ class KadenceApp(QObject):
|
||||
place itself, so the same point goes into the KWin rule, which is
|
||||
honoured when the window is mapped.
|
||||
"""
|
||||
if self._mini is None:
|
||||
return
|
||||
config = self._config
|
||||
if config.mini_timer_corner == "remember":
|
||||
self._controller.ensure_keep_above(None)
|
||||
@@ -166,7 +192,7 @@ class KadenceApp(QObject):
|
||||
return
|
||||
|
||||
size = (
|
||||
int(self._mini.property("width") or 252),
|
||||
int(self._mini.property("width") or 268),
|
||||
int(self._mini.property("height") or 152),
|
||||
)
|
||||
point = point_for(
|
||||
@@ -176,18 +202,24 @@ class KadenceApp(QObject):
|
||||
self._mini.setProperty("y", point[1])
|
||||
self._controller.ensure_keep_above(point)
|
||||
|
||||
# ---- window ------------------------------------------------------------
|
||||
|
||||
def show_window(self) -> None:
|
||||
self._window.show()
|
||||
self._window.raise_()
|
||||
self._window.requestActivate()
|
||||
"""Bring the countdown up — the tray's "Open Kadence", and `--mini`."""
|
||||
self._controller.setMiniVisible(True)
|
||||
|
||||
def toggle_window(self) -> None:
|
||||
if self._window.isVisible() and self._window.isActive():
|
||||
self._window.hide()
|
||||
else:
|
||||
self.show_window()
|
||||
self._controller.toggleMini()
|
||||
|
||||
def show_settings(self) -> None:
|
||||
"""Open the settings dialog, building it the first time it is asked for."""
|
||||
if self._settings is None:
|
||||
self._settings = self._create_window("SettingsDialog.qml", "settingsDialog")
|
||||
if self._settings is None: # pragma: no cover - a QML typo
|
||||
return
|
||||
self._settings.setProperty("visible", True)
|
||||
for call in ("raise", "requestActivate"):
|
||||
method = getattr(self._settings, call, None)
|
||||
if callable(method):
|
||||
method()
|
||||
|
||||
# ---- D-Bus -------------------------------------------------------------
|
||||
|
||||
@@ -206,7 +238,10 @@ class KadenceApp(QObject):
|
||||
"status": controller.statusLine,
|
||||
"send_report": lambda: (controller.sendReport(), "sending")[1],
|
||||
"toggle_mini": controller.toggleMini,
|
||||
# `--raise` and a second launch both land here. There is one window
|
||||
# to raise now, and it is the countdown.
|
||||
"raise_window": lambda: (self.show_window(), "shown")[1],
|
||||
"settings": lambda: (self.show_settings(), "settings opened")[1],
|
||||
}
|
||||
self._dbus = dbus_service.TimerDBusService(actions, self)
|
||||
if not dbus_service.register(self._dbus):
|
||||
@@ -215,8 +250,9 @@ class KadenceApp(QObject):
|
||||
# ---- lifecycle ---------------------------------------------------------
|
||||
|
||||
def quit(self) -> None:
|
||||
if self._mini is not None:
|
||||
self._mini.setProperty("visible", False)
|
||||
self._mini.setProperty("visible", False)
|
||||
if self._settings is not None:
|
||||
self._settings.setProperty("visible", False)
|
||||
self._controller.shutdown()
|
||||
if self._tray:
|
||||
self._tray.hide()
|
||||
|
||||
@@ -25,7 +25,7 @@ from PyQt6.QtGui import QDesktopServices
|
||||
from kadence_desktop.api import Api
|
||||
from kadence_desktop.blocking.manager import BlockerManager
|
||||
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.launcher import LauncherEntry
|
||||
from kadence_desktop.integrations.mpris import MediaController
|
||||
@@ -46,17 +46,15 @@ def clock(seconds: int) -> str:
|
||||
class Controller(QObject):
|
||||
# ---- notifications to QML ---------------------------------------------
|
||||
stateChanged = pyqtSignal()
|
||||
tasksChanged = pyqtSignal()
|
||||
statsChanged = pyqtSignal()
|
||||
reportChanged = pyqtSignal()
|
||||
settingsChanged = pyqtSignal()
|
||||
blockerChanged = pyqtSignal()
|
||||
tasksChanged = pyqtSignal()
|
||||
connectionChanged = pyqtSignal()
|
||||
configChanged = pyqtSignal()
|
||||
miniChanged = pyqtSignal()
|
||||
miniPlacementChanged = pyqtSignal()
|
||||
toast = pyqtSignal(str)
|
||||
showWindowRequested = pyqtSignal()
|
||||
settingsRequested = pyqtSignal()
|
||||
|
||||
def __init__(self, config: DesktopConfig, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -66,11 +64,14 @@ class Controller(QObject):
|
||||
# ---- state mirrors --------------------------------------------------
|
||||
self._state: dict[str, Any] = {}
|
||||
self._policy: dict[str, Any] = {}
|
||||
self._tasks: list[dict[str, Any]] = []
|
||||
self._task_counts: dict[str, Any] = {}
|
||||
self._stats: dict[str, Any] = {}
|
||||
self._report: dict[str, Any] = {}
|
||||
self._settings: dict[str, Any] = {}
|
||||
self._pinned_task_id: int | None = None
|
||||
"""The task pinned in the web app, so "Start focus" can attach it.
|
||||
|
||||
All that is left of this app's interest in the task list: there is no
|
||||
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._connection_error = ""
|
||||
self._remaining = 0
|
||||
@@ -116,9 +117,6 @@ class Controller(QObject):
|
||||
self._worker.stateChanged.connect(self._on_state)
|
||||
self._worker.policyChanged.connect(self._on_policy)
|
||||
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.commandFailed.connect(self._on_command_failed)
|
||||
self._worker.commandDone.connect(self._on_command_done)
|
||||
@@ -208,20 +206,6 @@ class Controller(QObject):
|
||||
task = current.get("task") or {}
|
||||
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)
|
||||
def completedToday(self) -> int:
|
||||
return int(self._state.get("completed_today", 0))
|
||||
@@ -230,63 +214,18 @@ class Controller(QObject):
|
||||
def goal(self) -> int:
|
||||
return int(self._state.get("goal", 8))
|
||||
|
||||
@pyqtProperty(int, 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)
|
||||
@pyqtProperty(bool, notify=stateChanged)
|
||||
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:
|
||||
return False
|
||||
pomodoro = self._settings.get("pomodoro", {})
|
||||
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
|
||||
return bool(self._state.get("timer_enabled", True))
|
||||
|
||||
@pyqtProperty(bool, notify=blockerChanged)
|
||||
def blockerActive(self) -> bool:
|
||||
@@ -345,6 +284,30 @@ class Controller(QObject):
|
||||
def blockerError(self) -> str:
|
||||
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)
|
||||
def miniVisible(self) -> bool:
|
||||
"""Whether the floating mini timer is pinned open by the user."""
|
||||
@@ -389,10 +352,8 @@ class Controller(QObject):
|
||||
if minutes:
|
||||
args["minutes"] = minutes
|
||||
current_task = self._state.get("current") or {}
|
||||
if not current_task.get("task"):
|
||||
pinned = next((task for task in self._tasks if task.get("pinned")), None)
|
||||
if pinned:
|
||||
args["task_id"] = pinned["id"]
|
||||
if not current_task.get("task") and self._pinned_task_id is not None:
|
||||
args["task_id"] = self._pinned_task_id
|
||||
self._send("start", args)
|
||||
|
||||
@pyqtSlot()
|
||||
@@ -419,8 +380,8 @@ class Controller(QObject):
|
||||
"""Toggle view-only mode on or off."""
|
||||
self._config.update(view_only=not enabled)
|
||||
self.configChanged.emit()
|
||||
self.settingsChanged.emit()
|
||||
self.patchSettings({"pomodoro": {"timer_enabled": enabled}})
|
||||
self.stateChanged.emit()
|
||||
self._patch_settings({"pomodoro": {"timer_enabled": enabled}})
|
||||
|
||||
@pyqtSlot()
|
||||
def pause(self) -> None:
|
||||
@@ -448,19 +409,6 @@ class Controller(QObject):
|
||||
def interrupt(self) -> None:
|
||||
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()
|
||||
def refreshSources(self) -> None:
|
||||
self.toast.emit("Pulling Jira, Todoist, Google Tasks…")
|
||||
@@ -470,43 +418,14 @@ class Controller(QObject):
|
||||
def sendReport(self) -> None:
|
||||
self._send("send_report")
|
||||
|
||||
@pyqtSlot()
|
||||
def loadReport(self) -> None:
|
||||
from PyQt6.QtCore import QMetaObject, Qt
|
||||
def _patch_settings(self, patch: dict[str, Any]) -> None:
|
||||
"""The one write this app still makes to the server's settings document.
|
||||
|
||||
QMetaObject.invokeMethod(self._worker, "refresh_report", Qt.ConnectionType.QueuedConnection)
|
||||
|
||||
@pyqtSlot()
|
||||
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.
|
||||
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
|
||||
document is edited in the web app.
|
||||
"""
|
||||
presets = self._settings.get("pomodoro", {}).get("presets", [])
|
||||
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"],
|
||||
}
|
||||
}
|
||||
)
|
||||
self._send("patch_settings", {"patch": patch})
|
||||
|
||||
@pyqtSlot(str, "QVariant")
|
||||
def setConfig(self, key: str, value: Any) -> None:
|
||||
@@ -675,22 +594,13 @@ class Controller(QObject):
|
||||
return max(1, min(30, seconds)) * 1000
|
||||
|
||||
def _on_tasks(self, payload: dict[str, Any]) -> None:
|
||||
self._tasks = list(payload.get("tasks", []))
|
||||
self._task_counts = dict(payload.get("counts", {}))
|
||||
pinned = next(
|
||||
(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()
|
||||
|
||||
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:
|
||||
self._connected = connected
|
||||
self._connection_error = detail
|
||||
|
||||
@@ -69,6 +69,7 @@ VERBS = (
|
||||
"SendReport",
|
||||
"ToggleMini",
|
||||
"Raise",
|
||||
"Settings",
|
||||
)
|
||||
|
||||
INTROSPECTION = "\n".join(
|
||||
@@ -146,9 +147,18 @@ class TimerAdaptor(QDBusAbstractAdaptor):
|
||||
|
||||
@pyqtSlot(result=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")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Settings(self) -> str:
|
||||
"""Open the settings dialog."""
|
||||
return self._service.run("settings")
|
||||
|
||||
|
||||
def register(service: TimerDBusService) -> bool:
|
||||
"""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
|
||||
// working on, and the three controls you actually reach for. It appears on
|
||||
// demand (tray, Ctrl+M, the D-Bus verb) and — if you let it — by itself
|
||||
// whenever an interval is running and the main window is out of the way.
|
||||
// working on, and the controls you actually reach for. It appears on demand
|
||||
// (tray, Ctrl+M, the D-Bus verb) and — if you let it — by itself whenever an
|
||||
// interval is running.
|
||||
//
|
||||
// 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
|
||||
@@ -14,22 +14,27 @@
|
||||
// that works on Wayland, and the window refuses keyboard focus rather than
|
||||
// being a `Qt.Tool` — Qt hides tool windows whenever the application is
|
||||
// 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.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Window
|
||||
|
||||
import "."
|
||||
import "components"
|
||||
|
||||
Window {
|
||||
id: mini
|
||||
|
||||
width: 252
|
||||
width: 268
|
||||
height: card.implicitHeight + 12
|
||||
color: "transparent"
|
||||
|
||||
// KWin's rule matches a substring of this, so it must stay distinct from
|
||||
// the main window's caption. Qt appends " — Kadence" to it.
|
||||
// KWin's rule matches a substring of this. Qt appends " — Kadence" to it.
|
||||
title: "Kadence mini"
|
||||
|
||||
flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
|
||||
@@ -48,20 +53,19 @@ Window {
|
||||
onTriggered: if (mini.visible) controller.saveMiniPosition(mini.x, mini.y)
|
||||
}
|
||||
|
||||
// A small pill button. `filled` marks the one action you want by default —
|
||||
// Resume when paused, Start when idle.
|
||||
// A small pill button. `filled` marks the one action you want by default.
|
||||
component MiniButton: Rectangle {
|
||||
id: button
|
||||
property string label: ""
|
||||
property bool filled: false
|
||||
signal clicked()
|
||||
|
||||
implicitWidth: buttonText.implicitWidth + 18
|
||||
implicitHeight: 24
|
||||
implicitWidth: buttonText.implicitWidth + 20
|
||||
implicitHeight: 25
|
||||
radius: 7
|
||||
color: filled
|
||||
? (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.color: buttonHover.hovered ? mini.accent : Theme.rule
|
||||
Behavior on color { ColorAnimation { duration: 110 } }
|
||||
@@ -104,7 +108,9 @@ Window {
|
||||
|
||||
TapHandler {
|
||||
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 {
|
||||
@@ -112,10 +118,10 @@ Window {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
spacing: 3
|
||||
anchors.margins: 13
|
||||
spacing: 4
|
||||
|
||||
// ---- state -------------------------------------------------------
|
||||
// ---- what is happening -------------------------------------------
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 7
|
||||
@@ -135,17 +141,30 @@ Window {
|
||||
+ (mini.paused ? " · PAUSED" : "")
|
||||
color: mini.paused ? Theme.amber : (controller.running ? mini.accent : Theme.muted)
|
||||
font.pixelSize: 9
|
||||
font.letterSpacing: 1.2
|
||||
font.letterSpacing: 1.3
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.sansFamily
|
||||
Layout.fillWidth: true
|
||||
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 {
|
||||
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
|
||||
color: Theme.faint
|
||||
font.pixelSize: 10
|
||||
font.family: Theme.sansFamily
|
||||
font.features: ({ "tnum": 1 })
|
||||
}
|
||||
Label {
|
||||
text: "✕"
|
||||
@@ -166,23 +185,22 @@ Window {
|
||||
}
|
||||
|
||||
// ---- the time ----------------------------------------------------
|
||||
Label {
|
||||
ClockFigures {
|
||||
text: controller.clockText
|
||||
color: Theme.ink
|
||||
pixelSize: 34
|
||||
ink: Theme.ink
|
||||
opacity: mini.paused ? 0.55 : 1
|
||||
font.pixelSize: 32
|
||||
font.family: Theme.displayFamily
|
||||
font.letterSpacing: -1
|
||||
Layout.topMargin: -2
|
||||
Layout.topMargin: -1
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
// ---- what you are on ---------------------------------------------
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 1
|
||||
text: 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
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
@@ -192,12 +210,15 @@ Window {
|
||||
// ---- controls, always visible --------------------------------------
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
Layout.topMargin: 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 {
|
||||
label: mini.paused ? "Resume" : "Pause"
|
||||
filled: mini.paused
|
||||
filled: controller.running
|
||||
visible: controller.running
|
||||
onClicked: mini.paused ? controller.resume() : controller.pause()
|
||||
}
|
||||
@@ -239,6 +260,11 @@ Window {
|
||||
height: 3
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.rule
|
||||
opacity: 0.6
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width * Math.max(0, Math.min(1, controller.progress))
|
||||
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."""
|
||||
|
||||
showRequested = pyqtSignal()
|
||||
settingsRequested = pyqtSignal()
|
||||
quitRequested = pyqtSignal()
|
||||
|
||||
def __init__(self, controller, parent: QObject | None = None) -> None:
|
||||
@@ -155,10 +156,17 @@ class Tray(QObject):
|
||||
)
|
||||
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.triggered.connect(self.showRequested.emit)
|
||||
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.triggered.connect(controller.openWebUi)
|
||||
self._menu.addAction(web)
|
||||
@@ -200,8 +208,8 @@ class Tray(QObject):
|
||||
self._action_view_mode.setChecked(not timer_enabled)
|
||||
|
||||
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
|
||||
# Left click toggles the window; middle click toggles the timer, which is
|
||||
# the fastest "start/pause" on the whole desktop.
|
||||
# Left click shows or hides the countdown; middle click toggles the timer,
|
||||
# which is the fastest "start/pause" on the whole desktop.
|
||||
if reason == QSystemTrayIcon.ActivationReason.Trigger:
|
||||
self.showRequested.emit()
|
||||
elif reason == QSystemTrayIcon.ActivationReason.MiddleClick:
|
||||
|
||||
@@ -24,9 +24,6 @@ class ApiWorker(QObject):
|
||||
stateChanged = pyqtSignal(dict)
|
||||
policyChanged = pyqtSignal(dict)
|
||||
tasksChanged = pyqtSignal(dict)
|
||||
statsChanged = pyqtSignal(dict)
|
||||
reportChanged = pyqtSignal(dict)
|
||||
settingsChanged = pyqtSignal(dict)
|
||||
connectionChanged = pyqtSignal(bool, str)
|
||||
commandFailed = pyqtSignal(str, str)
|
||||
commandDone = pyqtSignal(str, dict)
|
||||
@@ -49,7 +46,6 @@ class ApiWorker(QObject):
|
||||
self._timer.start(max(1, self._poll_seconds) * 1000)
|
||||
self.poll()
|
||||
self.refresh_tasks()
|
||||
self.refresh_stats()
|
||||
|
||||
@pyqtSlot(int)
|
||||
def set_interval(self, seconds: int) -> None:
|
||||
@@ -76,11 +72,11 @@ class ApiWorker(QObject):
|
||||
self.stateChanged.emit(state)
|
||||
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
|
||||
if self._slow_tick % 12 == 0:
|
||||
self.refresh_tasks()
|
||||
self.refresh_stats()
|
||||
|
||||
def _set_connected(self, connected: bool, detail: str) -> None:
|
||||
if connected != self._connected:
|
||||
@@ -94,27 +90,6 @@ class ApiWorker(QObject):
|
||||
except ApiError as 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 ----------------------------------------------------------
|
||||
|
||||
@pyqtSlot(str, dict)
|
||||
@@ -128,9 +103,6 @@ class ApiWorker(QObject):
|
||||
"abandon": lambda: self._api.abandon(args.get("reason")),
|
||||
"extend": lambda: self._api.extend(int(args.get("minutes", 5))),
|
||||
"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,
|
||||
"send_report": self._api.send_report,
|
||||
"patch_settings": lambda: self._api.patch_settings(dict(args.get("patch", {}))),
|
||||
@@ -156,9 +128,7 @@ class ApiWorker(QObject):
|
||||
return
|
||||
|
||||
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()
|
||||
if name == "patch_settings":
|
||||
self.refresh_settings()
|
||||
if name != "report_block":
|
||||
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
|
||||
Name=Kadence
|
||||
GenericName=Pomodoro timer
|
||||
Comment=Pomodoro timer, task list from Jira/Todoist/Google Tasks, and a focus guard
|
||||
Exec=kadence-desktop
|
||||
Comment=Pomodoro timer and focus guard — a tray agent with a floating countdown
|
||||
Exec=kadence-desktop --mini
|
||||
TryExec=kadence-desktop
|
||||
Icon=kadence
|
||||
Terminal=false
|
||||
Categories=Office;ProjectManagement;
|
||||
Keywords=pomodoro;timer;focus;tasks;productivity;
|
||||
StartupNotify=true
|
||||
StartupNotify=false
|
||||
StartupWMClass=kadence
|
||||
SingleMainWindow=true
|
||||
X-GNOME-UsesNotifications=true
|
||||
|
||||
# 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]
|
||||
Name=Start a pomodoro
|
||||
@@ -32,3 +31,7 @@ Exec=kadence-desktop --toggle
|
||||
[Desktop Action Report]
|
||||
Name=Send today's report
|
||||
Exec=kadence-desktop --report
|
||||
|
||||
[Desktop Action Settings]
|
||||
Name=Settings
|
||||
Exec=kadence-desktop --settings
|
||||
Reference in new issue
Block a user