diff --git a/quickshell/Common/DankSocket.qml b/quickshell/Common/DankSocket.qml index 93471baa..51abfad2 100644 --- a/quickshell/Common/DankSocket.qml +++ b/quickshell/Common/DankSocket.qml @@ -7,29 +7,31 @@ Item { property alias path: socket.path property alias parser: socket.parser property bool connected: false + property bool linkUp: false property int reconnectBaseMs: 400 property int reconnectMaxMs: 15000 property int _reconnectAttempt: 0 - signal connectionStateChanged() + signal connectionStateChanged onConnectedChanged: { - socket.connected = connected + socket.connected = connected; } Socket { id: socket onConnectionStateChanged: { - root.connectionStateChanged() + root.linkUp = connected; + root.connectionStateChanged(); if (connected) { - root._reconnectAttempt = 0 - return + root._reconnectAttempt = 0; + return; } if (root.connected) { - root._scheduleReconnect() + root._scheduleReconnect(); } } } @@ -39,24 +41,24 @@ Item { interval: 0 repeat: false onTriggered: { - socket.connected = false - Qt.callLater(() => socket.connected = true) + socket.connected = false; + Qt.callLater(() => socket.connected = true); } } function send(data) { - const json = typeof data === "string" ? data : JSON.stringify(data) - const message = json.endsWith("\n") ? json : json + "\n" - socket.write(message) - socket.flush() + const json = typeof data === "string" ? data : JSON.stringify(data); + const message = json.endsWith("\n") ? json : json + "\n"; + socket.write(message); + socket.flush(); } function _scheduleReconnect() { - const pow = Math.min(_reconnectAttempt, 10) - const base = Math.min(reconnectBaseMs * Math.pow(2, pow), reconnectMaxMs) - const jitter = Math.floor(Math.random() * Math.floor(base / 4)) - reconnectTimer.interval = base + jitter - reconnectTimer.restart() - _reconnectAttempt++ + const pow = Math.min(_reconnectAttempt, 10); + const base = Math.min(reconnectBaseMs * Math.pow(2, pow), reconnectMaxMs); + const jitter = Math.floor(Math.random() * Math.floor(base / 4)); + reconnectTimer.interval = base + jitter; + reconnectTimer.restart(); + _reconnectAttempt++; } } diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 4bdfc20c..7695144f 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -182,6 +182,7 @@ Singleton { property int firstDayOfWeek: -1 property bool showWeekNumber: false + property string calendarBackend: "auto" property bool use24HourClock: true property bool showSeconds: false property bool padHours12Hour: false diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 1c8240c6..f5484cb0 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -37,6 +37,7 @@ var SPEC = { firstDayOfWeek: { def: -1 }, showWeekNumber: { def: false }, + calendarBackend: { def: "auto" }, use24HourClock: { def: true }, showSeconds: { def: false }, padHours12Hour: { def: false }, diff --git a/quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml b/quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml index a7493de1..52e37e9d 100644 --- a/quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml +++ b/quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml @@ -1179,11 +1179,12 @@ BasePill { } function updatePosition() { - const globalPos = root.mapToGlobal(0, 0); - const screenX = screen.x || 0; - const screenY = screen.y || 0; - const relativeX = globalPos.x - screenX; - const relativeY = globalPos.y - screenY; + // Window-local maps directly to screen-local because the bar window spans the + // full screen edge; this avoids mixing mapToGlobal with a separately-tracked + // screen.x/.y origin, which desync on non-primary monitors and after DPMS/hotplug. + const localPos = root.mapToItem(null, 0, 0); + const relativeX = localPos.x; + const relativeY = localPos.y; if (root.isVerticalOrientation) { const edge = root.axis?.edge; @@ -1722,11 +1723,13 @@ BasePill { anchorPos = Qt.point(targetX, targetY); } } else { - const globalPos = targetItem.mapToGlobal(0, 0); - const screenX = screen.x || 0; - const screenY = screen.y || 0; - const relativeX = globalPos.x - screenX; - const relativeY = globalPos.y - screenY; + // Window-local maps directly to screen-local because the bar window spans + // the full screen edge; this avoids mixing mapToGlobal with a separately- + // tracked screen.x/.y origin, which desync on non-primary monitors and after + // DPMS/hotplug. + const localPos = targetItem.mapToItem(null, 0, 0); + const relativeX = localPos.x; + const relativeY = localPos.y; if (menuRoot.isVertical) { const edge = menuRoot.axis?.edge; diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml index fca2bef7..d98f94a7 100644 --- a/quickshell/Modules/DankDash/DankDashPopout.qml +++ b/quickshell/Modules/DankDash/DankDashPopout.qml @@ -227,6 +227,13 @@ DankPopout { return; } + if (root.currentTabIndex === 0 && overviewLoader.item?.handleKeyEvent) { + if (overviewLoader.item.handleKeyEvent(event)) { + event.accepted = true; + return; + } + } + if (root.currentTabIndex === 1 && mediaLoader.item?.handleKeyEvent) { if (mediaLoader.item.handleKeyEvent(event)) { event.accepted = true; @@ -356,6 +363,7 @@ DankPopout { sourceComponent: Component { OverviewTab { onCloseDash: root.dashVisible = false + onNavFocusRequested: mainContainer.forceActiveFocus() onSwitchToWeatherTab: { if (SettingsData.weatherEnabled) { root.currentTabIndex = 3; diff --git a/quickshell/Modules/DankDash/Overview/CalendarEventDetail.qml b/quickshell/Modules/DankDash/Overview/CalendarEventDetail.qml new file mode 100644 index 00000000..6c85ac77 --- /dev/null +++ b/quickshell/Modules/DankDash/Overview/CalendarEventDetail.qml @@ -0,0 +1,311 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + property var eventData: null + property bool canEdit: false + + signal editRequested + signal deleteRequested + signal closeRequested + + readonly property bool _descriptionIsHtml: /<[a-z][^>]*>/i.test((eventData && eventData.description) || "") + + function _styleAnchors(html) { + return html.replace(/]*)>/gi, (m, attrs) => { + const cleaned = attrs.replace(/style="[^"]*"/gi, ""); + return ""; + }); + } + + function _inlineMarkdown(line) { + let out = line.replace(/&/g, "&").replace(//g, ">"); + out = out.replace(/\\([\\`*_{}[\]()#+\-.!~>])/g, "$1"); + out = out.replace(/(?:https?:\/\/|www\.)[^\s<>)\]]*[^\s<>)\].,;:!?"']/g, (m, offset, s) => { + const prev = offset > 0 ? s[offset - 1] : ""; + if (prev === "(" || prev === "[" || prev === "\"" || prev === "'") + return m; + const href = m.startsWith("www.") ? "https://" + m : m; + return "" + m + ""; + }); + out = out.replace(/\[([^\]]+)\]\(([^()\s]+)\)/g, "$1"); + out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); + out = out.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, "$1$2"); + return out; + } + + // Descriptions arrive as HTML (Google) or markdown/plain text; both render + // as RichText so links become clickable anchors recolored to the theme. + function _descriptionRichText() { + const raw = ((eventData && eventData.description) || "").trim(); + if (raw === "") + return ""; + if (_descriptionIsHtml) + return _styleAnchors(raw); + + const parts = []; + let list = ""; + const closeList = () => { + if (list === "") + return; + parts.push(""); + list = ""; + }; + + const lines = raw.split("\n"); + for (let i = 0; i < lines.length; i++) { + const ul = lines[i].match(/^\s*[-*+]\s+(.+)$/); + const ol = lines[i].match(/^\s*\d+[.)]\s+(.+)$/); + if (ul || ol) { + const tag = ul ? "ul" : "ol"; + if (list !== tag) { + closeList(); + parts.push("<" + tag + ">"); + list = tag; + } + parts.push("
  • " + _inlineMarkdown((ul || ol)[1]) + "
  • "); + continue; + } + closeList(); + parts.push(_inlineMarkdown(lines[i]) + "
    "); + } + closeList(); + return _styleAnchors(parts.join("").replace(/$/, "")); + } + + function _timeText() { + if (!eventData) + return ""; + const dateStr = Qt.formatDate(eventData.start, "ddd, MMM d"); + if (eventData.allDay) + return I18n.tr("All day") + " · " + dateStr; + const fmt = SettingsData.use24HourClock ? "HH:mm" : "h:mm AP"; + const startStr = Qt.formatTime(eventData.start, fmt); + if (eventData.start.getTime() === eventData.end.getTime()) + return dateStr + " · " + startStr; + return dateStr + " · " + startStr + " – " + Qt.formatTime(eventData.end, fmt); + } + + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: Qt.rgba(0, 0, 0, 0.45) + + MouseArea { + anchors.fill: parent + onClicked: root.closeRequested() + } + } + + Rectangle { + anchors.centerIn: parent + width: Math.min(parent.width - Theme.spacingL * 2, 380) + height: Math.min(parent.height - Theme.spacingM * 2, body.implicitHeight + Theme.spacingL * 2) + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + border.color: Theme.outlineMedium + border.width: 1 + clip: true + + MouseArea { + anchors.fill: parent + } + + DankActionButton { + id: closeButton + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Theme.spacingXS + circular: false + iconName: "close" + iconSize: 16 + z: 1 + onClicked: root.closeRequested() + } + + DankFlickable { + anchors.fill: parent + anchors.margins: Theme.spacingL + anchors.topMargin: Theme.spacingL + contentWidth: width + contentHeight: body.implicitHeight + clip: true + + Column { + id: body + width: parent.width + spacing: Theme.spacingS + + Row { + width: parent.width + spacing: Theme.spacingS + + Rectangle { + width: 4 + height: titleText.implicitHeight + radius: 2 + anchors.top: parent.top + color: (root.eventData && root.eventData.color) ? root.eventData.color : Theme.primary + } + + StyledText { + id: titleText + width: parent.width - 4 - Theme.spacingS - closeButton.width + text: root.eventData ? root.eventData.title : "" + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + horizontalAlignment: Text.AlignLeft + wrapMode: Text.Wrap + maximumLineCount: 3 + elide: Text.ElideRight + } + } + + StyledText { + width: parent.width + text: root._timeText() + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.Wrap + } + + Row { + width: parent.width + spacing: Theme.spacingXS + visible: root.eventData && root.eventData.calendar + + DankIcon { + name: "calendar_month" + size: 14 + color: Theme.surfaceVariantText + anchors.top: parent.top + anchors.topMargin: 2 + } + + StyledText { + width: parent.width - 14 - Theme.spacingXS + text: { + if (!root.eventData) + return ""; + const acc = root.eventData.account || ""; + return root.eventData.calendar + (acc ? " · " + acc : ""); + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + } + + Row { + width: parent.width + spacing: Theme.spacingXS + visible: root.eventData && root.eventData.location + + DankIcon { + name: "place" + size: 14 + color: Theme.surfaceVariantText + anchors.top: parent.top + anchors.topMargin: 2 + } + + StyledText { + width: parent.width - 14 - Theme.spacingXS + text: root.eventData ? root.eventData.location : "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + } + + Row { + width: parent.width + spacing: Theme.spacingXS + visible: root.eventData && root.eventData.url + + DankIcon { + name: "link" + size: 14 + color: Theme.primary + anchors.top: parent.top + anchors.topMargin: 2 + } + + StyledText { + width: parent.width - 14 - Theme.spacingXS + text: root.eventData ? root.eventData.url : "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.primary + wrapMode: Text.WrapAnywhere + maximumLineCount: 2 + elide: Text.ElideRight + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.eventData && root.eventData.url) + Qt.openUrlExternally(root.eventData.url); + } + } + } + } + + StyledText { + id: descriptionText + width: parent.width + text: root._descriptionRichText() + visible: root.eventData && root.eventData.description + textFormat: Text.RichText + linkColor: Theme.primary + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + horizontalAlignment: Text.AlignLeft + wrapMode: Text.Wrap + onLinkActivated: link => Qt.openUrlExternally(link) + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: descriptionText.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor + } + } + + Row { + width: parent.width + spacing: Theme.spacingS + visible: root.canEdit + topPadding: Theme.spacingXS + + DankButton { + text: I18n.tr("Edit") + iconName: "edit" + buttonHeight: 32 + onClicked: root.editRequested() + } + + DankButton { + text: I18n.tr("Delete") + iconName: "delete" + buttonHeight: 32 + backgroundColor: Theme.withAlpha(Theme.error, 0.15) + textColor: Theme.error + onClicked: root.deleteRequested() + } + } + } + } + } +} diff --git a/quickshell/Modules/DankDash/Overview/CalendarEventEditor.qml b/quickshell/Modules/DankDash/Overview/CalendarEventEditor.qml new file mode 100644 index 00000000..7d357f0c --- /dev/null +++ b/quickshell/Modules/DankDash/Overview/CalendarEventEditor.qml @@ -0,0 +1,350 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + property var eventData: null + property date initialDate: new Date() + + signal saved + signal closeRequested + + property string fTitle: "" + property bool fAllDay: false + property date fDate: initialDate + property string fStart: "10:00" + property string fEnd: "11:00" + property string fLocation: "" + property string fDescription: "" + property string fCalendarId: "" + property int fReminder: -1 + property string errorText: "" + property bool saving: false + + readonly property var _cals: CalendarService.writableCalendars() + readonly property var _remLabels: [I18n.tr("No reminder"), I18n.tr("At start"), I18n.tr("5 min before"), I18n.tr("10 min before"), I18n.tr("15 min before"), I18n.tr("30 min before"), I18n.tr("1 hour before"), I18n.tr("1 day before")] + readonly property var _remMins: [-1, 0, 5, 10, 15, 30, 60, 1440] + + function _parseTime(value) { + const m = value.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!m) + return null; + const h = parseInt(m[1]); + const min = parseInt(m[2]); + if (h > 23 || min > 59) + return null; + return { + "h": h, + "m": min + }; + } + + function _isoFromDateTime(dateObj, h, m) { + const d = new Date(dateObj); + d.setHours(h, m, 0, 0); + return d.toISOString(); + } + + function _allDayIso(dateObj, dayOffset) { + return new Date(Date.UTC(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate() + dayOffset)).toISOString(); + } + + function _calendarName(id) { + for (let i = 0; i < _cals.length; i++) { + if (_cals[i].id === id) + return _cals[i].name; + } + return _cals.length > 0 ? _cals[0].name : ""; + } + + function save() { + const title = fTitle.trim(); + if (!title) { + errorText = I18n.tr("Title is required"); + return; + } + let calId = fCalendarId; + if (!calId) { + const def = CalendarService.defaultCalendar(); + calId = def ? def.id : ""; + } + if (!calId) { + errorText = I18n.tr("No writable calendar available"); + return; + } + let startIso, endIso; + if (fAllDay) { + startIso = _allDayIso(fDate, 0); + endIso = _allDayIso(fDate, 1); + } else { + const s = _parseTime(fStart); + const e = _parseTime(fEnd); + if (!s || !e) { + errorText = I18n.tr("Use HH:MM time format"); + return; + } + startIso = _isoFromDateTime(fDate, s.h, s.m); + endIso = _isoFromDateTime(fDate, e.h, e.m); + if (new Date(endIso).getTime() <= new Date(startIso).getTime()) { + errorText = I18n.tr("End must be after start"); + return; + } + } + const fields = { + "calendarId": calId, + "summary": title, + "description": fDescription, + "location": fLocation, + "start": startIso, + "end": endIso, + "allDay": fAllDay, + "reminders": fReminder >= 0 ? [ + { + "method": "popup", + "minutes": fReminder + } + ] : [] + }; + saving = true; + errorText = ""; + const cb = response => { + saving = false; + if (response.error) { + errorText = response.error; + return; + } + root.saved(); + }; + if (eventData && eventData.id) + CalendarService.updateEvent(eventData.id, fields, cb); + else + CalendarService.createEvent(fields, cb); + } + + Component.onCompleted: { + if (!eventData) { + fCalendarId = CalendarService.defaultCalendar() ? CalendarService.defaultCalendar().id : ""; + return; + } + fTitle = eventData.title || ""; + fAllDay = !!eventData.allDay; + fDate = eventData.start; + const fmt = "HH:mm"; + fStart = Qt.formatTime(eventData.start, fmt); + fEnd = Qt.formatTime(eventData.end, fmt); + fLocation = eventData.location || ""; + fDescription = eventData.description || ""; + fCalendarId = eventData.calendarId || ""; + if (eventData.reminders && eventData.reminders.length > 0) + fReminder = eventData.reminders[0].minutes; + } + + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: Qt.rgba(0, 0, 0, 0.45) + + MouseArea { + anchors.fill: parent + onClicked: root.closeRequested() + } + } + + Rectangle { + anchors.centerIn: parent + width: Math.min(parent.width - Theme.spacingL * 2, 400) + height: Math.min(parent.height - Theme.spacingM, 300) + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + border.color: Theme.outlineMedium + border.width: 1 + + MouseArea { + anchors.fill: parent + } + + DankFlickable { + anchors.fill: parent + anchors.margins: Theme.spacingM + contentWidth: width + contentHeight: form.implicitHeight + clip: true + + Column { + id: form + width: parent.width + spacing: Theme.spacingS + + StyledText { + width: parent.width + text: root.eventData ? I18n.tr("Edit event") : I18n.tr("New event") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + horizontalAlignment: Text.AlignLeft + } + + DankTextField { + width: parent.width + labelText: I18n.tr("Title") + leftIconName: "title" + leftIconSize: Theme.iconSize - 6 + placeholderText: I18n.tr("Event title") + text: root.fTitle + onTextChanged: root.fTitle = text + } + + DankToggle { + width: parent.width + text: I18n.tr("All day") + checked: root.fAllDay + onToggled: checked => root.fAllDay = checked + } + + Row { + width: parent.width + spacing: Theme.spacingXS + + DankActionButton { + circular: false + iconName: "chevron_left" + iconSize: 16 + onClicked: { + let d = new Date(root.fDate); + d.setDate(d.getDate() - 1); + root.fDate = d; + } + } + + StyledText { + width: parent.width - 72 + text: Qt.formatDate(root.fDate, "ddd, MMM d yyyy") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + height: 32 + } + + DankActionButton { + circular: false + iconName: "chevron_right" + iconSize: 16 + onClicked: { + let d = new Date(root.fDate); + d.setDate(d.getDate() + 1); + root.fDate = d; + } + } + } + + Row { + width: parent.width + spacing: Theme.spacingS + visible: !root.fAllDay + + DankTextField { + width: (parent.width - Theme.spacingS) / 2 + labelText: I18n.tr("Start") + leftIconName: "schedule" + leftIconSize: Theme.iconSize - 6 + placeholderText: "HH:MM" + text: root.fStart + onTextChanged: root.fStart = text + } + + DankTextField { + width: (parent.width - Theme.spacingS) / 2 + labelText: I18n.tr("End") + placeholderText: "HH:MM" + text: root.fEnd + onTextChanged: root.fEnd = text + } + } + + DankDropdown { + width: parent.width + text: I18n.tr("Calendar") + options: root._cals.map(c => c.name) + currentValue: root._calendarName(root.fCalendarId) + onValueChanged: value => { + for (let i = 0; i < root._cals.length; i++) { + if (root._cals[i].name === value) { + root.fCalendarId = root._cals[i].id; + return; + } + } + } + } + + DankDropdown { + width: parent.width + text: I18n.tr("Reminder") + options: root._remLabels + currentValue: root._remLabels[Math.max(0, root._remMins.indexOf(root.fReminder))] + onValueChanged: value => { + const idx = root._remLabels.indexOf(value); + if (idx >= 0) + root.fReminder = root._remMins[idx]; + } + } + + DankTextField { + width: parent.width + labelText: I18n.tr("Location") + leftIconName: "place" + leftIconSize: Theme.iconSize - 6 + placeholderText: I18n.tr("Add location") + text: root.fLocation + onTextChanged: root.fLocation = text + } + + DankTextField { + width: parent.width + labelText: I18n.tr("Notes") + leftIconName: "notes" + leftIconSize: Theme.iconSize - 6 + placeholderText: I18n.tr("Add notes") + text: root.fDescription + onTextChanged: root.fDescription = text + } + + StyledText { + width: parent.width + text: root.errorText + visible: root.errorText !== "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + wrapMode: Text.WordWrap + } + + Row { + width: parent.width + spacing: Theme.spacingS + + DankButton { + text: root.saving ? I18n.tr("Saving…") : I18n.tr("Save") + iconName: "check" + buttonHeight: 32 + backgroundColor: Theme.primary + textColor: Theme.primaryText + enabled: !root.saving + onClicked: root.save() + } + + DankButton { + text: I18n.tr("Cancel") + buttonHeight: 32 + onClicked: root.closeRequested() + } + } + } + } + } +} diff --git a/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml b/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml index 4a1c1145..d908a39a 100644 --- a/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/CalendarOverviewCard.qml @@ -8,14 +8,21 @@ Rectangle { id: root readonly property var log: Log.scoped("CalendarOverviewCard") + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + implicitWidth: SettingsData.showWeekNumber ? 736 : 700 property bool showEventDetails: false property date selectedDate: systemClock.date property var selectedDateEvents: [] property bool hasEvents: selectedDateEvents && selectedDateEvents.length > 0 + property var detailEvent: null + property bool showEditor: false + property var editorEvent: null signal closeDash + signal navFocusRequested function weekStartQt() { if (SettingsData.firstDayOfWeek >= 7 || SettingsData.firstDayOfWeek < 0) { @@ -79,7 +86,7 @@ Rectangle { } function updateSelectedDateEvents() { - if (CalendarService && CalendarService.khalAvailable) { + if (CalendarService && CalendarService.calendarAvailable) { const events = CalendarService.getEventsForDate(selectedDate); selectedDateEvents = events; } else { @@ -88,7 +95,7 @@ Rectangle { } function loadEventsForMonth() { - if (!CalendarService || !CalendarService.khalAvailable) { + if (!CalendarService || !CalendarService.calendarAvailable) { return; } @@ -104,11 +111,83 @@ Rectangle { CalendarService.loadEvents(startDate, endDate); } + function goToToday() { + const now = systemClock.date; + calendarGrid.selectedDate = now; + calendarGrid.displayDate = now; + root.selectedDate = now; + loadEventsForMonth(); + } + + function moveSelection(days) { + let d = new Date(calendarGrid.selectedDate); + d.setDate(d.getDate() + days); + calendarGrid.selectedDate = d; + root.selectedDate = d; + if (d.getMonth() !== calendarGrid.displayDate.getMonth() || d.getFullYear() !== calendarGrid.displayDate.getFullYear()) { + calendarGrid.displayDate = d; + loadEventsForMonth(); + } + } + + function shiftMonth(delta) { + let d = new Date(calendarGrid.displayDate); + d.setMonth(d.getMonth() + delta); + calendarGrid.displayDate = d; + loadEventsForMonth(); + } + + function handleKeyEvent(event) { + if (showEventDetails) { + if (event.key === Qt.Key_Escape) { + showEventDetails = false; + return true; + } + return false; + } + switch (event.key) { + case Qt.Key_Left: + case Qt.Key_H: + moveSelection(I18n.isRtl ? 1 : -1); + return true; + case Qt.Key_Right: + case Qt.Key_L: + moveSelection(I18n.isRtl ? -1 : 1); + return true; + case Qt.Key_Up: + case Qt.Key_K: + moveSelection(-7); + return true; + case Qt.Key_Down: + case Qt.Key_J: + moveSelection(7); + return true; + case Qt.Key_PageUp: + shiftMonth(-1); + return true; + case Qt.Key_PageDown: + shiftMonth(1); + return true; + case Qt.Key_T: + goToToday(); + return true; + case Qt.Key_Return: + case Qt.Key_Enter: + case Qt.Key_Space: + root.selectedDate = calendarGrid.selectedDate; + showEventDetails = true; + return true; + } + return false; + } + onSelectedDateChanged: updateSelectedDateEvents() onShowEventDetailsChanged: { if (showEventDetails) { taskInput.forceActiveFocus(); + } else { + navFocusRequested(); } } @@ -122,8 +201,8 @@ Rectangle { updateSelectedDateEvents(); } - function onKhalAvailableChanged() { - if (CalendarService && CalendarService.khalAvailable) { + function onCalendarAvailableChanged() { + if (CalendarService && CalendarService.calendarAvailable) { loadEventsForMonth(); } updateSelectedDateEvents(); @@ -143,6 +222,55 @@ Rectangle { anchors.margins: Theme.spacingM spacing: Theme.spacingS + Rectangle { + id: dankWarning + width: parent.width + visible: CalendarService && CalendarService.dankNeedsLaunch + height: visible ? Math.max(28, warningRow.implicitHeight) + Theme.spacingS : 0 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12) + border.color: Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.35) + border.width: 1 + + Row { + id: warningRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: "warning" + size: 16 + color: Theme.warning + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + width: parent.width - 16 - Theme.spacingS - (launchButton.visible ? launchButton.width + Theme.spacingS : 0) + anchors.verticalCenter: parent.verticalCenter + text: (CalendarService && CalendarService.dankBinaryExists) ? I18n.tr("DankCalendar isn't running") : I18n.tr("DankCalendar isn't installed") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + horizontalAlignment: Text.AlignLeft + wrapMode: Text.Wrap + } + + DankButton { + id: launchButton + anchors.verticalCenter: parent.verticalCenter + visible: CalendarService && CalendarService.dankBinaryExists + text: I18n.tr("Launch") + buttonHeight: 26 + backgroundColor: Theme.primary + textColor: Theme.primaryText + onClicked: CalendarService.launchDankCalendar() + } + } + } + Item { width: parent.width height: 40 @@ -173,11 +301,40 @@ Rectangle { } } + Rectangle { + width: 32 + height: 32 + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + radius: Theme.cornerRadius + visible: CalendarService && CalendarService.canCreateEvents + color: addEventArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + + 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; + } + } + } + StyledText { anchors.left: parent.left anchors.right: parent.right anchors.leftMargin: 32 + Theme.spacingS * 2 - anchors.rightMargin: Theme.spacingS + anchors.rightMargin: (CalendarService && CalendarService.canCreateEvents) ? 32 + Theme.spacingS * 2 : Theme.spacingS height: 40 anchors.verticalCenter: parent.verticalCenter text: { @@ -229,7 +386,7 @@ Rectangle { } StyledText { - width: parent.width - 56 + width: parent.width - 84 height: 28 text: calendarGrid.displayDate.toLocaleDateString(I18n.locale(), "MMMM yyyy") font.pixelSize: Theme.fontSizeMedium @@ -239,6 +396,28 @@ Rectangle { verticalAlignment: Text.AlignVCenter } + Rectangle { + width: 28 + height: 28 + radius: Theme.cornerRadius + color: todayArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + + 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() + } + } + Rectangle { width: 28 height: 28 @@ -388,6 +567,8 @@ Rectangle { height: width color: isToday ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : dayArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent" radius: Theme.cornerRadius + border.color: (isSelected && !isToday) ? Theme.primary : "transparent" + border.width: (isSelected && !isToday) ? 1 : 0 StyledText { anchors.centerIn: parent @@ -397,21 +578,31 @@ Rectangle { font.weight: isToday ? Font.Medium : Font.Normal } - Rectangle { + Row { anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter - anchors.bottomMargin: 4 - width: 12 - height: 2 - radius: Theme.cornerRadius - visible: CalendarService && CalendarService.khalAvailable && CalendarService.hasEventsForDate(dayDate) - color: isToday ? Qt.lighter(Theme.primary, 1.3) : Theme.primary - opacity: isToday ? 0.9 : 0.7 + anchors.bottomMargin: 3 + spacing: 2 + visible: CalendarService && CalendarService.calendarAvailable && CalendarService.hasEventsForDate(dayDate) - Behavior on opacity { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing + Repeater { + model: { + const evs = CalendarService.getEventsForDate(dayDate); + const seen = []; + for (let i = 0; i < evs.length && seen.length < 3; i++) { + const c = (evs[i].color && evs[i].color.length) ? evs[i].color : "primary"; + if (seen.indexOf(c) === -1) + seen.push(c); + } + return seen; + } + + Rectangle { + width: 5 + height: 5 + radius: 2.5 + color: modelData === "primary" ? (isToday ? Qt.lighter(Theme.primary, 1.3) : Theme.primary) : modelData + opacity: isToday ? 0.95 : 0.8 } } } @@ -423,6 +614,7 @@ Rectangle { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { + calendarGrid.selectedDate = dayDate; root.selectedDate = dayDate; root.showEventDetails = true; } @@ -622,7 +814,15 @@ Rectangle { } } - color: isDragging ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15) : (eventMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.06) : Theme.nestedSurface) + readonly property bool isTask: modelData && modelData.id && modelData.id.startsWith("task_") + readonly property color accentColor: { + if (isTask) + return modelData.completed ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Theme.primary; + return (modelData && modelData.color && modelData.color.length) ? modelData.color : Theme.primary; + } + readonly property color surfaceColor: isDragging ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15) : (eventMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.06) : Theme.nestedSurface) + + color: surfaceColor border.color: isDragging ? Theme.primary : (eventMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15) : Theme.outlineMedium) border.width: (isDragging || eventMouseArea.containsMouse) ? 1 : Theme.layerOutlineWidth @@ -660,15 +860,22 @@ Rectangle { } } - Rectangle { - width: 3 - height: parent.height - 6 + Item { + id: accentClip + width: 4 + clip: true + anchors.top: parent.top + anchors.bottom: parent.bottom anchors.left: parent.left - anchors.leftMargin: 3 - anchors.verticalCenter: parent.verticalCenter - radius: Theme.cornerRadius - color: (modelData && modelData.id && modelData.id.startsWith("task_")) ? (modelData.completed ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Theme.primary) : Theme.primary - opacity: 0.8 + + Rectangle { + width: taskItem.width + height: taskItem.height + radius: taskItem.radius + color: taskItem.accentColor + anchors.top: parent.top + anchors.left: parent.left + } } // Drag Handle @@ -767,6 +974,7 @@ Rectangle { font.pixelSize: Theme.fontSizeSmall color: (modelData && modelData.id && modelData.id.startsWith("task_") && modelData.completed) ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) : Theme.surfaceText font.weight: Font.Medium + horizontalAlignment: Text.AlignLeft elide: Text.ElideRight maximumLineCount: 1 } @@ -774,21 +982,24 @@ Rectangle { StyledText { width: parent.width text: { - if (!modelData || modelData.allDay) { - return I18n.tr("All day", "calendar task with no specific time"); - } else if (modelData.start && modelData.end) { + if (!modelData) + return ""; + const cal = (modelData.calendar && modelData.calendar.length) ? " · " + modelData.calendar : ""; + if (modelData.allDay) + return I18n.tr("All day", "calendar task with no specific time") + cal; + if (modelData.start && modelData.end) { const timeFormat = SettingsData.use24HourClock ? "HH:mm" : "h:mm AP"; const startTime = Qt.formatTime(modelData.start, timeFormat); - if (modelData.start.toDateString() !== modelData.end.toDateString() || modelData.start.getTime() !== modelData.end.getTime()) { - return startTime + " – " + Qt.formatTime(modelData.end, timeFormat); - } - return startTime; + if (modelData.start.toDateString() !== modelData.end.toDateString() || modelData.start.getTime() !== modelData.end.getTime()) + return startTime + " – " + Qt.formatTime(modelData.end, timeFormat) + cal; + return startTime + cal; } return ""; } font.pixelSize: Theme.fontSizeSmall color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) font.weight: Font.Normal + horizontalAlignment: Text.AlignLeft visible: text !== "" && modelData && modelData.id && !modelData.id.startsWith("task_") } } @@ -824,8 +1035,9 @@ Rectangle { taskItem.isEditing = false; } - Keys.onEscapePressed: { + Keys.onEscapePressed: event => { taskItem.isEditing = false; + event.accepted = true; } } } @@ -838,18 +1050,15 @@ Rectangle { anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 32 : 6 anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : 0 hoverEnabled: true - cursorShape: (modelData && (modelData.url || (modelData.id && modelData.id.startsWith("task_")))) ? Qt.PointingHandCursor : Qt.ArrowCursor - enabled: modelData && (modelData.url !== "" || (modelData.id && modelData.id.startsWith("task_"))) && !taskItem.isEditing + cursorShape: modelData ? Qt.PointingHandCursor : Qt.ArrowCursor + enabled: modelData && !taskItem.isEditing onClicked: { if (modelData && modelData.id && modelData.id.startsWith("task_")) { CalendarService.toggleTask(modelData.id); - } else if (modelData && modelData.url && modelData.url !== "") { - if (Qt.openUrlExternally(modelData.url) === false) { - log.warn("Failed to open URL: " + modelData.url); - } else { - root.closeDash(); - } + return; } + if (modelData) + root.detailEvent = modelData; } } @@ -953,7 +1162,7 @@ Rectangle { Text { text: I18n.tr("Add a task...", "placeholder in the new-task input field") color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4) - visible: !taskInput.text && !taskInput.activeFocus + visible: taskInput.text.length === 0 font.pixelSize: Theme.fontSizeSmall anchors.verticalCenter: parent.verticalCenter } @@ -965,6 +1174,52 @@ Rectangle { text = ""; } } + + Keys.onEscapePressed: event => { + root.showEventDetails = false; + event.accepted = true; + } + } + } + } + + Loader { + anchors.fill: parent + z: 1000 + active: root.detailEvent !== null + + sourceComponent: CalendarEventDetail { + eventData: root.detailEvent + canEdit: CalendarService && CalendarService.canCreateEvents && root.detailEvent && !root.detailEvent.readOnly && !(root.detailEvent.id && root.detailEvent.id.startsWith("task_")) + onCloseRequested: root.detailEvent = null + onEditRequested: { + root.editorEvent = root.detailEvent; + root.detailEvent = null; + root.showEditor = true; + } + onDeleteRequested: { + if (root.detailEvent && root.detailEvent.id) + CalendarService.deleteEvent(root.detailEvent.id, null); + root.detailEvent = null; + } + } + } + + Loader { + anchors.fill: parent + z: 1000 + active: root.showEditor + + sourceComponent: CalendarEventEditor { + eventData: root.editorEvent + initialDate: root.selectedDate + onCloseRequested: { + root.showEditor = false; + root.editorEvent = null; + } + onSaved: { + root.showEditor = false; + root.editorEvent = null; } } } diff --git a/quickshell/Modules/DankDash/OverviewTab.qml b/quickshell/Modules/DankDash/OverviewTab.qml index de7ea352..94976d86 100644 --- a/quickshell/Modules/DankDash/OverviewTab.qml +++ b/quickshell/Modules/DankDash/OverviewTab.qml @@ -14,6 +14,11 @@ Item { signal switchToWeatherTab signal switchToMediaTab signal closeDash + signal navFocusRequested + + function handleKeyEvent(event) { + return calendarCard.handleKeyEvent(event); + } Item { anchors.fill: parent @@ -54,12 +59,14 @@ Item { // Calendar - bottom middle (wider and taller) CalendarOverviewCard { + id: calendarCard x: parent.width * 0.2 - Theme.spacingM y: 100 + Theme.spacingM width: parent.width * 0.6 height: 300 onCloseDash: root.closeDash() + onNavFocusRequested: root.navFocusRequested() } // Media - bottom right (narrow and taller) diff --git a/quickshell/Modules/Settings/TimeWeatherTab.qml b/quickshell/Modules/Settings/TimeWeatherTab.qml index 53536679..635aebad 100644 --- a/quickshell/Modules/Settings/TimeWeatherTab.qml +++ b/quickshell/Modules/Settings/TimeWeatherTab.qml @@ -115,6 +115,43 @@ Item { } } + SettingsDropdownRow { + tab: "time" + tags: ["calendar", "backend", "daemon", "khal", "dankcalendar", "events"] + settingKey: "calendarBackend" + text: I18n.tr("Calendar Backend") + description: { + const resolved = CalendarService.activeBackend; + switch (resolved) { + case "dankcal": + return I18n.tr("Using DankCalendar%1", "calendar backend status").arg(CalendarService.isDankActive && CalendarService.calendars.length > 0 ? "" : " (connecting…)"); + case "khal": + return I18n.tr("Using khal", "calendar backend status"); + default: + return I18n.tr("No calendar source available", "calendar backend status"); + } + } + readonly property var _backendValues: ["auto", "khal", "dankcal"] + readonly property var _backendLabels: [I18n.tr("Auto", "calendar backend option"), I18n.tr("khal", "calendar backend option"), I18n.tr("DankCalendar", "calendar backend option")] + options: _backendLabels + currentValue: _backendLabels[Math.max(0, _backendValues.indexOf(SettingsData.calendarBackend))] + onValueChanged: value => { + const idx = _backendLabels.indexOf(value); + if (idx < 0) + return; + SettingsData.set("calendarBackend", _backendValues[idx]); + } + } + + DankButton { + text: I18n.tr("Launch DankCalendar") + iconName: "calendar_month" + backgroundColor: Theme.primary + textColor: Theme.primaryText + visible: CalendarService.dankNeedsLaunch && CalendarService.dankBinaryExists + onClicked: CalendarService.launchDankCalendar() + } + Rectangle { width: parent.width height: 1 diff --git a/quickshell/Services/CalendarDankBackend.qml b/quickshell/Services/CalendarDankBackend.qml new file mode 100644 index 00000000..f964a632 --- /dev/null +++ b/quickshell/Services/CalendarDankBackend.qml @@ -0,0 +1,478 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common +import qs.Services + +Item { + id: root + readonly property var log: Log.scoped("CalendarDankBackend") + + property bool enabled: false + + property string socketPath: "" + readonly property bool socketFound: socketPath.length > 0 + property bool connected: false + property bool binaryExists: false + property bool binaryChecked: false + + property var calendars: [] + property var events: [] + property var eventsByDate: ({}) + property string lastError: "" + property date focusDate: new Date() + property var _loadedFrom: null + property var _loadedTo: null + + property var pendingRequests: ({}) + property int requestCounter: 0 + + readonly property var fallbackPalette: ["#7287fd", "#f38ba8", "#a6e3a1", "#fab387", "#cba6f7", "#94e2d5", "#f9e2af", "#89dceb"] + + signal eventsUpdated + + onEnabledChanged: { + if (enabled) { + if (!connected) + discoverProcess.running = true; + return; + } + requestSocket.connected = false; + subscribeSocket.connected = false; + socketPath = ""; + connected = false; + } + + Component.onCompleted: { + binaryCheck.running = true; + discoverProcess.running = true; + } + + Process { + id: binaryCheck + command: ["sh", "-c", "command -v dankcal"] + running: false + onExited: code => { + root.binaryExists = (code === 0); + root.binaryChecked = true; + } + } + + Process { + id: discoverProcess + running: false + command: ["sh", "-c", "s=\"${DANKCAL_SOCKET:-}\"; if [ -S \"$s\" ]; then echo \"$s\"; exit 0; fi; for f in \"${XDG_RUNTIME_DIR:-/tmp}\"/dankcal-*.sock /tmp/dankcal-*.sock; do [ -S \"$f\" ] || continue; p=$(basename \"$f\" .sock); p=${p#dankcal-}; if kill -0 \"$p\" 2>/dev/null; then echo \"$f\"; exit 0; fi; done"] + + stdout: StdioCollector { + onStreamFinished: { + const path = text.trim().split('\n')[0] || ""; + if (path.length > 0) { + root._applySocketPath(path); + return; + } + if (!root.connected) { + if (root.socketPath !== "") + root.log.info("dankcal socket gone, waiting for daemon"); + requestSocket.connected = false; + subscribeSocket.connected = false; + root.socketPath = ""; + } + } + } + } + + Timer { + id: rediscoverTimer + interval: 3000 + repeat: true + running: root.enabled && !root.connected + onTriggered: { + if (!discoverProcess.running) + discoverProcess.running = true; + } + } + + function launch() { + if (!binaryExists) + return; + Quickshell.execDetached(["dankcal", "run", "-d", "--hidden"]); + if (enabled && !connected) + discoverProcess.running = true; + } + + function _applySocketPath(path) { + if (path === socketPath) { + if (socketFound && !connected) + requestSocket.connected = true; + return; + } + log.info("dankcal socket discovered:", path); + requestSocket.connected = false; + subscribeSocket.connected = false; + socketPath = path; + Qt.callLater(() => requestSocket.connected = true); + } + + DankSocket { + id: requestSocket + path: root.socketPath + connected: false + + onConnectionStateChanged: { + if (linkUp) { + root.connected = true; + subscribeSocket.connected = true; + root.log.info("connected to dankcal:", root.socketPath); + root.refreshCalendars(); + root.reloadEvents(); + return; + } + if (!root.connected && !root.socketFound) + return; + root.connected = false; + root._flushPending(); + requestSocket.connected = false; + subscribeSocket.connected = false; + root.log.info("dankcal disconnected, rediscovering"); + if (root.enabled) + discoverProcess.running = true; + } + + parser: SplitParser { + onRead: line => { + if (!line || line.length === 0) + return; + let response; + try { + response = JSON.parse(line); + } catch (e) { + return; + } + root._handleResponse(response); + } + } + } + + DankSocket { + id: subscribeSocket + path: root.socketPath + connected: false + + onConnectionStateChanged: { + if (linkUp) + root._sendSubscribe(); + } + + parser: SplitParser { + onRead: line => { + if (!line || line.length === 0) + return; + let event; + try { + event = JSON.parse(line); + } catch (e) { + return; + } + root._handleEvent(event); + } + } + } + + Timer { + id: refreshDebounce + interval: 400 + repeat: false + onTriggered: { + root.refreshCalendars(); + root.reloadEvents(); + } + } + + function _sendSubscribe() { + subscribeSocket.send({ + "id": _nextId(), + "method": "subscribe", + "params": { + "topics": ["accounts", "calendars", "events", "sync"] + } + }); + } + + function _nextId() { + requestCounter++; + return Date.now() + requestCounter; + } + + function _flushPending() { + const ids = Object.keys(pendingRequests); + for (const id of ids) { + const cb = pendingRequests[id]; + delete pendingRequests[id]; + if (cb) + cb({ + "error": "disconnected" + }); + } + } + + function _handleResponse(response) { + if (response.event) { + _handleEvent(response); + return; + } + const id = response.id; + if (!id) + return; + const cb = pendingRequests[id]; + if (cb) { + delete pendingRequests[id]; + cb(response); + } + } + + function _handleEvent(event) { + switch (event.event) { + case "accounts": + case "calendars": + refreshCalendars(); + refreshDebounce.restart(); + break; + case "events": + case "sync": + refreshDebounce.restart(); + break; + } + } + + function sendRequest(method, params, callback) { + if (!connected) { + if (callback) + callback({ + "error": "not connected to dankcal socket" + }); + return; + } + const id = _nextId(); + const req = { + "id": id, + "method": method + }; + if (params) + req.params = params; + if (callback) + pendingRequests[id] = callback; + requestSocket.send(req); + } + + function refreshCalendars() { + sendRequest("calendars.list", null, response => { + if (response.error) { + lastError = response.error; + return; + } + const list = response.result || []; + for (let i = 0; i < list.length; i++) { + if (!list[i].color) + list[i].color = fallbackPalette[i % fallbackPalette.length]; + } + calendars = list; + _rebuildEventsByDate(); + }); + } + + function calendarById(id) { + for (let i = 0; i < calendars.length; i++) { + if (calendars[i].id === id) + return calendars[i]; + } + return null; + } + + function writableCalendars() { + return calendars.filter(c => !c.readOnly); + } + + function defaultCalendar() { + const writable = writableCalendars().filter(c => !c.hidden); + return writable.length > 0 ? writable[0] : null; + } + + function loadEvents(startDate, endDate) { + const mid = new Date((startDate.getTime() + endDate.getTime()) / 2); + focusDate = mid; + _ensureWindow(); + } + + function _ensureWindow() { + if (!connected) + return; + if (!_loadedFrom || !_loadedTo) { + reloadEvents(); + return; + } + const margin = 14 * 86400000; + const t = focusDate.getTime(); + if (t < _loadedFrom.getTime() + margin || t > _loadedTo.getTime() - margin) + reloadEvents(); + else + _rebuildEventsByDate(); + } + + function reloadEvents() { + if (!connected) + return; + const from = new Date(focusDate.getTime() - 60 * 86400000); + const to = new Date(focusDate.getTime() + 90 * 86400000); + sendRequest("events.list", { + "from": from.toISOString(), + "to": to.toISOString(), + "limit": 5000 + }, response => { + if (response.error) { + lastError = response.error; + return; + } + _loadedFrom = from; + _loadedTo = to; + const raw = (response.result || {}).events || []; + events = raw.map(e => _normalizeEvent(e)); + _rebuildEventsByDate(); + }); + } + + function _dayBoundary(iso) { + const d = new Date(iso); + return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + } + + function _normalizeEvent(e) { + const allDay = !!e.allDay; + const id = e.id || ""; + if (id.startsWith("task_")) + log.warn("daemon event id collides with task prefix:", id); + return { + "id": id, + "calendarId": e.calendarId || "", + "title": e.summary || "(untitled)", + "description": e.description || "", + "location": e.location || "", + "url": e.url || "", + "start": allDay ? _dayBoundary(e.start) : new Date(e.start), + "end": allDay ? _dayBoundary(e.end) : new Date(e.end), + "allDay": allDay, + "status": e.status || "confirmed", + "recurringId": e.recurringId || "", + "attendees": e.attendees || [], + "organizer": e.organizer || null, + "reminders": e.reminders || [] + }; + } + + function decorateEvent(ev) { + const cal = calendarById(ev.calendarId); + const out = Object.assign({}, ev); + out.color = cal ? cal.color : fallbackPalette[0]; + out.calendar = cal ? cal.name : ""; + out.account = cal ? (cal.accountName || cal.accountId || "") : ""; + out.readOnly = cal ? !!cal.readOnly : false; + out.isMultiDay = ev.start.toDateString() !== ev.end.toDateString(); + return out; + } + + function _hiddenCalendarIds() { + const hidden = {}; + for (let i = 0; i < calendars.length; i++) { + if (calendars[i].hidden) + hidden[calendars[i].id] = true; + } + return hidden; + } + + function _clampForDay(ev, cur, endDay) { + const out = Object.assign({}, ev); + const dayStart = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate()); + const startDay = new Date(ev.start.getFullYear(), ev.start.getMonth(), ev.start.getDate()); + if (dayStart.getTime() === startDay.getTime()) { + out.start = new Date(ev.start); + } else { + out.start = new Date(dayStart); + if (!ev.allDay) + out.start.setHours(0, 0, 0, 0); + } + if (dayStart.getTime() === endDay.getTime()) { + out.end = new Date(ev.end); + } else { + out.end = new Date(dayStart); + if (!ev.allDay) + out.end.setHours(23, 59, 59, 999); + } + return out; + } + + function _rebuildEventsByDate() { + const hidden = _hiddenCalendarIds(); + const map = {}; + for (const raw of events) { + if (raw.status === "cancelled") + continue; + if (hidden[raw.calendarId]) + continue; + const ev = decorateEvent(raw); + const lastInstant = ev.allDay ? new Date(ev.end.getTime() - 1) : ev.end; + let cur = new Date(ev.start.getFullYear(), ev.start.getMonth(), ev.start.getDate()); + let endDay = new Date(lastInstant.getFullYear(), lastInstant.getMonth(), lastInstant.getDate()); + if (endDay < cur) + endDay = new Date(cur); + while (cur <= endDay) { + const key = Qt.formatDate(cur, "yyyy-MM-dd"); + if (!map[key]) + map[key] = []; + if (!map[key].some(e => e.id === ev.id)) + map[key].push(_clampForDay(ev, cur, endDay)); + cur.setDate(cur.getDate() + 1); + } + } + eventsByDate = map; + eventsUpdated(); + } + + function createEvent(fields, callback) { + sendRequest("events.create", fields, response => { + if (response.error) + lastError = response.error; + else + reloadEvents(); + if (callback) + callback(response); + }); + } + + function updateEvent(id, fields, callback) { + const params = Object.assign({ + "id": id + }, fields); + sendRequest("events.update", params, response => { + if (response.error) + lastError = response.error; + else + reloadEvents(); + if (callback) + callback(response); + }); + } + + function deleteEvent(id, callback) { + sendRequest("events.delete", { + "id": id + }, response => { + if (response.error) + lastError = response.error; + else + reloadEvents(); + if (callback) + callback(response); + }); + } +} diff --git a/quickshell/Services/CalendarKhalBackend.qml b/quickshell/Services/CalendarKhalBackend.qml new file mode 100644 index 00000000..38e20f0b --- /dev/null +++ b/quickshell/Services/CalendarKhalBackend.qml @@ -0,0 +1,237 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io +import qs.Common +import qs.Services + +Item { + id: root + readonly property var log: Log.scoped("CalendarKhalBackend") + + property bool installed: false + property var eventsByDate: ({}) + property bool isLoading: false + property string lastError: "" + property date lastStartDate + property date lastEndDate + property string dateFormat: "MM/dd/yyyy" + + function checkAvailability() { + if (!formatProcess.running) + formatProcess.running = true; + } + + function loadCurrentMonth() { + let today = new Date(); + let firstDay = new Date(today.getFullYear(), today.getMonth(), 1); + let lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); + let startDate = new Date(firstDay); + startDate.setDate(startDate.getDate() - firstDay.getDay() - 7); + let endDate = new Date(lastDay); + endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()) + 7); + loadEvents(startDate, endDate); + } + + function loadEvents(startDate, endDate) { + if (!installed) + return; + if (eventsProcess.running) + return; + root.lastStartDate = startDate; + root.lastEndDate = endDate; + root.isLoading = true; + let startDateStr = Qt.formatDate(startDate, root.dateFormat); + let endDateStr = Qt.formatDate(endDate, root.dateFormat); + eventsProcess.requestStartDate = startDate; + eventsProcess.requestEndDate = endDate; + eventsProcess.command = ["khal", "list", "--json", "title", "--json", "description", "--json", "start-date", "--json", "start-time", "--json", "end-date", "--json", "end-time", "--json", "all-day", "--json", "location", "--json", "url", startDateStr, endDateStr]; + eventsProcess.running = true; + } + + function _parseDateFormat(formatExample) { + return formatExample.replace("12", "MM").replace("21", "dd").replace("2013", "yyyy"); + } + + Component.onCompleted: checkAvailability() + + Process { + id: formatProcess + + command: ["khal", "printformats"] + running: false + onExited: exitCode => { + if (exitCode !== 0) + checkProcess.running = true; + } + + stdout: StdioCollector { + onStreamFinished: { + let lines = text.split('\n'); + for (let line of lines) { + if (!line.startsWith('dateformat:')) + continue; + let formatExample = line.substring(line.indexOf(':') + 1).trim(); + root.dateFormat = root._parseDateFormat(formatExample); + break; + } + checkProcess.running = true; + } + } + } + + Process { + id: checkProcess + + command: ["khal", "list", "today"] + running: false + onExited: exitCode => { + root.installed = (exitCode === 0); + if (root.installed) + root.loadCurrentMonth(); + } + } + + Process { + id: eventsProcess + + property date requestStartDate + property date requestEndDate + property string rawOutput: "" + + running: false + onExited: exitCode => { + root.isLoading = false; + if (exitCode !== 0) { + root.lastError = "Failed to load events (exit code: " + exitCode + ")"; + return; + } + try { + let newEventsByDate = {}; + let lines = eventsProcess.rawOutput.split('\n'); + for (let line of lines) { + line = line.trim(); + if (!line || line === "[]") + continue; + + let dayEvents = JSON.parse(line); + for (let event of dayEvents) { + if (!event.title) + continue; + + let startDate, endDate; + if (event['start-date']) + startDate = Date.fromLocaleString(I18n.locale(), event['start-date'], root.dateFormat); + else + startDate = new Date(); + if (event['end-date']) + endDate = Date.fromLocaleString(I18n.locale(), event['end-date'], root.dateFormat); + else + endDate = new Date(startDate); + + let startTime = new Date(startDate); + let endTime = new Date(endDate); + if (event['start-time'] && event['all-day'] !== "True") { + let timeStr = event['start-time']; + if (timeStr) { + let timeParts = timeStr.match(/(\d+):(\d+)(?::\d+)?\s*(AM|PM)?/i); + if (timeParts) { + let hours = parseInt(timeParts[1]); + let minutes = parseInt(timeParts[2]); + if (timeParts[3]) { + let period = timeParts[3].toUpperCase(); + if (period === 'PM' && hours !== 12) + hours += 12; + else if (period === 'AM' && hours === 12) + hours = 0; + } + startTime.setHours(hours, minutes); + if (event['end-time']) { + let endTimeParts = event['end-time'].match(/(\d+):(\d+)(?::\d+)?\s*(AM|PM)?/i); + if (endTimeParts) { + let endHours = parseInt(endTimeParts[1]); + let endMinutes = parseInt(endTimeParts[2]); + if (endTimeParts[3]) { + let endPeriod = endTimeParts[3].toUpperCase(); + if (endPeriod === 'PM' && endHours !== 12) + endHours += 12; + else if (endPeriod === 'AM' && endHours === 12) + endHours = 0; + } + endTime.setHours(endHours, endMinutes); + } + } else { + endTime = new Date(startTime); + endTime.setHours(startTime.getHours() + 1); + } + } + } + } + let eventId = event.title + "_" + event['start-date'] + "_" + (event['start-time'] || 'allday'); + let extractedUrl = ""; + if (!event.url && event.description) { + let urlMatch = event.description.match(/https?:\/\/[^\s]+/); + if (urlMatch) + extractedUrl = urlMatch[0]; + } + let eventTemplate = { + "id": eventId, + "title": event.title || "Untitled Event", + "start": startTime, + "end": endTime, + "location": event.location || "", + "description": event.description || "", + "url": event.url || extractedUrl, + "calendar": "", + "color": "", + "allDay": event['all-day'] === "True", + "isMultiDay": startDate.toDateString() !== endDate.toDateString() + }; + let currentDate = new Date(startDate); + while (currentDate <= endDate) { + let dateKey = Qt.formatDate(currentDate, "yyyy-MM-dd"); + if (!newEventsByDate[dateKey]) + newEventsByDate[dateKey] = []; + + let existingEvent = newEventsByDate[dateKey].find(e => e.id === eventId); + if (existingEvent) { + currentDate.setDate(currentDate.getDate() + 1); + continue; + } + let dayEvent = Object.assign({}, eventTemplate); + if (currentDate.getTime() === startDate.getTime()) { + dayEvent.start = new Date(startTime); + } else { + dayEvent.start = new Date(currentDate); + if (!dayEvent.allDay) + dayEvent.start.setHours(0, 0, 0, 0); + } + if (currentDate.getTime() === endDate.getTime()) { + dayEvent.end = new Date(endTime); + } else { + dayEvent.end = new Date(currentDate); + if (!dayEvent.allDay) + dayEvent.end.setHours(23, 59, 59, 999); + } + newEventsByDate[dateKey].push(dayEvent); + currentDate.setDate(currentDate.getDate() + 1); + } + } + } + root.eventsByDate = newEventsByDate; + root.lastError = ""; + } catch (error) { + root.lastError = "Failed to parse events JSON: " + error.toString(); + root.eventsByDate = {}; + } + eventsProcess.rawOutput = ""; + } + + stdout: SplitParser { + splitMarker: "\n" + onRead: data => { + eventsProcess.rawOutput += data + "\n"; + } + } + } +} diff --git a/quickshell/Services/CalendarService.qml b/quickshell/Services/CalendarService.qml index da8eeba0..85ce1599 100644 --- a/quickshell/Services/CalendarService.qml +++ b/quickshell/Services/CalendarService.qml @@ -11,71 +11,87 @@ Singleton { id: root readonly property var log: Log.scoped("CalendarService") - property bool khalAvailable: true // Always true to enable DMS calendar card UI - property bool khalInstalled: false // Tracks if khal is actually on the system + readonly property string backendPref: SettingsData.calendarBackend + readonly property string activeBackend: { + switch (backendPref) { + case "khal": + return "khal"; + case "dankcal": + return "dankcal"; + default: + if (dankBackend.connected) + return "dankcal"; + if (khalBackend.installed) + return "khal"; + return "none"; + } + } + + readonly property bool calendarAvailable: activeBackend !== "none" + readonly property bool isDankActive: activeBackend === "dankcal" + readonly property bool canCreateEvents: isDankActive && dankBackend.connected + property bool khalAvailable: true // compatibility alias - calendar card UI gate + + readonly property bool dankConnected: dankBackend.connected + readonly property bool dankBinaryExists: dankBackend.binaryExists + readonly property bool dankNeedsLaunch: backendPref === "dankcal" && !dankBackend.connected + + property var calendars: dankBackend.calendars property var eventsByDate: ({}) - property var khalEventsByDate: ({}) property var taskEventsByDate: ({}) property var localTasks: ({}) - property bool isLoading: false + property bool isLoading: khalBackend.isLoading property string lastError: "" + + property bool _rangeSet: false property date lastStartDate property date lastEndDate - property string khalDateFormat: "MM/dd/yyyy" - onKhalEventsByDateChanged: mergeEvents() onTaskEventsByDateChanged: mergeEvents() - - function checkKhalAvailability() { - if (!khalCheckProcess.running) - khalCheckProcess.running = true; + onActiveBackendChanged: { + mergeEvents(); + if (_rangeSet) + loadEvents(lastStartDate, lastEndDate); } - function detectKhalDateFormat() { - if (!khalFormatProcess.running) - khalFormatProcess.running = true; + CalendarKhalBackend { + id: khalBackend + onEventsByDateChanged: root.mergeEvents() } - function parseKhalDateFormat(formatExample) { - let qtFormat = formatExample.replace("12", "MM").replace("21", "dd").replace("2013", "yyyy"); - return { - format: qtFormat, - parser: null - }; - } - - function loadCurrentMonth() { - if (!root.khalAvailable) - return; - let today = new Date(); - let firstDay = new Date(today.getFullYear(), today.getMonth(), 1); - let lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); - // Add padding - let startDate = new Date(firstDay); - startDate.setDate(startDate.getDate() - firstDay.getDay() - 7); - let endDate = new Date(lastDay); - endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()) + 7); - loadEvents(startDate, endDate); + CalendarDankBackend { + id: dankBackend + enabled: root.backendPref === "dankcal" || root.backendPref === "auto" + onEventsByDateChanged: root.mergeEvents() + onConnectedChanged: { + if (connected && root._rangeSet) + root.loadEvents(root.lastStartDate, root.lastEndDate); + } } function loadEvents(startDate, endDate) { - if (!root.khalInstalled) { - return; - } - if (eventsProcess.running) { - return; - } - // Store last requested date range for refresh timer root.lastStartDate = startDate; root.lastEndDate = endDate; - root.isLoading = true; - // Format dates for khal using detected format - let startDateStr = Qt.formatDate(startDate, root.khalDateFormat); - let endDateStr = Qt.formatDate(endDate, root.khalDateFormat); - eventsProcess.requestStartDate = startDate; - eventsProcess.requestEndDate = endDate; - eventsProcess.command = ["khal", "list", "--json", "title", "--json", "description", "--json", "start-date", "--json", "start-time", "--json", "end-date", "--json", "end-time", "--json", "all-day", "--json", "location", "--json", "url", startDateStr, endDateStr]; - eventsProcess.running = true; + root._rangeSet = true; + switch (activeBackend) { + case "dankcal": + dankBackend.loadEvents(startDate, endDate); + break; + case "khal": + khalBackend.loadEvents(startDate, endDate); + break; + } + } + + function _activeBackendEventsByDate() { + switch (activeBackend) { + case "dankcal": + return dankBackend.eventsByDate; + case "khal": + return khalBackend.eventsByDate; + default: + return {}; + } } function getEventsForDate(date) { @@ -84,11 +100,54 @@ Singleton { } function hasEventsForDate(date) { - let events = getEventsForDate(date); - return events.length > 0; + return getEventsForDate(date).length > 0; + } + + function writableCalendars() { + return isDankActive ? dankBackend.writableCalendars() : []; + } + + function defaultCalendar() { + return isDankActive ? dankBackend.defaultCalendar() : null; + } + + function launchDankCalendar() { + dankBackend.launch(); + } + + function createEvent(fields, callback) { + if (isDankActive) { + dankBackend.createEvent(fields, callback); + return; + } + if (callback) + callback({ + "error": "read-only backend" + }); + } + + function updateEvent(id, fields, callback) { + if (isDankActive) { + dankBackend.updateEvent(id, fields, callback); + return; + } + if (callback) + callback({ + "error": "read-only backend" + }); + } + + function deleteEvent(id, callback) { + if (isDankActive) { + dankBackend.deleteEvent(id, callback); + return; + } + if (callback) + callback({ + "error": "read-only backend" + }); } - // In-memory Task CRUD methods function loadTasks(text) { if (!text || text.trim() === "") { root.localTasks = {}; @@ -129,8 +188,7 @@ Singleton { "description": "Task from your Planner", "url": "", "calendar": "Todo Planner", - "color": "#10B981" // Pastel Green - , + "color": "#10B981", "allDay": true, "isMultiDay": false }); @@ -142,9 +200,8 @@ Singleton { function addTaskForDate(date, text) { let dateKey = Qt.formatDate(date, "yyyy-MM-dd"); let tasks = Object.assign({}, root.localTasks); - if (!tasks[dateKey]) { + if (!tasks[dateKey]) tasks[dateKey] = []; - } let taskId = (new Date().getTime()) + "-dms"; tasks[dateKey].push({ "id": taskId, @@ -187,11 +244,10 @@ Singleton { let list = tasks[dateKey]; let filtered = list.filter(item => item.id !== cleanId); if (filtered.length !== list.length) { - if (filtered.length === 0) { + if (filtered.length === 0) delete tasks[dateKey]; - } else { + else tasks[dateKey] = filtered; - } updated = true; break; } @@ -208,20 +264,17 @@ Singleton { let tasks = Object.assign({}, root.localTasks); let v = tasks[dateKey] || []; let idToItem = {}; - for (let item of v) { + for (let item of v) idToItem[item.id] = item; - } let newV = []; for (let tid of orderedIds) { - if (idToItem[tid]) { + if (idToItem[tid]) newV.push(idToItem[tid]); - } } let orderedSet = new Set(orderedIds); for (let item of v) { - if (!orderedSet.has(item.id)) { + if (!orderedSet.has(item.id)) newV.push(item); - } } tasks[dateKey] = newV; root.localTasks = tasks; @@ -254,30 +307,24 @@ Singleton { function mergeEvents() { let merged = {}; + let backendEvents = _activeBackendEventsByDate(); - // Merge khal events - for (let dateKey in root.khalEventsByDate) { - merged[dateKey] = [].concat(root.khalEventsByDate[dateKey]); - } + for (let dateKey in backendEvents) + merged[dateKey] = [].concat(backendEvents[dateKey]); - // Merge task events for (let dateKey in root.taskEventsByDate) { - if (!merged[dateKey]) { + if (!merged[dateKey]) merged[dateKey] = []; - } for (let event of root.taskEventsByDate[dateKey]) { - if (!merged[dateKey].some(e => e.id === event.id)) { + if (!merged[dateKey].some(e => e.id === event.id)) merged[dateKey].push(event); - } } } - // Sort events within each date for (let dateKey in merged) { let list = merged[dateKey]; - for (let idx = 0; idx < list.length; idx++) { + for (let idx = 0; idx < list.length; idx++) list[idx]._origIdx = idx; - } list.sort((a, b) => { let diff = a.start.getTime() - b.start.getTime(); if (diff !== 0) @@ -289,12 +336,6 @@ Singleton { root.eventsByDate = merged; } - // Initialize on component completion - Component.onCompleted: { - detectKhalDateFormat(); - } - - // Atomic file view for tasks FileView { id: tasksFileView path: Quickshell.env("HOME") + "/.config/niri-calendar-todo/tasks.json" @@ -304,233 +345,11 @@ Singleton { watchChanges: true printErrors: false - onLoaded: { - loadTasks(tasksFileView.text()); - } + onLoaded: loadTasks(tasksFileView.text()) onLoadFailed: { root.localTasks = {}; root.taskEventsByDate = {}; } } - - // Process for detecting khal date format - Process { - id: khalFormatProcess - - command: ["khal", "printformats"] - running: false - onExited: exitCode => { - if (exitCode !== 0) { - checkKhalAvailability(); - } - } - - stdout: StdioCollector { - onStreamFinished: { - let lines = text.split('\n'); - for (let line of lines) { - if (line.startsWith('dateformat:')) { - let formatExample = line.substring(line.indexOf(':') + 1).trim(); - let formatInfo = parseKhalDateFormat(formatExample); - root.khalDateFormat = formatInfo.format; - break; - } - } - checkKhalAvailability(); - } - } - } - - // Process for checking khal configuration - Process { - id: khalCheckProcess - - command: ["khal", "list", "today"] - running: false - onExited: exitCode => { - root.khalInstalled = (exitCode === 0); - if (root.khalInstalled) { - loadCurrentMonth(); - } else { - loadEvents(root.lastStartDate || new Date(), root.lastEndDate || new Date()); - } - } - } - - // Process for loading events - Process { - id: eventsProcess - - property date requestStartDate - property date requestEndDate - property string rawOutput: "" - - running: false - onExited: exitCode => { - root.isLoading = false; - if (exitCode !== 0) { - root.lastError = "Failed to load events (exit code: " + exitCode + ")"; - return; - } - try { - let newEventsByDate = {}; - let lines = eventsProcess.rawOutput.split('\n'); - for (let line of lines) { - line = line.trim(); - if (!line || line === "[]") - continue; - - // Parse JSON line - let dayEvents = JSON.parse(line); - // Process each event in this day's array - for (let event of dayEvents) { - if (!event.title) - continue; - - // Parse start and end dates using detected format - let startDate, endDate; - if (event['start-date']) { - startDate = Date.fromLocaleString(I18n.locale(), event['start-date'], root.khalDateFormat); - } else { - startDate = new Date(); - } - if (event['end-date']) { - endDate = Date.fromLocaleString(I18n.locale(), event['end-date'], root.khalDateFormat); - } else { - endDate = new Date(startDate); - } - // Create start/end times - let startTime = new Date(startDate); - let endTime = new Date(endDate); - if (event['start-time'] && event['all-day'] !== "True") { - // Parse time if available and not all-day - let timeStr = event['start-time']; - if (timeStr) { - // Match time with optional seconds and AM/PM - let timeParts = timeStr.match(/(\d+):(\d+)(?::\d+)?\s*(AM|PM)?/i); - if (timeParts) { - let hours = parseInt(timeParts[1]); - let minutes = parseInt(timeParts[2]); - - // Handle AM/PM conversion if present - if (timeParts[3]) { - let period = timeParts[3].toUpperCase(); - if (period === 'PM' && hours !== 12) { - hours += 12; - } else if (period === 'AM' && hours === 12) { - hours = 0; - } - } - - startTime.setHours(hours, minutes); - if (event['end-time']) { - let endTimeParts = event['end-time'].match(/(\d+):(\d+)(?::\d+)?\s*(AM|PM)?/i); - if (endTimeParts) { - let endHours = parseInt(endTimeParts[1]); - let endMinutes = parseInt(endTimeParts[2]); - - // Handle AM/PM conversion if present - if (endTimeParts[3]) { - let endPeriod = endTimeParts[3].toUpperCase(); - if (endPeriod === 'PM' && endHours !== 12) { - endHours += 12; - } else if (endPeriod === 'AM' && endHours === 12) { - endHours = 0; - } - } - - endTime.setHours(endHours, endMinutes); - } - } else { - // Default to 1 hour duration on same day - endTime = new Date(startTime); - endTime.setHours(startTime.getHours() + 1); - } - } - } - } - // Create unique ID for this event (to track multi-day events) - let eventId = event.title + "_" + event['start-date'] + "_" + (event['start-time'] || 'allday'); - // Create event object template - let extractedUrl = ""; - if (!event.url && event.description) { - let urlMatch = event.description.match(/https?:\/\/[^\s]+/); - if (urlMatch) { - extractedUrl = urlMatch[0]; - } - } - let eventTemplate = { - "id": eventId, - "title": event.title || "Untitled Event", - "start": startTime, - "end": endTime, - "location": event.location || "", - "description": event.description || "", - "url": event.url || extractedUrl, - "calendar": "", - "color": "", - "allDay": event['all-day'] === "True", - "isMultiDay": startDate.toDateString() !== endDate.toDateString() - }; - // Add event to each day it spans - let currentDate = new Date(startDate); - while (currentDate <= endDate) { - let dateKey = Qt.formatDate(currentDate, "yyyy-MM-dd"); - if (!newEventsByDate[dateKey]) - newEventsByDate[dateKey] = []; - - // Check if this exact event is already added to this date (prevent duplicates) - let existingEvent = newEventsByDate[dateKey].find(e => { - return e.id === eventId; - }); - if (existingEvent) { - // Move to next day without adding duplicate - currentDate.setDate(currentDate.getDate() + 1); - continue; - } - // Create a copy of the event for this date - let dayEvent = Object.assign({}, eventTemplate); - // For multi-day events, adjust the display time for this specific day - if (currentDate.getTime() === startDate.getTime()) { - // First day - use original start time - dayEvent.start = new Date(startTime); - } else { - // Subsequent days - start at beginning of day for all-day events - dayEvent.start = new Date(currentDate); - if (!dayEvent.allDay) - dayEvent.start.setHours(0, 0, 0, 0); - } - if (currentDate.getTime() === endDate.getTime()) { - // Last day - use original end time - dayEvent.end = new Date(endTime); - } else { - // Earlier days - end at end of day for all-day events - dayEvent.end = new Date(currentDate); - if (!dayEvent.allDay) - dayEvent.end.setHours(23, 59, 59, 999); - } - newEventsByDate[dateKey].push(dayEvent); - // Move to next day - currentDate.setDate(currentDate.getDate() + 1); - } - } - } - root.khalEventsByDate = newEventsByDate; - root.lastError = ""; - } catch (error) { - root.lastError = "Failed to parse events JSON: " + error.toString(); - root.khalEventsByDate = {}; - } - // Reset for next run - eventsProcess.rawOutput = ""; - } - - stdout: SplitParser { - splitMarker: "\n" - onRead: data => { - eventsProcess.rawOutput += data + "\n"; - } - } - } } diff --git a/quickshell/Widgets/DankTextField.qml b/quickshell/Widgets/DankTextField.qml index d56983bb..50cb3428 100644 --- a/quickshell/Widgets/DankTextField.qml +++ b/quickshell/Widgets/DankTextField.qml @@ -23,6 +23,7 @@ StyledRect { property alias text: textInput.text property string placeholderText: "" + property string labelText: "" property alias font: textInput.font property alias textColor: textInput.color property alias echoMode: textInput.echoMode @@ -85,8 +86,10 @@ StyledRect { textInput.insert(textInput.cursorPosition, str); } + readonly property real labelBandHeight: Math.round(Theme.fontSizeSmall * 1.4) + Theme.spacingXS * 2 + width: 200 - height: Math.round(Theme.fontSizeMedium * 3) + height: labelText !== "" ? Math.round(Theme.fontSizeMedium * 3) + labelBandHeight : Math.round(Theme.fontSizeMedium * 3) radius: cornerRadius color: backgroundColor border.color: textInput.activeFocus ? focusedBorderColor : normalBorderColor @@ -97,13 +100,27 @@ StyledRect { anchors.left: parent.left anchors.leftMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenter: textInput.verticalCenter name: leftIconName size: leftIconSize color: textInput.activeFocus ? leftIconFocusedColor : leftIconColor visible: leftIconName !== "" } + StyledText { + id: fieldLabel + + anchors.left: textInput.left + anchors.right: textInput.right + anchors.top: parent.top + anchors.topMargin: Theme.spacingXS + text: root.labelText + visible: root.labelText !== "" + font.pixelSize: Theme.fontSizeSmall + color: textInput.activeFocus ? Theme.primary : Theme.surfaceVariantText + elide: Text.ElideRight + } + TextInput { id: textInput @@ -112,7 +129,7 @@ StyledRect { anchors.right: rightButtonsRow.left anchors.rightMargin: rightButtonsRow.visible ? Theme.spacingS : Theme.spacingM anchors.top: parent.top - anchors.topMargin: root.topPadding + anchors.topMargin: root.labelText !== "" ? root.labelBandHeight : root.topPadding anchors.bottom: parent.bottom anchors.bottomMargin: root.bottomPadding font.pixelSize: Theme.fontSizeMedium