Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions plugins/apptime/README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
# App Time

A Ryoku shell plugin that answers "what did I actually do on this machine?".
It watches Hyprland focus events, banks foreground time per app per day, pauses
while you are idle, and shows your **top apps in hours and minutes** in a panel
under its bar glyph — for today or any archived day.
It follows the focused window through the Wayland toplevel protocol, banks
foreground time per app per day, pauses while you are idle, and shows your
**top apps in hours and minutes** in a panel under its bar glyph — for today or
any archived day.

## What it looks like

- **Bar glyph**: a small clock + today's total (e.g. `3h 12m`). Left click
opens the panel. Hover/open tints it with the live accent.
- **Panel**: `TODAY` header with the day total and ‹ › chevrons to browse the
per-day archive (click the date to jump back to today). Each app row has a
rank, name, accent usage bar (relative to the leader) and its time in hours
rank, name, accent tick meter (relative to the leader) and its time in hours
& minutes — no seconds.

Theming comes from the plugin kit's `Theme`/`Scheme` singletons, which resolve
the daemon palette (a fixed named scheme or the live wallpaper colours), so
the widget retints with the desktop like every other Ryoku component.
the widget retints with the desktop like every other Ryoku component. The panel
is built from the kit's own parts (`MicroLabel`, `GlyphIcon` chevrons) so it
reads like a native surface.

## What it runs, reads, and writes

