Add Ongoing to the Panama Daybook

This commit is contained in:
Gabriel Brown
2026-08-17 12:18:04 -04:00
parent 1ce5c732b7
commit a5450fdb6d
12 changed files with 483 additions and 47 deletions
+2 -1
View File
@@ -20,7 +20,8 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Panel | Workspaces, CPU/RAM/GPU, clock, weather, quiet event capsule, media, clipboard, tray, status | Live |
| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live |
| Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live |
| Date menu | Two-column Daybook with EDS agenda, notifications, month calendar, weather, and media | Live |
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
| Quick settings | Wi-Fi, Bluetooth, audio input/output, caffeine, night light, focus | Live |
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
+10 -2
View File
@@ -191,8 +191,16 @@ Settings remains searchable in Vicinae for hardware and account panels.
Click the clock to open the two-column Daybook on **Agenda**. The month grid,
today's schedule, Up Next, weather, and conditional media controls stay visible
together. `SUPER + B` opens **Notifications** in that same surface; returning to
Agenda preserves the selected date and visible month.
together. Its right pane has three first-class pages:
- **Agenda** — the selected day's calendar.
- **Ongoing** — Focus, Caffeine, recording, screen sharing, camera, and
microphone activity, visible until each state ends.
- **Notifications** — grouped history, Do Not Disturb, and clear actions.
`SUPER + B` opens Notifications directly. Switching pages preserves the
selected date and visible month. Brief completed events such as screenshots and
device changes remain in Signal Glass instead of accumulating in Ongoing.
Calendar data comes from Evolution Data Server, so the Google, iCloud,
Nextcloud, and local calendars already configured for GNOME remain the source
@@ -7,8 +7,6 @@ import qs.modules.quicksettings
Item {
id: root
signal requestNotifications
readonly property bool isToday: CalendarAgenda.selectedDateKey === CalendarAgenda.dateKey(clock.date)
readonly property var upNext: CalendarAgenda.nextEventForSelectedDate
readonly property var listedEvents: CalendarAgenda.selectedEvents.filter(event => !root.upNext || event.id !== root.upNext.id)
@@ -238,10 +236,6 @@ Item {
}
}
NotificationSummary {
width: parent.width
onActivated: root.requestNotifications()
}
}
function relativeStart(event: var): string {
@@ -1,4 +1,4 @@
// GNOME's date/time menu: the calendar and the message tray in one panel,
// GNOME's date/time menu: calendar, ongoing activity and messages in one panel,
// dropping from the top centre because that is where GNOME put it and the
// muscle memory is the whole point.
//
@@ -20,8 +20,10 @@ import qs.widgets
PanelWindow {
id: root
// "notifications" is the existing overlay key, so Super+B, the notification
// IPC target and the clock all land here.
readonly property bool ongoingContentFits: panel.ongoingContentFits
// "notifications" remains the overlay key so existing keybinds keep their
// contract; the surface itself now has Agenda, Ongoing and Notifications.
visible: ShellState.notificationsOpen
color: "transparent"
@@ -1,5 +1,5 @@
// The approved A · Daybook: month context on the left, the selected day's
// schedule or grouped notification history on the right.
// schedule, persistent activity or grouped notification history on the right.
import QtQuick
import Quickshell
@@ -46,7 +46,7 @@ Item {
Text {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.right: headerActions.left
anchors.right: dateMenuTabs.left
anchors.rightMargin: Theme.itemSpacing
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(clock.date, "dddd, MMMM d")
@@ -59,35 +59,36 @@ Item {
}
Row {
id: headerActions
id: dateMenuTabs
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
HeaderPill {
visible: ShellState.dateMenuPage === "notifications"
text: "← Back to agenda"
text: "Agenda"
active: ShellState.dateMenuPage === "agenda"
onClicked: ShellState.dateMenuPage = "agenda"
}
HeaderPill {
text: "Do Not Disturb"
active: Notifs.doNotDisturb
icon: "notifications-disabled-symbolic"
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
text: "Ongoing"
active: ShellState.dateMenuPage === "ongoing"
badge: Ongoing.count
onClicked: ShellState.dateMenuPage = "ongoing"
}
HeaderPill {
text: "Notifications"
active: ShellState.dateMenuPage === "notifications"
badge: Notifs.history.length
onClicked: ShellState.dateMenuPage = "notifications"
}
HeaderPill {
visible: ShellState.dateMenuPage === "agenda"
text: "Open Calendar ↗"
text: "Calendar ↗"
onClicked: CalendarAgenda.openCalendar(CalendarAgenda.selectedDate)
}
HeaderPill {
visible: ShellState.dateMenuPage === "notifications" && Notifs.hasNotifications
text: "Clear all"
onClicked: Notifs.dismissAll()
}
}
}
@@ -165,10 +166,16 @@ Item {
anchors.top: parent.top
anchors.margins: 14
visible: ShellState.dateMenuPage === "agenda"
onRequestNotifications: {
ShellState.dateMenuPage = "notifications";
Notifs.markAllRead();
}
OngoingPane {
id: ongoingPane
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 14
maxHeight: body.height - 28
visible: ShellState.dateMenuPage === "ongoing"
}
Column {
@@ -186,6 +193,8 @@ Item {
Text {
anchors.left: parent.left
anchors.right: notificationActions.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Notifications"
color: Theme.fg
@@ -194,14 +203,24 @@ Item {
font.weight: Font.DemiBold
}
Text {
Row {
id: notificationActions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: Notifs.history.length + (Notifs.history.length === 1 ? " item" : " items")
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
spacing: 6
HeaderPill {
text: "Do Not Disturb"
active: Notifs.doNotDisturb
icon: "notifications-disabled-symbolic"
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
}
HeaderPill {
visible: Notifs.hasNotifications
text: "Clear all"
onClicked: Notifs.dismissAll()
}
}
}
@@ -215,9 +234,14 @@ Item {
}
}
readonly property real rightContentHeight: ShellState.dateMenuPage === "notifications"
? notificationPane.implicitHeight
: agenda.implicitHeight
readonly property real rightContentHeight: {
if (ShellState.dateMenuPage === "notifications")
return notificationPane.implicitHeight;
if (ShellState.dateMenuPage === "ongoing")
return ongoingPane.implicitHeight;
return agenda.implicitHeight;
}
readonly property bool ongoingContentFits: ongoingPane.implicitHeight <= body.height - 28
component HeaderPill: Rectangle {
id: pill
@@ -225,6 +249,7 @@ Item {
required property string text
property string icon: ""
property bool active: false
property int badge: -1
signal clicked
width: pillRow.implicitWidth + 20
@@ -260,6 +285,27 @@ Item {
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
visible: pill.badge > 0
width: visible ? badgeText.implicitWidth + 9 : 0
height: 17
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(pill.active ? Theme.accent : Theme.fg, 0.12)
Text {
id: badgeText
anchors.centerIn: parent
text: String(pill.badge)
color: pill.active ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: 9
font.weight: Font.DemiBold
}
}
}
MouseArea {
@@ -0,0 +1,263 @@
// Persistent work and privacy activity inside the Daybook.
import QtQuick
import qs.config
import qs.services
import qs.modules.quicksettings
Item {
id: root
property real maxHeight: 500
implicitHeight: content.implicitHeight
Column {
id: content
width: parent.width
spacing: 12
Item {
width: parent.width
height: 38
Column {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: "PERSISTENT ACTIVITY"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.9
}
Text {
text: "Ongoing"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
}
Text {
anchors.right: parent.right
anchors.bottom: parent.bottom
text: Ongoing.count === 0 ? "Nothing active" : Ongoing.count + (Ongoing.count === 1 ? " activity" : " activities")
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
Item {
width: parent.width
height: visible ? 190 : 0
visible: Ongoing.count === 0
Column {
anchors.centerIn: parent
spacing: 8
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "\u{F04A5}"
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: 24
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Nothing ongoing"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Focus, Caffeine, recording and privacy activity appear here."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
ScrollColumn {
width: parent.width
// Header + footer + the two 12px Column gaps consume 116px.
// Keeping that outside the scroll budget prevents the pane from
// growing beyond the Daybook when every producer is active.
maxHeight: Math.max(0, root.maxHeight - 116)
spacing: 8
visible: Ongoing.count > 0
Repeater {
model: Ongoing.activities
Rectangle {
id: activityRow
required property var modelData
width: parent.width
height: 62
radius: Theme.cardRadius
border.width: 0
color: rowMouse.containsMouse
? Theme.alpha(Theme.fg, 0.10)
: Theme.alpha(Theme.fg, 0.055)
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Text {
id: activityIcon
anchors.left: parent.left
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
text: activityRow.modelData.glyph
color: root.toneColor(activityRow.modelData.tone)
font.family: Theme.fontMono
font.pixelSize: 17
}
Column {
anchors.left: activityIcon.right
anchors.leftMargin: 11
anchors.right: activityAction.visible ? activityAction.left : activityState.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: activityRow.modelData.label
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: activityRow.modelData.detail
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
id: activityState
anchors.right: activityAction.visible ? activityAction.left : parent.right
anchors.rightMargin: activityAction.visible ? 7 : 12
anchors.verticalCenter: parent.verticalCenter
width: stateLabel.implicitWidth + 16
height: 25
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(root.toneColor(activityRow.modelData.tone), 0.12)
Text {
id: stateLabel
anchors.centerIn: parent
text: activityRow.modelData.state
color: root.toneColor(activityRow.modelData.tone)
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
Rectangle {
id: activityAction
z: 2
anchors.right: parent.right
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
visible: activityRow.modelData.action !== ""
width: visible ? actionLabel.implicitWidth + 18 : 0
height: 28
radius: Theme.pillRadius
border.width: 0
color: actionMouse.containsMouse
? Theme.alpha(activityRow.modelData.tone === "danger" ? Theme.danger : Theme.fg, 0.20)
: Theme.alpha(activityRow.modelData.tone === "danger" ? Theme.danger : Theme.fg, 0.08)
Text {
id: actionLabel
anchors.centerIn: parent
text: activityRow.modelData.action
color: activityRow.modelData.tone === "danger" ? Theme.danger : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: actionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Ongoing.invoke(activityRow.modelData.kind)
}
}
MouseArea {
id: rowMouse
anchors.fill: parent
z: 1
enabled: activityRow.modelData.kind !== "caffeine"
hoverEnabled: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: Ongoing.activate(activityRow.modelData.kind)
}
}
}
}
Rectangle {
width: parent.width
height: 54
radius: Theme.cardRadius
border.width: 0
visible: Ongoing.count > 0
color: Theme.alpha(Theme.fg, 0.035)
Column {
anchors.left: parent.left
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
text: "Visible until it ends"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
text: "Completed screenshots, devices and other brief events stay in Signal Glass."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
function toneColor(tone: string): color {
if (tone === "danger")
return Theme.danger;
if (tone === "warn")
return Theme.warn;
if (tone === "ok")
return Theme.ok;
return Theme.accent;
}
}
@@ -9,4 +9,5 @@ MonthArrow 1.0 MonthArrow.qml
NotificationGroup 1.0 NotificationGroup.qml
NotificationList 1.0 NotificationList.qml
NotificationSummary 1.0 NotificationSummary.qml
OngoingPane 1.0 OngoingPane.qml
WeatherCard 1.0 WeatherCard.qml
@@ -0,0 +1,98 @@
pragma Singleton
// One presentation model for state that remains active until it is explicitly
// stopped or naturally completes. Transient confirmations stay in Signal Glass.
import Quickshell
import QtQuick
Singleton {
id: root
readonly property var activities: {
const result = [];
if (FocusSession.active) {
result.push({
kind: "focus",
glyph: "\u{F051F}",
label: "Focus session",
detail: (FocusSession.workspaceLabel || "Focused workspace") + " · " + FocusSession.statusText,
state: FocusSession.remainingText,
tone: FocusSession.paused ? "warn" : "accent",
action: FocusSession.paused ? "Resume" : "Pause"
});
}
if (Caffeine.enabled) {
result.push({
kind: "caffeine",
glyph: "\u{F0F2E}",
label: "Caffeine",
detail: "Automatic sleep is blocked",
state: "On",
tone: "ok",
action: "Turn off"
});
}
if (PrivacyState.recordingActive) {
result.push({
kind: "recording",
glyph: "\u{F044A}",
label: "Screen recording",
detail: "Panama · " + root.recordingElapsed,
state: "Live",
tone: "danger",
action: "Stop"
});
}
if (PrivacyState.screenSharingActive)
result.push(root.privacyActivity("screen", "\u{F0379}", "Screen sharing", PrivacyState.screenSharingApp));
if (PrivacyState.cameraActive)
result.push(root.privacyActivity("camera", "\u{F0100}", "Camera", PrivacyState.cameraApp));
if (PrivacyState.microphoneActive)
result.push(root.privacyActivity("microphone", "\u{F036C}", "Microphone", PrivacyState.microphoneApp));
return result;
}
readonly property int count: activities.length
readonly property string recordingElapsed: {
const total = Capture.recordingSeconds;
const seconds = String(total % 60).padStart(2, "0");
const minutes = Math.floor(total / 60) % 60;
const hours = Math.floor(total / 3600);
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
: `${minutes}:${seconds}`;
}
function privacyActivity(kind: string, glyph: string, label: string, app: string): var {
return {
kind,
glyph,
label,
detail: app || "Managed by the application",
state: "In use",
tone: "warn",
action: ""
};
}
function activate(kind: string): void {
if (kind === "focus")
FocusSession.activateWorkspace();
else if (["recording", "screen", "camera", "microphone"].indexOf(kind) >= 0)
ShellState.open("activity");
}
function invoke(kind: string): void {
if (kind === "focus")
FocusSession.pauseOrResume();
else if (kind === "caffeine")
Caffeine.toggle();
else if (kind === "recording")
Capture.stopRecording();
}
}
@@ -138,6 +138,8 @@ Singleton {
root.applyStates(false, true, false, "", "Contract fixture", "", true);
else if (name === "screen-on")
root.applyStates(false, false, true, "", "", "Contract fixture", true);
else if (name === "all-on")
root.applyStates(true, true, true, "Contract fixture", "Contract fixture", "Contract fixture", true);
else if (name === "all-off")
root.applyStates(false, false, false, "", "", "", true);
}
@@ -35,8 +35,8 @@ Singleton {
readonly property bool anyOverlayOpen: activeOverlay !== ""
// The date menu is one surface with two deliberate entry points. The clock
// opens the Daybook; Super+B and notification actions open message history.
// The date menu keeps calendar context fixed while its right pane switches
// between the schedule, persistent activity and message history.
property string dateMenuPage: "agenda"
// 0 means "use the currently focused workspace". A positive value lets a
@@ -53,12 +53,12 @@ Singleton {
}
function openDateMenu(page: string): void {
root.dateMenuPage = page === "notifications" ? "notifications" : "agenda";
root.dateMenuPage = root.normalizedDateMenuPage(page);
root.activeOverlay = "notifications";
}
function toggleDateMenu(page: string): void {
const target = page === "notifications" ? "notifications" : "agenda";
const target = root.normalizedDateMenuPage(page);
if (root.activeOverlay === "notifications" && root.dateMenuPage === target) {
root.activeOverlay = "";
return;
@@ -67,6 +67,10 @@ Singleton {
root.activeOverlay = "notifications";
}
function normalizedDateMenuPage(page: string): string {
return ["agenda", "ongoing", "notifications"].indexOf(page) >= 0 ? page : "agenda";
}
function openOverview(workspaceId: int): void {
root.overviewWorkspaceId = Math.max(0, workspaceId);
root.overviewQuery = "";
+5 -2
View File
@@ -79,7 +79,7 @@ ShellRoot {
Overview {}
// QuickSettings is the window; QuickSettingsPanel is its content.
QuickSettings {}
DateMenu {}
DateMenu { id: dateMenu }
ClipboardPanel {}
CaptureOverlay {}
IntelligenceResult {}
@@ -268,6 +268,7 @@ ShellRoot {
function reset(): void { CalendarAgenda.clearFixture(); }
function refresh(): void { CalendarAgenda.refresh(); }
function open(): void { ShellState.openDateMenu("agenda"); }
function page(name: string): void { ShellState.openDateMenu(name); }
function close(): void { ShellState.close(); }
function select(date: string): void { CalendarAgenda.selectIsoDate(date); }
function status(): string {
@@ -281,7 +282,9 @@ ShellRoot {
selectedDate: CalendarAgenda.selectedDateKey,
selectedEventCount: CalendarAgenda.selectedEvents.length,
capsuleVisible: CalendarAgenda.capsuleVisible,
capsuleText: CalendarAgenda.capsuleText
capsuleText: CalendarAgenda.capsuleText,
ongoingCount: Ongoing.count,
ongoingFits: dateMenu.ongoingContentFits
});
}
}
@@ -8,6 +8,8 @@ fail() {
}
cleanup() {
qs ipc call focus end >/dev/null 2>&1 || true
qs ipc call activity clear >/dev/null 2>&1 || true
qs ipc call calendar-agenda reset >/dev/null 2>&1 || true
qs ipc call calendar-agenda close >/dev/null 2>&1 || true
}
@@ -40,6 +42,18 @@ jq -e '.open == true and .page == "notifications"' \
<<<"$(qs ipc call calendar-agenda status)" >/dev/null \
|| fail 'notification open did not route to Notifications'
qs ipc call activity fixture microphone-on >/dev/null
qs ipc call calendar-agenda page ongoing >/dev/null
jq -e '.open == true and .page == "ongoing" and .ongoingCount >= 1' \
<<<"$(qs ipc call calendar-agenda status)" >/dev/null \
|| fail 'persistent activity did not route to Ongoing'
qs ipc call activity fixture all-on >/dev/null
qs ipc call focus start >/dev/null
jq -e '.ongoingCount == 5 and .ongoingFits == true' \
<<<"$(qs ipc call calendar-agenda status)" >/dev/null \
|| fail 'crowded Ongoing activity does not fit the Daybook'
hyprctl layers | rg -q 'namespace: qs-popover-datemenu' \
|| fail 'Daybook layer did not map'
rg -q 'implicitWidth: 760' config/dot/quickshell/modules/datemenu/DateMenu.qml \