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>
278 lines
11 KiB
QML
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 } }
|
|
}
|
|
}
|
|
}
|
|
}
|