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
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user