- **Runs**: nothing external. No subprocesses, no shell, no network, no
privileged calls. All data stays on the machine.
- **Reads**:
- Hyprland focus events and the toplevel list through `Quickshell.Hyprland`
(same source as the built-in dock): `activewindowv2`, `closewindow`, and
each window's class.
- The focused window through `Quickshell.Wayland` `ToplevelManager`, the
`zwlr_foreign_toplevel_management` protocol: the activated toplevel's
`appId`. Compositor-neutral (Hyprland or niri), with no compositor IPC.
- Real input-idle state through `Quickshell.Wayland` `IdleMonitor`, the
Wayland `ext-idle-notify` protocol — keyboard *and* pointer, tracked by
the compositor, honouring idle inhibitors (caffeine).
Expand Down Expand Up @@ -53,11 +56,11 @@ the widget retints with the desktop like every other Ryoku component.
guard against counting a machine you walked away from.
- Windows that are not "using an app" are excluded: `hyprlock`, XDG desktop
portals.
- **Granularity is per app class**, not per window: every Firefox window is
- **Granularity is per app id**, not per window: every Firefox window is
one "Firefox" entry.
- Each day is archived at local midnight and browsable from the panel; the
archive is kept in `usage-*.json` files (no automatic pruning yet).
- App labels are a heuristic from the WM class (`.desktop` name lookup is a
- App labels are a heuristic from the app id (`.desktop` name lookup is a
future improvement).

## Development
Expand Down
206 changes: 123 additions & 83 deletions plugins/apptime/content/Panel.qml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// apptime panel: per-day top apps, hours & minutes only (no seconds), with
// usage bars. Browse the archive with the chevrons (or click the date to jump
// back to today). The host draws the card; this content is transparent and the
// host sizes the card from implicitHeight.
// apptime panel: per-day top apps, hours & minutes only (no seconds), in the
// plugin kit's dossier idiom — a MicroLabel eyebrow, vector chevrons and a
// squared tick meter. Browse the archive with the chevrons (or click the day to
// jump back to today). The host draws the card; this content is transparent and
// the host sizes the card from implicitHeight.
import QtQuick
import Ryoku.PluginKit
import Ryoku.PluginKit.Singletons

Item {
Expand All @@ -18,114 +20,155 @@ Item {
readonly property var list: service ? service.selTopList : []
readonly property int total: service ? service.selTotalSeconds : 0
readonly property bool browsing: service ? !service.selIsToday : false
// the host keeps this content alive after the panel closes, so the browse
// position is gated on the live open flag (not the always-true `active`).
readonly property bool panelOpen: pluginApi ? pluginApi.panelOpen : false

onPanelOpenChanged: if (!panelOpen && service) service.goToday()

// a squared, static level meter: no timers, reads as a printed gauge.
component TickMeter: Item {
id: meter
property real frac: 0
property int ticks: 16
readonly property real gap: 2 * root.s
readonly property real tickW: Math.max(1, (width - gap * (ticks - 1)) / ticks)
readonly property int lit: Math.max(frac > 0 ? 1 : 0,
Math.min(ticks, Math.round(frac * ticks)))
implicitHeight: 4 * root.s

Repeater {
model: meter.ticks
Rectangle {
required property int index
x: index * (meter.tickW + meter.gap)
width: meter.tickW
height: meter.height
color: index < meter.lit ? Theme.accent : Theme.hair
Behavior on color { ColorAnimation { duration: Motion.fast } }
}
}
}

implicitWidth: root.widthBudget
implicitHeight: col.implicitHeight + 6 * root.s

Column {
id: col
width: root.width
spacing: 8 * root.s
spacing: 9 * root.s

// ---- header: nav chevrons + day + day total ----
// ---- eyebrow: identity + the day total ----
Item {
id: head
width: root.width
height: 20 * root.s
height: 13 * root.s

MicroLabel {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
label: "App Time"
s: root.s
}

// older day
Text {
id: prevBtn
x: 0
width: 16 * root.s
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "\u2039"
color: prevMa.containsMouse
? (service && service.canOlder ? Theme.accent : Theme.faint)
: (service && service.canOlder ? Theme.bright : Theme.faint)
text: root.service ? root.service.fmtHM(root.total) : "0m"
color: Theme.bright
font.family: Theme.mono
font.pixelSize: 16 * root.s
horizontalAlignment: Text.AlignHCenter
font.pixelSize: 12.5 * root.s
font.weight: Font.Medium
}
}

// ---- day nav: older chevron / day / newer chevron, position at right ----
Item {
id: head
width: root.width
height: 22 * root.s

GlyphIcon {
id: prevBtn
name: "chevron-left"
width: 15 * root.s
height: 15 * root.s
color: root.service && root.service.canOlder
? (prevMa.containsMouse ? Theme.accent : Theme.bright)
: Theme.faint
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
MouseArea {
id: prevMa
anchors.fill: prevBtn
anchors.margins: -5 * root.s
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: service ? service.canOlder : false
onClicked: if (service) service.stepDay(1)
enabled: root.service ? root.service.canOlder : false
onClicked: if (root.service) root.service.stepDay(1)
}

// brand dot
Rectangle {
id: dot
width: 6 * root.s
height: 6 * root.s
radius: 1.5 * root.s
color: Theme.brand
anchors.left: prevBtn.right
anchors.leftMargin: 4 * root.s
anchors.verticalCenter: parent.verticalCenter
}

// day label ("TODAY" or "Sep 3"); click jumps back to today
Text {
id: dayLabel
text: service ? service.selLabel : "TODAY"
anchors.left: prevBtn.right
anchors.leftMargin: 10 * root.s
anchors.verticalCenter: parent.verticalCenter
text: root.service ? root.service.selLabel : "TODAY"
color: root.browsing
? (dayMa.containsMouse ? Theme.accent : Theme.bright)
: Theme.dim
font.family: Theme.mono
font.pixelSize: 10 * root.s
font.pixelSize: 11 * root.s
font.weight: Font.DemiBold
font.letterSpacing: 1.6 * root.s
font.letterSpacing: 1.8 * root.s
font.capitalization: Font.AllUppercase
anchors.left: dot.right
anchors.leftMargin: 7 * root.s
anchors.verticalCenter: parent.verticalCenter
}
MouseArea {
id: dayMa
anchors.fill: dayLabel
anchors.margins: -4 * root.s
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: root.browsing
onClicked: if (service) service.goToday()
onClicked: if (root.service) root.service.goToday()
}

// newer day
Text {
GlyphIcon {
id: nextBtn
anchors.left: dayLabel.right
anchors.leftMargin: 7 * root.s
width: 16 * root.s
anchors.leftMargin: 10 * root.s
anchors.verticalCenter: parent.verticalCenter
text: "\u203A"
color: nextMa.containsMouse
? (service && service.canNewer ? Theme.accent : Theme.faint)
: (service && service.canNewer ? Theme.bright : Theme.faint)
font.family: Theme.mono
font.pixelSize: 16 * root.s
horizontalAlignment: Text.AlignHCenter
name: "chevron-right"
width: 15 * root.s
height: 15 * root.s
color: root.service && root.service.canNewer
? (nextMa.containsMouse ? Theme.accent : Theme.bright)
: Theme.faint
}
MouseArea {
id: nextMa
anchors.fill: nextBtn
anchors.margins: -5 * root.s
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: service ? service.canNewer : false
onClicked: if (service) service.stepDay(-1)
enabled: root.service ? root.service.canNewer : false
onClicked: if (root.service) root.service.stepDay(-1)
}

// day total
// marginalia: live marker or where this day sits in the archive
Text {
text: service ? service.fmtHM(root.total) : "0m"
color: root.browsing ? Theme.dim : Theme.bright
font.family: Theme.mono
font.pixelSize: 12.5 * root.s
font.weight: Font.Medium
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: {
if (!root.service) return "";
if (root.service.selIndex === 0) return "LIVE";
return root.service.pad2(root.service.selIndex) + " / "
+ root.service.pad2(root.service.dates.length);
}
color: root.service && root.service.selIndex === 0 ? Theme.accent : Theme.faint
font.family: Theme.mono
font.pixelSize: 9.5 * root.s
font.letterSpacing: 1.2 * root.s
}
}

Expand All @@ -140,9 +183,9 @@ Item {
Column {
id: listCol
width: root.width
spacing: 12 * root.s
spacing: 11 * root.s
visible: root.list.length > 0
topPadding: 6 * root.s
topPadding: 4 * root.s
bottomPadding: 2 * root.s

Repeater {
Expand All @@ -154,21 +197,22 @@ Item {
width: root.width
height: 30 * root.s

readonly property real rankW: 16 * root.s
readonly property real timeW: 58 * root.s
readonly property real rankW: 20 * root.s
readonly property real timeW: 56 * root.s

Text {
x: 0
width: rowI.rankW
anchors.verticalCenter: parent.verticalCenter
text: rowI.index + 1
color: Theme.faint
text: String(rowI.index + 1).padStart(2, "0")
color: rowI.index === 0 ? Theme.accent : Theme.faint
font.family: Theme.mono
font.pixelSize: 10 * root.s
font.letterSpacing: 0.5 * root.s
}
Column {
x: rowI.rankW + 8 * root.s
y: 3 * root.s
x: rowI.rankW + 6 * root.s
y: 2 * root.s
width: root.width - x - rowI.timeW - 8 * root.s
spacing: 5 * root.s

Expand All @@ -180,21 +224,9 @@ Item {
font.family: Theme.font
font.pixelSize: 12.5 * root.s
}
Item {
TickMeter {
width: parent.width
height: 3 * root.s

Rectangle {
anchors.fill: parent
radius: 1.5 * root.s
color: Theme.hair
}
Rectangle {
width: parent.width * rowI.modelData.fraction
height: parent.height
radius: 1.5 * root.s
color: Theme.accent
}
frac: rowI.modelData.fraction
}
}
Text {
Expand All @@ -215,13 +247,21 @@ Item {
Item {
id: emptyBox
width: root.width
height: 72 * root.s
height: 78 * root.s
visible: root.list.length === 0

Column {
anchors.centerIn: parent
spacing: 5 * root.s
spacing: 7 * root.s

GlyphIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: root.browsing ? "archive" : "list"
width: 22 * root.s
height: 22 * root.s
color: Theme.faint
stroke: 1.6
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.browsing ? "Nothing tracked that day"
Expand Down
Loading