From 240adfa28f580e55394b5cc886ef2a7ede5af14e Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 6 Jul 2026 12:40:41 -0400 Subject: [PATCH] calendar: support tasks/VTODO from dank calendar --- quickshell/Modules/DankBar/DankBarContent.qml | 14 +- .../Overview/CalendarOverviewCard.qml | 307 ++++++------------ quickshell/Services/CalendarDankBackend.qml | 193 ++++++++++- quickshell/Services/CalendarService.qml | 51 ++- 4 files changed, 339 insertions(+), 226 deletions(-) diff --git a/quickshell/Modules/DankBar/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index 59ce86524..6996e103d 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -941,12 +941,16 @@ Item { section: topBarContent.getWidgetSection(parent) parentScreen: barWindow.screen onClicked: { - if (powerMenuModalLoader) { - powerMenuModalLoader.active = true; - if (powerMenuModalLoader.item) { - powerMenuModalLoader.item.openCentered(); - } + if (!powerMenuModalLoader) + return; + powerMenuModalLoader.active = true; + if (!powerMenuModalLoader.item) + return; + if (powerMenuModalLoader.item.shouldBeVisible) { + powerMenuModalLoader.item.close(); + return; } + powerMenuModalLoader.item.openCentered(); } } } diff --git a/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml b/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml index fd89d86c2..45930a4db 100644 --- a/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml @@ -86,12 +86,10 @@ Rectangle { } function updateSelectedDateEvents() { - if (CalendarService && CalendarService.calendarAvailable) { - const events = CalendarService.getEventsForDate(selectedDate); - selectedDateEvents = events; - } else { - selectedDateEvents = []; - } + const events = (CalendarService && CalendarService.calendarAvailable) ? CalendarService.getEventsForDate(selectedDate) : []; + if (JSON.stringify(events) === JSON.stringify(selectedDateEvents)) + return; + selectedDateEvents = events; } function loadEventsForMonth() { @@ -276,57 +274,29 @@ Rectangle { height: 40 visible: showEventDetails - Rectangle { - width: 32 - height: 32 + DankActionButton { + buttonSize: 32 + iconSize: 14 + iconName: "arrow_back" + iconColor: Theme.primary anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: Theme.spacingS - radius: Theme.cornerRadius - color: backButtonArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) - - DankIcon { - anchors.centerIn: parent - name: "arrow_back" - size: 14 - color: Theme.primary - } - - MouseArea { - id: backButtonArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.showEventDetails = false - } + onClicked: root.showEventDetails = false } - Rectangle { - width: 32 - height: 32 + DankActionButton { + buttonSize: 32 + iconSize: 16 + iconName: "event" + iconColor: Theme.primary anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right anchors.rightMargin: Theme.spacingS - radius: Theme.cornerRadius visible: CalendarService && CalendarService.canCreateEvents - color: addEventArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) - - DankIcon { - anchors.centerIn: parent - name: "event" - size: 16 - color: Theme.primary - } - - MouseArea { - id: addEventArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - root.editorEvent = null; - root.showEditor = true; - } + onClicked: { + root.editorEvent = null; + root.showEditor = true; } } @@ -358,31 +328,12 @@ Rectangle { height: 28 visible: !showEventDetails - Rectangle { - width: 28 - height: 28 - radius: Theme.cornerRadius - color: prevMonthArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) - - DankIcon { - anchors.centerIn: parent - name: "chevron_left" - size: 14 - color: Theme.primary - } - - MouseArea { - id: prevMonthArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - let newDate = new Date(calendarGrid.displayDate); - newDate.setMonth(newDate.getMonth() - 1); - calendarGrid.displayDate = newDate; - loadEventsForMonth(); - } - } + DankActionButton { + buttonSize: 28 + iconSize: 14 + iconName: "chevron_left" + iconColor: Theme.primary + onClicked: root.shiftMonth(-1) } StyledText { @@ -396,53 +347,20 @@ Rectangle { verticalAlignment: Text.AlignVCenter } - Rectangle { - width: 28 - height: 28 - radius: Theme.cornerRadius - color: todayArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) - - DankIcon { - anchors.centerIn: parent - name: "today" - size: 14 - color: Theme.primary - } - - MouseArea { - id: todayArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.goToToday() - } + DankActionButton { + buttonSize: 28 + iconSize: 14 + iconName: "today" + iconColor: Theme.primary + onClicked: root.goToToday() } - Rectangle { - width: 28 - height: 28 - radius: Theme.cornerRadius - color: nextMonthArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) - - DankIcon { - anchors.centerIn: parent - name: "chevron_right" - size: 14 - color: Theme.primary - } - - MouseArea { - id: nextMonthArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - let newDate = new Date(calendarGrid.displayDate); - newDate.setMonth(newDate.getMonth() + 1); - calendarGrid.displayDate = newDate; - loadEventsForMonth(); - } - } + DankActionButton { + buttonSize: 28 + iconSize: 14 + iconName: "chevron_right" + iconColor: Theme.primary + onClicked: root.shiftMonth(1) } } @@ -814,12 +732,16 @@ Rectangle { } } - readonly property bool isTask: modelData && modelData.id && modelData.id.startsWith("task_") - readonly property color accentColor: { - if (isTask) - return modelData.completed ? Theme.withAlpha(Theme.primary, 0.4) : Theme.primary; - return (modelData && modelData.color && modelData.color.length) ? modelData.color : Theme.primary; + readonly property bool isLocalTask: taskId.startsWith("task_") + readonly property bool isDankTask: taskId.startsWith("vtodo_") + readonly property bool isTask: isLocalTask || isDankTask + readonly property bool canModify: isLocalTask || (isDankTask && modelData && !modelData.readOnly) + readonly property color baseAccent: { + if (isLocalTask || !modelData || !modelData.color || !modelData.color.length) + return Theme.primary; + return modelData.color; } + readonly property color accentColor: (isTask && modelData && modelData.completed) ? Theme.withAlpha(baseAccent, 0.4) : baseAccent readonly property color surfaceColor: isDragging ? Theme.primaryPressed : (eventMouseArea.containsMouse ? Theme.primaryBackground : Theme.nestedSurface) color: surfaceColor @@ -888,13 +810,13 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter radius: Theme.cornerRadius color: "transparent" - visible: modelData && modelData.id && modelData.id.startsWith("task_") && !taskItem.isEditing + visible: taskItem.isLocalTask && !taskItem.isEditing DankIcon { anchors.centerIn: parent name: "drag_indicator" size: 14 - color: dragMouseArea.containsMouse ? Theme.primary : Theme.surfaceTextAlpha + color: dragMouseArea.containsMouse ? Theme.primary : Theme.surfaceTextMedium } MouseArea { @@ -937,34 +859,14 @@ Rectangle { } } - // Checkbox status icon - Rectangle { - id: checkboxContainer - width: 24 - height: 24 - anchors.left: parent.left - anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? (taskItem.isEditing ? 8 : 32) : 8 - anchors.verticalCenter: parent.verticalCenter - radius: Theme.cornerRadius - color: "transparent" - visible: modelData && modelData.id && modelData.id.startsWith("task_") - - DankIcon { - anchors.centerIn: parent - name: (modelData && modelData.completed) ? "check_box" : "check_box_outline_blank" - size: 16 - color: (modelData && modelData.completed) ? Theme.primary : Theme.onSurface_38 - } - } - Column { id: eventContent anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 60 : (Theme.spacingS + 6) - anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : Theme.spacingXS + anchors.leftMargin: taskItem.isLocalTask ? 60 : taskItem.isDankTask ? 36 : (Theme.spacingS + 6) + anchors.rightMargin: taskItem.canModify ? 64 : Theme.spacingXS spacing: Theme.spacingXXS visible: !taskItem.isEditing @@ -972,7 +874,7 @@ Rectangle { width: parent.width text: modelData ? modelData.title : "" font.pixelSize: Theme.fontSizeSmall - color: (modelData && modelData.id && modelData.id.startsWith("task_") && modelData.completed) ? Theme.surfaceTextSecondary : Theme.surfaceText + color: (taskItem.isTask && modelData && modelData.completed) ? Theme.surfaceTextSecondary : Theme.surfaceText font.weight: Font.Medium horizontalAlignment: Text.AlignLeft elide: Text.ElideRight @@ -1000,7 +902,7 @@ Rectangle { color: Theme.surfaceTextMedium font.weight: Font.Normal horizontalAlignment: Text.AlignLeft - visible: text !== "" && modelData && modelData.id && !modelData.id.startsWith("task_") + visible: text !== "" && !taskItem.isLocalTask } } @@ -1047,90 +949,73 @@ Rectangle { id: eventMouseArea anchors.fill: parent - anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 32 : 6 - anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : 0 + anchors.leftMargin: taskItem.isLocalTask ? 32 : 6 + anchors.rightMargin: taskItem.canModify ? 64 : 0 hoverEnabled: true cursorShape: modelData ? Qt.PointingHandCursor : Qt.ArrowCursor enabled: modelData && !taskItem.isEditing onClicked: { - if (modelData && modelData.id && modelData.id.startsWith("task_")) { + if (!modelData) + return; + if (taskItem.isTask && taskItem.canModify) { CalendarService.toggleTask(modelData.id); return; } - if (modelData) - root.detailEvent = modelData; + root.detailEvent = modelData; } } - // Delete / Cancel Button - Rectangle { + DankActionButton { + buttonSize: 24 + iconSize: 16 + iconName: (modelData && modelData.completed) ? "check_box" : "check_box_outline_blank" + iconColor: (modelData && modelData.completed) ? Theme.primary : Theme.surfaceText + anchors.left: parent.left + anchors.leftMargin: taskItem.isLocalTask ? (taskItem.isEditing ? 8 : 32) : 8 + anchors.verticalCenter: parent.verticalCenter + visible: taskItem.isTask + enabled: taskItem.canModify && !taskItem.isEditing + onClicked: CalendarService.toggleTask(modelData.id) + } + + DankActionButton { id: deleteButton - width: 24 - height: 24 + buttonSize: 24 + iconSize: 14 + iconName: taskItem.isEditing ? "close" : "delete" + iconColor: taskItem.isEditing ? Theme.surfaceText : Theme.error anchors.right: parent.right anchors.rightMargin: Theme.spacingS anchors.verticalCenter: parent.verticalCenter - radius: Theme.cornerRadius - color: deleteMouseArea.containsMouse ? (taskItem.isEditing ? Theme.primaryHover : Qt.rgba(0.9, 0.2, 0.2, 0.15)) : Theme.withAlpha(Qt.rgba(0.9, 0.2, 0.2, 0.15), 0) - visible: modelData && modelData.id && modelData.id.startsWith("task_") - - DankIcon { - anchors.centerIn: parent - name: taskItem.isEditing ? "close" : "delete" - size: 14 - color: deleteMouseArea.containsMouse ? (taskItem.isEditing ? Theme.primary : Qt.rgba(0.9, 0.2, 0.2, 1.0)) : Theme.onSurface_38 - } - - MouseArea { - id: deleteMouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (taskItem.isEditing) { - taskItem.isEditing = false; - } else if (modelData && modelData.id) { - CalendarService.removeTask(modelData.id); - } + visible: taskItem.canModify + onClicked: { + if (taskItem.isEditing) { + taskItem.isEditing = false; + return; } + if (modelData && modelData.id) + CalendarService.removeTask(modelData.id); } } - // Edit / Save Button - Rectangle { - id: editButton - width: 24 - height: 24 + DankActionButton { + buttonSize: 24 + iconSize: 14 + iconName: taskItem.isEditing ? "check" : "edit" + iconColor: taskItem.isEditing ? Theme.primary : Theme.surfaceText anchors.right: deleteButton.left anchors.rightMargin: Theme.spacingXS anchors.verticalCenter: parent.verticalCenter - radius: Theme.cornerRadius - color: editMouseArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) - visible: modelData && modelData.id && modelData.id.startsWith("task_") - - DankIcon { - anchors.centerIn: parent - name: taskItem.isEditing ? "check" : "edit" - size: 14 - color: editMouseArea.containsMouse ? Theme.primary : Theme.onSurface_38 - } - - MouseArea { - id: editMouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (taskItem.isEditing) { - let txt = editInput.text.trim(); - if (txt !== "" && modelData && modelData.id) { - CalendarService.editTask(modelData.id, txt); - } - taskItem.isEditing = false; - } else { - taskItem.isEditing = true; - } + visible: taskItem.canModify + onClicked: { + if (!taskItem.isEditing) { + taskItem.isEditing = true; + return; } + let txt = editInput.text.trim(); + if (txt !== "" && modelData && modelData.id) + CalendarService.editTask(modelData.id, txt); + taskItem.isEditing = false; } } } diff --git a/quickshell/Services/CalendarDankBackend.qml b/quickshell/Services/CalendarDankBackend.qml index a1f53d502..bbea820cc 100644 --- a/quickshell/Services/CalendarDankBackend.qml +++ b/quickshell/Services/CalendarDankBackend.qml @@ -21,6 +21,9 @@ Item { property var calendars: [] property var events: [] property var eventsByDate: ({}) + property var tasks: [] + property var tasksByDate: ({}) + property var _pendingTaskState: ({}) property string lastError: "" property date focusDate: new Date() property var _loadedFrom: null @@ -130,6 +133,7 @@ Item { root.log.info("connected to dankcal:", root.socketPath); root.refreshCalendars(); root.reloadEvents(); + root.reloadTasks(); return; } if (!root.connected && !root.socketFound) @@ -193,12 +197,19 @@ Item { } } + Timer { + id: tasksDebounce + interval: 400 + repeat: false + onTriggered: root.reloadTasks() + } + function _sendSubscribe() { subscribeSocket.send({ "id": _nextId(), "method": "subscribe", "params": { - "topics": ["accounts", "calendars", "events", "sync"] + "topics": ["accounts", "calendars", "events", "tasks", "sync"] } }); } @@ -243,8 +254,14 @@ Item { refreshDebounce.restart(); break; case "events": + refreshDebounce.restart(); + break; + case "tasks": + tasksDebounce.restart(); + break; case "sync": refreshDebounce.restart(); + tasksDebounce.restart(); break; } } @@ -282,6 +299,7 @@ Item { } calendars = list; _rebuildEventsByDate(); + _rebuildTasksByDate(); }); } @@ -353,7 +371,7 @@ Item { function _normalizeEvent(e) { const allDay = !!e.allDay; const id = e.id || ""; - if (id.startsWith("task_")) + if (id.startsWith("task_") || id.startsWith("vtodo_")) log.warn("daemon event id collides with task prefix:", id); return { "id": id, @@ -478,4 +496,175 @@ Item { callback(response); }); } + + function reloadTasks() { + if (!connected) + return; + sendRequest("tasks.list", { + "includeCompleted": true, + "limit": 5000 + }, response => { + if (response.error) { + lastError = response.error; + return; + } + const raw = (response.result || {}).tasks || []; + tasks = raw.map(t => _applyPendingState(_normalizeTask(t))); + _rebuildTasksByDate(); + }); + } + + // A completion toggle is applied optimistically; slow providers can serve a + // reload that predates the write, so the desired state wins over a + // disagreeing reload until the daemon confirms it or the hold expires. + function _applyPendingState(t) { + const pending = _pendingTaskState[t.id]; + if (!pending) + return t; + if (t.completed === pending.completed || Date.now() > pending.expires) { + delete _pendingTaskState[t.id]; + return t; + } + return Object.assign({}, t, { + "completed": pending.completed, + "status": pending.completed ? "completed" : "needs_action" + }); + } + + function _normalizeTask(t) { + const allDay = !!t.allDay; + return { + "id": t.id || "", + "calendarId": t.calendarId || "", + "title": t.summary || "(untitled)", + "description": t.description || "", + "location": t.location || "", + "status": t.status || "needs_action", + "completed": t.status === "completed", + "priority": t.priority || 0, + "due": t.due ? (allDay ? _dayBoundary(t.due) : new Date(t.due)) : null, + "allDay": allDay + }; + } + + function taskById(id) { + for (let i = 0; i < tasks.length; i++) { + if (tasks[i].id === id) + return tasks[i]; + } + return null; + } + + function taskCalendars() { + return calendars.filter(c => c.holdsTasks && !c.readOnly); + } + + function defaultTaskCalendar() { + const writable = taskCalendars().filter(c => !c.hidden); + return writable.length > 0 ? writable[0] : null; + } + + function _taskAsEvent(t) { + const cal = calendarById(t.calendarId); + return { + "id": "vtodo_" + t.id, + "title": t.title, + "description": t.description, + "location": t.location, + "url": "", + "calendar": cal ? cal.name : "", + "color": cal ? cal.color : fallbackPalette[0], + "readOnly": cal ? !!cal.readOnly : false, + "start": t.due, + "end": t.due, + "allDay": t.allDay, + "completed": t.completed, + "status": t.status, + "isMultiDay": false + }; + } + + function _rebuildTasksByDate() { + const hidden = _hiddenCalendarIds(); + const map = {}; + for (const t of tasks) { + if (!t.due || t.status === "cancelled" || hidden[t.calendarId]) + continue; + const key = Qt.formatDate(t.due, "yyyy-MM-dd"); + if (!map[key]) + map[key] = []; + map[key].push(_taskAsEvent(t)); + } + tasksByDate = map; + } + + function _patchTask(id, changes) { + const next = tasks.slice(); + for (let i = 0; i < next.length; i++) { + if (next[i].id !== id) + continue; + next[i] = Object.assign({}, next[i], changes); + break; + } + tasks = next; + _rebuildTasksByDate(); + } + + function createTask(fields, callback) { + sendRequest("tasks.create", fields, response => { + if (response.error) + lastError = response.error; + reloadTasks(); + if (callback) + callback(response); + }); + } + + function updateTask(id, fields, callback) { + const params = Object.assign({ + "id": id + }, fields); + sendRequest("tasks.update", params, response => { + if (response.error) + lastError = response.error; + reloadTasks(); + if (callback) + callback(response); + }); + } + + function completeTask(id, completed, callback) { + _pendingTaskState[id] = { + "completed": completed, + "expires": Date.now() + 15000 + }; + _patchTask(id, { + "completed": completed, + "status": completed ? "completed" : "needs_action" + }); + sendRequest("tasks.complete", { + "id": id, + "completed": completed + }, response => { + if (response.error) { + lastError = response.error; + delete _pendingTaskState[id]; + reloadTasks(); + } + if (callback) + callback(response); + }); + } + + function deleteTask(id, callback) { + sendRequest("tasks.delete", { + "id": id + }, response => { + if (response.error) + lastError = response.error; + reloadTasks(); + if (callback) + callback(response); + }); + } } diff --git a/quickshell/Services/CalendarService.qml b/quickshell/Services/CalendarService.qml index 29a6bbb27..ad40b4567 100644 --- a/quickshell/Services/CalendarService.qml +++ b/quickshell/Services/CalendarService.qml @@ -63,6 +63,7 @@ Singleton { id: dankBackend enabled: root.backendPref === "dankcal" || root.backendPref === "auto" onEventsByDateChanged: root.mergeEvents() + onTasksByDateChanged: root.mergeEvents() onConnectedChanged: { if (connected && root._rangeSet) root.loadEvents(root.lastStartDate, root.lastEndDate); @@ -198,6 +199,17 @@ Singleton { } function addTaskForDate(date, text) { + const taskCal = isDankActive && dankBackend.connected ? dankBackend.defaultTaskCalendar() : null; + if (taskCal) { + const due = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + dankBackend.createTask({ + "calendarId": taskCal.id, + "summary": text, + "allDay": true, + "due": due.toISOString() + }); + return; + } let dateKey = Qt.formatDate(date, "yyyy-MM-dd"); let tasks = Object.assign({}, root.localTasks); if (!tasks[dateKey]) @@ -214,6 +226,13 @@ Singleton { } function toggleTask(taskId) { + if (taskId.startsWith("vtodo_")) { + const id = taskId.slice(6); + const task = dankBackend.taskById(id); + if (task) + dankBackend.completeTask(id, !task.completed); + return; + } let cleanId = taskId.replace("task_", ""); let tasks = Object.assign({}, root.localTasks); let updated = false; @@ -237,6 +256,10 @@ Singleton { } function removeTask(taskId) { + if (taskId.startsWith("vtodo_")) { + dankBackend.deleteTask(taskId.slice(6)); + return; + } let cleanId = taskId.replace("task_", ""); let tasks = Object.assign({}, root.localTasks); let updated = false; @@ -283,6 +306,12 @@ Singleton { } function editTask(taskId, newText) { + if (taskId.startsWith("vtodo_")) { + dankBackend.updateTask(taskId.slice(6), { + "summary": newText + }); + return; + } let cleanId = taskId.replace("task_", ""); let tasks = Object.assign({}, root.localTasks); let updated = false; @@ -305,6 +334,17 @@ Singleton { } } + function _mergeInto(merged, byDate) { + for (let dateKey in byDate) { + if (!merged[dateKey]) + merged[dateKey] = []; + for (let event of byDate[dateKey]) { + if (!merged[dateKey].some(e => e.id === event.id)) + merged[dateKey].push(event); + } + } + } + function mergeEvents() { let merged = {}; let backendEvents = _activeBackendEventsByDate(); @@ -312,14 +352,9 @@ Singleton { for (let dateKey in backendEvents) merged[dateKey] = [].concat(backendEvents[dateKey]); - for (let dateKey in root.taskEventsByDate) { - if (!merged[dateKey]) - merged[dateKey] = []; - for (let event of root.taskEventsByDate[dateKey]) { - if (!merged[dateKey].some(e => e.id === event.id)) - merged[dateKey].push(event); - } - } + _mergeInto(merged, root.taskEventsByDate); + if (isDankActive) + _mergeInto(merged, dankBackend.tasksByDate); for (let dateKey in merged) { let list = merged[dateKey];