diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 8e747f5bb..19d03819b 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -377,6 +377,9 @@ Singleton { property bool updaterUseCustomCommand: false property string updaterCustomCommand: "" property string updaterTerminalAdditionalParams: "" + property bool updaterShowLatestNews: false + property string updaterLatestNewsUrl: "" + property string updaterLatestNewsRegex: "" property string displayNameMode: "system" property var screenPreferences: ({}) diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 060a681e1..021d18024 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -266,6 +266,9 @@ var SPEC = { updaterUseCustomCommand: { def: false }, updaterCustomCommand: { def: "" }, updaterTerminalAdditionalParams: { def: "" }, + updaterShowLatestNews: { def: false }, + updaterLatestNewsUrl: { def: "" }, + updaterLatestNewsRegex: { def: "" }, displayNameMode: { def: "system" }, screenPreferences: { def: {} }, diff --git a/quickshell/Modules/Settings/SystemUpdaterTab.qml b/quickshell/Modules/Settings/SystemUpdaterTab.qml index c1a554417..ab8ca9cc6 100644 --- a/quickshell/Modules/Settings/SystemUpdaterTab.qml +++ b/quickshell/Modules/Settings/SystemUpdaterTab.qml @@ -138,6 +138,115 @@ Item { } } } + + SettingsToggleRow { + text: I18n.tr("Show Latest News") + description: I18n.tr("Show your distro's latest news") + checked: SettingsData.updaterShowLatestNews + onToggled: checked => { + SettingsData.set("updaterShowLatestNews", checked); + } + } + + FocusScope { + width: parent.width - Theme.spacingM * 2 + height: latestNewsUrlColumn.implicitHeight + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + + Column { + id: latestNewsUrlColumn + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Custom feed to parse") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + id: updaterLatestNewsFeed + width: parent.width + height: 48 + placeholderText: "https://archlinux.org/feeds/news/" + backgroundColor: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) + normalBorderColor: Theme.outlineMedium + focusedBorderColor: Theme.primary + + Component.onCompleted: { + if (SettingsData.updaterLatestNewsUrl) { + text = SettingsData.updaterLatestNewsUrl; + } + } + + onTextEdited: SettingsData.set("updaterLatestNewsUrl", text.trim()) + + MouseArea { + anchors.fill: parent + onPressed: mouse => { + updaterLatestNewsFeed.forceActiveFocus(); + mouse.accepted = false; + } + } + } + } + } + + FocusScope { + width: parent.width - Theme.spacingM * 2 + implicitHeight: latestNewsRegexColumn.implicitHeight + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + + Column { + id: latestNewsRegexColumn + width: parent.width + spacing: Theme.spacingXS + + StyledText { + width: parent.width + text: I18n.tr("Custom feed regex") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + id: updaterLatestNewsRegex + width: parent.width + height: 48 + placeholderText: "\s*([^<]+)<\/title>\s*<link>([^<]+)<\/link>\s*<description>([\s\S]*?)<\/description>[\s\S]*?<pubDate>([^<]+)<\/pubDate>" + backgroundColor: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) + normalBorderColor: Theme.outlineMedium + focusedBorderColor: Theme.primary + + Component.onCompleted: { + if (SettingsData.updaterLatestNewsRegex) { + text = SettingsData.updaterLatestNewsRegex; + } + } + + onTextEdited: SettingsData.set("updaterLatestNewsRegex", text.trim()) + + MouseArea { + anchors.fill: parent + onPressed: mouse => { + updaterLatestNewsRegex.forceActiveFocus(); + mouse.accepted = false; + } + } + } + + StyledText { + width: parent.width + text: I18n.tr("Don't include the regex delimeters and flags. It will use a global flag by default. It must produce 4 matches in this exact order: title, description, link, pubDate") + font.pixelSize: Theme.fontSizeSmall + font.italic: true + color: Theme.surfaceVariantText + opacity: 0.7 + } + } + } } } } diff --git a/quickshell/Modules/SystemUpdatePopout.qml b/quickshell/Modules/SystemUpdatePopout.qml index 2836a764f..dc3a58cee 100644 --- a/quickshell/Modules/SystemUpdatePopout.qml +++ b/quickshell/Modules/SystemUpdatePopout.qml @@ -41,6 +41,9 @@ DankPopout { antialiasing: true smooth: true + property bool newsExpanded: false + + // Background layers Repeater { model: [ { @@ -77,12 +80,13 @@ DankPopout { y: Theme.spacingL spacing: Theme.spacingL + // Header Item { width: parent.width height: 40 StyledText { - text: I18n.tr("System Updates") + text: updaterPanel.newsExpanded ? I18n.tr("Latest News") : I18n.tr("System Updates") font.pixelSize: Theme.fontSizeLarge color: Theme.surfaceText font.weight: Font.Medium @@ -95,8 +99,27 @@ DankPopout { anchors.verticalCenter: parent.verticalCenter spacing: Theme.spacingXS + DankActionButton { + id: backToUpdatesButton + visible: updaterPanel.newsExpanded + opacity: visible ? 1.0 : 0.0 + buttonSize: 28 + iconName: "arrow_back" + iconSize: 18 + iconColor: Theme.primary + onClicked: { + updaterPanel.newsExpanded = false + } + + Behavior on opacity { + NumberAnimation { duration: Theme.shortDuration } + } + } + StyledText { anchors.verticalCenter: parent.verticalCenter + visible: !updaterPanel.newsExpanded + opacity: visible ? 1.0 : 0.0 text: { if (SystemUpdateService.isChecking) return "Checking..."; @@ -112,10 +135,15 @@ DankPopout { return Theme.error; return Theme.surfaceText; } + + Behavior on opacity { + NumberAnimation { duration: Theme.shortDuration } + } } DankActionButton { id: checkForUpdatesButton + visible: !updaterPanel.newsExpanded buttonSize: 28 iconName: "refresh" iconSize: 18 @@ -127,6 +155,10 @@ DankPopout { SystemUpdateService.checkForUpdates(); } + Behavior on opacity { + NumberAnimation { duration: Theme.shortDuration } + } + RotationAnimation { target: checkForUpdatesButton property: "rotation" @@ -146,11 +178,13 @@ DankPopout { } } + // Main content Rectangle { width: parent.width height: { let usedHeight = 40 + Theme.spacingL; usedHeight += 48 + Theme.spacingL; + usedHeight += latestNewsTicker.shouldShow ? latestNewsTicker.height + Theme.spacingL : 0; return parent.height - usedHeight; } radius: Theme.cornerRadius @@ -158,10 +192,21 @@ DankPopout { border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05) border.width: 0 + // Updates view Column { anchors.fill: parent anchors.margins: Theme.spacingM anchors.rightMargin: 0 + opacity: updaterPanel.newsExpanded ? 0 : 1 + enabled: opacity === 1 // Only interactive when fully visible + visible: true + + Behavior on opacity { + NumberAnimation { + duration: Theme.longDuration + easing.type: Easing.InOutQuad + } + } StyledText { id: statusText @@ -255,8 +300,250 @@ DankPopout { } } } + + // Latest news view + Column { + anchors.fill: parent + anchors.margins: Theme.spacingM + anchors.rightMargin: 0 + opacity: updaterPanel.newsExpanded ? 1 : 0 + enabled: opacity === 1 + visible: true + + Behavior on opacity { + NumberAnimation { + duration: Theme.longDuration + easing.type: Easing.InOutQuad + } + } + + DankListView { + id: expandedNewsList + width: parent.width + height: parent.height + clip: true + spacing: Theme.spacingM + model: SystemUpdateService.latestNews + boundsBehavior: Flickable.StopAtBounds + + delegate: Rectangle { + id: newsItem + width: ListView.view.width - Theme.spacingS + radius: Theme.cornerRadius + color: newsMouseArea.containsMouse ? Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.15) + border.color: newsItem.expanded ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) + border.width: 1 + + property bool expanded: false + + height: newsContentColumn.height + Theme.spacingS * 2 + + Behavior on height { + NumberAnimation { + duration: Theme.longDuration + easing.type: Easing.OutCubic + } + } + + Column { + id: newsContentColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacingS + spacing: Theme.spacingXS + + // News titles + Row { + width: parent.width + spacing: Theme.spacingS + + Column { + width: parent.width - expandIndicator.width + spacing: 2 + + StyledText { + width: parent.width + text: modelData.title || "" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + wrapMode: Text.WordWrap + } + + StyledText { + width: parent.width + text: modelData.pubDate || "" + opacity: 0.7 + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: modelData.pubDate && modelData.pubDate.length > 0 + wrapMode: Text.WordWrap + } + } + + Item { + id: expandIndicator + visible: modelData.description && modelData.description.length > 0 + width: visible ? expandIcon.width : 0 + height: visible ? expandIcon.height : 0 + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + id: expandIcon + name: newsItem.expanded ? "expand_less" : "expand_more" + size: Theme.iconSizeSmall + opacity: 0.7 + } + } + } + + // Separator between titles and description + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.15) + visible: newsItem.expanded && modelData.description && modelData.description.length > 0 + opacity: newsItem.expanded ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Easing.InOutQuad + } + } + } + + // Description + StyledText { + width: parent.width + text: modelData.description || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + visible: modelData.description && modelData.description.length > 0 && newsItem.expanded + opacity: newsItem.expanded ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Easing.InOutQuad + } + } + } + + // Footer with external link + Item { + width: parent.width + visible: newsItem.expanded && modelData.link + height: externalLinkButton.height + + DankActionButton { + id: externalLinkButton + visible: modelData.link && newsItem.expanded + opacity: visible ? 1 : 0 + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + buttonSize: 28 + iconName: "open_in_new" + iconSize: 14 + iconColor: Theme.primary + z: 100 + onClicked: { + Qt.openUrlExternally(modelData.link) + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Easing.InOutQuad + } + } + } + } + } + MouseArea { + id: newsMouseArea + anchors.fill: parent + anchors.rightMargin: newsItem.expanded && externalLinkButton.visible ? externalLinkButton.width + Theme.spacingS : 0 + hoverEnabled: true + cursorShape: modelData.description && modelData.description.length > 0 ? Qt.PointingHandCursor : Qt.ArrowCursor + enabled: modelData.description && modelData.description.length > 0 + onClicked: { + newsItem.expanded = !newsItem.expanded + } + } + } + } + } + } + + // News ticker + Item { + id: latestNewsTicker + width: parent.width + // This smooths the transition of the main content rectangle resizing. + property bool shouldShow: SettingsData.updaterShowLatestNews && !updaterPanel.newsExpanded && SystemUpdateService.latestNews && SystemUpdateService.latestNews.length > 0 + height: shouldShow ? 16 : 0 + opacity: shouldShow ? 0.7 : 0 + + Behavior on opacity { + NumberAnimation { duration: Theme.shortDuration } + } + + MouseArea { + id: tickerMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + updaterPanel.newsExpanded = true + } + } + + Item { + width: parent.width - Theme.spacingM * 2 + height: parent.height + anchors.centerIn: parent + + Row { + width: parent.width + height: parent.height + spacing: Theme.spacingS + + StyledText { + width: parent.width - extendNewsIcon.width - Theme.spacingS + height: parent.height + text: SystemUpdateService.latestNews && SystemUpdateService.latestNews.length > 0 ? SystemUpdateService.latestNews[0].title : "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + opacity: tickerMouseArea.containsMouse ? 1.0 : 0.7 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + + Behavior on opacity { + NumberAnimation { duration: Theme.shortDuration } + } + } + + DankIcon { + id: extendNewsIcon + height: parent.height + width: height + name: "arrow_forward" + size: Theme.iconSizeSmall + color: Theme.primary + opacity: tickerMouseArea.containsMouse ? 1.0 : 0.7 + + Behavior on opacity { + NumberAnimation { duration: Theme.shortDuration } + } + } + } + } } + // Buttons Row { width: parent.width height: 48 diff --git a/quickshell/Services/SystemUpdateService.qml b/quickshell/Services/SystemUpdateService.qml index a767a18bf..1bc3fed79 100644 --- a/quickshell/Services/SystemUpdateService.qml +++ b/quickshell/Services/SystemUpdateService.qml @@ -21,11 +21,40 @@ Singleton { property string shellVersion: "" property string shellCodename: "" property string semverVersion: "" + property var latestNews: [] + property int lastNewsCheckTimestamp: 0 function getParsedShellVersion() { return parseVersion(semverVersion); } + function parseCommonSymbols(text) { + return text.replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/–/g, '–') + .replace(/…/g, '…') + .trim(); + } + + function latestNewsParserProducer(match) { + // Strip HTML tags from description + const cleanTitle = parseCommonSymbols(match[1]); + + let cleanDescription = match[3].replace(/<[^>]*>/g, ''); + cleanDescription = parseCommonSymbols(cleanDescription); + + return { + "title": cleanTitle, + "link": match[2].trim(), + "description": cleanDescription, + "pubDate": match[4] ? match[4].trim() : "" + }; + } + readonly property var archBasedUCSettings: { "listUpdatesSettings": { "params": [], @@ -66,6 +95,43 @@ Singleton { } } + + readonly property var archBasedLatestNewsSettings: { + "url": "https://archlinux.org/feeds/news/", + "parserSettings": { + "lineRegex": /<item>\s*<title>([^<]+)<\/title>\s*<link>([^<]+)<\/link>\s*<description>([\s\S]*?)<\/description>[\s\S]*?<pubDate>([^<]+)<\/pubDate>/g, + "entryProducer": latestNewsParserProducer + } + } + + readonly property var fedoraBasedLatestNewsSettings: { + "url": "https://fedoramagazine.org/feed/", + "parserSettings": { + "lineRegex": /<item>\s*<title>([^<]+)<\/title>\s*<link>([^<]+)<\/link>[\s\S]*?<pubDate>([^<]+)<\/pubDate>[\s\S]*?<description>([\s\S]*?)<\/description>[\s\S]*?/g, + "entryProducer": function(match) { + const cleanTitle = parseCommonSymbols(match[1]); + + let cleanDescription = match[4].replace(/<[^>]*>/g, '').replace("]]>", ''); + cleanDescription = parseCommonSymbols(cleanDescription); + + return { + "title": cleanTitle, + "link": match[2].trim(), + "description": cleanDescription, + "pubDate": match[3] ? match[3].trim() : "" + }; + } + } + } + + readonly property var customLatestNewsSettings: { + "url": SettingsData.updaterLatestNewsUrl, + "parserSettings": { + "lineRegex": RegExp(SettingsData.updaterLatestNewsRegex, "g"), + "entryProducer": latestNewsParserProducer + } + } + readonly property var fedoraBasedPMSettings: { "listUpdatesSettings": { "params": ["list", "--upgrades", "--quiet", "--color=never"], @@ -96,6 +162,15 @@ Singleton { "paru": archBasedPMSettings, "dnf": fedoraBasedPMSettings } + readonly property var latestNewsParserParams: { + "arch": archBasedLatestNewsSettings, + "cachyos": archBasedLatestNewsSettings, + "manjaro": archBasedLatestNewsSettings, + "endeavouros": archBasedLatestNewsSettings, + "fedora": fedoraBasedLatestNewsSettings, + "custom": customLatestNewsSettings + } + readonly property list<string> supportedDistributions: ["arch", "cachyos", "manjaro", "endeavouros", "fedora"] readonly property int updateCount: availableUpdates.length readonly property bool helperAvailable: pkgManager !== "" && distributionSupported @@ -223,6 +298,46 @@ Singleton { } } + Process { + id: latestNewsChecker + command: ["curl", SettingsData.updaterLatestNewsUrl || latestNewsParserParams[distribution].url] + onExited: exitCode => { + const correctExitCodes = [0]; + if (correctExitCodes.includes(exitCode)) { + lastNewsCheckTimestamp = Date.now(); + parseLatestNews(stdout.text); + } else { + console.warn("SystemUpdate: Failed downloading the latest news feed."); + } + } + + stdout: StdioCollector {} + } + + + function parseLatestNews(feed) { + const isCustom = !!SettingsData.updaterLatestNewsUrl && !!SettingsData.updaterLatestNewsRegex; + const parserParams = isCustom ? latestNewsParserParams["custom"] : latestNewsParserParams[distribution]; + const regex = parserParams.parserSettings.lineRegex; + const entryProducer = parserParams.parserSettings.entryProducer; + + const news = []; + let match; + + // Use exec() in a loop to get all matches with global regex + while ((match = regex.exec(feed)) !== null) { + news.push(entryProducer(match)); + } + + if (news.length === 0) { + console.log("SystemUpdate: Empty latest news feed."); + return; + } + + latestNews = news; + console.log(`SystemUpdate: Found ${news.length} news items`); + } + function checkForUpdates() { if (!distributionSupported || (!pkgManager && !updChecker) || isChecking) return; @@ -234,6 +349,15 @@ Singleton { updateChecker.command = [pkgManager].concat(packageManagerParams[pkgManager].listUpdatesSettings.params); } updateChecker.running = true; + + if (SettingsData.updaterShowLatestNews && Date.now() > lastNewsCheckTimestamp + 10) { + if (!latestNewsParserParams[distribution] && (SettingsData.updaterLatestNewsUrl === "" || SettingsData.updaterLatestNewsRegex === "")) { + console.log(`SystemUpdate: ${distribution} latest news not supported by default. Add a custom regex and feed.`); + return; + } + + latestNewsChecker.running = true; + } } function parseUpdates(output) { diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index fa9d8c327..7c8c770da 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -3185,7 +3185,10 @@ "updater", "updates", "upgrade", - "widget" + "widget", + "news", + "feed", + "rss" ], "icon": "refresh", "description": "When updater widget is used, then hide it if no update found"