diff --git a/AppShell.qml b/AppShell.qml index 4f7d6b4..a372f7b 100644 --- a/AppShell.qml +++ b/AppShell.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Io +import Clavis.Weather 1.0 import Clavis.WeatherMap 1.0 import qs.Modules.Bar import qs.Modules.Keystone @@ -14,12 +15,47 @@ import qs.Services Item { id: root + function sendReloadNotification(success, errorString) { + const command = [ + "notify-send", + "--app-name=Clavis", + "--icon=org.quickshell", + "--urgency=" + (success ? "low" : "critical"), + "--expire-time=" + (success ? "4000" : "10000") + ]; + + if (success) + command.push("--transient"); + + command.push( + success ? qsTr("Configuration reloaded") + : qsTr("Configuration reload failed"), + success ? qsTr("Clavis is running with the latest configuration.") + : (errorString || qsTr("Unknown reload error")) + ); + Quickshell.execDetached(command); + } + Component.onCompleted: { I18nService.initialize(); WallpaperService.primaryInstance = true; AwwwWallpaperService.primaryInstance = true; } + Connections { + target: Quickshell + + function onReloadCompleted() { + Quickshell.inhibitReloadPopup(); + root.sendReloadNotification(true, ""); + } + + function onReloadFailed(errorString) { + Quickshell.inhibitReloadPopup(); + root.sendReloadNotification(false, errorString); + } + } + WallpaperBackground {} Bar {} @@ -134,6 +170,30 @@ Item { } } + IpcHandler { + target: "weather" + + function setLocation(latitude: string, longitude: string, + name: string): string { + const parsedLatitude = Number(latitude); + const parsedLongitude = Number(longitude); + if (!isFinite(parsedLatitude) || !isFinite(parsedLongitude) + || parsedLatitude < -90 || parsedLatitude > 90 + || parsedLongitude < -180 || parsedLongitude > 180) { + return "INVALID_LOCATION"; + } + + WeatherPlugin.setManualLocation( + parsedLatitude, parsedLongitude, name || ""); + return "OK"; + } + + function clearLocation(): string { + WeatherPlugin.clearManualLocation(); + return "OK"; + } + } + IpcHandler { target: "weather-map" diff --git a/Common/functions/WeatherFormat.js b/Common/functions/WeatherFormat.js new file mode 100644 index 0000000..e98a33c --- /dev/null +++ b/Common/functions/WeatherFormat.js @@ -0,0 +1,32 @@ +.pragma library + +function textForCode(code, fallback) { + const normalized = Number(code) + + if (normalized === 0) return "晴" + if (normalized === 1) return "晴间多云" + if (normalized === 2) return "多云" + if (normalized === 3) return "阴" + if (normalized === 45) return "雾" + if (normalized === 48) return "雾凇" + if (normalized >= 51 && normalized <= 55) return "毛毛雨" + if (normalized === 56 || normalized === 57) return "冻毛毛雨" + if (normalized === 61) return "小雨" + if (normalized === 63) return "中雨" + if (normalized === 65) return "大雨" + if (normalized === 66 || normalized === 67) return "冻雨" + if (normalized === 71) return "小雪" + if (normalized === 73) return "中雪" + if (normalized === 75) return "大雪" + if (normalized === 77) return "米雪" + if (normalized === 80) return "阵雨" + if (normalized === 81) return "较强阵雨" + if (normalized === 82) return "强阵雨" + if (normalized === 85) return "阵雪" + if (normalized === 86) return "强阵雪" + if (normalized === 95) return "雷暴" + if (normalized === 96 || normalized === 99) return "雷暴伴冰雹" + + const fallbackText = String(fallback || "").trim() + return fallbackText.length > 0 && fallbackText !== "Unknown" ? fallbackText : "未知" +} diff --git a/Modules/ControlCenter/ControlCenterWindow.qml b/Modules/ControlCenter/ControlCenterWindow.qml index 2126c85..2578f3e 100644 --- a/Modules/ControlCenter/ControlCenterWindow.qml +++ b/Modules/ControlCenter/ControlCenterWindow.qml @@ -25,7 +25,6 @@ FloatingWindow { Component.onCompleted: I18nService.initialize() property real contentPadding: 8 - property int currentPage: 0 property bool navExpanded: width > 900 readonly property var pages: [ ({ "id": "account", "title": qsTr("账户"), "icon": "account_circle", "source": "AccountPage.qml" }), @@ -36,6 +35,13 @@ FloatingWindow { ({ "id": "weather", "title": qsTr("天气"), "icon": "partly_cloudy_day", "source": "WeatherPage.qml" }), ({ "id": "advanced", "title": qsTr("高级"), "icon": "tune", "source": "AdvancedPage.qml" }) ] + property int currentPage: { + const requestedPage = Quickshell.env( + "CLAVIS_CONTROL_CENTER_PAGE"); + const requestedIndex = pages.findIndex( + page => page.id === requestedPage); + return Math.max(0, requestedIndex); + } function pageSource(index) { if (index < 0 || index >= pages.length) diff --git a/Modules/ControlCenter/WeatherApiSettings.qml b/Modules/ControlCenter/WeatherApiSettings.qml index de4afaf..5012a24 100644 --- a/Modules/ControlCenter/WeatherApiSettings.qml +++ b/Modules/ControlCenter/WeatherApiSettings.qml @@ -24,12 +24,76 @@ StyledFlickable { property string feedbackText: "" property bool feedbackError: false property string selectedMapMode: "temp" + property bool locationFieldsInitialized: false + property string locationFeedbackText: "" + property bool locationFeedbackError: false Component.onCompleted: { + root.initializeLocationFields() if (!WeatherPlugin.hasValidData && !WeatherPlugin.loading) WeatherPlugin.refresh() } + function initializeLocationFields() { + if (!WeatherPlugin.hasValidData) + return + + locationNameField.text = WeatherPlugin.locationName || "" + latitudeField.text = Number(WeatherPlugin.latitude).toFixed(6) + longitudeField.text = Number(WeatherPlugin.longitude).toFixed(6) + locationFieldsInitialized = true + } + + function notifyMainWeather(method, argumentsList) { + const command = [ + "qs", + "--path", + Paths.shellDir + "/shell.qml", + "ipc", + "call", + "weather", + method + ] + for (const argument of argumentsList || []) + command.push(String(argument)) + Quickshell.execDetached(command) + } + + function saveLocation() { + const latitude = Number(latitudeField.text.trim()) + const longitude = Number(longitudeField.text.trim()) + if (!isFinite(latitude) || latitude < -90 || latitude > 90) { + locationFeedbackError = true + locationFeedbackText = qsTr("纬度必须介于 -90 和 90 之间") + latitudeField.forceActiveFocus() + return + } + if (!isFinite(longitude) || longitude < -180 || longitude > 180) { + locationFeedbackError = true + locationFeedbackText = qsTr("经度必须介于 -180 和 180 之间") + longitudeField.forceActiveFocus() + return + } + + const locationName = locationNameField.text.trim() + || qsTr("手动位置") + WeatherPlugin.setManualLocation(latitude, longitude, locationName) + root.notifyMainWeather("setLocation", [ + latitude, longitude, locationName + ]) + locationFeedbackError = false + locationFeedbackText = qsTr("位置已保存,正在刷新天气") + locationFieldsInitialized = true + } + + function clearLocation() { + WeatherPlugin.clearManualLocation() + root.notifyMainWeather("clearLocation", []) + locationFeedbackError = false + locationFeedbackText = qsTr("已恢复自动定位,正在刷新天气") + locationFieldsInitialized = false + } + function applyApiKey() { const value = apiKeyField.text.trim() if (value.length < 16) { @@ -62,6 +126,15 @@ StyledFlickable { ]) } + Connections { + target: WeatherPlugin + + function onDataChanged() { + if (!root.locationFieldsInitialized) + root.initializeLocationFields() + } + } + Connections { target: WeatherMapPlugin @@ -91,6 +164,143 @@ StyledFlickable { y: 28 spacing: 24 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: locationContent.implicitHeight + 48 + radius: Appearance.rounding.large + color: Appearance.colors.colSurfaceContainer + + ColumnLayout { + id: locationContent + + anchors.fill: parent + anchors.margins: 24 + spacing: 16 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Rectangle { + Layout.preferredWidth: 44 + Layout.preferredHeight: 44 + radius: Appearance.rounding.full + color: Appearance.colors.colPrimaryContainer + + MaterialSymbol { + anchors.centerIn: parent + text: "edit_location_alt" + iconSize: 22 + fill: 1 + color: Appearance.colors.colOnPrimaryContainer + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + Layout.fillWidth: true + text: qsTr("天气位置") + color: Appearance.colors.colOnSurface + font.family: Sizes.fontFamily + font.pixelSize: 16 + font.weight: Font.Medium + textFormat: Text.PlainText + } + + Text { + Layout.fillWidth: true + text: WeatherPlugin.hasManualLocation + ? qsTr("使用手动位置") + : qsTr("使用网络自动定位") + color: Appearance.colors.colOnSurfaceVariant + font.family: Sizes.fontFamily + font.pixelSize: 12 + textFormat: Text.PlainText + } + } + } + + MaterialTextField { + id: locationNameField + + Layout.fillWidth: true + placeholderText: qsTr("位置名称") + maximumLength: 96 + Material.containerStyle: Material.Outlined + onAccepted: root.saveLocation() + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + MaterialTextField { + id: latitudeField + + Layout.fillWidth: true + placeholderText: qsTr("纬度") + inputMethodHints: Qt.ImhFormattedNumbersOnly + validator: DoubleValidator { + bottom: -90 + top: 90 + decimals: 8 + } + Material.containerStyle: Material.Outlined + onAccepted: root.saveLocation() + } + + MaterialTextField { + id: longitudeField + + Layout.fillWidth: true + placeholderText: qsTr("经度") + inputMethodHints: Qt.ImhFormattedNumbersOnly + validator: DoubleValidator { + bottom: -180 + top: 180 + decimals: 8 + } + Material.containerStyle: Material.Outlined + onAccepted: root.saveLocation() + } + } + + InlineStatusBanner { + Layout.fillWidth: true + visible: root.locationFeedbackText !== "" + tone: root.locationFeedbackError ? "error" : "success" + message: root.locationFeedbackText + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Item { Layout.fillWidth: true } + + Button { + text: qsTr("恢复自动定位") + flat: true + enabled: WeatherPlugin.hasManualLocation + onClicked: root.clearLocation() + } + + Button { + text: qsTr("保存位置") + highlighted: true + enabled: latitudeField.acceptableInput + && longitudeField.acceptableInput + Material.background: Appearance.colors.colPrimary + Material.foreground: Appearance.colors.colOnPrimary + onClicked: root.saveLocation() + } + } + } + } + WeatherMapCard { id: weatherMap diff --git a/Modules/FilePicker/FilePickerWindow.qml b/Modules/FilePicker/FilePickerWindow.qml index aeeab27..1f724e9 100644 --- a/Modules/FilePicker/FilePickerWindow.qml +++ b/Modules/FilePicker/FilePickerWindow.qml @@ -197,6 +197,8 @@ FloatingWindow { _completionHandled = false; visible = true; Qt.callLater(() => { + root.raise(); + root.requestActivate(); dialogFocus.forceActiveFocus(); fileGrid.forceActiveFocus(); }); diff --git a/Modules/Keystone/Styles/Shared/KeystoneSurface.qml b/Modules/Keystone/Styles/Shared/KeystoneSurface.qml index e5d1527..6c16f99 100644 --- a/Modules/Keystone/Styles/Shared/KeystoneSurface.qml +++ b/Modules/Keystone/Styles/Shared/KeystoneSurface.qml @@ -27,6 +27,7 @@ Variants { property int topMargin: 0 property int maxPillRadius: 24 property bool showTopEdgeCurves: !detached + property var activePopupWindow: null function invoke(methodName) { if (instances.length === 0) @@ -101,6 +102,15 @@ Variants { return "TOOLS_OPENED"; } + function dismissPopups() { + root.closeKeystonePopups(); + } + + Component.onDestruction: { + if (styleSurface.activePopupWindow === keystoneWindow) + styleSurface.activePopupWindow = null; + } + anchors { top: true bottom: true @@ -121,6 +131,24 @@ Variants { // ============================================================ // 【物理挖孔层 (Mask Region)】 // ============================================================ + MouseArea { + id: outsideDismissArea + + anchors.fill: parent + enabled: styleSurface.activePopupWindow !== null + acceptedButtons: Qt.AllButtons + preventStealing: true + + onPressed: mouse => { + const activeWindow = styleSurface.activePopupWindow; + if (activeWindow + && typeof activeWindow.dismissPopups === "function") { + activeWindow.dismissPopups(); + } + mouse.accepted = true; + } + } + Item { id: hitBoxRegion anchors.top: maskContainer.top @@ -133,7 +161,8 @@ Variants { } mask: Region { - item: hitBoxRegion + item: styleSurface.activePopupWindow !== null + ? outsideDismissArea : hitBoxRegion } // ============================================================ @@ -919,8 +948,21 @@ Variants { focus: root.hasClosablePopup onHasClosablePopupChanged: { - if (root.hasClosablePopup) + if (root.hasClosablePopup) { + const previousWindow = + styleSurface.activePopupWindow; + if (previousWindow + && previousWindow !== keystoneWindow + && typeof previousWindow.dismissPopups + === "function") { + previousWindow.dismissPopups(); + } + styleSurface.activePopupWindow = keystoneWindow; root.forceActiveFocus(); + } else if (styleSurface.activePopupWindow + === keystoneWindow) { + styleSurface.activePopupWindow = null; + } } Keys.onEscapePressed: (event) => { diff --git a/Modules/Launcher/LauncherWindow.qml b/Modules/Launcher/LauncherWindow.qml index 3ca7e85..916f374 100644 --- a/Modules/Launcher/LauncherWindow.qml +++ b/Modules/Launcher/LauncherWindow.qml @@ -37,6 +37,7 @@ PanelWindow { property string query: "" property int selectedResultIndex: -1 property string selectedResultId: "" + property string selectedResultQuery: "" property string clipboardActionState: "idle" property string clipboardActionEntryId: "" property bool clipboardActionKeepOpen: false @@ -293,6 +294,7 @@ PanelWindow { } function selectResult(index) { + root.selectedResultQuery = root.query; if (root.activeResults.length === 0 || index < 0) { root.selectedResultIndex = -1; root.selectedResultId = ""; @@ -338,6 +340,11 @@ PanelWindow { return; } + if (root.selectedResultQuery !== root.query) { + root.selectResult(0); + return; + } + let restoredIndex = -1; if (root.selectedResultId !== "") { for (let index = 0; @@ -598,6 +605,7 @@ PanelWindow { root.previousLocalMode = "apps"; root.selectedResultIndex = -1; root.selectedResultId = ""; + root.selectedResultQuery = ""; root.modeRailExpanded = false; root.modeFocusIndex = -1; root.railProgress = 0; diff --git a/Modules/Sidebars/Left/DailyForecastTrendCard.qml b/Modules/Sidebars/Left/DailyForecastTrendCard.qml index 9908ac1..90a5f61 100644 --- a/Modules/Sidebars/Left/DailyForecastTrendCard.qml +++ b/Modules/Sidebars/Left/DailyForecastTrendCard.qml @@ -4,6 +4,7 @@ import QtQuick.Layouts import qs.Common import qs.Widgets.common import qs.Widgets.weather +import "../../../Common/functions/WeatherFormat.js" as WeatherFormat Rectangle { id: root @@ -72,6 +73,13 @@ Rectangle { return epoch ? Qt.formatDateTime(new Date(epoch * 1000), "M/d") : "--" } + function weatherLabel(item) { + return WeatherFormat.textForCode( + valueAt(item, "weatherCode", -1), + item ? item.weatherText : "" + ) + } + Timer { id: initialPositionTimer interval: 0 @@ -178,8 +186,6 @@ Rectangle { visible: root.currentTab === 0 property bool initialPositionApplied: false - onContentXChanged: trendCanvas.requestPaint() - Component.onCompleted: initialPositionTimer.restart() onContentWidthChanged: initialPositionTimer.restart() onWidthChanged: initialPositionTimer.restart() @@ -195,14 +201,15 @@ Rectangle { property real columnWidth: root.itemWidth property real topTextY: 8 property real topLabelSpacing: 3 - property real dayIconSize: Math.max(46, Math.min(60, columnWidth * 0.46)) - property real dayIconY: 56 + property real conditionTextY: 52 + property real dayIconSize: Math.max(42, Math.min(52, columnWidth * 0.44)) + property real dayIconY: 68 property real chartTopInset: 166 property real chartBottomInset: Math.max(chartTopInset + 72, height - 126) property real rainLabelY: chartBottomInset + 18 property real nightIconSize: dayIconSize property real nightIconY: height - nightIconSize - 12 - property real highTempTextY: 102 + property real highTempTextY: 119 property real lowTempTextY: nightIconY - 30 Canvas { @@ -356,6 +363,8 @@ Rectangle { model: root.modelCount() delegate: Item { + id: dayDelegate + x: root.itemWidth * index width: root.itemWidth height: trendContent.height @@ -364,6 +373,13 @@ Rectangle { property var dayItem: root.itemAt(index) property var dayPart: dayItem.day || ({}) property var nightPart: dayItem.night || ({}) + readonly property bool animationActive: + root.foreground + && dayDelegate.x + dayDelegate.width + >= trendFlick.contentX - dayDelegate.width + && dayDelegate.x + <= trendFlick.contentX + trendFlick.width + + dayDelegate.width Rectangle { anchors.fill: parent @@ -408,6 +424,18 @@ Rectangle { iconName: dayPart.iconName || "" night: false style: "fill" + playing: dayDelegate.animationActive + } + + Text { + width: parent.width + y: trendContent.conditionTextY + text: root.weatherLabel(dayPart) + color: Appearance.colors.colOnSurfaceVariant + font.family: "LXGW WenKai GB Screen" + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight } Text { @@ -441,6 +469,7 @@ Rectangle { iconName: nightPart.iconName || "" night: true style: "fill" + playing: dayDelegate.animationActive } } } diff --git a/Modules/Sidebars/Left/HourlyForecastTrendCard.qml b/Modules/Sidebars/Left/HourlyForecastTrendCard.qml index ca28a51..915b297 100644 --- a/Modules/Sidebars/Left/HourlyForecastTrendCard.qml +++ b/Modules/Sidebars/Left/HourlyForecastTrendCard.qml @@ -4,6 +4,7 @@ import QtQuick.Layouts import qs.Common import qs.Widgets.common import qs.Widgets.weather +import "../../../Common/functions/WeatherFormat.js" as WeatherFormat Rectangle { id: root @@ -41,6 +42,13 @@ Rectangle { return epoch ? Qt.formatDateTime(new Date(epoch * 1000), "hh:00") : "--" } + function weatherLabel(item) { + return WeatherFormat.textForCode( + valueAt(item, "weatherCode", -1), + item ? item.weatherText : "" + ) + } + ColumnLayout { anchors.fill: parent anchors.margins: 0 @@ -139,8 +147,6 @@ Rectangle { contentHeight: height visible: root.currentTab === 0 - onContentXChanged: trendCanvas.requestPaint() - Item { id: trendContent width: trendFlick.contentWidth @@ -149,7 +155,8 @@ Rectangle { property real topTextY: 6 property real iconY: 28 property real iconSize: Math.max(46, Math.min(60, root.itemWidth * 0.46)) - property real chartTopInset: 96 + property real conditionTextY: iconY + iconSize - 2 + property real chartTopInset: 106 property real chartBottomInset: Math.max(chartTopInset + 70, height - 30) Canvas { @@ -244,11 +251,20 @@ Rectangle { model: root.modelCount() delegate: Item { + id: hourDelegate + x: root.itemWidth * index width: root.itemWidth height: trendContent.height property var hourItem: root.itemAt(index) + readonly property bool animationActive: + root.foreground + && hourDelegate.x + hourDelegate.width + >= trendFlick.contentX - hourDelegate.width + && hourDelegate.x + <= trendFlick.contentX + trendFlick.width + + hourDelegate.width Text { width: parent.width @@ -269,6 +285,18 @@ Rectangle { iconName: hourItem.iconName || "" night: hourItem.isDaylight === undefined ? false : !hourItem.isDaylight style: "fill" + playing: hourDelegate.animationActive + } + + Text { + width: parent.width + y: trendContent.conditionTextY + text: root.weatherLabel(hourItem) + color: Appearance.colors.colOnSurfaceVariant + font.family: "LXGW WenKai GB Screen" + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight } } } diff --git a/Modules/Sidebars/Left/LeftSidebarWindow.qml b/Modules/Sidebars/Left/LeftSidebarWindow.qml index 3a7537d..63da53a 100644 --- a/Modules/Sidebars/Left/LeftSidebarWindow.qml +++ b/Modules/Sidebars/Left/LeftSidebarWindow.qml @@ -8,21 +8,39 @@ Item { property var panelScreen: null property int sidebarWidth: 540 property int gap: 24 - readonly property alias blurBackgroundItem: panelSurface + readonly property alias blurBackgroundItem: blurRegionAnchor readonly property int sidebarY: Sizes.barHeight + gap readonly property real closedSlideOffset: -(sidebarWidth + gap) - readonly property int enterDuration: Animations.durations.large - readonly property int exitDuration: Animations.durations.large + readonly property int enterDuration: + Animations.animation.expressiveFastSpatial.duration + readonly property int exitDuration: + Animations.animation.emphasizedAccel.duration readonly property int qsTargetHeight: Math.max(0, height - sidebarY - gap) property bool panelPresented: false property bool contentRetained: false + property bool blurActive: false + property bool contentActive: false readonly property bool panelActive: WidgetState.leftSidebarOpen || panelPresented function beginPresentation() { panelPresented = true contentRetained = true + blurActive = false + contentActive = false + contentActivationTimer.stop() + } + + function finishOpening() { + if (!WidgetState.leftSidebarOpen) + return + + // Submit the final, stationary blur region first, then wake services + // and content animations on a later frame instead of piling all work + // onto the last frame of the slide transition. + blurActive = true + contentActivationTimer.restart() } function finishClosing() { @@ -39,6 +57,8 @@ Item { panelPresented = WidgetState.leftSidebarOpen contentRetained = WidgetState.leftSidebarOpen || PersonalizationConfig.keepSidebarsLoaded + blurActive = WidgetState.leftSidebarOpen + contentActive = WidgetState.leftSidebarOpen } Connections { @@ -47,6 +67,11 @@ Item { function onLeftSidebarOpenChanged() { if (WidgetState.leftSidebarOpen) root.beginPresentation() + else { + contentActivationTimer.stop() + root.blurActive = false + root.contentActive = false + } } } @@ -72,6 +97,17 @@ Item { && localPosition.y <= sidebarContentFrame.height; } + Timer { + id: contentActivationTimer + + interval: 50 + repeat: false + onTriggered: { + if (WidgetState.leftSidebarOpen) + root.contentActive = true + } + } + Item { id: animController @@ -103,12 +139,20 @@ Item { id: openTransition to: "open" - NumberAnimation { - target: animController - property: "slideOffset" - duration: root.enterDuration - easing.type: Easing.OutBack - easing.overshoot: 0.3 + SequentialAnimation { + NumberAnimation { + target: animController + property: "slideOffset" + duration: root.enterDuration + easing.type: + Animations.animation.expressiveFastSpatial.type + easing.bezierCurve: + Animations.animation.expressiveFastSpatial.bezierCurve + } + + ScriptAction { + script: root.finishOpening() + } } }, Transition { @@ -120,8 +164,10 @@ Item { target: animController property: "slideOffset" duration: root.exitDuration - easing.type: Easing.InBack - easing.overshoot: 0.18 + easing.type: + Animations.animation.emphasizedAccel.type + easing.bezierCurve: + Animations.animation.emphasizedAccel.bezierCurve } ScriptAction { @@ -145,6 +191,20 @@ Item { radius: Appearance.rounding.large } + // Keep compositor blur out of slide animations. Updating a moving + // blur region every frame is considerably more expensive than moving the + // already rendered panel surface. + Item { + id: blurRegionAnchor + + visible: root.blurActive + x: panelSurface.x + y: panelSurface.y + width: panelSurface.width + height: panelSurface.height + property real radius: panelSurface.radius + } + Item { id: sidebarContentFrame @@ -170,8 +230,8 @@ Item { LeftSidebarContent { anchors.fill: parent screenName: root.panelScreen ? root.panelScreen.name : "" - foreground: WidgetState.leftSidebarOpen - presentationActive: root.panelActive + foreground: root.contentActive + presentationActive: root.contentActive } } } diff --git a/Modules/Sidebars/Left/WeatherView.qml b/Modules/Sidebars/Left/WeatherView.qml index 307f602..a861007 100644 --- a/Modules/Sidebars/Left/WeatherView.qml +++ b/Modules/Sidebars/Left/WeatherView.qml @@ -2,10 +2,12 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls import Qt5Compat.GraphicalEffects +import Quickshell import qs.Common import qs.Widgets.common import qs.Widgets.weather import Clavis.Weather 1.0 +import "../../../Common/functions/WeatherFormat.js" as WeatherFormat Item { id: root @@ -20,6 +22,17 @@ Item { property color headerErrorInk: lightHeaderPalette ? Qt.rgba(1.0, 0.79, 0.82, 0.96) : Qt.rgba(0.62, 0.14, 0.18, 0.88) property real currentEpoch: Math.floor(Date.now() / 1000) + function openWeatherSettings() { + WidgetState.leftSidebarOpen = false; + Quickshell.execDetached([ + "env", + "CLAVIS_CONTROL_CENTER_PAGE=weather", + "qs", + "--path", + Paths.shellDir + "/controlcenter.qml" + ]); + } + function validNumber(value) { return value !== undefined && value !== null && !isNaN(value) } @@ -262,25 +275,34 @@ Item { color: "transparent" border.width: 1 border.color: Qt.rgba(Appearance.colors.colOutlineVariant.r, Appearance.colors.colOutlineVariant.g, Appearance.colors.colOutlineVariant.b, 0.34) - layer.enabled: true - layer.effect: OpacityMask { - maskSource: Rectangle { - width: weatherPanel.width - height: weatherPanel.height - radius: weatherPanel.radius - } - } - WeatherBackground { + // Only the full-bleed animated background needs a rounded mask. Keeping + // the scrolling cards out of this layer avoids repainting the entire + // weather page into an off-screen texture on every scroll frame. + Item { + id: weatherBackgroundClip + anchors.fill: parent - weatherCode: WeatherPlugin.currentWeatherCode - iconName: WeatherPlugin.currentIconName - windSpeedMs: WeatherPlugin.currentWindSpeedMs - windGustsMs: WeatherPlugin.currentWindGustsMs - night: root.currentIsNight() - rainBounceY: flick.y + dailyForecastCard.y - flick.contentY - scrollProgress: Math.max(0, Math.min(1, flick.contentY / 340)) - animate: root.foreground + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: weatherPanel.width + height: weatherPanel.height + radius: weatherPanel.radius + } + } + + WeatherBackground { + anchors.fill: parent + weatherCode: WeatherPlugin.currentWeatherCode + iconName: WeatherPlugin.currentIconName + windSpeedMs: WeatherPlugin.currentWindSpeedMs + windGustsMs: WeatherPlugin.currentWindGustsMs + night: root.currentIsNight() + rainBounceY: flick.y + dailyForecastCard.y - flick.contentY + scrollProgress: Math.max(0, Math.min(1, flick.contentY / 340)) + animate: root.foreground && !flick.moving + } } Rectangle { @@ -336,7 +358,11 @@ Item { implicitWidth: 38 implicitHeight: 38 Layout.alignment: Qt.AlignVCenter - onClicked: console.log("Open weather settings") + onClicked: root.openWeatherSettings() + + StyledToolTip { + text: qsTr("编辑位置信息") + } background: Rectangle { radius: width / 2 @@ -434,8 +460,10 @@ Item { spacing: 14 Item { + id: currentSummary + width: parent.width - height: Math.max(220, flick.height - 452 - 286 - contentColumn.spacing * 2) + height: Math.max(280, flick.height - 452 - 286 - contentColumn.spacing * 2) Column { anchors.left: parent.left @@ -445,7 +473,10 @@ Item { Text { width: parent.width - text: WeatherPlugin.currentWeatherText || qsTr("未知") + text: WeatherFormat.textForCode( + WeatherPlugin.currentWeatherCode, + WeatherPlugin.currentWeatherText || qsTr("未知") + ) color: Appearance.colors.colOnImage font.family: "LXGW WenKai GB Screen" font.pixelSize: 26 @@ -481,6 +512,10 @@ Item { weatherCode: WeatherPlugin.currentWeatherCode iconName: WeatherPlugin.currentIconName night: root.currentIsNight() + playing: root.foreground + && !flick.moving + && currentSummary.y + currentSummary.height >= flick.contentY + && currentSummary.y <= flick.contentY + flick.height } } @@ -513,13 +548,21 @@ Item { height: 452 sourceModel: WeatherPlugin.dailyTrendForecast foreground: root.foreground + && !flick.moving + && dailyForecastCard.y + dailyForecastCard.height >= flick.contentY + && dailyForecastCard.y <= flick.contentY + flick.height } HourlyForecastTrendCard { + id: hourlyForecastCard + width: parent.width height: 286 sourceModel: WeatherPlugin.hourlyForecast foreground: root.foreground + && !flick.moving + && hourlyForecastCard.y + hourlyForecastCard.height >= flick.contentY + && hourlyForecastCard.y <= flick.contentY + flick.height } RowLayout { @@ -535,6 +578,7 @@ Item { viewportContentY: flick.contentY viewportHeight: flick.height activationEnabled: root.presentationActive + && !flick.moving staggerIndex: 0 WeatherPrecipitationCard { @@ -554,6 +598,7 @@ Item { viewportContentY: flick.contentY viewportHeight: flick.height activationEnabled: root.presentationActive + && !flick.moving staggerIndex: 1 WeatherWindCard { @@ -581,6 +626,7 @@ Item { viewportContentY: flick.contentY viewportHeight: flick.height activationEnabled: root.presentationActive + && !flick.moving staggerIndex: 0 WeatherAqiCard { @@ -601,6 +647,7 @@ Item { viewportContentY: flick.contentY viewportHeight: flick.height activationEnabled: root.presentationActive + && !flick.moving staggerIndex: 1 WeatherHumidityCard { @@ -628,6 +675,7 @@ Item { viewportContentY: flick.contentY viewportHeight: flick.height activationEnabled: root.presentationActive + && !flick.moving staggerIndex: 0 WeatherUvCard { @@ -648,6 +696,7 @@ Item { viewportContentY: flick.contentY viewportHeight: flick.height activationEnabled: root.presentationActive + && !flick.moving staggerIndex: 1 WeatherVisibilityCard { @@ -672,6 +721,7 @@ Item { viewportContentY: flick.contentY viewportHeight: flick.height activationEnabled: root.presentationActive + && !flick.moving staggerIndex: 0 WeatherPressureCard { @@ -692,6 +742,7 @@ Item { viewportContentY: flick.contentY viewportHeight: flick.height activationEnabled: root.presentationActive + && !flick.moving staggerIndex: 1 WeatherAstroCard { @@ -721,6 +772,7 @@ Item { viewportContentY: flick.contentY viewportHeight: flick.height activationEnabled: root.presentationActive + && !flick.moving staggerIndex: 0 WeatherAstroCard { diff --git a/Modules/Sidebars/Left/WeatherViewPreview.qml b/Modules/Sidebars/Left/WeatherViewPreview.qml index 3e0ff11..03e31d7 100644 --- a/Modules/Sidebars/Left/WeatherViewPreview.qml +++ b/Modules/Sidebars/Left/WeatherViewPreview.qml @@ -271,7 +271,7 @@ Item { Item { width: parent.width - height: Math.max(220, flick.height - 452 - 286 - contentColumn.spacing * 2) + height: Math.max(280, flick.height - 452 - 286 - contentColumn.spacing * 2) Column { anchors.left: parent.left diff --git a/README.md b/README.md index 6fee7d1..8cb7b7f 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,12 @@ Niri 的 `~/.config/niri/config.kdl` 需要包含: ```kdl include "colors.kdl" +include optional=true "clavis/cursor.kdl" ``` +Clavis 将 Niri 光标设置写入 `~/.config/niri/clavis/cursor.kdl`,不会修改 +`~/.config/niri/dms/` 下由其他 shell 管理的配置。 + Yazi 会自动读取 `~/.config/yazi/theme.toml`,无需修改主配置。自制 Zsh prompt 需要在 `.zshrc` 的 `precmd` 中加载生成的配色片段;对应源码仓库内维护了 完整示例配置。 diff --git a/Services/ThemeService.qml b/Services/ThemeService.qml index d38f7f3..8727c25 100644 --- a/Services/ThemeService.qml +++ b/Services/ThemeService.qml @@ -255,8 +255,8 @@ Singleton { if (!root.isNiriSession) return; - const niriDmsDir = Paths.homeDir + "/.config/niri/dms"; - const cursorPath = niriDmsDir + "/cursor.kdl"; + const niriClavisDir = Paths.homeDir + "/.config/niri/clavis"; + const cursorPath = niriClavisDir + "/cursor.kdl"; const themeName = root.effectiveCursorTheme(); const size = PersonalizationConfig.cursorSize; const hideWhenTyping = PersonalizationConfig.cursorHideWhenTyping; @@ -284,7 +284,7 @@ cursor { writeNiriCursorProcess.command = [ "bash", "-c", - "mkdir -p " + root.shellQuote(niriDmsDir) + " && printf '%s' " + root.shellQuote(content) + " > " + root.shellQuote(cursorPath) + "mkdir -p " + root.shellQuote(niriClavisDir) + " && printf '%s' " + root.shellQuote(content) + " > " + root.shellQuote(cursorPath) ]; writeNiriCursorProcess.running = false; writeNiriCursorProcess.running = true; diff --git a/Widgets/weather/MeteoIcon.qml b/Widgets/weather/MeteoIcon.qml index 4381b26..081cc8f 100644 --- a/Widgets/weather/MeteoIcon.qml +++ b/Widgets/weather/MeteoIcon.qml @@ -69,12 +69,17 @@ Item { scale: root.fittedScale transformOrigin: Item.Center visible: root.animated && status === LottieAnimation.Ready - source: root.lottieSource - autoPlay: true + source: root.animated ? root.lottieSource : "" + autoPlay: root.playing loops: LottieAnimation.Infinite onStatusChanged: { - if (status === LottieAnimation.Ready && root.playing) play() + if (status !== LottieAnimation.Ready) + return + if (root.playing) + play() + else + pause() } } @@ -83,6 +88,11 @@ Item { else if (lottieIcon.status === LottieAnimation.Ready) lottieIcon.play() } + onAnimatedChanged: { + if (!animated) + lottieIcon.pause() + } + Image { anchors.fill: parent visible: !lottieIcon.visible diff --git a/Widgets/weather/WeatherBackground.qml b/Widgets/weather/WeatherBackground.qml index a69dce9..da698b6 100644 --- a/Widgets/weather/WeatherBackground.qml +++ b/Widgets/weather/WeatherBackground.qml @@ -1151,7 +1151,9 @@ Item { } Timer { - interval: 16 + // The simulation is tuned around a 33 ms base frame. Running it at + // 60 fps only doubles Canvas paints without adding useful detail. + interval: 33 running: root.animate repeat: true @@ -1233,12 +1235,18 @@ Item { } onAnimateChanged: { if (animate) { + if (hasSnowScene()) + resetSnowScene() + if (hasLeafScene()) + resetLeafScene() ensureLeafPopulation() } else { leafSpawnTimer.stop() } } onRainBounceYChanged: { + if (!animate) + return if (hasSnowScene()) resetSnowScene() if (hasLeafScene()) @@ -1250,5 +1258,4 @@ Item { if (hasLeafScene()) resetLeafScene() } - onScrollProgressChanged: canvas.requestPaint() } diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index c78e421..91908a6 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -10,7 +10,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # Find Qt 6 basics -find_package(Qt6 REQUIRED COMPONENTS Core Gui Qml Quick Network ShaderTools LinguistTools) +find_package(Qt6 REQUIRED COMPONENTS Core DBus Gui Qml Quick Network ShaderTools LinguistTools) find_package(Qt6Keychain REQUIRED) find_package(PkgConfig REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) diff --git a/core/src/CMakeLists.txt b/core/src/CMakeLists.txt index fbe7c00..3e1e7a6 100644 --- a/core/src/CMakeLists.txt +++ b/core/src/CMakeLists.txt @@ -55,6 +55,7 @@ set_target_properties(ClavisWeatherMapCore PROPERTIES POSITION_INDEPENDENT_CODE target_link_libraries(ClavisWeatherMapCore PRIVATE Qt6::Core + Qt6::DBus Qt6::Gui Qt6::Network Qt6Keychain::Qt6Keychain diff --git a/core/src/weather_map_provider.cpp b/core/src/weather_map_provider.cpp index 67eb1fb..179f7a1 100644 --- a/core/src/weather_map_provider.cpp +++ b/core/src/weather_map_provider.cpp @@ -2,6 +2,10 @@ #include #include +#include +#include +#include +#include #include #include #include @@ -24,6 +28,53 @@ constexpr auto kKeychainService = "Clavis.Quickshell.WeatherMap"; constexpr auto kOpenWeatherKeychainEntry = "openweather-api-key"; constexpr auto kMapTilerKeychainEntry = "maptiler-api-key"; +void selectAvailableKeychainBackend() +{ +#ifdef Q_OS_LINUX + if (!qEnvironmentVariableIsEmpty("QTKEYCHAIN_BACKEND")) + return; + + const QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) + return; + + auto *busInterface = bus.interface(); + if (busInterface) { + const QDBusReply secretServiceAvailable = + busInterface->isServiceRegistered( + QStringLiteral("org.freedesktop.secrets") + ); + if (secretServiceAvailable.isValid() + && secretServiceAvailable.value()) { + return; + } + } + + QDBusInterface kwallet( + QStringLiteral("org.kde.kwalletd6"), + QStringLiteral("/modules/kwalletd6"), + QStringLiteral("org.kde.KWallet"), + bus + ); + const QDBusReply walletName = kwallet.call( + QStringLiteral("networkWallet") + ); + if (walletName.isValid()) + qputenv("QTKEYCHAIN_BACKEND", "kwallet6"); +#endif +} + +QString keychainFailureMessage( + const QString &summary, + const QKeychain::Job *job +) +{ + const QString detail = job ? job->errorString().trimmed() : QString(); + return detail.isEmpty() + ? summary + : QStringLiteral("%1:%2").arg(summary, detail); +} + qint64 cacheControlMaxAge(const QByteArray &header) { const QList directives = header.split(','); @@ -44,6 +95,8 @@ qint64 cacheControlMaxAge(const QByteArray &header) WeatherMapProvider::WeatherMapProvider(QObject *parent) : QObject(parent) { + selectAvailableKeychainBackend(); + const QString genericCache = QStandardPaths::writableLocation( QStandardPaths::GenericCacheLocation ); @@ -279,7 +332,10 @@ QVariantMap WeatherMapProvider::storeApiKey(const QString &apiKey) emit credentialOperationFinished( QStringLiteral("openweather_store"), false, - QStringLiteral("无法保存 OpenWeather 密钥") + keychainFailureMessage( + QStringLiteral("无法保存 OpenWeather 密钥"), + finishedJob + ) ); return; } @@ -339,7 +395,10 @@ QVariantMap WeatherMapProvider::clearApiKey() emit credentialOperationFinished( QStringLiteral("openweather_clear"), false, - QStringLiteral("无法清除 OpenWeather 密钥") + keychainFailureMessage( + QStringLiteral("无法清除 OpenWeather 密钥"), + finishedJob + ) ); return; } @@ -412,7 +471,10 @@ QVariantMap WeatherMapProvider::storeMapTilerApiKey(const QString &apiKey) emit credentialOperationFinished( QStringLiteral("maptiler_store"), false, - QStringLiteral("无法保存 MapTiler 密钥") + keychainFailureMessage( + QStringLiteral("无法保存 MapTiler 密钥"), + finishedJob + ) ); return; } @@ -473,7 +535,10 @@ QVariantMap WeatherMapProvider::clearMapTilerApiKey() emit credentialOperationFinished( QStringLiteral("maptiler_clear"), false, - QStringLiteral("无法清除 MapTiler 密钥") + keychainFailureMessage( + QStringLiteral("无法清除 MapTiler 密钥"), + finishedJob + ) ); return; } @@ -947,7 +1012,10 @@ void WeatherMapProvider::loadOpenWeatherApiKey(bool forceRefresh) replaceApiKey({}, forceRefresh); setStatus( QStringLiteral("keychain_error"), - QStringLiteral("无法访问系统密钥环") + keychainFailureMessage( + QStringLiteral("无法访问系统密钥环"), + finishedJob + ) ); loadMapTilerApiKey(forceRefresh); return;