Files
timka123 d280101bce
docker / image (push) Failing after 24s
lint / ruff (push) Failing after 28s
tests / backend (push) Successful in 37s
tests / desktop (push) Successful in 22s
tests / android-core (push) Successful in 49s
tests / web (push) Successful in 1m20s
android / apk (push) Successful in 8m49s
disallow to disable guard during focus
2026-10-28 09:50:58 +03:00

379 lines
14 KiB
QML

// 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
/**
* A one-way ratchet: while this is set the switch can be turned on but not
* off, so it greys only when it is *already* on. `available` greys both
* directions, which is the wrong shape for "you may not switch this off".
*/
property bool lockedOn: false
readonly property bool settable: available && !(lockedOn && on)
signal toggledTo(bool value)
Layout.fillWidth: true
spacing: 12
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Label {
text: toggleRow.label
color: toggleRow.settable ? 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.settable
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
}
Banner {
visible: controller.blockerLocked
text: controller.blockerLockNote
}
Toggle {
label: "Enforce the server's rules here"
detail: "Off, this machine is a timer and nothing else."
on: dialog.config.enforce_blocking
lockedOn: controller.blockerLocked
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
lockedOn: controller.blockerLocked
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
lockedOn: controller.blockerLocked
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()
}
}