Files
timka123andClaude Opus 5 8e659cd315 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>
2026-10-22 18:00:52 +03:00

278 lines
11 KiB
QML

// 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 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
// bottom edge; both of those put things on top of each other, and a timer you
// have to hover to operate is a timer you cannot operate at a glance.
//
// Dragging uses the compositor's own move (`startSystemMove`), the only thing
// 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: 268
height: card.implicitHeight + 12
color: "transparent"
// KWin's rule matches a substring of this. Qt appends " — Kadence" to it.
title: "Kadence mini"
flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
| Qt.WindowDoesNotAcceptFocus
readonly property color accent: Theme.kindColor(controller.kind)
readonly property bool paused: controller.status === "paused"
// Placement is decided in app.py: it is the only piece that knows about
// screens, and on Wayland it has to go through a KWin rule anyway.
onXChanged: saveTimer.restart()
onYChanged: saveTimer.restart()
Timer {
id: saveTimer
interval: 800
onTriggered: if (mini.visible) controller.saveMiniPosition(mini.x, mini.y)
}
// 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 + 20
implicitHeight: 25
radius: 7
color: filled
? (buttonHover.hovered ? Qt.lighter(mini.accent, 1.08) : mini.accent)
: (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 } }
Label {
id: buttonText
anchors.fill: parent
text: button.label
color: button.filled ? "white" : Theme.ink
font.pixelSize: 11
font.weight: button.filled ? Font.DemiBold : Font.Normal
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
HoverHandler { id: buttonHover; cursorShape: Qt.PointingHandCursor }
TapHandler {
grabPermissions: PointerHandler.TakeOverForbidden
onTapped: button.clicked()
}
}
Rectangle {
id: card
anchors.fill: parent
anchors.margins: 6
radius: 14
color: Theme.surface
border.width: 1
border.color: Theme.rule
implicitHeight: body.implicitHeight + 24 + progressLane.height + 5
HoverHandler { id: hover }
DragHandler {
target: null
grabPermissions: PointerHandler.CanTakeOverFromAnything
onActiveChanged: if (active) mini.startSystemMove()
}
TapHandler {
acceptedButtons: Qt.LeftButton
// 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 {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 13
spacing: 4
// ---- what is happening -------------------------------------------
RowLayout {
Layout.fillWidth: true
spacing: 7
Rectangle {
width: 6; height: 6; radius: 3
color: controller.running ? mini.accent : Theme.faint
SequentialAnimation on opacity {
running: controller.running && !mini.paused
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 900 }
NumberAnimation { to: 1.0; duration: 900 }
}
}
Label {
text: (controller.running ? controller.kindLabel : "Idle").toUpperCase()
+ (mini.paused ? " · PAUSED" : "")
color: mini.paused ? Theme.amber : (controller.running ? mini.accent : Theme.muted)
font.pixelSize: 9
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: "✕"
color: closeHover.hovered ? Theme.ink : Theme.faint
font.pixelSize: 11
opacity: hover.hovered ? 1 : 0.35
Behavior on opacity { NumberAnimation { duration: 120 } }
HoverHandler { id: closeHover; cursorShape: Qt.PointingHandCursor }
// The card's DragHandler has CanTakeOverFromAnything so the whole
// pill is draggable; without refusing the take-over here, the tiny
// press-move jitter of a real click steals this tap into a
// startSystemMove() and the close button never fires.
TapHandler {
grabPermissions: PointerHandler.TakeOverForbidden
onTapped: controller.setMiniVisible(false)
}
}
}
// ---- the time ----------------------------------------------------
ClockFigures {
text: controller.clockText
pixelSize: 34
ink: Theme.ink
opacity: mini.paused ? 0.55 : 1
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 for the web app")
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
elide: Text.ElideRight
}
// ---- controls, always visible --------------------------------------
RowLayout {
Layout.fillWidth: true
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: controller.running
visible: controller.running
onClicked: mini.paused ? controller.resume() : controller.pause()
}
MiniButton {
label: "Finish"
visible: controller.running
onClicked: controller.complete()
}
MiniButton {
label: "+5"
visible: controller.running
onClicked: controller.extend(5)
}
MiniButton {
label: "Start focus"
filled: true
visible: !controller.running
onClicked: controller.startWork()
}
MiniButton {
label: "Break"
visible: !controller.running
onClicked: controller.startBreak()
}
Item { Layout.fillWidth: true }
}
}
// ---- progress, in a lane of its own ----------------------------------
Item {
id: progressLane
objectName: "progressLane"
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 1
anchors.rightMargin: 1
anchors.bottomMargin: 1
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
color: mini.accent
opacity: controller.running ? (mini.paused ? 0.45 : 1) : 0.25
Behavior on width { NumberAnimation { duration: 400 } }
}
}
}
}