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