From d7dc8ae5d26fea6831920e6b8b8a3e4c9fb53827 Mon Sep 17 00:00:00 2001 From: bbedward Date: Sun, 4 Oct 2026 15:42:35 -0400 Subject: [PATCH] lock: allow customization, including any desktop widget, to be added and arranged on lock screen --- core/go.mod | 2 +- core/go.sum | 4 +- .../qmlchecks/lockscreen_input_test.go | 2 +- dank-qml-common | 2 +- flake.lock | 6 +- flake.nix | 2 +- quickshell/Common/CacheData.qml | 5 +- quickshell/Common/SessionData.qml | 10 + quickshell/Common/SettingsData.qml | 93 +- quickshell/Common/settings/SessionSpec.js | 6 - quickshell/Common/settings/SettingsSpec.js | 28 +- quickshell/Common/settings/SettingsStore.js | 43 + .../Modals/Settings/SettingsContent.qml | 2 +- .../DankDash/Overview/DashEditControls.qml | 3 +- quickshell/Modules/DesktopWidgetLayer.qml | 35 +- quickshell/Modules/Lock/Lock.qml | 10 + .../Modules/Lock/LockBackgroundSampler.qml | 96 ++ quickshell/Modules/Lock/LockPlacement.js | 65 + .../Modules/Lock/LockPlacementPreparer.qml | 61 + .../Modules/Lock/LockScreenBackground.qml | 152 ++ quickshell/Modules/Lock/LockScreenContent.qml | 1494 ++--------------- quickshell/Modules/Lock/LockScreenDemo.qml | 55 +- quickshell/Modules/Lock/LockSurface.qml | 5 + quickshell/Modules/Lock/LockWidgetItem.qml | 237 +++ quickshell/Modules/Lock/LockWidgetLayer.qml | 272 +++ .../Modules/Lock/Widgets/LockAuthWidget.qml | 1199 +++++++++++++ .../Modules/Lock/Widgets/LockClockWidget.qml | 267 +++ .../Modules/Lock/Widgets/LockDateWidget.qml | 40 + .../Lock/Widgets/LockNotificationsWidget.qml | 254 +++ .../Modules/Lock/Widgets/LockPowerWidget.qml | 47 + .../Modules/Lock/Widgets/LockStatusWidget.qml | 39 + .../Plugins/DesktopPluginComponent.qml | 1 + .../Modules/Plugins/DesktopPluginWrapper.qml | 400 +---- .../Modules/Plugins/DesktopWidgetContent.qml | 166 ++ .../Modules/Plugins/DesktopWidgetGeometry.qml | 155 ++ .../Plugins/DesktopWidgetGridLines.qml | 38 + .../Modules/Settings/DesktopWidgetBrowser.qml | 6 +- .../Settings/DesktopWidgetInstanceCard.qml | 6 +- .../DesktopWidgetTypeSettings.qml | 120 ++ .../Modules/Settings/DesktopWidgetTab.qml | 68 +- quickshell/Modules/Settings/LockScreenTab.qml | 148 +- .../LockWidgetSettings/LockAuthSettings.qml | 51 + .../LockWidgetSettings/LockClockSettings.qml | 92 + .../LockWidgetSettings/LockDateSettings.qml | 34 + .../LockNotificationsSettings.qml | 22 + .../LockWidgetSettings/LockPowerSettings.qml | 66 + .../LockWidgetSettings/LockStatusSettings.qml | 31 + .../Modules/Settings/NotificationsTab.qml | 16 +- .../Modules/Settings/WeatherSettingsTab.qml | 11 +- .../Settings/Widgets/SettingsColorPicker.qml | 15 +- .../Settings/Widgets/SettingsReorderRow.qml | 1 + quickshell/Services/DesktopWidgetRegistry.qml | 56 +- quickshell/Services/LockPlacementService.qml | 84 + quickshell/Services/SessionService.qml | 1 + quickshell/shell.qml | 2 + .../translations/settings_search_index.json | 277 +-- 56 files changed, 4244 insertions(+), 2159 deletions(-) create mode 100644 quickshell/Modules/Lock/LockBackgroundSampler.qml create mode 100644 quickshell/Modules/Lock/LockPlacement.js create mode 100644 quickshell/Modules/Lock/LockPlacementPreparer.qml create mode 100644 quickshell/Modules/Lock/LockScreenBackground.qml create mode 100644 quickshell/Modules/Lock/LockWidgetItem.qml create mode 100644 quickshell/Modules/Lock/LockWidgetLayer.qml create mode 100644 quickshell/Modules/Lock/Widgets/LockAuthWidget.qml create mode 100644 quickshell/Modules/Lock/Widgets/LockClockWidget.qml create mode 100644 quickshell/Modules/Lock/Widgets/LockDateWidget.qml create mode 100644 quickshell/Modules/Lock/Widgets/LockNotificationsWidget.qml create mode 100644 quickshell/Modules/Lock/Widgets/LockPowerWidget.qml create mode 100644 quickshell/Modules/Lock/Widgets/LockStatusWidget.qml create mode 100644 quickshell/Modules/Plugins/DesktopWidgetContent.qml create mode 100644 quickshell/Modules/Plugins/DesktopWidgetGeometry.qml create mode 100644 quickshell/Modules/Plugins/DesktopWidgetGridLines.qml create mode 100644 quickshell/Modules/Settings/DesktopWidgetSettings/DesktopWidgetTypeSettings.qml create mode 100644 quickshell/Modules/Settings/LockWidgetSettings/LockAuthSettings.qml create mode 100644 quickshell/Modules/Settings/LockWidgetSettings/LockClockSettings.qml create mode 100644 quickshell/Modules/Settings/LockWidgetSettings/LockDateSettings.qml create mode 100644 quickshell/Modules/Settings/LockWidgetSettings/LockNotificationsSettings.qml create mode 100644 quickshell/Modules/Settings/LockWidgetSettings/LockPowerSettings.qml create mode 100644 quickshell/Modules/Settings/LockWidgetSettings/LockStatusSettings.qml create mode 100644 quickshell/Services/LockPlacementService.qml diff --git a/core/go.mod b/core/go.mod index 7c36c0f20..181188a41 100644 --- a/core/go.mod +++ b/core/go.mod @@ -82,7 +82,7 @@ require ( ) require ( - github.com/AvengeMedia/dankgo v1.6.3-0.20261002194355-b7285561e19f + github.com/AvengeMedia/dankgo v1.6.3-0.20261004193546-f560632e8bd2 github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect diff --git a/core/go.sum b/core/go.sum index 022468cec..c7e440973 100644 --- a/core/go.sum +++ b/core/go.sum @@ -1,7 +1,7 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/AvengeMedia/dankgo v1.6.3-0.20261002194355-b7285561e19f h1:9n423TuyY2cF0/sZg0JjGSPx9yW6zSdgsVFoSVEHmek= -github.com/AvengeMedia/dankgo v1.6.3-0.20261002194355-b7285561e19f/go.mod h1:Fj+FTCumlv8YjKmdv75DREJuGmqU9EnGpBroZuCWc/k= +github.com/AvengeMedia/dankgo v1.6.3-0.20261004193546-f560632e8bd2 h1:RX0Dy4ays41fEEN86JVf7d4zYoCeq49ttgv7Ynjy56c= +github.com/AvengeMedia/dankgo v1.6.3-0.20261004193546-f560632e8bd2/go.mod h1:Fj+FTCumlv8YjKmdv75DREJuGmqU9EnGpBroZuCWc/k= github.com/AvengeMedia/dgop v1.6.3-0.20260920163359-59c7d81ae3b9 h1:ssoiAng7ng500BgtZa1riJH6kKudQVM8Uk9a9kF7mDA= github.com/AvengeMedia/dgop v1.6.3-0.20260920163359-59c7d81ae3b9/go.mod h1:8TFIP72NLxCbogF2hHU6sXj3RoTbHQW8beeCrisHs/Y= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= diff --git a/core/internal/qmlchecks/lockscreen_input_test.go b/core/internal/qmlchecks/lockscreen_input_test.go index f4c82dd1c..560dc12de 100644 --- a/core/internal/qmlchecks/lockscreen_input_test.go +++ b/core/internal/qmlchecks/lockscreen_input_test.go @@ -9,7 +9,7 @@ import ( ) func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) { - data, err := os.ReadFile("../../../quickshell/Modules/Lock/LockScreenContent.qml") + data, err := os.ReadFile("../../../quickshell/Modules/Lock/Widgets/LockAuthWidget.qml") if err != nil { t.Fatalf("read lock screen QML: %v", err) } diff --git a/dank-qml-common b/dank-qml-common index 13470a9b5..07d634a3f 160000 --- a/dank-qml-common +++ b/dank-qml-common @@ -1 +1 @@ -Subproject commit 13470a9b545f4c2c2fb8f237db6de2b5f7c26b41 +Subproject commit 07d634a3fc731afdcd309d385f3134ba8c22b37c diff --git a/flake.lock b/flake.lock index 147ecfede..2867378ff 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "dank-qml-common": { "flake": false, "locked": { - "lastModified": 1790974203, - "narHash": "sha256-C2DAGa/NkSye4NaANuxxkSeNb4xX1g3gZBsKO5KRIzk=", + "lastModified": 1791142152, + "narHash": "sha256-tVzNbgalwjVVyzYm5d6FjKkCaZ5DbZPw9uyvnTNC2EA=", "owner": "AvengeMedia", "repo": "dank-qml-common", - "rev": "13470a9b545f4c2c2fb8f237db6de2b5f7c26b41", + "rev": "07d634a3fc731afdcd309d385f3134ba8c22b37c", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 1bd064bbf..54179ec77 100644 --- a/flake.nix +++ b/flake.nix @@ -111,7 +111,7 @@ inherit version; pname = "dms-shell"; src = ./core; - vendorHash = "sha256-WFufeGR21sidUJVGQTygubcaBB/aOIkKcBKf5dtwwVk="; + vendorHash = "sha256-dScMtUqccp6C8b/hIOFTjdgm+mOy921bEbTuXqcp4ag="; subPackages = [ "cmd/dms" ]; diff --git a/quickshell/Common/CacheData.qml b/quickshell/Common/CacheData.qml index 19d11c6a5..6af147a1f 100644 --- a/quickshell/Common/CacheData.qml +++ b/quickshell/Common/CacheData.qml @@ -24,7 +24,7 @@ Singleton { readonly property var _pinKeys: ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"] readonly property var _historyKeys: ["browserUsageHistory", "filePickerUsageHistory"] - readonly property var _dataKeys: ["fileBrowserSettings", "processFilterTypes", "pluginViewSort", "pluginViewFilter", "dashFocusCardId", "controlCenterCollapsedCategories", "mediaLyricsOpen", "matugenPreviews", "matugenSeedPreviews", "matugenAppliedKey"].concat(_pinKeys, _historyKeys) + readonly property var _dataKeys: ["fileBrowserSettings", "processFilterTypes", "pluginViewSort", "pluginViewFilter", "dashFocusCardId", "controlCenterCollapsedCategories", "mediaLyricsOpen", "matugenPreviews", "matugenSeedPreviews", "matugenAppliedKey", "lockScreenPlacementSamples"].concat(_pinKeys, _historyKeys) property string pluginViewFilter: "enabled" property string dashFocusCardId: "" @@ -33,6 +33,7 @@ Singleton { property var matugenPreviews: ({}) property var matugenSeedPreviews: ({}) property string matugenAppliedKey: "" + property var lockScreenPlacementSamples: ({}) property var pluginViewSort: ({ by: "modified", descending: true @@ -137,6 +138,7 @@ Singleton { matugenPreviews = typeof cache.matugenPreviews?.key === "string" ? cache.matugenPreviews : {}; matugenSeedPreviews = cache.matugenSeedPreviews && typeof cache.matugenSeedPreviews === "object" && !Array.isArray(cache.matugenSeedPreviews) ? cache.matugenSeedPreviews : {}; matugenAppliedKey = typeof cache.matugenAppliedKey === "string" ? cache.matugenAppliedKey : ""; + lockScreenPlacementSamples = cache.lockScreenPlacementSamples && typeof cache.lockScreenPlacementSamples === "object" && !Array.isArray(cache.lockScreenPlacementSamples) ? cache.lockScreenPlacementSamples : {}; const pluginSort = cache.pluginViewSort; pluginViewSort = { by: ["name", "author", "modified"].includes(pluginSort?.by) ? pluginSort.by : "modified", @@ -218,6 +220,7 @@ Singleton { "matugenPreviews": matugenPreviews, "matugenSeedPreviews": matugenSeedPreviews, "matugenAppliedKey": matugenAppliedKey, + "lockScreenPlacementSamples": lockScreenPlacementSamples, "fileBrowserSettings": fileBrowserSettings, "configVersion": cacheConfigVersion }; diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index a7fd178e7..d5c7151df 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -292,6 +292,7 @@ Singleton { property var activeDisplayProfileModes: ({}) property var desktopWidgetGridSettings: ({}) property var desktopWidgetInstancePositions: ({}) + property var lockScreenAutoPositions: ({}) property var islandFreePositions: ({}) property var builtInPluginState: ({}) property bool greeterSyncPending: false @@ -651,6 +652,15 @@ Singleton { saveSettings(); } + function setLockScreenAutoPositions(screenKey, placement) { + if (JSON.stringify(lockScreenAutoPositions[screenKey] ?? null) === JSON.stringify(placement)) + return; + const updated = Object.assign({}, lockScreenAutoPositions); + updated[screenKey] = placement; + lockScreenAutoPositions = updated; + saveSettings(); + } + function removeDesktopWidgetInstancePositions(instanceId) { if (!(instanceId in desktopWidgetInstancePositions)) return; diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 3dddd416b..fa14800a0 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -21,7 +21,7 @@ Singleton { id: root readonly property var log: Log.scoped("SettingsData") - readonly property int settingsConfigVersion: 37 + readonly property int settingsConfigVersion: 38 readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" @@ -773,13 +773,7 @@ Singleton { property bool modalDarkenBackground: Spec.SPEC.modalDarkenBackground.def property bool lockScreenShowPowerActions: Spec.SPEC.lockScreenShowPowerActions.def - property bool lockScreenShowSystemIcons: Spec.SPEC.lockScreenShowSystemIcons.def - property bool lockScreenShowTime: Spec.SPEC.lockScreenShowTime.def - property string lockScreenClockStyle: Spec.SPEC.lockScreenClockStyle.def - property bool lockScreenShowDate: Spec.SPEC.lockScreenShowDate.def property bool lockScreenShowProfileImage: Spec.SPEC.lockScreenShowProfileImage.def - property bool lockScreenShowPasswordField: Spec.SPEC.lockScreenShowPasswordField.def - property bool lockScreenShowMediaPlayer: Spec.SPEC.lockScreenShowMediaPlayer.def property bool lockScreenShowWeather: Spec.SPEC.lockScreenShowWeather.def property bool lockScreenPowerOffMonitorsOnLock: Spec.SPEC.lockScreenPowerOffMonitorsOnLock.def property bool lockAtStartup: Spec.SPEC.lockAtStartup.def @@ -813,7 +807,6 @@ Singleton { property bool lockScreenSecurityKeyShortcutEnabled: Spec.SPEC.lockScreenSecurityKeyShortcutEnabled.def property bool greeterPamExternallyManaged: Spec.SPEC.greeterPamExternallyManaged.def property string lockScreenInactiveColor: Spec.SPEC.lockScreenInactiveColor.def - property int lockScreenNotificationMode: Spec.SPEC.lockScreenNotificationMode.def property bool lockScreenVideoEnabled: Spec.SPEC.lockScreenVideoEnabled.def property string lockScreenVideoPath: Spec.SPEC.lockScreenVideoPath.def property bool lockScreenVideoCycling: Spec.SPEC.lockScreenVideoCycling.def @@ -1119,6 +1112,34 @@ Singleton { property var desktopWidgetInstances: Spec.SPEC.desktopWidgetInstances.def property var desktopWidgetGroups: Spec.SPEC.desktopWidgetGroups.def + property var lockScreenWidgetInstances: Spec.SPEC.lockScreenWidgetInstances.def + readonly property var widgetInstanceListKeys: ["desktopWidgetInstances", "lockScreenWidgetInstances"] + + // The greeter still reads these three shared keys, so they follow the lock widgets. + onLockScreenWidgetInstancesChanged: { + const status = lockWidgetInstance("lockStatus"); + const auth = lockWidgetInstance("lockAuth"); + const power = lockWidgetInstance("lockPower"); + const mirror = (key, value) => { + if (root[key] !== value) + set(key, value); + }; + mirror("lockScreenShowWeather", !!status && status.enabled !== false && (status.config?.showWeather ?? true)); + mirror("lockScreenShowProfileImage", !!auth && (auth.config?.showProfileImage ?? true)); + mirror("lockScreenShowPowerActions", !!power && power.enabled !== false); + } + + function lockWidgetInstance(widgetType) { + return (lockScreenWidgetInstances || []).find(inst => inst.widgetType === widgetType) ?? null; + } + + function resetLockScreenWidgets() { + for (const inst of lockScreenWidgetInstances || []) + SessionData.removeDesktopWidgetInstancePositions(inst.id); + for (const inst of Spec.SPEC.lockScreenWidgetInstances.def) + SessionData.removeDesktopWidgetInstancePositions(inst.id); + resetToDefault(["lockScreenWidgetInstances"]); + } function getDefaultSystemMonitorConfig() { return { @@ -1149,45 +1170,53 @@ Singleton { }; } - function createDesktopWidgetInstance(widgetType, name, config) { - const id = "dw_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9); + function widgetInstanceListKey(instanceId) { + return widgetInstanceListKeys.find(key => (root[key] || []).some(inst => inst.id === instanceId)) ?? "desktopWidgetInstances"; + } + + function createDesktopWidgetInstance(widgetType, name, config, listKey = "desktopWidgetInstances") { + const lockScreen = listKey === "lockScreenWidgetInstances"; const instance = { - id: id, + id: (lockScreen ? "lw_" : "dw_") + Date.now() + "_" + Math.random().toString(36).substr(2, 9), widgetType: widgetType, name: name || widgetType, enabled: true, - config: config || {} + config: Object.assign(lockScreen ? { + syncPositionAcrossScreens: true + } : {}, config || {}) }; - const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || [])); + const instances = JSON.parse(JSON.stringify(root[listKey] || [])); instances.push(instance); - desktopWidgetInstances = instances; + root[listKey] = instances; saveSettings(); return instance; } function updateDesktopWidgetInstance(instanceId, updates) { - const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || [])); + const listKey = widgetInstanceListKey(instanceId); + const instances = JSON.parse(JSON.stringify(root[listKey] || [])); const idx = instances.findIndex(inst => inst.id === instanceId); if (idx === -1) return; Object.assign(instances[idx], updates); - desktopWidgetInstances = instances; + root[listKey] = instances; saveSettings(); } function updateDesktopWidgetInstanceConfig(instanceId, configUpdates) { - const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || [])); + const listKey = widgetInstanceListKey(instanceId); + const instances = JSON.parse(JSON.stringify(root[listKey] || [])); const idx = instances.findIndex(inst => inst.id === instanceId); if (idx === -1) return; instances[idx].config = Object.assign({}, instances[idx].config || {}, configUpdates); - desktopWidgetInstances = instances; + root[listKey] = instances; saveSettings(); } function removeDesktopWidgetInstance(instanceId) { - const instances = (desktopWidgetInstances || []).filter(inst => inst.id !== instanceId); - desktopWidgetInstances = instances; + const listKey = widgetInstanceListKey(instanceId); + root[listKey] = (root[listKey] || []).filter(inst => inst.id !== instanceId); SessionData.removeDesktopWidgetInstancePositions(instanceId); saveSettings(); } @@ -1196,23 +1225,21 @@ Singleton { const source = getDesktopWidgetInstance(instanceId); if (!source) return null; - const newId = "dw_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9); - const instance = { - id: newId, - widgetType: source.widgetType, - name: source.name + " (Copy)", - enabled: source.enabled, - config: JSON.parse(JSON.stringify(source.config || {})) - }; - const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || [])); - instances.push(instance); - desktopWidgetInstances = instances; - saveSettings(); + const instance = createDesktopWidgetInstance(source.widgetType, source.name + " (Copy)", JSON.parse(JSON.stringify(source.config || {})), widgetInstanceListKey(instanceId)); + if (!source.enabled) + updateDesktopWidgetInstance(instance.id, { + enabled: false + }); return instance; } function getDesktopWidgetInstance(instanceId) { - return (desktopWidgetInstances || []).find(inst => inst.id === instanceId) || null; + for (const key of widgetInstanceListKeys) { + const found = (root[key] || []).find(inst => inst.id === instanceId); + if (found) + return found; + } + return null; } function moveDesktopWidgetInstanceToGroup(instanceId, groupId, newIndexInGroup) { diff --git a/quickshell/Common/settings/SessionSpec.js b/quickshell/Common/settings/SessionSpec.js index 237ab7276..10d6d885c 100644 --- a/quickshell/Common/settings/SessionSpec.js +++ b/quickshell/Common/settings/SessionSpec.js @@ -36,9 +36,6 @@ var LOCAL_SPEC = { terminalOverride: { def: "" }, - perModeWallpaper: { - def: false - }, wallpaperPathLight: { def: "" }, @@ -250,9 +247,6 @@ var LOCAL_SPEC = { desktopWidgetGridSettings: { def: {} }, - desktopWidgetInstancePositions: { - def: {} - }, islandFreePositions: { def: {} }, diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 39046ad2b..4aae0c632 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -3,6 +3,10 @@ .import "../../DankCommon/Common/settings/SpecUtil.js" as Util .import "DockConfig.js" as DockConfig +function lockWidgetDefaults() { + return Shared.lockWidgetDefaults(); +} + var LOCAL_SPEC = { dockConfigs: { def: [DockConfig.create("dock", "Dock")] @@ -1143,24 +1147,6 @@ var LOCAL_SPEC = { modalDarkenBackground: { def: true }, - lockScreenShowSystemIcons: { - def: true - }, - lockScreenShowTime: { - def: true - }, - lockScreenClockStyle: { - def: "horizontal" - }, - lockScreenShowDate: { - def: true - }, - lockScreenShowPasswordField: { - def: true - }, - lockScreenShowMediaPlayer: { - def: true - }, lockScreenPowerOffMonitorsOnLock: { def: false }, @@ -1204,9 +1190,6 @@ var LOCAL_SPEC = { lockScreenInactiveColor: { def: "#000000" }, - lockScreenNotificationMode: { - def: 0 - }, lockScreenVideoEnabled: { def: false }, @@ -1379,9 +1362,6 @@ var LOCAL_SPEC = { updaterIgnoredPackages: { def: [] }, - displayNameMode: { - def: "system" - }, screenPreferences: { def: {} }, diff --git a/quickshell/Common/settings/SettingsStore.js b/quickshell/Common/settings/SettingsStore.js index 31fbcd444..bb6d81dbc 100644 --- a/quickshell/Common/settings/SettingsStore.js +++ b/quickshell/Common/settings/SettingsStore.js @@ -713,9 +713,52 @@ function migrateToVersion(obj, targetVersion) { settings.configVersion = 37; } + if (currentVersion < 38 && targetVersion >= 38) { + migrateLockScreenWidgets(settings); + settings.configVersion = 38; + } + return settings; } +var LOCK_WIDGET_MOVED_KEYS = ["lockScreenShowSystemIcons", "lockScreenShowTime", "lockScreenClockStyle", "lockScreenShowDate", "lockScreenShowPasswordField", "lockScreenShowMediaPlayer", "lockScreenNotificationMode"]; +var LOCK_WIDGET_GREETER_KEYS = ["lockScreenShowPowerActions", "lockScreenShowProfileImage", "lockScreenShowWeather"]; + +function migrateLockScreenWidgets(settings) { + if (Array.isArray(settings.lockScreenWidgetInstances)) + return; + const present = key => key in settings; + if (!LOCK_WIDGET_MOVED_KEYS.concat(LOCK_WIDGET_GREETER_KEYS).some(present)) + return; + const instances = SpecModule.lockWidgetDefaults(); + const byId = id => instances.find(inst => inst.id === id); + if (present("lockScreenShowTime")) + byId("lock_clock").enabled = !!settings.lockScreenShowTime; + if (present("lockScreenClockStyle")) + byId("lock_clock").config.style = settings.lockScreenClockStyle; + if (present("lockScreenShowDate")) + byId("lock_date").enabled = !!settings.lockScreenShowDate; + byId("lock_auth").config.profileVisibility = settings.lockScreenShowProfileImage === false ? "never" : "always"; + byId("lock_auth").config.passwordVisibility = settings.lockScreenShowPasswordField === false ? "typing" : "always"; + if (present("lockScreenNotificationMode")) { + const mode = Number(settings.lockScreenNotificationMode) || 0; + byId("lock_notifications").enabled = mode > 0; + if (mode > 0) + byId("lock_notifications").config.mode = mode; + } + if (present("lockScreenShowSystemIcons")) + byId("lock_status").enabled = !!settings.lockScreenShowSystemIcons; + if (present("lockScreenShowMediaPlayer")) + byId("lock_status").config.showMediaPlayer = !!settings.lockScreenShowMediaPlayer; + if (present("lockScreenShowWeather")) + byId("lock_status").config.showWeather = !!settings.lockScreenShowWeather; + if (present("lockScreenShowPowerActions")) + byId("lock_power").enabled = !!settings.lockScreenShowPowerActions; + settings.lockScreenWidgetInstances = instances; + for (const key of LOCK_WIDGET_MOVED_KEYS) + delete settings[key]; +} + function migrateControlCenterHeader(widgets, fixedHeader, columns) { const header = widgets.filter(widget => CC_HEADER_IDS.includes(widget?.id)); if (!fixedHeader && header.length === 0) diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 248d367cd..88873a0ac 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -426,7 +426,7 @@ FocusScope { "plugins_manage": "PluginsManageTab.qml" }) - readonly property var pagesWithParentModal: ["dankbar_widgets", "window_rules", "notification_rules", "display_config", "users", "time_weather", "weather", "lock_screen", "greeter", "dank_dash", "wallpaper_cycling", "theme_schedule", "surface_shadows", "keybinds", "dankbar_settings", "dankbar_appearance", "bar_widget", "dock_general", "dock_widgets", "dock_appearance", "dock_advanced", "launcher", "theme", "theme_apps", "media_player", "desktop_widgets", "autostart", "compositor_layout", "updater"] + readonly property var pagesWithParentModal: ["dankbar_widgets", "window_rules", "notification_rules", "display_config", "users", "time_weather", "weather", "lock_screen", "greeter", "dank_dash", "wallpaper_cycling", "theme_schedule", "surface_shadows", "keybinds", "dankbar_settings", "dankbar_appearance", "bar_widget", "dock_general", "dock_widgets", "dock_appearance", "dock_advanced", "launcher", "theme", "theme_apps", "media_player", "desktop_widgets", "desktop_widget", "autostart", "compositor_layout", "updater"] Column { anchors.fill: parent diff --git a/quickshell/Modules/DankDash/Overview/DashEditControls.qml b/quickshell/Modules/DankDash/Overview/DashEditControls.qml index 3fff1a12e..b80895b0a 100644 --- a/quickshell/Modules/DankDash/Overview/DashEditControls.qml +++ b/quickshell/Modules/DankDash/Overview/DashEditControls.qml @@ -11,6 +11,7 @@ Item { property bool vertical: false property bool canAdd: true property bool hasWidgets: true + property bool canClear: true property bool hasOptions: false property bool hasCustomActions: false property string pendingAction: "" @@ -172,7 +173,7 @@ Item { iconName: "clear_all" armedColor: Theme.error armedTextColor: Theme.onError - visible: root.hasWidgets + visible: root.hasWidgets && root.canClear onConfirmed: root.clearRequested() } diff --git a/quickshell/Modules/DesktopWidgetLayer.qml b/quickshell/Modules/DesktopWidgetLayer.qml index 6075c9b8c..eb256b6eb 100644 --- a/quickshell/Modules/DesktopWidgetLayer.qml +++ b/quickshell/Modules/DesktopWidgetLayer.qml @@ -3,7 +3,6 @@ import Quickshell import qs.Common import qs.Services import qs.Modules.Plugins -import qs.Modules.BuiltinDesktopPlugins Variants { id: root @@ -12,7 +11,7 @@ Variants { Component.onCompleted: Qt.callLater(autoEnablePluginsForInstances) function autoEnablePluginsForInstances() { - const instances = SettingsData.desktopWidgetInstances || []; + const instances = (SettingsData.desktopWidgetInstances || []).concat(SettingsData.lockScreenWidgetInstances || []); const pluginTypes = new Set(); for (const inst of instances) { @@ -45,17 +44,6 @@ Variants { required property var modelData readonly property var screen: modelData - readonly property string screenKey: SettingsData.getScreenDisplayName(screen) - - function shouldShowOnScreen(prefs) { - if (!Array.isArray(prefs) || prefs.length === 0 || prefs.includes("all")) - return true; - return prefs.some(p => { - if (typeof p === "string") - return p === screenKey || p === modelData.name; - return p?.name === modelData.name || p === screenKey; - }); - } // Layer surfaces stack by map order, so recreate them in list order on // reorder/enable/display-pref changes or once a plugin component loads (#2715). @@ -104,14 +92,6 @@ Variants { onTriggered: screenDelegate.rebuilding = false } - property Component clockComponent: Component { - DesktopClockWidget {} - } - - property Component systemMonitorComponent: Component { - SystemMonitorWidget {} - } - property Instantiator widgetInstantiator: Instantiator { model: ScriptModel { objectProp: "id" @@ -133,23 +113,12 @@ Variants { if (!liveInstanceData.enabled) return false; const prefs = liveInstanceData.config?.displayPreferences ?? ["all"]; - return screenDelegate.shouldShowOnScreen(prefs); + return DesktopWidgetRegistry.showsOnScreen(prefs, screenDelegate.screen); } pluginId: liveInstanceData.widgetType instanceId: instanceIdRef instanceData: liveInstanceData - builtinComponent: { - switch (liveInstanceData.widgetType) { - case "desktopClock": - return screenDelegate.clockComponent; - case "systemMonitor": - return screenDelegate.systemMonitorComponent; - default: - return null; - } - } - pluginService: (liveInstanceData.widgetType !== "desktopClock" && liveInstanceData.widgetType !== "systemMonitor") ? PluginService : null screen: screenDelegate.screen widgetEnabled: shouldBeVisible } diff --git a/quickshell/Modules/Lock/Lock.qml b/quickshell/Modules/Lock/Lock.qml index f46f229db..d8c051dae 100644 --- a/quickshell/Modules/Lock/Lock.qml +++ b/quickshell/Modules/Lock/Lock.qml @@ -13,6 +13,7 @@ Scope { readonly property var log: Log.scoped("Lock") property string sharedPasswordBuffer: "" + property bool inputRevealed: false property bool shouldLock: false onSharedPasswordBufferChanged: { @@ -22,6 +23,7 @@ Scope { } onShouldLockChanged: { + inputRevealed = false; IdleService.isShellLocked = shouldLock; } @@ -223,6 +225,12 @@ Scope { resumeRelockTimer.restart(); } + function onLockEditorRequested() { + if (shouldLock) + return; + demoWindow.showDemo(); + } + function onLoginctlStateChanged() { if (SessionService.active && pendingLock) { pendingLock = false; @@ -285,6 +293,8 @@ Scope { lock: sessionLock pam: sharedPam sharedPasswordBuffer: root.sharedPasswordBuffer + inputRevealed: root.inputRevealed + onInputRevealRequested: revealed => root.inputRevealed = revealed screenName: lockSurface.currentScreenName isLocked: shouldLock onUnlockRequested: root.unlock() diff --git a/quickshell/Modules/Lock/LockBackgroundSampler.qml b/quickshell/Modules/Lock/LockBackgroundSampler.qml new file mode 100644 index 000000000..c0e59ce14 --- /dev/null +++ b/quickshell/Modules/Lock/LockBackgroundSampler.qml @@ -0,0 +1,96 @@ +import QtQuick +import QtQuick.Window +import "LockPlacement.js" as Placement + +Item { + id: root + + required property Item sourceItem + property string renderKey: "" + property bool ready: false + property bool busy: false + property string capturedKey: "" + property var capture: null + readonly property size sourceSize: Qt.size(sourceItem.width, sourceItem.height) + readonly property real sampleScale: 128 / Math.max(1, sourceItem.width, sourceItem.height) + readonly property int sampleWidth: Math.max(1, Math.round(sourceItem.width * sampleScale)) + readonly property int sampleHeight: Math.max(1, Math.round(sourceItem.height * sampleScale)) + readonly property bool sourceVisible: sourceItem.visible && (sourceItem.Window.window?.visible ?? false) + + signal sampled(var luminances, int sampleWidth, int sampleHeight) + signal failed + + onRenderKeyChanged: schedule.restart() + onReadyChanged: schedule.restart() + onSourceVisibleChanged: schedule.restart() + onSourceSizeChanged: schedule.restart() + Component.onCompleted: schedule.restart() + + function finish() { + if (capture) + canvas.unloadImage(capture.url); + capture = null; + busy = false; + if (capturedKey !== renderKey) + schedule.restart(); + } + + Timer { + id: schedule + interval: 0 + onTriggered: { + if (!root.ready || !root.sourceVisible || root.busy || !canvas.available || root.sourceItem.width <= 0 || root.sourceItem.height <= 0) + return; + root.capturedKey = root.renderKey; + root.busy = true; + const started = root.sourceItem.grabToImage(result => { + if (!root.ready || root.capturedKey !== root.renderKey) { + root.finish(); + return; + } + root.capture = result; + canvas.loadImage(result.url); + if (canvas.isImageLoaded(result.url)) + canvas.requestPaint(); + }, Qt.size(root.sampleWidth, root.sampleHeight)); + if (!started) { + root.finish(); + root.failed(); + } + } + } + + Canvas { + id: canvas + width: root.sampleWidth + height: root.sampleHeight + opacity: 0 + onAvailableChanged: schedule.restart() + onImageLoaded: requestPaint() + onPaint: { + if (!root.capture) + return; + if (isImageError(root.capture.url)) { + root.finish(); + root.failed(); + return; + } + if (!root.ready || root.capturedKey !== root.renderKey) { + root.finish(); + return; + } + if (!isImageLoaded(root.capture.url)) + return; + const context = getContext("2d"); + context.clearRect(0, 0, width, height); + context.drawImage(root.capture.url, 0, 0, width, height); + const pixels = context.getImageData(0, 0, width, height).data; + const luminances = []; + for (let i = 0; i < pixels.length; i += 4) + luminances.push(Placement.luminance(pixels[i] / 255, pixels[i + 1] / 255, pixels[i + 2] / 255)); + context.clearRect(0, 0, width, height); + root.finish(); + root.sampled(luminances, width, height); + } + } +} diff --git a/quickshell/Modules/Lock/LockPlacement.js b/quickshell/Modules/Lock/LockPlacement.js new file mode 100644 index 000000000..830d8fa90 --- /dev/null +++ b/quickshell/Modules/Lock/LockPlacement.js @@ -0,0 +1,65 @@ +.pragma library + +function luminance(red, green, blue) { + const linear = value => value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); + return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue); +} + +function intersects(a, b) { + return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; +} + +function candidates(width, height, blockWidth, blockHeight, inset, obstacles) { + const right = width - inset - blockWidth; + const bottom = height - inset - blockHeight; + if (right < inset || bottom < inset) + return []; + const result = []; + for (const y of [inset, (height - blockHeight) / 2, bottom]) { + for (const x of [(width - blockWidth) / 2, inset, right]) { + const rect = { + x, + y, + width: blockWidth, + height: blockHeight + }; + if (!obstacles.some(obstacle => intersects(rect, obstacle))) + result.push(rect); + } + } + return result; +} + +function choose(luma, sampleWidth, sampleHeight, width, height, choices, foregrounds) { + if (!choices.length || !foregrounds.length || !luma.length || width <= 0 || height <= 0) + return null; + let best = null; + let bestScore = -Infinity; + for (const rect of choices) { + const left = Math.max(0, Math.floor(rect.x * sampleWidth / width)); + const top = Math.max(0, Math.floor(rect.y * sampleHeight / height)); + const right = Math.min(sampleWidth, Math.ceil((rect.x + rect.width) * sampleWidth / width)); + const bottom = Math.min(sampleHeight, Math.ceil((rect.y + rect.height) * sampleHeight / height)); + const contrasts = []; + let edges = 0; + for (let y = top; y < bottom; y++) { + for (let x = left; x < right; x++) { + const value = luma[y * sampleWidth + x]; + contrasts.push(Math.min(...foregrounds.map(foreground => (Math.max(value, foreground) + 0.05) / (Math.min(value, foreground) + 0.05)))); + if (x > left) + edges += Math.abs(value - luma[y * sampleWidth + x - 1]); + if (y > top) + edges += Math.abs(value - luma[(y - 1) * sampleWidth + x]); + } + } + if (!contrasts.length) + continue; + contrasts.sort((a, b) => a - b); + const score = contrasts[Math.floor(contrasts.length / 10)] - edges / contrasts.length * 4; + if (score <= bestScore) + continue; + bestScore = score; + best = rect; + } + return best; +} diff --git a/quickshell/Modules/Lock/LockPlacementPreparer.qml b/quickshell/Modules/Lock/LockPlacementPreparer.qml new file mode 100644 index 000000000..7ce607b62 --- /dev/null +++ b/quickshell/Modules/Lock/LockPlacementPreparer.qml @@ -0,0 +1,61 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Services + +Variants { + model: Quickshell.screens + + Scope { + id: scope + + required property var modelData + readonly property string backgroundKey: LockPlacementService.backgroundKey(modelData.name, modelData.width, modelData.height) + property string failedKey: "" + property bool sampling: false + onBackgroundKeyChanged: failedKey = "" + + Loader { + id: preparation + active: scope.sampling || (!IdleService.isShellLocked && LockPlacementService.neededFor(scope.modelData) && scope.failedKey !== scope.backgroundKey && !LockPlacementService.hasSample(scope.modelData.name, scope.backgroundKey)) + asynchronous: true + + sourceComponent: PanelWindow { + screen: scope.modelData + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + color: "transparent" + WlrLayershell.namespace: "dms:lock-placement" + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + mask: Region {} + + Item { + anchors.fill: parent + opacity: 0 + + LockScreenBackground { + id: background + anchors.fill: parent + screenName: scope.modelData.name + } + + LockBackgroundSampler { + id: sampler + sourceItem: background + ready: background.ready && !IdleService.isShellLocked + renderKey: scope.backgroundKey + onBusyChanged: scope.sampling = busy + onFailed: scope.failedKey = scope.backgroundKey + onSampled: (luminances, sampleWidth, sampleHeight) => LockPlacementService.storeSample(scope.modelData.name, scope.backgroundKey, luminances, sampleWidth, sampleHeight) + } + } + } + } + } +} diff --git a/quickshell/Modules/Lock/LockScreenBackground.qml b/quickshell/Modules/Lock/LockScreenBackground.qml new file mode 100644 index 000000000..e4d06e72d --- /dev/null +++ b/quickshell/Modules/Lock/LockScreenBackground.qml @@ -0,0 +1,152 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets +import qs.DankCommon.Session + +Item { + id: root + property string screenName: "" + readonly property bool hasCustomWallpaper: SettingsData.lockScreenWallpaperPath !== "" + + function encodeFileUrl(path) { + if (!path) + return ""; + return "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/'); + } + readonly property bool ready: wallpaperBackground.active ? (wallpaperBackground.item?.ready ?? false) : (backdropLoader.item?.ready || backdropLoader.item?.liveReady || false) + readonly property string renderKey: LockPlacementService.backgroundKey(screenName, width, height) + + Rectangle { + anchors.fill: parent + color: SettingsData.effectiveWallpaperBackgroundColor + } + + Loader { + id: backdropLoader + anchors.fill: parent + active: { + if (root.hasCustomWallpaper) + return false; + var currentWallpaper = SessionData.getMonitorWallpaper(screenName); + return !currentWallpaper || (currentWallpaper && currentWallpaper.startsWith("#")); + } + asynchronous: true + + sourceComponent: DankBackdrop { + screenName: root.screenName + blur: Theme.lockScreenBlur + blurMax: Theme.lockScreenBlurMax + } + } + + Loader { + id: wallpaperBackground + anchors.fill: parent + + readonly property string wallpaperSource: { + if (root.hasCustomWallpaper) + return root.encodeFileUrl(SettingsData.lockScreenWallpaperPath); + var w = SessionData.getMonitorWallpaper(screenName); + return (w && !w.startsWith("#")) ? encodeFileUrl(w) : ""; + } + readonly property string fillModeName: { + if (SettingsData.lockScreenWallpaperFillMode !== "") + return SettingsData.lockScreenWallpaperFillMode; + return root.hasCustomWallpaper ? "Fill" : SessionData.getMonitorWallpaperFillMode(root.screenName); + } + + readonly property real screenScale: CompositorService.getScreenScale(Quickshell.screens.find(s => s.name === root.screenName) ?? null) + readonly property size decodeSize: Qt.size(Math.round(width * screenScale), Math.round(height * screenScale)) + + active: wallpaperSource !== "" && width > 0 && height > 0 + asynchronous: false + + sourceComponent: fillModeName === "Scrolling" ? scrollWallpaperComp : plainWallpaperComp + + layer.enabled: true + layer.effect: MultiEffect { + autoPaddingEnabled: false + blurEnabled: true + blur: Theme.lockScreenBlur + blurMax: Theme.lockScreenBlurMax + blurMultiplier: 1 + } + + Behavior on opacity { + NumberAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + + Component { + id: plainWallpaperComp + Image { + readonly property bool ready: status === Image.Ready || status === Image.Error + source: wallpaperBackground.wallpaperSource + sourceSize: wallpaperBackground.decodeSize + fillMode: Theme.getFillMode(wallpaperBackground.fillModeName) + smooth: true + cache: true + asynchronous: false + } + } + + Component { + id: scrollWallpaperComp + Item { + readonly property bool ready: scrollSource.status === Image.Ready || scrollSource.status === Image.Error + Image { + id: scrollSource + anchors.fill: parent + visible: false + source: wallpaperBackground.wallpaperSource + sourceSize: wallpaperBackground.decodeSize + asynchronous: false + cache: true + } + + ShaderEffectSource { + id: scrollSrc + sourceItem: scrollSource + hideSource: true + live: false + } + + ShaderEffect { + anchors.fill: parent + + readonly property var scrollPos: SessionData.getMonitorScrollPosition(screenName) + + property variant source1: scrollSrc + property variant source2: scrollSrc + property real progress: 0.0 + property real fillMode: Theme.getShaderFillMode(wallpaperBackground.fillModeName) + property real scrollX: scrollPos.scrollX + property real scrollY: scrollPos.scrollY + property real imageWidth1: scrollSource.implicitWidth > 0 ? scrollSource.implicitWidth : 1 + property real imageHeight1: scrollSource.implicitHeight > 0 ? scrollSource.implicitHeight : 1 + property real imageWidth2: imageWidth1 + property real imageHeight2: imageHeight1 + property real screenWidth: width > 0 ? width : 1 + property real screenHeight: height > 0 ? height : 1 + property vector4d fillColor: Qt.vector4d(0, 0, 0, 1) + + fragmentShader: Qt.resolvedUrl("../../Shaders/qsb/wp_fade.frag.qsb") + } + } + } + + Rectangle { + anchors.fill: parent + color: Theme.screenOffColor + opacity: Theme.lockScreenScrimAlpha + } +} diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index ce2375040..818507faa 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -1,29 +1,28 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Effects -import QtQuick.Layouts import QtQuick.Window -import Quickshell import qs.Common import qs.Modals -import qs.Modules.Notifications as Notifications +import qs.Modules.ControlCenter.Widgets +import qs.Modules.DankDash.Overview +import qs.Modules.Settings.DesktopWidgetSettings +import qs.Modules.Settings.Widgets import qs.Services import qs.Widgets import qs.DankCommon.Session -import "../../Common/KeyUtils.js" as KeyUtils -Item { +FocusScope { id: root readonly property var log: Log.scoped("LockScreenContent") - function encodeFileUrl(path) { - if (!path) - return ""; - return "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/'); - } - property string passwordBuffer: "" + property bool inputRevealed: false + signal inputRevealRequested(bool revealed) + + function setInputRevealed(revealed) { + inputRevealRequested(revealed); + } property bool demoMode: false property var pam: demoPam property string screenName: "" @@ -32,21 +31,17 @@ Item { property bool lockerReadySent: false property bool lockerReadyArmed: false property var sessionLock: null - readonly property bool hasCustomWallpaper: SettingsData.lockScreenWallpaperPath !== "" - readonly property string lockFontFamily: SettingsData.lockScreenFontFamily - - component ClockDigitText: StyledText { - font.pixelSize: LockMetrics.clockSize - font.weight: Theme.fontWeight - color: Theme.lockScreenContentColor - horizontalAlignment: Text.AlignHCenter - font.family: root.lockFontFamily !== "" ? root.lockFontFamily : resolvedFontFamily - } + property Item authWidget: null + readonly property Item backgroundItem: background + readonly property bool backgroundReady: background.ready + readonly property string backgroundKey: background.renderKey signal unlockRequested signal passwordEdited(string text) + signal authFailed function resetLockState() { + widgetLayer.refreshPlacement(); lockerReadySent = false; lockerReadyArmed = true; unlocking = false; @@ -56,54 +51,33 @@ Item { } function focusPasswordField() { - if (demoMode || !passwordField) + if (demoMode) return; - passwordField.forceActiveFocus(); + authWidget?.focusPasswordField(); } - function currentAuthFeedbackText() { - if (!pam) - return ""; - if (pam.u2fState === "insert" && !pam.u2fPending) - return I18n.tr("Insert your security key..."); - if (pam.u2fState === "waiting" && !pam.u2fPending) - return I18n.tr("Touch your security key..."); - if (pam.lockMessage && pam.lockMessage.length > 0) - return pam.lockMessage; - if (root.pamState === "error") - return I18n.tr("Authentication error - try again"); - if (root.pamState === "max") - return I18n.tr("Too many attempts - locked out"); - if (root.pamState === "fail") - return I18n.tr("Incorrect password - try again"); - if (pam.fprint.status === "disabled") - return ""; - if (pam.fprintState === "error") - return I18n.tr("Fingerprint error"); - if (pam.fprintState === "max") - return I18n.tr("Maximum fingerprint attempts reached. Please use password."); - if (pam.fprintState === "fail") - return I18n.tr("Fingerprint not recognized (%1/%2). Please try again or use password.", "lock screen message, %1 is attempts used, %2 is max attempts").arg(pam.fprint.tries).arg(SettingsData.maxFprintTries); - return ""; - } - - function authFeedbackIsHint() { - return pam && (pam.u2fState === "waiting" || pam.u2fState === "insert") && !pam.u2fPending; - } - - function canStartSecurityKeyUnlock() { - return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.passwd.active && !pam.u2f.active && !pam.u2fPending && !root.unlocking; - } - - function triggerSecurityKeyUnlock() { - if (!canStartSecurityKeyUnlock()) + function showPowerMenu() { + if (demoMode) return; - passwordField.clear(); - pam.u2f.startForAlternativeAuth(); + powerMenu.show(); } - function securityKeyShortcutMatches(event) { - return SettingsData.lockScreenSecurityKeyShortcutEnabled && KeyUtils.eventMatchesCombo(event, SettingsData.lockScreenSecurityKeyShortcut); + function contentColor(mode, custom) { + switch (mode) { + case "primary": + return Theme.primary; + case "secondary": + return Theme.secondary; + case "custom": + return custom; + } + return Theme.lockScreenContentColor; + } + + function addLockWidget(widgetType) { + const def = DesktopWidgetRegistry.getWidget(widgetType); + const instance = SettingsData.createDesktopWidgetInstance(widgetType, def?.name ?? widgetType, DesktopWidgetRegistry.getDefaultConfig(widgetType), "lockScreenWidgetInstances"); + widgetLayer.pendingIds = widgetLayer.pendingIds.concat([instance.id]); } Component.onCompleted: { @@ -173,1221 +147,22 @@ Item { onVisibleChanged: maybeSend() onOpacityChanged: maybeSend() - Rectangle { + LockScreenBackground { + id: background anchors.fill: parent - color: SettingsData.effectiveWallpaperBackgroundColor + screenName: root.screenName } - Loader { + LockWidgetLayer { + id: widgetLayer anchors.fill: parent - active: { - if (root.hasCustomWallpaper) - return false; - var currentWallpaper = SessionData.getMonitorWallpaper(screenName); - return !currentWallpaper || (currentWallpaper && currentWallpaper.startsWith("#")); - } - asynchronous: true - - sourceComponent: DankBackdrop { - screenName: root.screenName - blur: Theme.lockScreenBlur - blurMax: Theme.lockScreenBlurMax - } - } - - Loader { - id: wallpaperBackground - anchors.fill: parent - - readonly property string wallpaperSource: { - if (root.hasCustomWallpaper) - return root.encodeFileUrl(SettingsData.lockScreenWallpaperPath); - var w = SessionData.getMonitorWallpaper(screenName); - return (w && !w.startsWith("#")) ? encodeFileUrl(w) : ""; - } - readonly property string fillModeName: { - if (SettingsData.lockScreenWallpaperFillMode !== "") - return SettingsData.lockScreenWallpaperFillMode; - return root.hasCustomWallpaper ? "Fill" : SessionData.getMonitorWallpaperFillMode(root.screenName); - } - - readonly property real screenScale: CompositorService.getScreenScale(Quickshell.screens.find(s => s.name === root.screenName) ?? null) - readonly property size decodeSize: Qt.size(Math.round(width * screenScale), Math.round(height * screenScale)) - - active: wallpaperSource !== "" && width > 0 && height > 0 - asynchronous: false - - sourceComponent: fillModeName === "Scrolling" ? scrollWallpaperComp : plainWallpaperComp - - layer.enabled: true - layer.effect: MultiEffect { - autoPaddingEnabled: false - blurEnabled: true - blur: Theme.lockScreenBlur - blurMax: Theme.lockScreenBlurMax - blurMultiplier: 1 - } - - Behavior on opacity { - NumberAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - - Component { - id: plainWallpaperComp - Image { - source: wallpaperBackground.wallpaperSource - sourceSize: wallpaperBackground.decodeSize - fillMode: Theme.getFillMode(wallpaperBackground.fillModeName) - smooth: true - cache: true - asynchronous: false - } - } - - Component { - id: scrollWallpaperComp - Item { - Image { - id: scrollSource - anchors.fill: parent - visible: false - source: wallpaperBackground.wallpaperSource - sourceSize: wallpaperBackground.decodeSize - asynchronous: false - cache: true - } - - ShaderEffectSource { - id: scrollSrc - sourceItem: scrollSource - hideSource: true - live: false - } - - ShaderEffect { - anchors.fill: parent - - readonly property var scrollPos: SessionData.getMonitorScrollPosition(screenName) - - property variant source1: scrollSrc - property variant source2: scrollSrc - property real progress: 0.0 - property real fillMode: Theme.getShaderFillMode(wallpaperBackground.fillModeName) - property real scrollX: scrollPos.scrollX - property real scrollY: scrollPos.scrollY - property real imageWidth1: scrollSource.implicitWidth > 0 ? scrollSource.implicitWidth : 1 - property real imageHeight1: scrollSource.implicitHeight > 0 ? scrollSource.implicitHeight : 1 - property real imageWidth2: imageWidth1 - property real imageHeight2: imageHeight1 - property real screenWidth: width > 0 ? width : 1 - property real screenHeight: height > 0 ? height : 1 - property vector4d fillColor: Qt.vector4d(0, 0, 0, 1) - - fragmentShader: Qt.resolvedUrl("../../Shaders/qsb/wp_fade.frag.qsb") - } - } - } - - Rectangle { - anchors.fill: parent - color: Theme.screenOffColor - opacity: Theme.lockScreenScrimAlpha - } - - SystemClock { - id: systemClock - - precision: SystemClock.Seconds - } - - Rectangle { - anchors.fill: parent - color: "transparent" - - Item { - id: clockContainer - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.verticalCenter - anchors.bottomMargin: LockMetrics.fieldHeight - width: parent.width - height: verticalClock.visible ? verticalClock.implicitHeight : clockText.implicitHeight - visible: SettingsData.lockScreenShowTime - - Row { - id: clockText - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - spacing: 0 - visible: SettingsData.lockScreenClockStyle !== "vertical" - - property string fullTimeStr: { - const format = SettingsData.getEffectiveTimeFormat(); - return systemClock.date.toLocaleTimeString(Qt.locale(), format); - } - property var timeParts: fullTimeStr.split(':') - property string hours: timeParts[0] || "" - property string minutes: timeParts[1] || "" - property string secondsWithAmPm: timeParts.length > 2 ? timeParts[2] : "" - property string seconds: secondsWithAmPm.replace(/\s*(AM|PM|am|pm)$/i, '') - property string ampm: { - const match = fullTimeStr.match(/\s*(AM|PM|am|pm)$/i); - return match ? match[0].trim() : ""; - } - property bool hasSeconds: timeParts.length > 2 - - ClockDigitText { - width: clockText.hours.length > 1 ? LockMetrics.clockDigitWidth : 0 - text: clockText.hours.length > 1 ? clockText.hours[0] : "" - } - - ClockDigitText { - width: LockMetrics.clockDigitWidth - text: clockText.hours.length > 1 ? clockText.hours[1] : clockText.hours.length > 0 ? clockText.hours[0] : "" - } - - ClockDigitText { - text: ":" - } - - ClockDigitText { - width: LockMetrics.clockDigitWidth - text: clockText.minutes.length > 0 ? clockText.minutes[0] : "" - } - - ClockDigitText { - width: LockMetrics.clockDigitWidth - text: clockText.minutes.length > 1 ? clockText.minutes[1] : "" - } - - ClockDigitText { - text: clockText.hasSeconds ? ":" : "" - visible: clockText.hasSeconds - } - - ClockDigitText { - width: LockMetrics.clockDigitWidth - text: clockText.hasSeconds && clockText.seconds.length > 0 ? clockText.seconds[0] : "" - visible: clockText.hasSeconds - } - - ClockDigitText { - width: LockMetrics.clockDigitWidth - text: clockText.hasSeconds && clockText.seconds.length > 1 ? clockText.seconds[1] : "" - visible: clockText.hasSeconds - } - - ClockDigitText { - width: Theme.iconSizeSmall - text: " " - visible: clockText.ampm !== "" - } - - ClockDigitText { - text: clockText.ampm - visible: clockText.ampm !== "" - } - } - - Column { - id: verticalClock - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - spacing: -Theme.spacingS - visible: SettingsData.lockScreenClockStyle === "vertical" - - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: 0 - - ClockDigitText { - width: clockText.hours.length > 1 ? LockMetrics.clockDigitWidth : 0 - text: clockText.hours.length > 1 ? clockText.hours[0] : "" - } - ClockDigitText { - width: LockMetrics.clockDigitWidth - text: clockText.hours.length > 1 ? clockText.hours[1] : clockText.hours.length > 0 ? clockText.hours[0] : "" - } - } - - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: 0 - - ClockDigitText { - width: LockMetrics.clockDigitWidth - text: clockText.minutes.length > 0 ? clockText.minutes[0] : "" - } - ClockDigitText { - width: LockMetrics.clockDigitWidth - text: clockText.minutes.length > 1 ? clockText.minutes[1] : "" - } - } - - StyledText { - anchors.horizontalCenter: parent.horizontalCenter - text: clockText.hasSeconds ? clockText.seconds + (clockText.ampm !== "" ? " " + clockText.ampm : "") : clockText.ampm - font.pixelSize: Theme.fontSizeDisplay - font.weight: Theme.fontWeight - font.family: root.lockFontFamily !== "" ? root.lockFontFamily : resolvedFontFamily - color: Theme.lockScreenContentColor - visible: clockText.hasSeconds || clockText.ampm !== "" - } - } - } - - StyledText { - id: dateText - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: clockContainer.bottom - anchors.topMargin: Theme.spacingXS - visible: SettingsData.lockScreenShowDate - text: { - if (SettingsData.lockDateFormat && SettingsData.lockDateFormat.length > 0) { - return systemClock.date.toLocaleDateString(I18n.locale(), SettingsData.lockDateFormat); - } - return systemClock.date.toLocaleDateString(I18n.locale(), Locale.LongFormat); - } - font.pixelSize: Theme.fontSizeXLarge - font.family: root.lockFontFamily !== "" ? root.lockFontFamily : resolvedFontFamily - color: Theme.lockScreenContentColor - opacity: 1 - } - - Item { - id: lockNotificationPanel - - readonly property int notificationMode: SettingsData.lockScreenNotificationMode - readonly property var notifications: NotificationService.groupedNotifications - readonly property int totalCount: { - let count = 0; - for (const group of notifications) { - count += group.count || 0; - } - return count; - } - readonly property bool hasNotifications: totalCount > 0 - readonly property var appNameGroups: { - const groups = {}; - for (const group of notifications) { - const appName = (group.appName || "Unknown").toLowerCase(); - if (!groups[appName]) { - groups[appName] = { - appName: group.appName || I18n.tr("Unknown"), - count: 0, - latestNotification: group.latestNotification - }; - } - groups[appName].count += group.count || 0; - if (group.latestNotification && (!groups[appName].latestNotification || group.latestNotification.time > groups[appName].latestNotification.time)) { - groups[appName].latestNotification = group.latestNotification; - } - } - return Object.values(groups).sort((a, b) => b.count - a.count); - } - - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: capsLockRow.bottom - anchors.topMargin: Theme.spacingM - width: Math.min(LockMetrics.notificationCardWidth, parent.width - Theme.spacingXL * 2) - height: notificationMode === 0 || !hasNotifications ? 0 : contentLoader.height - visible: notificationMode > 0 && hasNotifications - clip: true - - Loader { - id: contentLoader - anchors.left: parent.left - anchors.right: parent.right - active: lockNotificationPanel.notificationMode > 0 && lockNotificationPanel.hasNotifications - sourceComponent: lockNotificationPanel.notificationMode === 1 ? countOnlyComponent : notificationListComponent - } - - Component { - id: countOnlyComponent - - LockNotificationCard { - width: parent.width - height: Theme.listItemHeight - color: Theme.notificationFloatingSurface - Accessible.name: countLabel.text - - Row { - anchors.centerIn: parent - spacing: Theme.spacingS - - DankIcon { - name: "notifications" - size: Theme.iconSize - color: Theme.onSurfaceVariant - anchors.verticalCenter: parent.verticalCenter - } - - StyledText { - id: countLabel - text: lockNotificationPanel.totalCount === 1 ? I18n.tr("1 notification") : I18n.tr("%1 notifications").arg(lockNotificationPanel.totalCount) - font.pixelSize: Theme.fontSizeMedium - color: Theme.onSurface - anchors.verticalCenter: parent.verticalCenter - } - } - } - } - - Component { - id: notificationListComponent - - DankFlickable { - width: parent.width - height: Math.min(notificationColumn.implicitHeight, LockMetrics.notificationMaxHeight, Math.max(0, root.height - lockNotificationPanel.y - Theme.spacingXL)) - contentHeight: notificationColumn.implicitHeight - clip: true - - Column { - id: notificationColumn - width: parent.width - spacing: Theme.groupedListGap - - Repeater { - id: notificationRepeater - model: lockNotificationPanel.appNameGroups.slice(0, LockMetrics.notificationLimit) - - Notifications.NotificationCard { - required property var modelData - required property int index - width: notificationColumn.width - notificationData: modelData.latestNotification - groupCount: modelData.count - interactive: false - headerOnly: lockNotificationPanel.notificationMode === 2 - showActions: false - showDismiss: false - showClose: false - showTime: !headerOnly - animateHeight: false - firstInGroup: index === 0 - lastInGroup: index === notificationRepeater.count - 1 - } - } - - StyledText { - width: parent.width - visible: lockNotificationPanel.appNameGroups.length > LockMetrics.notificationLimit - text: I18n.tr("+ %1 more", "lock screen notification list overflow, %1 is a count of hidden apps").arg(lockNotificationPanel.appNameGroups.length - LockMetrics.notificationLimit) - font.pixelSize: Theme.fontSizeSmall - color: Theme.lockScreenContentColor - horizontalAlignment: Text.AlignHCenter - } - } - } - } - } - - ColumnLayout { - id: passwordLayout - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: dateText.visible ? dateText.bottom : clockContainer.bottom - anchors.topMargin: Theme.spacingL - spacing: Theme.spacingM - width: Math.min(LockMetrics.passwordRowWidth, parent.width - Theme.spacingXL * 2) - - RowLayout { - LayoutMirroring.enabled: I18n.isRtl - LayoutMirroring.childrenInherit: true - spacing: Theme.spacingM - Layout.fillWidth: true - - DankCircularImage { - Layout.preferredWidth: LockMetrics.avatarSize - ringWidth: Theme.avatarRingWidth - ringColor: Theme.avatarRingColor - Layout.preferredHeight: LockMetrics.fieldHeight - imageSource: { - if (PortalService.profileImage === "") - return ""; - if (PortalService.profileImage.startsWith("/")) - return encodeFileUrl(PortalService.profileImage); - return PortalService.profileImage; - } - fallbackIcon: "material:person" - visible: SettingsData.lockScreenShowProfileImage - } - - Rectangle { - id: passwordBox - - property bool showPassword: false - property real errorOffset: 0 - readonly property bool focusRingShown: passwordField.activeFocus && Theme.focusRingWidth > 0 - transform: Translate { - x: Math.max(-LockMetrics.shakeDistance, Math.min(LockMetrics.shakeDistance, passwordBox.errorOffset)) - } - - Layout.fillWidth: true - Layout.preferredHeight: LockMetrics.fieldHeight - radius: Theme.fullRadius(width, height) - color: Theme.cardSurface - border.width: focusRingShown ? Math.max(Theme.outlineWidth, Theme.focusRingWidth) : Theme.layerOutlineWidth - border.color: focusRingShown ? Theme.focusRingColor : Theme.outlineMedium - Accessible.name: I18n.tr("Password") - visible: SettingsData.lockScreenShowPasswordField || root.passwordBuffer.length > 0 - - Item { - id: lockIconContainer - anchors.left: parent.left - anchors.leftMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - width: Theme.iconSizeSmall - height: Theme.iconSizeSmall - - DankIcon { - id: lockIcon - - anchors.centerIn: parent - name: { - if (pam.u2fPending) - return "passkey"; - switch (pam.fprint.status) { - case "max": - case "stopped": - return "fingerprint_off"; - case "active": - return "fingerprint"; - case "retrying": - return "hourglass_empty"; - } - if (pam.u2f.active) - return "passkey"; - return "lock"; - } - size: Theme.iconSizeSmall - color: { - if (pam.fprint.tries >= SettingsData.maxFprintTries) - return Theme.error; - if (pam.u2fState !== "") - return Theme.tertiary; - return passwordField.activeFocus ? Theme.primary : Theme.surfaceVariantText; - } - opacity: pam.passwd.active ? 0 : 1 - - Behavior on opacity { - NumberAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - } - - FocusScope { - id: passwordField - - KeyNavigation.tab: virtualKeyboardButton.visible ? virtualKeyboardButton : powerButton - KeyNavigation.backtab: powerButton - Accessible.role: Accessible.EditableText - Accessible.name: I18n.tr("Password") - - readonly property string text: root.passwordBuffer - property int cursorPosition: text.length - - signal accepted - - function clampCursorPosition() { - cursorPosition = Math.max(0, Math.min(cursorPosition, text.length)); - } - - function clear() { - root.passwordEdited(""); - cursorPosition = 0; - } - - function insertText(value) { - if (value.length === 0) - return; - clampCursorPosition(); - const pos = cursorPosition; - root.passwordEdited(text.slice(0, pos) + value + text.slice(pos)); - cursorPosition = pos + value.length; - } - - function backspace() { - clampCursorPosition(); - if (cursorPosition === 0) - return; - const pos = cursorPosition; - root.passwordEdited(text.slice(0, pos - 1) + text.slice(pos)); - cursorPosition = pos - 1; - } - - function deleteForward() { - clampCursorPosition(); - if (cursorPosition === text.length) - return; - const pos = cursorPosition; - root.passwordEdited(text.slice(0, pos) + text.slice(pos + 1)); - cursorPosition = pos; - } - - function deleteToLineStart() { - clampCursorPosition(); - if (cursorPosition === 0) - return; - root.passwordEdited(text.slice(cursorPosition)); - cursorPosition = 0; - } - - function deleteToLineEnd() { - clampCursorPosition(); - if (cursorPosition === text.length) - return; - root.passwordEdited(text.slice(0, cursorPosition)); - } - - function deleteWordBackward() { - clampCursorPosition(); - if (cursorPosition === 0) - return; - let pos = cursorPosition; - while (pos > 0 && text.charAt(pos - 1) === " ") - pos--; - while (pos > 0 && text.charAt(pos - 1) !== " ") - pos--; - root.passwordEdited(text.slice(0, pos) + text.slice(cursorPosition)); - cursorPosition = pos; - } - - function isPrintableText(value) { - if (value.length === 0) - return false; - const code = value.charCodeAt(0); - return code >= 0x20 && code !== 0x7f; - } - - anchors.fill: parent - anchors.leftMargin: lockIconContainer.width + Theme.spacingM * 2 - anchors.rightMargin: { - let margin = Theme.spacingM; - if (loadingSpinner.visible) { - margin += loadingSpinner.width; - } - if (enterButton.visible) { - margin += enterButton.width + Theme.spacingXXS; - } - if (securityKeyButton.visible) { - margin += securityKeyButton.width; - } - if (virtualKeyboardButton.visible) { - margin += virtualKeyboardButton.width; - } - if (revealButton.visible) { - margin += revealButton.width; - } - return margin; - } - opacity: 0 - focus: true - enabled: !demoMode - activeFocusOnTab: !demoMode - onTextChanged: cursorPosition = text.length - onAccepted: { - if (!demoMode && !root.unlocking && !pam.passwd.active && !pam.u2fPending) { - pam.passwd.start(); - } - } - Keys.onPressed: event => handleKey(event) - - function handleKey(event) { - if (demoMode) { - return; - } - - root.pam.retryFprintOnActivity(); - - if (root.unlocking) { - event.accepted = true; - return; - } - - if (event.key === Qt.Key_Escape) { - if (keyboardController.isKeyboardActive) { - keyboardController.hide(); - event.accepted = true; - return; - } - if (pam.u2fPending) { - pam.cancelU2fPending(); - event.accepted = true; - return; - } - clear(); - event.accepted = true; - return; - } - - if (pam.passwd.active) { - log.debug("PAM is active, ignoring input"); - event.accepted = true; - return; - } - - if ((event.modifiers & Qt.ControlModifier) && !(event.modifiers & (Qt.AltModifier | Qt.MetaModifier))) { - if (securityKeyShortcutMatches(event) && canStartSecurityKeyUnlock()) { - triggerSecurityKeyUnlock(); - event.accepted = true; - return; - } - - switch (event.key) { - case Qt.Key_A: - cursorPosition = 0; - event.accepted = true; - return; - case Qt.Key_E: - cursorPosition = text.length; - event.accepted = true; - return; - case Qt.Key_B: - clampCursorPosition(); - cursorPosition = Math.max(0, cursorPosition - 1); - event.accepted = true; - return; - case Qt.Key_F: - clampCursorPosition(); - cursorPosition = Math.min(text.length, cursorPosition + 1); - event.accepted = true; - return; - case Qt.Key_U: - deleteToLineStart(); - event.accepted = true; - return; - case Qt.Key_K: - deleteToLineEnd(); - event.accepted = true; - return; - case Qt.Key_W: - case Qt.Key_Backspace: - deleteWordBackward(); - event.accepted = true; - return; - case Qt.Key_H: - backspace(); - event.accepted = true; - return; - case Qt.Key_D: - deleteForward(); - event.accepted = true; - return; - } - } - - switch (event.key) { - case Qt.Key_Return: - case Qt.Key_Enter: - accepted(); - event.accepted = true; - return; - case Qt.Key_Backspace: - backspace(); - event.accepted = true; - return; - case Qt.Key_Delete: - deleteForward(); - event.accepted = true; - return; - case Qt.Key_Left: - clampCursorPosition(); - cursorPosition = Math.max(0, cursorPosition - 1); - event.accepted = true; - return; - case Qt.Key_Right: - clampCursorPosition(); - cursorPosition = Math.min(text.length, cursorPosition + 1); - event.accepted = true; - return; - case Qt.Key_Home: - cursorPosition = 0; - event.accepted = true; - return; - case Qt.Key_End: - cursorPosition = text.length; - event.accepted = true; - return; - } - - if (isPrintableText(event.text)) { - insertText(event.text); - event.accepted = true; - } - } - - // IME commits use a hidden password input: https://github.com/AvengeMedia/DankMaterialShell/issues/2950 - TextInput { - id: imeCommitSink - - focus: true - width: Theme.dividerWidth - height: 1 - opacity: 0 - cursorDelegate: Item {} - echoMode: TextInput.Password - inputMethodHints: Qt.ImhHiddenText | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase - KeyNavigation.tab: virtualKeyboardButton.visible ? virtualKeyboardButton : powerButton - KeyNavigation.backtab: powerButton - Keys.onPressed: event => { - passwordField.handleKey(event); - if (!event.accepted && (event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier))) - event.accepted = true; - } - onTextChanged: { - if (text.length === 0) - return; - const committed = text; - text = ""; - if (demoMode || root.unlocking || pam.passwd.active) - return; - passwordField.insertText(committed); - } - } - - Component.onCompleted: { - if (!demoMode) { - forceActiveFocus(); - } - } - - onVisibleChanged: { - if (visible && !demoMode) { - forceActiveFocus(); - } - } - } - - KeyboardController { - id: keyboardController - target: passwordField - rootObject: root - expressive: true - } - - StyledText { - id: placeholder - - anchors.left: lockIconContainer.right - anchors.leftMargin: Theme.spacingM - anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))))) - anchors.rightMargin: Theme.spacingXXS - anchors.verticalCenter: parent.verticalCenter - text: { - if (demoMode) { - return ""; - } - if (root.unlocking) { - return I18n.tr("Unlocking...", "lock screen status text while unlocking"); - } - if (pam.u2fPending) { - if (pam.u2fState === "insert") - return I18n.tr("Insert your security key..."); - return I18n.tr("Touch your security key..."); - } - if (pam.passwd.active) { - return I18n.tr("Authenticating...", "lock screen status text while the password is checked"); - } - return I18n.tr("Password", "lock screen password field placeholder") + "…"; - } - color: root.unlocking ? Theme.primary : (pam.passwd.active ? Theme.primary : Theme.outline) - font.pixelSize: Theme.fontSizeMedium - opacity: (demoMode || root.passwordBuffer.length === 0) ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - - Behavior on color { - ColorAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - - Item { - id: passwordViewport - - property real scrollX: 0 - property real scrollY: 0 - - function followCursor() { - const rect = passwordDisplay.cursorRectangle; - const contentWidth = passwordDisplay.contentWidth + passwordCursor.width; - const contentHeight = passwordDisplay.contentHeight; - scrollX = followAxis(scrollX, rect.x, rect.x + passwordCursor.width, contentWidth, width); - scrollY = contentHeight < height ? (height - contentHeight) / 2 : followAxis(scrollY, rect.y, rect.y + rect.height, contentHeight, height); - } - - function followAxis(offset, start, end, contentSize, viewSize) { - if (contentSize <= viewSize) - return 0; - if (start + offset < 0) - return -start; - if (end + offset > viewSize) - return viewSize - end; - return Math.max(viewSize - contentSize, Math.min(0, offset)); - } - - anchors.left: lockIconContainer.right - anchors.leftMargin: Theme.spacingM - anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))))) - anchors.rightMargin: Theme.spacingXXS - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.topMargin: Theme.spacingXS - anchors.bottomMargin: Theme.spacingXS - clip: true - - MouseArea { - anchors.fill: parent - enabled: !root.demoMode - onClicked: passwordField.forceActiveFocus() - } - - onWidthChanged: followCursor() - onHeightChanged: followCursor() - - TextEdit { - id: passwordDisplay - LayoutMirroring.enabled: false - - x: passwordViewport.scrollX - y: passwordViewport.scrollY - width: wrapMode === TextEdit.NoWrap ? implicitWidth : passwordViewport.width - readOnly: true - activeFocusOnPress: false - selectByMouse: false - wrapMode: passwordBox.showPassword ? TextEdit.Wrap : TextEdit.NoWrap - text: { - if (demoMode) { - return "••••••••"; - } - if (passwordBox.showPassword) { - return root.passwordBuffer; - } - return "•".repeat(root.passwordBuffer.length); - } - color: Theme.surfaceText - font.family: Theme.fontFamily - font.weight: Theme.fontWeight - font.pixelSize: passwordBox.showPassword ? Theme.fontSizeMedium : Theme.fontSizeLarge - opacity: (demoMode || root.passwordBuffer.length > 0) ? 1 : 0 - - onTextChanged: cursorPosition = passwordField.cursorPosition - onCursorRectangleChanged: passwordViewport.followCursor() - onContentWidthChanged: passwordViewport.followCursor() - onContentHeightChanged: passwordViewport.followCursor() - - Behavior on opacity { - NumberAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - - DankTextCursor { - id: passwordCursor - - x: passwordDisplay.x + passwordDisplay.cursorRectangle.x - y: passwordDisplay.y + passwordDisplay.cursorRectangle.y - height: passwordDisplay.cursorRectangle.height - shown: !demoMode && passwordField.activeFocus && !pam.passwd.active && !pam.u2fPending && !root.unlocking - - readonly property int fieldCursorPosition: passwordField.cursorPosition - readonly property string fieldText: passwordField.text - - onFieldCursorPositionChanged: { - passwordDisplay.cursorPosition = fieldCursorPosition; - resetBlink(); - } - - onFieldTextChanged: resetBlink() - } - } - - LockActionButton { - id: revealButton - - activeFocusOnTab: false - Accessible.name: parent.showPassword ? I18n.tr("Hide password") : I18n.tr("Show password") - - anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))) - anchors.rightMargin: 0 - anchors.verticalCenter: parent.verticalCenter - iconName: parent.showPassword ? "visibility_off" : "visibility" - buttonSize: Theme.buttonHeightXS - visible: !demoMode && root.passwordBuffer.length > 0 && !pam.passwd.active && !root.unlocking - enabled: visible - onClicked: parent.showPassword = !parent.showPassword - } - LockActionButton { - id: securityKeyButton - - activeFocusOnTab: false - - anchors.right: enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right) - anchors.rightMargin: 0 - anchors.verticalCenter: parent.verticalCenter - iconName: "passkey" - buttonSize: Theme.buttonHeightXS - visible: root.canStartSecurityKeyUnlock() - enabled: visible - tooltipText: SettingsData.lockScreenSecurityKeyShortcutEnabled ? I18n.tr("Security key (%1)", "lock screen security key button tooltip with shortcut").arg(SettingsData.lockScreenSecurityKeyShortcut) : I18n.tr("Security key", "lock screen security key button tooltip") - onClicked: root.triggerSecurityKeyUnlock() - } - LockActionButton { - id: virtualKeyboardButton - Keys.onEscapePressed: { - keyboardController.hide(); - passwordField.forceActiveFocus(); - } - - KeyNavigation.tab: powerButton - KeyNavigation.backtab: passwordField - Accessible.name: I18n.tr("On-screen keyboard") - - anchors.right: securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)) - anchors.rightMargin: securityKeyButton.visible || enterButton.visible ? 0 : Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - iconName: "keyboard" - buttonSize: Theme.buttonHeightXS - visible: !demoMode && !pam.passwd.active && !root.unlocking && !pam.u2fPending - enabled: visible - onClicked: { - if (keyboardController.isKeyboardActive) { - keyboardController.hide(); - } else { - keyboardController.show(); - } - } - } - - Rectangle { - id: loadingSpinner - - anchors.right: enterButton.visible ? enterButton.left : parent.right - anchors.rightMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - width: Theme.iconSize - height: Theme.iconSize - radius: Theme.fullRadius(width, height) - color: "transparent" - visible: !demoMode && (pam.passwd.active || root.unlocking) - - DankIcon { - anchors.centerIn: parent - name: "check_circle" - size: Theme.iconSizeSmall - color: Theme.primary - visible: root.unlocking - - opacity: root.unlocking ? 1 : 0 - Behavior on opacity { - NumberAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - - Item { - anchors.fill: parent - visible: pam.passwd.active && !root.unlocking - - Rectangle { - width: Theme.iconSizeSmall - height: Theme.iconSizeSmall - radius: Theme.fullRadius(width, height) - anchors.centerIn: parent - color: "transparent" - border.color: Theme.primarySelected - border.width: Theme.outlineWidthFocused - } - - Rectangle { - width: Theme.iconSizeSmall - height: Theme.iconSizeSmall - radius: Theme.fullRadius(width, height) - anchors.centerIn: parent - color: "transparent" - border.color: Theme.primary - border.width: Theme.outlineWidthFocused - - Rectangle { - width: parent.width - height: parent.height / 2 - anchors.top: parent.top - anchors.topMargin: -Theme.outlineWidth - anchors.horizontalCenter: parent.horizontalCenter - color: Theme.cardSurface - } - - RotationAnimator on rotation { - running: pam.passwd.active && !root.unlocking - loops: Animation.Infinite - duration: Anims.durLong - from: 0 - to: 360 - } - } - } - } - - LockActionButton { - id: enterButton - - activeFocusOnTab: false - Accessible.name: I18n.tr("Unlock", "verb, lock screen submit button") - - anchors.right: parent.right - anchors.rightMargin: Theme.spacingXXS - anchors.verticalCenter: parent.verticalCenter - iconName: "keyboard_return" - buttonSize: Theme.buttonHeightXS - visible: (demoMode || (!pam.passwd.active && !root.unlocking && !pam.u2fPending)) - enabled: !demoMode - onClicked: { - if (!demoMode && !root.unlocking && !pam.u2fPending) { - pam.passwd.start(); - } - } - - Behavior on opacity { - NumberAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - - Behavior on border.color { - ColorAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - } - - StyledText { - id: authFeedbackText - - Layout.fillWidth: true - Layout.preferredHeight: text.length > 0 ? Math.min(implicitHeight, Math.ceil(Theme.fontSizeSmall * 4.5)) : 0 - text: root.currentAuthFeedbackText() - color: root.authFeedbackIsHint() ? Theme.outline : Theme.error - font.pixelSize: Theme.fontSizeSmall - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - maximumLineCount: 3 - elide: Text.ElideRight - opacity: text.length > 0 ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - } - - Row { - id: capsLockRow - anchors.top: passwordLayout.bottom - anchors.topMargin: Theme.spacingS - anchors.horizontalCenter: passwordLayout.horizontalCenter - spacing: Theme.spacingXS - opacity: DMSService.capsLockState ? 1 : 0 - - DankIcon { - name: "shift_lock" - size: Theme.iconSizeSmall - color: Theme.error - anchors.verticalCenter: parent.verticalCenter - } - - StyledText { - text: I18n.tr("Caps Lock is on") - font.pixelSize: Theme.fontSizeSmall - color: Theme.error - anchors.verticalCenter: parent.verticalCenter - } - - Behavior on opacity { - NumberAnimation { - duration: LockMetrics.effectsDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - - StyledText { - anchors.top: parent.top - anchors.left: parent.left - anchors.margins: Theme.spacingXL - text: I18n.tr("DEMO MODE - Click anywhere to exit") - font.pixelSize: Theme.fontSizeSmall - color: Theme.lockScreenContentColor - opacity: Theme.pendingOpacity - visible: demoMode - } - - LockStatusRow { - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: Theme.spacingXL - visible: SettingsData.lockScreenShowSystemIcons - interactive: !root.demoMode - showMediaPlayer: SettingsData.lockScreenShowMediaPlayer - showWeather: SettingsData.lockScreenShowWeather - useFahrenheit: SettingsData.useFahrenheit - } - - LockActionButton { - id: powerButton - KeyNavigation.tab: passwordField - KeyNavigation.backtab: virtualKeyboardButton.visible ? virtualKeyboardButton : passwordField - Accessible.name: I18n.tr("Power Options") - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.margins: Theme.spacingXL - visible: SettingsData.lockScreenShowPowerActions - iconName: "power_settings_new" - iconColor: Theme.onSecondaryContainer - backgroundColor: Theme.secondaryContainer - radius: pressed ? Theme.cornerRadiusS : Theme.fullRadius(width, height) - buttonSize: Theme.buttonHeightM - onClicked: { - if (demoMode) { - log.debug("Demo: Power Menu"); - } else { - powerMenu.show(); - } - } - } + focus: true + screenName: root.screenName + lockHost: root + editMode: root.demoMode + bottomInset: editorLoader.item?.fabReserved ?? 0 + onFocusStolen: root.focusPasswordField() + onOptionsRequested: instanceData => editorLoader.item?.showOptions(instanceData) } Pam { @@ -1401,7 +176,7 @@ Item { function onUnlockRequested() { root.unlocking = true; lockerReadyArmed = false; - passwordField.clear(); + root.passwordEdited(""); root.unlockRequested(); } @@ -1410,17 +185,9 @@ Item { if (root.pam.state === "") return; root.unlocking = false; - errorShake.restart(); + root.authFailed(); placeholderDelay.restart(); - passwordField.clear(); - } - - function onU2fPendingChanged() { - if (!root.pam.u2fPending) - return; - passwordField.clear(); - if (keyboardController.isKeyboardActive) - keyboardController.hide(); + root.passwordEdited(""); } function onUnlockInProgressChanged() { @@ -1429,34 +196,6 @@ Item { } } - SequentialAnimation { - id: errorShake - NumberAnimation { - target: passwordBox - property: "errorOffset" - to: LockMetrics.shakeDistance - duration: LockMetrics.shakeDuration / 3 - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial - } - NumberAnimation { - target: passwordBox - property: "errorOffset" - to: -LockMetrics.shakeDistance - duration: LockMetrics.shakeDuration / 3 - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial - } - NumberAnimation { - target: passwordBox - property: "errorOffset" - to: 0 - duration: LockMetrics.shakeDuration / 3 - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial - } - } - Timer { id: placeholderDelay @@ -1464,10 +203,126 @@ Item { onTriggered: root.pamState = "" } - MouseArea { + Loader { + id: editorLoader anchors.fill: parent - enabled: demoMode - onClicked: root.unlockRequested() + active: root.demoMode + + sourceComponent: FocusScope { + id: editor + + property bool libraryOpen: false + readonly property real fabReserved: fabBar.reservedHeight + + function closeLibrary() { + libraryOpen = false; + forceActiveFocus(); + } + + function showOptions(instanceData) { + optionsSheet.instanceId = instanceData?.id ?? ""; + optionsSheet.opened = true; + } + + focus: true + Component.onCompleted: forceActiveFocus() + + Keys.onEscapePressed: { + if (editControls.pendingAction !== "") { + editControls.cancelConfirmation(); + return; + } + if (libraryOpen) { + closeLibrary(); + return; + } + root.unlockRequested(); + } + + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_G: + widgetLayer.toggleGrid(); + break; + case Qt.Key_Z: + widgetLayer.stepGrid(-10); + break; + case Qt.Key_X: + widgetLayer.stepGrid(10); + break; + default: + return; + } + event.accepted = true; + } + + MouseArea { + anchors.fill: parent + visible: editor.libraryOpen + acceptedButtons: Qt.AllButtons + onClicked: editor.closeLibrary() + } + + Loader { + anchors.centerIn: parent + active: editor.libraryOpen + + sourceComponent: CcWidgetLibrary { + width: Math.min(implicitWidth, editor.width - Theme.spacingL * 2) + height: Math.min(implicitHeight, editor.height - Theme.spacingL * 2) + widgets: DesktopWidgetRegistry.registeredWidgetsList.filter(widget => widget.id !== "lockAuth" || !SettingsData.lockWidgetInstance("lockAuth")).map(widget => ({ + id: widget.id, + text: widget.name, + icon: widget.icon, + description: widget.description + })) + Component.onCompleted: reset() + onChosen: widgetId => { + root.addLockWidget(widgetId); + editor.closeLibrary(); + } + onDismissed: editor.closeLibrary() + } + } + + DankBottomSheet { + id: optionsSheet + + property string instanceId: "" + readonly property var instanceData: SettingsData.getDesktopWidgetInstance(instanceId) + readonly property var widgetDef: DesktopWidgetRegistry.getWidget(instanceData?.widgetType ?? "") + + maximumWidth: Math.min(Theme.mediumBreakpoint, editor.width - Theme.spacingXL * 2) + topMargin: editor.height * 0.3 + returnFocusItem: editor + title: instanceData?.name || widgetDef?.name || "" + onDismissRequested: opened = false + onActiveChanged: { + if (!active) + instanceId = ""; + } + + DesktopWidgetTypeSettings { + width: parent.width + instanceId: optionsSheet.instanceId + instanceData: optionsSheet.instanceData + widgetDef: optionsSheet.widgetDef + } + } + + SettingsFabBar { + id: fabBar + shown: !editor.libraryOpen && !optionsSheet.active + + DashEditControls { + id: editControls + canClear: false + onAddRequested: editor.libraryOpen = true + onResetRequested: SettingsData.resetLockScreenWidgets() + onFinished: root.unlockRequested() + } + } + } } LockPowerMenu { @@ -1475,9 +330,8 @@ Item { expressive: true showLogout: true onClosed: { - if (!demoMode && passwordField && passwordField.forceActiveFocus) { - Qt.callLater(() => passwordField.forceActiveFocus()); - } + if (!demoMode) + Qt.callLater(() => root.focusPasswordField()); } onSwitchUserRequested: { switchUserPicker.showFromLockScreen(); diff --git a/quickshell/Modules/Lock/LockScreenDemo.qml b/quickshell/Modules/Lock/LockScreenDemo.qml index 502eef1dd..f9f98aff7 100644 --- a/quickshell/Modules/Lock/LockScreenDemo.qml +++ b/quickshell/Modules/Lock/LockScreenDemo.qml @@ -5,27 +5,12 @@ import Quickshell import Quickshell.Wayland import qs.Services -PanelWindow { +Scope { id: root readonly property var log: Log.scoped("LockScreenDemo") property bool demoActive: false - visible: demoActive - - anchors { - top: true - bottom: true - left: true - right: true - } - - WlrLayershell.layer: WlrLayershell.Overlay - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - - color: "transparent" - function showDemo(): void { log.debug("Showing lock screen demo"); demoActive = true; @@ -36,13 +21,37 @@ PanelWindow { demoActive = false; } - Loader { - anchors.fill: parent - active: demoActive - sourceComponent: LockScreenContent { - demoMode: true - screenName: root.screen?.name ?? "" - onUnlockRequested: root.hideDemo() + Variants { + model: root.demoActive ? Quickshell.screens : [] + + PanelWindow { + id: demoWindow + + required property var modelData + + screen: modelData + visible: true + + anchors { + top: true + bottom: true + left: true + right: true + } + + WlrLayershell.namespace: "dms:lock-demo" + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + color: "transparent" + + LockScreenContent { + anchors.fill: parent + demoMode: true + screenName: demoWindow.screen?.name ?? "" + onUnlockRequested: root.hideDemo() + } } } } diff --git a/quickshell/Modules/Lock/LockSurface.qml b/quickshell/Modules/Lock/LockSurface.qml index 4704683c3..3668a8bdd 100644 --- a/quickshell/Modules/Lock/LockSurface.qml +++ b/quickshell/Modules/Lock/LockSurface.qml @@ -11,6 +11,9 @@ FocusScope { required property var pam required property string sharedPasswordBuffer required property string screenName + property bool inputRevealed: false + signal inputRevealRequested(bool revealed) + required property bool isLocked signal passwordChanged(string newPassword) @@ -49,6 +52,8 @@ FocusScope { sessionLock: root.lock pam: root.pam passwordBuffer: root.sharedPasswordBuffer + inputRevealed: root.inputRevealed + onInputRevealRequested: revealed => root.inputRevealRequested(revealed) screenName: root.screenName enabled: !videoScreensaver.active focus: !videoScreensaver.active diff --git a/quickshell/Modules/Lock/LockWidgetItem.qml b/quickshell/Modules/Lock/LockWidgetItem.qml new file mode 100644 index 000000000..ccdc3f5d3 --- /dev/null +++ b/quickshell/Modules/Lock/LockWidgetItem.qml @@ -0,0 +1,237 @@ +import QtQuick +import qs.Common +import qs.Widgets +import qs.Modules.Plugins + +Item { + id: root + + required property var instanceData + required property var screen + required property var hostLayer + property var lockHost: null + property bool editMode: false + + readonly property string instanceId: instanceData?.id ?? "" + readonly property string widgetType: instanceData?.widgetType ?? "" + readonly property bool removable: widgetType !== "lockAuth" + readonly property bool selected: hostLayer.selectedInstanceId === instanceId + readonly property bool interacting: editChrome.item?.interacting ?? false + readonly property var contrastColors: content.item?.contrastColors ?? [] + readonly property bool hasSavedPosition: geometry.hasSavedPosition + readonly property bool automaticPlacement: widgetType === "lockClock" && (instanceData?.config?.autoPosition ?? true) && !hasSavedPosition + readonly property bool dragging: editChrome.item?.dragging ?? false + readonly property var stock: hostLayer.stockRect(widgetType, root) + property bool snappedCenterX: false + property bool snappedCenterY: false + + signal removeRequested + signal optionsRequested + + readonly property bool loaded: content.item !== null + onLoadedChanged: { + if (loaded) + Qt.callLater(() => hostLayer.placeIfPending(root)); + } + + x: geometry.widgetX + y: geometry.widgetY + width: geometry.widgetWidth + height: geometry.widgetHeight + activeFocusOnTab: editMode + Accessible.name: instanceData?.name || I18n.tr("Widget") + Accessible.role: Accessible.Button + onActiveFocusChanged: { + if (editMode && activeFocus) + hostLayer.selectedInstanceId = instanceId; + } + Keys.onReturnPressed: { + if (editMode) + optionsRequested(); + } + Keys.onSpacePressed: { + if (editMode) + optionsRequested(); + } + + function guideFor(value, guides) { + const threshold = geometry.gridEnabled ? geometry.gridSize : Theme.spacingXL; + let best; + let bestDistance = threshold; + for (const guide of guides) { + const distance = Math.abs(value - guide); + if (distance > bestDistance) + continue; + best = guide; + bestDistance = distance; + } + return best; + } + + function guidedPosition(startX, startY, dx, dy) { + const rawX = Math.max(0, Math.min(startX + dx, geometry.screenWidth - width)); + const rawY = Math.max(0, Math.min(startY + dy, geometry.screenHeight - height)); + const snapped = geometry.dragMoveTo(startX, startY, dx, dy); + const centerX = (hostLayer.width - width) / 2; + const centerY = (hostLayer.height - height) / 2; + const guidesX = [centerX]; + const guidesY = [centerY]; + if (stock) { + guidesX.push(stock.x); + guidesY.push(stock.y); + } + const guideX = guideFor(rawX, guidesX); + const guideY = guideFor(rawY, guidesY); + snappedCenterX = guideX === centerX; + snappedCenterY = guideY === centerY; + return Qt.point(guideX ?? snapped.x, guideY ?? snapped.y); + } + + function commitPosition(finalX, finalY) { + if (stock && finalX === stock.x && finalY === stock.y) { + SessionData.resetDesktopWidgetInstanceGeometry(instanceId, ["x", "y"]); + return; + } + geometry.savePosition(finalX, finalY); + if (widgetType === "lockClock" && (instanceData?.config?.autoPosition ?? true)) + SettingsData.updateDesktopWidgetInstanceConfig(instanceId, { + autoPosition: false + }); + } + + DesktopWidgetGeometry { + id: geometry + instanceId: root.instanceId + instanceData: root.instanceData + screen: root.screen + minWidth: content.contentMinWidth + minHeight: content.contentMinHeight + forceSquare: content.contentForceSquare + defaultWidth: content.contentDefaultWidth + defaultHeight: content.contentDefaultHeight + defaultX: root.stock ? root.stock.x : screenWidth / 2 - savedWidth / 2 + defaultY: root.stock ? root.stock.y : screenHeight / 2 - savedHeight / 2 + } + + DesktopWidgetContent { + id: content + anchors.fill: parent + active: root.visible + focus: root.widgetType === "lockAuth" + pluginId: root.widgetType + instanceId: root.instanceId + instanceData: root.instanceData + screen: root.screen + geometry: geometry + lockScreen: true + lockHost: root.lockHost + persistDefaults: false + } + + Loader { + id: editChrome + anchors.fill: parent + active: root.editMode + + sourceComponent: Item { + readonly property bool interacting: dragArea.pressed || chrome.resizing + readonly property bool dragging: dragArea.pressed + + MouseArea { + id: dragArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + + property point startPos + property real startX + property real startY + property bool moved: false + + onPressed: mouse => { + root.hostLayer.selectedInstanceId = root.instanceId; + root.forceActiveFocus(); + moved = false; + startPos = mapToItem(root.parent, mouse.x, mouse.y); + startX = geometry.widgetX; + startY = geometry.widgetY; + geometry.dragOverrideX = startX; + geometry.dragOverrideY = startY; + } + + onPositionChanged: mouse => { + if (!pressed) + return; + const current = mapToItem(root.parent, mouse.x, mouse.y); + if (!moved && Math.hypot(current.x - startPos.x, current.y - startPos.y) < Qt.styleHints.startDragDistance) + return; + moved = true; + const next = root.guidedPosition(startX, startY, current.x - startPos.x, current.y - startPos.y); + geometry.dragOverrideX = next.x; + geometry.dragOverrideY = next.y; + } + + onReleased: { + const finalX = geometry.dragOverrideX; + const finalY = geometry.dragOverrideY; + geometry.clearDragOverrides(); + root.snappedCenterX = false; + root.snappedCenterY = false; + if (moved) { + root.commitPosition(finalX, finalY); + return; + } + root.optionsRequested(); + } + + onCanceled: { + geometry.clearDragOverrides(); + root.snappedCenterX = false; + root.snappedCenterY = false; + } + } + + DankGridEditChrome { + id: chrome + + property real startWidth + property real startHeight + property point startPos + + anchors.fill: parent + anchors.margins: -contentInset - Theme.spacingL + visible: root.selected + cornerRadius: Theme.cornerRadius + dragging: dragArea.pressed + removable: root.removable + hasOptions: true + cornerResize: content.contentResizable + sizeText: Math.round(root.width) + "×" + Math.round(root.height) + onRemoveRequested: root.removeRequested() + onOptionsRequested: root.optionsRequested() + onResizeStarted: (px, py) => { + startPos = Qt.point(px, py); + startWidth = geometry.widgetWidth; + startHeight = geometry.widgetHeight; + geometry.dragOverrideW = startWidth; + geometry.dragOverrideH = startHeight; + resizing = true; + } + onResizeMoved: (px, py) => { + const next = geometry.dragResizeTo(startWidth, startHeight, px - startPos.x, py - startPos.y); + geometry.dragOverrideW = next.width; + geometry.dragOverrideH = next.height; + } + onResizeEnded: { + resizing = false; + geometry.saveSize(geometry.dragOverrideW, geometry.dragOverrideH); + geometry.clearDragOverrides(); + } + onResizeCanceled: { + resizing = false; + geometry.clearDragOverrides(); + } + } + } + } +} diff --git a/quickshell/Modules/Lock/LockWidgetLayer.qml b/quickshell/Modules/Lock/LockWidgetLayer.qml new file mode 100644 index 000000000..f66a78c6c --- /dev/null +++ b/quickshell/Modules/Lock/LockWidgetLayer.qml @@ -0,0 +1,272 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Modules.Plugins +import "LockPlacement.js" as Placement +import qs.DankCommon.Session + +FocusScope { + id: root + + required property string screenName + property var lockHost: null + property bool editMode: false + property string selectedInstanceId: "" + + readonly property var screen: Quickshell.screens.find(s => s.name === screenName) ?? null + readonly property var instances: SettingsData.lockScreenWidgetInstances || [] + readonly property string screenKey: SettingsData.getScreenDisplayName(screen) + property var _gridSettingsTrigger: SessionData.desktopWidgetGridSettings + readonly property int gridSize: { + void _gridSettingsTrigger; + return SessionData.getDesktopWidgetGridSetting(screenKey, "size", 40); + } + readonly property bool gridEnabled: { + void _gridSettingsTrigger; + return SessionData.getDesktopWidgetGridSetting(screenKey, "enabled", false); + } + + signal focusStolen + signal optionsRequested(var instanceData) + + // Widgets added from the editor start in the nearest free area instead of on top of another widget. + property var pendingIds: [] + property real bottomInset: 0 + + function rectOf(item) { + return { + x: item.x, + y: item.y, + width: item.width, + height: item.height + }; + } + + function placeIfPending(item) { + if (!pendingIds.includes(item.instanceId)) + return; + pendingIds = pendingIds.filter(id => id !== item.instanceId); + if (!editMode || item.width <= 0 || item.height <= 0) + return; + const obstacles = []; + for (let i = 0; i < repeater.count; i++) { + const other = repeater.itemAt(i); + if (other && other !== item && other.visible) + obstacles.push(rectOf(other)); + } + const rect = rectOf(item); + if (!obstacles.some(obstacle => Placement.intersects(rect, obstacle))) + return; + const free = Placement.candidates(width, height - bottomInset, item.width, item.height, Theme.spacingXL, obstacles); + if (!free.length) + return; + const centerX = width / 2; + const centerY = height / 2; + free.sort((a, b) => Math.hypot(a.x + a.width / 2 - centerX, a.y + a.height / 2 - centerY) - Math.hypot(b.x + b.width / 2 - centerX, b.y + b.height / 2 - centerY)); + item.commitPosition(free[0].x, free[0].y); + } + + function toggleGrid() { + SessionData.setDesktopWidgetGridSetting(screenKey, "enabled", !gridEnabled); + } + + function stepGrid(delta) { + SessionData.setDesktopWidgetGridSetting(screenKey, "size", Math.max(10, Math.min(200, gridSize + delta))); + } + + activeFocusOnTab: false + onActiveFocusChanged: { + if (activeFocus) + focusStolen(); + } + + function itemOfType(widgetType) { + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i); + if (item && item.visible && item.widgetType === widgetType) + return item; + } + return null; + } + + readonly property real edgeInset: Theme.spacingXL * 2 + property bool completed: false + property var lockedPositions: ({}) + readonly property string backgroundKey: LockPlacementService.backgroundKey(screenName, width, height) + readonly property var autoPositions: editMode && completed ? currentPlacement() : lockedPositions + + function currentPlacement() { + return LockPlacementService.layoutFor(screenName, LockPlacementService.sampleFor(screenName, backgroundKey), placementKey, placeClocks); + } + + function refreshPlacement() { + if (!completed || editMode || width <= 0 || height <= 0) + return; + lockedPositions = currentPlacement(); + SessionData.setLockScreenAutoPositions(screenKey, { + width: width, + height: height, + positions: lockedPositions + }); + } + + Component.onCompleted: { + completed = true; + refreshPlacement(); + } + onWidthChanged: refreshPlacement() + onHeightChanged: refreshPlacement() + onEditModeChanged: refreshPlacement() + + readonly property string placementKey: JSON.stringify(instances.map(instance => { + const item = itemById(instance.id); + return [instance.id, instance.enabled, instance.config, item?.width, item?.height, item?.automaticPlacement ? null : item?.x, item?.automaticPlacement ? null : item?.y, item?.hasSavedPosition, item?.contrastColors]; + }).concat([width, height, Theme.primary.toString(), Theme.secondary.toString(), Theme.lockScreenContentColor.toString(), SessionData.desktopWidgetInstancePositions])) + + function itemById(id) { + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i); + if (item && item.visible && item.instanceId === id) + return item; + } + return null; + } + + function stockRect(widgetType, item) { + const centerX = (width - item.width) / 2; + switch (widgetType) { + case "lockClock": + return item.automaticPlacement && autoPositions[item.instanceId] ? autoPositions[item.instanceId] : { + x: Math.max(0, centerX), + y: edgeInset + }; + case "lockDate": + return { + x: edgeInset, + y: edgeInset + }; + case "lockAuth": + return { + x: centerX, + y: height / 2 - item.height / 2 + }; + case "lockNotifications": + { + const auth = itemOfType("lockAuth"); + return { + x: centerX, + y: auth ? auth.y + auth.height + Theme.spacingL : height / 2 + Theme.spacingL + }; + } + case "lockStatus": + return { + x: width - Theme.spacingXL - item.width, + y: Theme.spacingXL + }; + case "lockPower": + return { + x: Theme.spacingXL, + y: height - Theme.spacingXL - item.height + }; + } + return null; + } + + function placeClocks(luminances, sampleWidth, sampleHeight) { + const clocks = []; + const obstacles = []; + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i); + if (!item || !item.visible) + continue; + if (item.automaticPlacement) { + clocks.push(item); + continue; + } + obstacles.push({ + x: item.x - Theme.spacingL, + y: item.y - Theme.spacingL, + width: item.width + Theme.spacingL * 2, + height: item.height + Theme.spacingL * 2 + }); + } + const positions = {}; + for (const clock of clocks) { + const choices = Placement.candidates(width, height, clock.width, clock.height, edgeInset, obstacles); + const foregrounds = clock.contrastColors.map(color => Placement.luminance(color.r, color.g, color.b)); + const chosen = Placement.choose(luminances, sampleWidth, sampleHeight, width, height, choices, foregrounds); + if (!chosen) + continue; + positions[clock.instanceId] = { + x: chosen.x, + y: chosen.y + }; + obstacles.push(chosen); + } + return positions; + } + + readonly property var draggedItem: { + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i); + if (item && item.dragging) + return item; + } + return null; + } + + Rectangle { + x: (parent.width - width) / 2 + width: Theme.dividerWidth + height: parent.height + visible: root.editMode && root.draggedItem !== null + color: root.draggedItem?.snappedCenterX ? Theme.primary : Theme.withAlpha(Theme.primary, Theme.stateLayerDrag) + } + + Rectangle { + y: (parent.height - height) / 2 + width: parent.width + height: Theme.dividerWidth + visible: root.editMode && root.draggedItem !== null + color: root.draggedItem?.snappedCenterY ? Theme.primary : Theme.withAlpha(Theme.primary, Theme.stateLayerDrag) + } + + Loader { + anchors.fill: parent + active: root.editMode && root.gridEnabled + + sourceComponent: DesktopWidgetGridLines { + gridSize: root.gridSize + extentWidth: root.width + extentHeight: root.height + } + } + + Repeater { + id: repeater + model: ScriptModel { + objectProp: "id" + values: root.instances + } + + LockWidgetItem { + required property var modelData + required property int index + + readonly property var liveInstanceData: root.instances.find(inst => inst.id === modelData.id) ?? modelData + + instanceData: liveInstanceData + screen: root.screen + hostLayer: root + lockHost: root.lockHost + editMode: root.editMode + visible: liveInstanceData.enabled !== false && DesktopWidgetRegistry.showsOnScreen(liveInstanceData.config?.displayPreferences, root.screen) + z: interacting ? repeater.count + 1 : repeater.count - index + onRemoveRequested: SettingsData.removeDesktopWidgetInstance(liveInstanceData.id) + onOptionsRequested: root.optionsRequested(liveInstanceData) + } + } +} diff --git a/quickshell/Modules/Lock/Widgets/LockAuthWidget.qml b/quickshell/Modules/Lock/Widgets/LockAuthWidget.qml new file mode 100644 index 000000000..d57a4c9d4 --- /dev/null +++ b/quickshell/Modules/Lock/Widgets/LockAuthWidget.qml @@ -0,0 +1,1199 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import qs.Common +import qs.Services +import qs.Widgets +import qs.DankCommon.Session +import "../../../Common/KeyUtils.js" as KeyUtils + +Item { + id: root + + readonly property var log: Log.scoped("LockAuthWidget") + + property var instanceData: null + property var lockHost: null + readonly property var cfg: instanceData?.config ?? ({}) + readonly property string profileVisibility: cfg.profileVisibility ?? (cfg.showProfileImage === false ? "never" : "always") + readonly property string passwordVisibility: cfg.passwordVisibility ?? (cfg.showPasswordField === false ? "typing" : "always") + readonly property bool inputRevealed: lockHost?.inputRevealed ?? false + readonly property bool authVisible: demoMode || inputRevealed || passwordBuffer.length > 0 || authenticating || unlocking || failed || (pam?.u2fPending ?? false) || currentAuthFeedbackText() !== "" + readonly property bool showProfileImage: profileVisibility === "always" || (profileVisibility === "typing" && authVisible) + readonly property bool showPasswordField: passwordVisibility === "always" || authVisible + readonly property string style: cfg.style ?? "expressive" + readonly property bool contained: style === "pill" || style === "expressive" + readonly property bool dots: style === "dots" + readonly property bool ring: style === "ring" + readonly property bool morph: style === "expressive" + readonly property bool minimal: style === "minimal" + readonly property bool authenticating: (pam?.passwd.active ?? false) && !unlocking + readonly property bool failed: pamState !== "" + readonly property var morphShapes: ["cookie4", "clover4", "sunny", "cookie9", "softBurst", "pentagon", "oval", "cookie6"] + readonly property string morphShape: morphShapes[passwordBuffer.length % morphShapes.length] + readonly property real ringSize: LockMetrics.fieldHeight * 2.5 + readonly property real ringStroke: Theme.spacingS + property int typedCount: 0 + property real keyAngle: 0 + + onPasswordBufferChanged: { + const grew = passwordBuffer.length > typedCount; + typedCount = passwordBuffer.length; + if (!grew || demoMode) + return; + lockHost.setInputRevealed(true); + keyAngle = (passwordBuffer.length * 137.5) % 360; + if (ring) + keyPulse.restart(); + if (morph) + morphPulse.restart(); + } + readonly property color accentColor: Theme.primary + readonly property color plainColor: Theme.lockScreenContentColor + readonly property bool resizable: true + readonly property real minWidth: LockMetrics.fieldWidth / 2 + readonly property real minHeight: implicitHeight + property alias showPassword: passwordBox.showPassword + + readonly property var pam: lockHost?.pam ?? null + readonly property bool demoMode: lockHost?.demoMode ?? true + readonly property bool unlocking: lockHost?.unlocking ?? false + readonly property string pamState: lockHost?.pamState ?? "" + readonly property string passwordBuffer: lockHost?.passwordBuffer ?? "" + + implicitWidth: Math.min(LockMetrics.passwordRowWidth, (lockHost?.width ?? LockMetrics.passwordRowWidth) - Theme.spacingXL * 2) + implicitHeight: passwordLayout.implicitHeight + Theme.spacingS + capsLockRow.implicitHeight + + function encodeFileUrl(path) { + if (!path) + return ""; + return "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/'); + } + + function focusPasswordField() { + if (demoMode) + return; + passwordField.forceActiveFocus(); + } + + function currentAuthFeedbackText() { + if (!pam) + return ""; + const promptInFeedback = dots || ring; + if (pam.u2fState === "insert" && (!pam.u2fPending || promptInFeedback)) + return I18n.tr("Insert your security key..."); + if (pam.u2fState === "waiting" && (!pam.u2fPending || promptInFeedback)) + return I18n.tr("Touch your security key..."); + if (pam.lockMessage && pam.lockMessage.length > 0) + return pam.lockMessage; + if (pamState === "error") + return I18n.tr("Authentication error - try again"); + if (pamState === "max") + return I18n.tr("Too many attempts - locked out"); + if (pamState === "fail") + return I18n.tr("Incorrect password - try again"); + if (pam.fprint.status === "disabled") + return ""; + if (pam.fprintState === "error") + return I18n.tr("Fingerprint error"); + if (pam.fprintState === "max") + return I18n.tr("Maximum fingerprint attempts reached. Please use password."); + if (pam.fprintState === "fail") + return I18n.tr("Fingerprint not recognized (%1/%2). Please try again or use password.", "lock screen message, %1 is attempts used, %2 is max attempts").arg(pam.fprint.tries).arg(SettingsData.maxFprintTries); + return ""; + } + + function authFeedbackIsHint() { + return pam && (pam.u2fState === "waiting" || pam.u2fState === "insert") && (!pam.u2fPending || dots || ring); + } + + function canStartSecurityKeyUnlock() { + return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.passwd.active && !pam.u2f.active && !pam.u2fPending && !unlocking; + } + + function triggerSecurityKeyUnlock() { + if (!canStartSecurityKeyUnlock()) + return; + passwordField.clear(); + pam.u2f.startForAlternativeAuth(); + } + + function securityKeyShortcutMatches(event) { + return SettingsData.lockScreenSecurityKeyShortcutEnabled && KeyUtils.eventMatchesCombo(event, SettingsData.lockScreenSecurityKeyShortcut); + } + + onLockHostChanged: { + if (!lockHost) + return; + lockHost.authWidget = root; + focusPasswordField(); + } + + Component.onDestruction: { + if (lockHost && lockHost.authWidget === root) + lockHost.authWidget = null; + } + + Connections { + target: root.lockHost + enabled: root.lockHost !== null + + function onAuthFailed() { + errorShake.restart(); + passwordBox.showPassword = false; + passwordField.clear(); + } + } + + Connections { + target: root.pam + enabled: root.pam !== null + + function onU2fPendingChanged() { + if (!root.pam.u2fPending) + return; + passwordField.clear(); + if (keyboardController.isKeyboardActive) + keyboardController.hide(); + } + } + + ColumnLayout { + id: passwordLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + spacing: Theme.spacingM + + Item { + id: ringIndicator + + readonly property color toneColor: root.failed ? Theme.error : root.accentColor + readonly property real radius: root.ringSize / 2 - root.ringStroke / 2 + + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: root.ringSize + Layout.preferredHeight: root.ringSize + visible: root.ring + opacity: root.showPasswordField || root.showProfileImage ? 1 : 0 + transform: Translate { + x: Math.max(-LockMetrics.shakeDistance, Math.min(LockMetrics.shakeDistance, passwordBox.errorOffset)) + } + + Shape { + anchors.fill: parent + opacity: root.showPasswordField ? 1 : 0 + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: { + if (root.failed) + return Theme.withAlpha(Theme.error, Theme.pendingOpacity); + if (root.authenticating || root.passwordBuffer.length === 0) + return Theme.withAlpha(root.plainColor, Theme.stateLayerDrag); + return Theme.withAlpha(root.accentColor, Theme.pendingOpacity); + } + strokeWidth: root.ringStroke + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: root.ringSize / 2 + centerY: root.ringSize / 2 + radiusX: ringIndicator.radius + radiusY: ringIndicator.radius + startAngle: 0 + sweepAngle: 360 + } + + Behavior on strokeColor { + ColorAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + } + + Shape { + id: keySegment + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + opacity: 0 + + ShapePath { + strokeColor: root.accentColor + strokeWidth: root.ringStroke + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: root.ringSize / 2 + centerY: root.ringSize / 2 + radiusX: ringIndicator.radius + radiusY: ringIndicator.radius + startAngle: root.keyAngle - 90 + sweepAngle: 30 + } + } + + SequentialAnimation { + id: keyPulse + PropertyAction { + target: keySegment + property: "opacity" + value: 1 + } + NumberAnimation { + target: keySegment + property: "opacity" + to: 0 + duration: Theme.expressiveDurations.expressiveSlowEffects + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + + Shape { + id: verifyArc + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + visible: root.ring && root.authenticating + + ShapePath { + strokeColor: root.accentColor + strokeWidth: root.ringStroke + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: root.ringSize / 2 + centerY: root.ringSize / 2 + radiusX: ringIndicator.radius + radiusY: ringIndicator.radius + startAngle: -90 + sweepAngle: 90 + } + } + + RotationAnimator on rotation { + running: verifyArc.visible + loops: Animation.Infinite + duration: Anims.durLong + from: 0 + to: 360 + } + } + + DankCircularImage { + anchors.centerIn: parent + width: root.ringSize - root.ringStroke * 4 + height: width + visible: root.showProfileImage + imageSource: { + if (PortalService.profileImage === "") + return ""; + if (PortalService.profileImage.startsWith("/")) + return root.encodeFileUrl(PortalService.profileImage); + return PortalService.profileImage; + } + fallbackIcon: "material:person" + } + + DankIcon { + anchors.centerIn: parent + visible: !root.showProfileImage + name: root.unlocking ? "lock_open" : "lock" + size: Theme.iconSizeLarge + color: ringIndicator.toneColor + } + + MouseArea { + anchors.fill: parent + enabled: !root.demoMode + onClicked: passwordField.forceActiveFocus() + } + } + + RowLayout { + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + spacing: Theme.spacingM + Layout.fillWidth: true + + DankCircularImage { + Layout.preferredWidth: LockMetrics.avatarSize + ringWidth: Theme.avatarRingWidth + ringColor: Theme.avatarRingColor + Layout.preferredHeight: LockMetrics.fieldHeight + imageSource: { + if (PortalService.profileImage === "") + return ""; + if (PortalService.profileImage.startsWith("/")) + return root.encodeFileUrl(PortalService.profileImage); + return PortalService.profileImage; + } + fallbackIcon: "material:person" + visible: root.showProfileImage && !root.ring + } + + Rectangle { + id: passwordBox + + property bool showPassword: false + property real errorOffset: 0 + readonly property bool focusRingShown: passwordField.activeFocus && Theme.focusRingWidth > 0 + transform: Translate { + x: Math.max(-LockMetrics.shakeDistance, Math.min(LockMetrics.shakeDistance, passwordBox.errorOffset)) + } + + Layout.fillWidth: true + Layout.preferredHeight: root.ring ? 0 : LockMetrics.fieldHeight + radius: root.style === "expressive" ? Theme.cornerRadiusXL : Theme.fullRadius(width, height) + color: { + if (!root.contained) + return "transparent"; + return root.style === "expressive" ? Theme.surfaceContainerHigh : Theme.cardSurface; + } + border.width: { + if (!root.contained) + return 0; + return focusRingShown ? Math.max(Theme.outlineWidth, Theme.focusRingWidth) : Theme.layerOutlineWidth; + } + border.color: focusRingShown ? Theme.focusRingColor : Theme.outlineMedium + Accessible.name: I18n.tr("Password") + opacity: root.showPasswordField ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + + Rectangle { + id: underline + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: root.style === "minimal" + height: passwordField.activeFocus ? Theme.outlineWidthFocused : Theme.dividerWidth + color: { + if (root.pamState !== "") + return Theme.error; + return passwordField.activeFocus ? root.accentColor : Theme.withAlpha(root.plainColor, Theme.pendingOpacity); + } + + Behavior on color { + ColorAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + + Item { + id: dotViewport + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.right: passwordViewport.right + anchors.top: parent.top + anchors.bottom: parent.bottom + clip: true + visible: root.dots && !passwordBox.showPassword + + Row { + id: dotRow + x: Math.min(0, dotViewport.width - width) + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + Repeater { + model: root.demoMode ? 6 : Math.max(1, root.passwordBuffer.length) + + Rectangle { + required property int index + readonly property bool filled: root.demoMode || index < root.passwordBuffer.length + readonly property color tone: root.pamState !== "" ? Theme.error : root.accentColor + + anchors.verticalCenter: parent.verticalCenter + width: Theme.iconSizeSmall + height: width + radius: Theme.fullRadius(width, height) + color: filled ? tone : "transparent" + border.width: Theme.outlineWidthFocused + border.color: filled ? tone : Theme.withAlpha(root.plainColor, Theme.pendingOpacity) + scale: filled ? 1 : 0.8 + + Behavior on scale { + NumberAnimation { + duration: LockMetrics.shakeDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } + } + } + } + } + + Item { + id: lockIconContainer + anchors.left: parent.left + anchors.leftMargin: root.morph ? Theme.spacingS : Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + visible: !root.dots && !root.ring && !root.minimal + width: !visible ? 0 : (root.morph ? LockMetrics.fieldHeight - Theme.spacingS * 2 : Theme.iconSizeSmall) + height: root.morph ? width : Theme.iconSizeSmall + + DankMaterialShape { + id: morphContainer + anchors.fill: parent + visible: root.morph + shape: root.morphShape + color: root.failed ? Theme.errorContainer : Theme.primaryContainer + + SequentialAnimation { + id: morphPulse + NumberAnimation { + target: morphContainer + property: "scale" + to: 1.15 + duration: LockMetrics.shakeDuration / 2 + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + NumberAnimation { + target: morphContainer + property: "scale" + to: 1 + duration: LockMetrics.shakeDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + } + } + + Behavior on color { + ColorAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + + DankLoadingIndicator { + anchors.centerIn: parent + size: parent.width + contained: true + visible: root.morph && root.authenticating + running: visible + } + + DankIcon { + id: lockIcon + + anchors.centerIn: parent + visible: !(root.morph && root.authenticating) + name: { + if (!root.pam) + return "lock"; + if (root.pam.u2fPending) + return "passkey"; + switch (root.pam.fprint.status) { + case "max": + case "stopped": + return "fingerprint_off"; + case "active": + return "fingerprint"; + case "retrying": + return "hourglass_empty"; + } + if (root.pam.u2f.active) + return "passkey"; + return "lock"; + } + size: Theme.iconSizeSmall + color: { + if (root.morph) + return root.failed ? Theme.onErrorContainer : Theme.onPrimaryContainer; + if (root.pam && root.pam.fprint.tries >= SettingsData.maxFprintTries) + return Theme.error; + if (root.pam && root.pam.u2fState !== "") + return Theme.tertiary; + if (passwordField.activeFocus) + return root.accentColor; + return root.contained ? Theme.surfaceVariantText : Theme.withAlpha(root.plainColor, Theme.pendingOpacity); + } + opacity: root.pam?.passwd.active && !root.morph ? 0 : 1 + + Behavior on opacity { + NumberAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + } + + FocusScope { + id: passwordField + + KeyNavigation.tab: virtualKeyboardButton.visible ? virtualKeyboardButton : passwordField + KeyNavigation.backtab: virtualKeyboardButton.visible ? virtualKeyboardButton : passwordField + Accessible.role: Accessible.EditableText + Accessible.name: I18n.tr("Password") + + readonly property string text: root.passwordBuffer + property int cursorPosition: text.length + + signal accepted + + function clampCursorPosition() { + cursorPosition = Math.max(0, Math.min(cursorPosition, text.length)); + } + + function clear() { + root.lockHost?.passwordEdited(""); + cursorPosition = 0; + } + + function insertText(value) { + if (value.length === 0) + return; + clampCursorPosition(); + const pos = cursorPosition; + root.lockHost.passwordEdited(text.slice(0, pos) + value + text.slice(pos)); + cursorPosition = pos + value.length; + } + + function backspace() { + clampCursorPosition(); + if (cursorPosition === 0) + return; + const pos = cursorPosition; + root.lockHost.passwordEdited(text.slice(0, pos - 1) + text.slice(pos)); + cursorPosition = pos - 1; + } + + function deleteForward() { + clampCursorPosition(); + if (cursorPosition === text.length) + return; + const pos = cursorPosition; + root.lockHost.passwordEdited(text.slice(0, pos) + text.slice(pos + 1)); + cursorPosition = pos; + } + + function deleteToLineStart() { + clampCursorPosition(); + if (cursorPosition === 0) + return; + root.lockHost.passwordEdited(text.slice(cursorPosition)); + cursorPosition = 0; + } + + function deleteToLineEnd() { + clampCursorPosition(); + if (cursorPosition === text.length) + return; + root.lockHost.passwordEdited(text.slice(0, cursorPosition)); + } + + function deleteWordBackward() { + clampCursorPosition(); + if (cursorPosition === 0) + return; + let pos = cursorPosition; + while (pos > 0 && text.charAt(pos - 1) === " ") + pos--; + while (pos > 0 && text.charAt(pos - 1) !== " ") + pos--; + root.lockHost.passwordEdited(text.slice(0, pos) + text.slice(cursorPosition)); + cursorPosition = pos; + } + + function isPrintableText(value) { + if (value.length === 0) + return false; + const code = value.charCodeAt(0); + return code >= 0x20 && code !== 0x7f; + } + + anchors.fill: parent + anchors.leftMargin: lockIconContainer.width + Theme.spacingM * 2 + anchors.rightMargin: { + let margin = Theme.spacingM; + if (loadingSpinner.visible) + margin += loadingSpinner.width; + if (enterButton.visible) + margin += enterButton.width + Theme.spacingXXS; + if (securityKeyButton.visible) + margin += securityKeyButton.width; + if (virtualKeyboardButton.visible) + margin += virtualKeyboardButton.width; + if (revealButton.visible) + margin += revealButton.width; + return margin; + } + opacity: 0 + focus: true + enabled: !root.demoMode + activeFocusOnTab: !root.demoMode + onTextChanged: cursorPosition = text.length + onAccepted: { + if (!root.demoMode && !root.unlocking && !root.pam.passwd.active && !root.pam.u2fPending) + root.pam.passwd.start(); + } + Keys.onPressed: event => handleKey(event) + + function handleKey(event) { + if (root.demoMode) + return; + + root.pam.retryFprintOnActivity(); + + if (root.unlocking) { + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Escape) { + if (keyboardController.isKeyboardActive) { + keyboardController.hide(); + event.accepted = true; + return; + } + if (root.pam.u2fPending) { + root.pam.cancelU2fPending(); + event.accepted = true; + return; + } + clear(); + root.lockHost.setInputRevealed(false); + passwordBox.showPassword = false; + event.accepted = true; + return; + } + + if (root.pam.passwd.active) { + root.log.debug("PAM is active, ignoring input"); + event.accepted = true; + return; + } + + if ((event.modifiers & Qt.ControlModifier) && !(event.modifiers & (Qt.AltModifier | Qt.MetaModifier))) { + if (root.securityKeyShortcutMatches(event) && root.canStartSecurityKeyUnlock()) { + root.triggerSecurityKeyUnlock(); + event.accepted = true; + return; + } + + switch (event.key) { + case Qt.Key_A: + cursorPosition = 0; + event.accepted = true; + return; + case Qt.Key_E: + cursorPosition = text.length; + event.accepted = true; + return; + case Qt.Key_B: + clampCursorPosition(); + cursorPosition = Math.max(0, cursorPosition - 1); + event.accepted = true; + return; + case Qt.Key_F: + clampCursorPosition(); + cursorPosition = Math.min(text.length, cursorPosition + 1); + event.accepted = true; + return; + case Qt.Key_U: + deleteToLineStart(); + event.accepted = true; + return; + case Qt.Key_K: + deleteToLineEnd(); + event.accepted = true; + return; + case Qt.Key_W: + case Qt.Key_Backspace: + deleteWordBackward(); + event.accepted = true; + return; + case Qt.Key_H: + backspace(); + event.accepted = true; + return; + case Qt.Key_D: + deleteForward(); + event.accepted = true; + return; + } + } + + switch (event.key) { + case Qt.Key_Return: + case Qt.Key_Enter: + accepted(); + event.accepted = true; + return; + case Qt.Key_Backspace: + backspace(); + event.accepted = true; + return; + case Qt.Key_Delete: + deleteForward(); + event.accepted = true; + return; + case Qt.Key_Left: + clampCursorPosition(); + cursorPosition = Math.max(0, cursorPosition - 1); + event.accepted = true; + return; + case Qt.Key_Right: + clampCursorPosition(); + cursorPosition = Math.min(text.length, cursorPosition + 1); + event.accepted = true; + return; + case Qt.Key_Home: + cursorPosition = 0; + event.accepted = true; + return; + case Qt.Key_End: + cursorPosition = text.length; + event.accepted = true; + return; + } + + if (isPrintableText(event.text)) { + insertText(event.text); + event.accepted = true; + } + } + + // IME commits use a hidden password input: https://github.com/AvengeMedia/DankMaterialShell/issues/2950 + TextInput { + id: imeCommitSink + + focus: true + width: Theme.dividerWidth + height: 1 + opacity: 0 + cursorDelegate: Item {} + echoMode: TextInput.Password + inputMethodHints: Qt.ImhHiddenText | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + KeyNavigation.tab: passwordField.KeyNavigation.tab + KeyNavigation.backtab: passwordField.KeyNavigation.backtab + Keys.onPressed: event => { + passwordField.handleKey(event); + if (!event.accepted && (event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier))) + event.accepted = true; + } + onTextChanged: { + if (text.length === 0) + return; + const committed = text; + text = ""; + if (root.demoMode || root.unlocking || root.pam.passwd.active) + return; + passwordField.insertText(committed); + } + } + + onVisibleChanged: { + if (visible) + root.focusPasswordField(); + } + } + + KeyboardController { + id: keyboardController + target: passwordField + rootObject: root.lockHost + expressive: true + } + + StyledText { + id: placeholder + + anchors.left: lockIconContainer.right + anchors.leftMargin: root.minimal ? 0 : Theme.spacingM + anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))))) + anchors.rightMargin: Theme.spacingXXS + anchors.verticalCenter: parent.verticalCenter + visible: !root.dots && !root.ring + text: { + if (root.demoMode || !root.pam) + return ""; + if (root.unlocking) + return I18n.tr("Unlocking...", "lock screen status text while unlocking"); + if (root.pam.u2fPending) { + if (root.pam.u2fState === "insert") + return I18n.tr("Insert your security key..."); + return I18n.tr("Touch your security key..."); + } + if (root.pam.passwd.active) + return I18n.tr("Authenticating...", "lock screen status text while the password is checked"); + return I18n.tr("Password", "lock screen password field placeholder") + "…"; + } + color: { + if (root.unlocking || (root.pam?.passwd.active ?? false)) + return root.accentColor; + return root.contained ? Theme.outline : Theme.withAlpha(root.plainColor, Theme.pendingOpacity); + } + font.pixelSize: Theme.fontSizeMedium + elide: Text.ElideRight + wrapMode: Text.NoWrap + opacity: (root.demoMode || root.passwordBuffer.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + + Behavior on color { + ColorAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + + Item { + id: passwordViewport + + property real scrollX: 0 + property real scrollY: 0 + + function followCursor() { + const rect = passwordDisplay.cursorRectangle; + const contentWidth = passwordDisplay.contentWidth + passwordCursor.width; + const contentHeight = passwordDisplay.contentHeight; + scrollX = followAxis(scrollX, rect.x, rect.x + passwordCursor.width, contentWidth, width); + scrollY = contentHeight < height ? (height - contentHeight) / 2 : followAxis(scrollY, rect.y, rect.y + rect.height, contentHeight, height); + } + + function followAxis(offset, start, end, contentSize, viewSize) { + if (contentSize <= viewSize) + return 0; + if (start + offset < 0) + return -start; + if (end + offset > viewSize) + return viewSize - end; + return Math.max(viewSize - contentSize, Math.min(0, offset)); + } + + anchors.left: lockIconContainer.right + anchors.leftMargin: root.minimal ? 0 : Theme.spacingM + anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))))) + anchors.rightMargin: Theme.spacingXXS + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.topMargin: Theme.spacingXS + anchors.bottomMargin: Theme.spacingXS + clip: true + visible: (!root.dots || passwordBox.showPassword) && !root.ring + + MouseArea { + anchors.fill: parent + enabled: !root.demoMode + onClicked: passwordField.forceActiveFocus() + } + + onWidthChanged: followCursor() + onHeightChanged: followCursor() + + TextEdit { + id: passwordDisplay + LayoutMirroring.enabled: false + + x: passwordViewport.scrollX + y: passwordViewport.scrollY + width: implicitWidth + readOnly: true + activeFocusOnPress: false + selectByMouse: false + wrapMode: TextEdit.NoWrap + text: { + if (root.demoMode) + return "••••••••"; + if (passwordBox.showPassword) + return root.passwordBuffer; + return "•".repeat(root.passwordBuffer.length); + } + color: root.contained ? Theme.surfaceText : root.plainColor + font.family: Theme.fontFamily + font.weight: Theme.fontWeight + font.pixelSize: passwordBox.showPassword ? Theme.fontSizeMedium : (root.contained ? Theme.fontSizeLarge : Theme.fontSizeXLarge) + opacity: (root.demoMode || root.passwordBuffer.length > 0) ? 1 : 0 + + onTextChanged: cursorPosition = passwordField.cursorPosition + onCursorRectangleChanged: passwordViewport.followCursor() + onContentWidthChanged: passwordViewport.followCursor() + onContentHeightChanged: passwordViewport.followCursor() + + Behavior on opacity { + NumberAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + + DankTextCursor { + id: passwordCursor + + x: passwordDisplay.x + passwordDisplay.cursorRectangle.x + y: passwordDisplay.y + passwordDisplay.cursorRectangle.y + height: passwordDisplay.cursorRectangle.height + shown: !root.demoMode && root.showPasswordField && !root.ring && passwordField.activeFocus && !(root.pam?.passwd.active ?? false) && !(root.pam?.u2fPending ?? false) && !root.unlocking + + readonly property int fieldCursorPosition: passwordField.cursorPosition + readonly property string fieldText: passwordField.text + + onFieldCursorPositionChanged: { + passwordDisplay.cursorPosition = fieldCursorPosition; + resetBlink(); + } + + onFieldTextChanged: resetBlink() + } + } + + LockActionButton { + id: revealButton + + activeFocusOnTab: false + Accessible.name: parent.showPassword ? I18n.tr("Hide password") : I18n.tr("Show password") + + anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))) + anchors.rightMargin: 0 + anchors.verticalCenter: parent.verticalCenter + iconName: parent.showPassword ? "visibility_off" : "visibility" + buttonSize: Theme.buttonHeightXS + visible: !root.demoMode && !root.ring && !root.minimal && root.passwordBuffer.length > 0 && !(root.pam?.passwd.active ?? false) && !root.unlocking + enabled: visible + onClicked: parent.showPassword = !parent.showPassword + } + + LockActionButton { + id: securityKeyButton + + activeFocusOnTab: false + + anchors.right: enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right) + anchors.rightMargin: 0 + anchors.verticalCenter: parent.verticalCenter + iconName: "passkey" + buttonSize: Theme.buttonHeightXS + visible: root.canStartSecurityKeyUnlock() && !root.ring + enabled: visible && root.showPasswordField + tooltipText: SettingsData.lockScreenSecurityKeyShortcutEnabled ? I18n.tr("Security key (%1)", "lock screen security key button tooltip with shortcut").arg(SettingsData.lockScreenSecurityKeyShortcut) : I18n.tr("Security key", "lock screen security key button tooltip") + onClicked: root.triggerSecurityKeyUnlock() + } + + LockActionButton { + id: virtualKeyboardButton + Keys.onEscapePressed: { + keyboardController.hide(); + passwordField.forceActiveFocus(); + } + + KeyNavigation.tab: passwordField + KeyNavigation.backtab: passwordField + Accessible.name: I18n.tr("On-screen keyboard") + + anchors.right: securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)) + anchors.rightMargin: securityKeyButton.visible || enterButton.visible ? 0 : Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: "keyboard" + buttonSize: Theme.buttonHeightXS + visible: !root.demoMode && !root.ring && !root.minimal && !(root.pam?.passwd.active ?? false) && !root.unlocking && !(root.pam?.u2fPending ?? false) + enabled: visible && root.showPasswordField + onClicked: { + if (keyboardController.isKeyboardActive) + keyboardController.hide(); + else + keyboardController.show(); + } + } + + Item { + id: loadingSpinner + + anchors.right: enterButton.visible ? enterButton.left : parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + width: Theme.iconSize + height: Theme.iconSize + visible: !root.demoMode && !root.ring && !root.morph && ((root.pam?.passwd.active ?? false) || root.unlocking) + + DankIcon { + anchors.centerIn: parent + name: "check_circle" + size: Theme.iconSizeSmall + color: root.accentColor + visible: root.unlocking + + opacity: root.unlocking ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + + DankLoadingIndicator { + anchors.centerIn: parent + size: Theme.iconSize + contained: root.style === "expressive" + color: root.style === "expressive" ? Theme.onPrimaryContainer : root.accentColor + visible: (root.pam?.passwd.active ?? false) && !root.unlocking + running: visible + } + } + + LockActionButton { + id: enterButton + + activeFocusOnTab: false + Accessible.name: I18n.tr("Unlock", "verb, lock screen submit button") + + anchors.right: parent.right + anchors.rightMargin: Theme.spacingXXS + anchors.verticalCenter: parent.verticalCenter + iconName: "keyboard_return" + buttonSize: Theme.buttonHeightXS + visible: !root.ring && !root.minimal && (root.demoMode || (!(root.pam?.passwd.active ?? false) && !root.unlocking && !(root.pam?.u2fPending ?? false))) + enabled: !root.demoMode && root.showPasswordField + onClicked: { + if (!root.demoMode && !root.unlocking && !root.pam.u2fPending) + root.pam.passwd.start(); + } + + Behavior on opacity { + NumberAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + + Behavior on border.color { + ColorAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + } + + StyledText { + id: authFeedbackText + + Layout.fillWidth: true + Layout.preferredHeight: text.length > 0 ? Math.min(implicitHeight, Math.ceil(Theme.fontSizeSmall * 4.5)) : 0 + text: root.currentAuthFeedbackText() + color: root.authFeedbackIsHint() ? Theme.outline : Theme.error + font.pixelSize: Theme.fontSizeSmall + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + opacity: text.length > 0 ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + } + + Row { + id: capsLockRow + anchors.top: passwordLayout.bottom + anchors.topMargin: Theme.spacingS + anchors.horizontalCenter: passwordLayout.horizontalCenter + spacing: Theme.spacingXS + opacity: DMSService.capsLockState ? 1 : 0 + + DankIcon { + name: "shift_lock" + size: Theme.iconSizeSmall + color: Theme.error + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Caps Lock is on") + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + anchors.verticalCenter: parent.verticalCenter + } + + Behavior on opacity { + NumberAnimation { + duration: LockMetrics.effectsDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + } + + MouseArea { + anchors.fill: parent + visible: !root.demoMode && !root.showPasswordField + onClicked: { + root.lockHost.setInputRevealed(true); + root.focusPasswordField(); + } + } + + SequentialAnimation { + id: errorShake + NumberAnimation { + target: passwordBox + property: "errorOffset" + to: LockMetrics.shakeDistance + duration: LockMetrics.shakeDuration / 3 + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + NumberAnimation { + target: passwordBox + property: "errorOffset" + to: -LockMetrics.shakeDistance + duration: LockMetrics.shakeDuration / 3 + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + NumberAnimation { + target: passwordBox + property: "errorOffset" + to: 0 + duration: LockMetrics.shakeDuration / 3 + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } +} diff --git a/quickshell/Modules/Lock/Widgets/LockClockWidget.qml b/quickshell/Modules/Lock/Widgets/LockClockWidget.qml new file mode 100644 index 000000000..cc66982c8 --- /dev/null +++ b/quickshell/Modules/Lock/Widgets/LockClockWidget.qml @@ -0,0 +1,267 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Widgets +import qs.DankCommon.Session +import "../../../DankCommon/Common/Hct.js" as Hct + +Item { + id: root + + property var instanceData: null + property var lockHost: null + readonly property var cfg: instanceData?.config ?? ({}) + readonly property string style: cfg.style ?? "expressive" + readonly property bool textStyle: style === "horizontal" || style === "vertical" + readonly property string colorMode: cfg.colorMode ?? "default" + readonly property var primaryHct: Hct.toHct(Theme.primary) + readonly property color shadowFaceColor: { + if (colorMode !== "default") + return contentColor; + return Theme.isLightMode ? Hct.fromHct(primaryHct.hue, primaryHct.chroma, 65) : Theme.accentOnPrimaryContainer; + } + readonly property color shadowColor: { + if (colorMode === "default") + return Theme.isLightMode ? Hct.fromHct(primaryHct.hue, primaryHct.chroma, 30) : Theme.primaryContainer; + const source = Hct.toHct(shadowFaceColor); + return Hct.fromHct(source.hue, source.chroma, source.tone > 50 ? 30 : 80); + } + readonly property int weightOverride: cfg.weight ?? 0 + readonly property int digitWeight: weightOverride > 0 ? weightOverride : Theme.fontWeight + readonly property color contentColor: lockHost?.contentColor(cfg.colorMode ?? "default", cfg.customColor ?? "#ffffff") ?? Theme.lockScreenContentColor + readonly property string fontFamily: cfg.fontFamily || SettingsData.lockScreenFontFamily + readonly property color minutesColor: (cfg.twoTone ?? true) ? Theme.primary : contentColor + readonly property var contrastColors: style === "vertical" ? [shadowFaceColor, shadowColor] : style === "horizontal" || style === "expressive" ? [contentColor, minutesColor] : [contentColor] + readonly property real clockSize: LockMetrics.clockSize * (style === "horizontal" ? 1.5 : 1) + readonly property real clockDigitWidth: clockSize * 0.625 + + readonly property bool resizable: true + readonly property bool forceSquare: style === "analog" + readonly property real boxWidth: style === "analog" ? LockMetrics.clockSize * 2 : LockMetrics.clockSize * 4 + readonly property real boxHeight: style === "analog" ? LockMetrics.clockSize * 2 : LockMetrics.clockSize * 1.4 + readonly property real naturalWidth: face.item?.implicitWidth ?? 0 + readonly property real naturalHeight: face.item?.implicitHeight ?? 0 + readonly property real defaultWidth: textStyle ? naturalWidth : boxWidth + readonly property real defaultHeight: textStyle ? naturalHeight : boxHeight + readonly property real minWidth: textStyle ? naturalWidth / 2 : LockMetrics.fieldHeight * 2 + readonly property real minHeight: textStyle ? naturalHeight / 2 : LockMetrics.fieldHeight + readonly property real fitScale: textStyle && naturalWidth > 0 && naturalHeight > 0 ? Math.min(width / naturalWidth, height / naturalHeight) : 1 + + implicitWidth: textStyle ? naturalWidth : boxWidth + implicitHeight: textStyle ? naturalHeight : boxHeight + + component ClockDigitText: StyledText { + font.pixelSize: root.clockSize + font.weight: root.digitWeight + font.italic: root.cfg.italic ?? false + font.features: ({ + "tnum": 1 + }) + color: root.contentColor + horizontalAlignment: Text.AlignHCenter + font.family: root.fontFamily !== "" ? root.fontFamily : resolvedFontFamily + } + + SystemClock { + id: systemClock + precision: (root.style === "analog" ? (root.cfg.showAnalogSeconds ?? true) : SettingsData.getEffectiveTimeFormat().includes("s")) ? SystemClock.Seconds : SystemClock.Minutes + } + + QtObject { + id: time + + readonly property string ampm: SettingsData.use24HourClock ? "" : systemClock.date.toLocaleTimeString(I18n.locale(), "AP") + readonly property string hours: { + const format = SettingsData.use24HourClock || SettingsData.padHours12Hour ? "hh" : "h"; + const text = systemClock.date.toLocaleTimeString(I18n.locale(), ampm ? format + " AP" : format); + return ampm ? text.replace(ampm, "").trim() : text; + } + readonly property string minutes: systemClock.date.toLocaleTimeString(I18n.locale(), "mm") + readonly property string seconds: systemClock.date.toLocaleTimeString(I18n.locale(), "ss") + readonly property bool hasSeconds: SettingsData.showSeconds + } + + Loader { + id: face + anchors.fill: root.textStyle ? undefined : parent + anchors.centerIn: root.textStyle ? parent : undefined + width: root.textStyle ? root.naturalWidth : undefined + height: root.textStyle ? root.naturalHeight : undefined + scale: root.fitScale + sourceComponent: { + switch (root.style) { + case "vertical": + return shadowFace; + case "expressive": + return expressiveFace; + case "analog": + return analogFace; + } + return horizontalFace; + } + } + + Component { + id: horizontalFace + + Item { + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + + Row { + id: row + anchors.centerIn: parent + layoutDirection: Qt.LeftToRight + spacing: 0 + + ClockDigitText { + id: hourText + text: time.hours + ":" + } + + ClockDigitText { + text: time.minutes + color: root.minutesColor + } + + ClockDigitText { + text: ":" + time.seconds + color: root.minutesColor + visible: time.hasSeconds + } + + ClockDigitText { + text: " " + time.ampm + font.pixelSize: root.clockSize / 3 + anchors.baseline: hourText.baseline + visible: time.ampm !== "" + } + } + } + } + + Component { + id: shadowFace + + Item { + id: shadowItem + + readonly property real digitSize: root.clockSize * 1.25 + readonly property real tracking: -digitSize * 0.1 + readonly property real rowAdvance: digitSize * 0.7 + readonly property int weight: root.weightOverride > 0 ? root.weightOverride : 1000 + readonly property bool variableFont: root.fontFamily === "" || root.fontFamily === Theme.defaultFontFamily + // One cell width for all four glyphs keeps the columns aligned in fonts without tabular figures. + readonly property real cellWidth: Math.max(hoursRow.firstAdvance, hoursRow.secondAdvance, minutesRow.firstAdvance, minutesRow.secondAdvance) + implicitWidth: Math.max(hoursRow.width, minutesRow.width) + implicitHeight: Math.max(hoursRow.height, minutesRow.y + minutesRow.height) + + ShadowRow { + id: hoursRow + x: (shadowItem.width - shadowItem.implicitWidth) / 2 + text: time.hours.padStart(2, "0") + } + + ShadowRow { + id: minutesRow + x: hoursRow.x + y: hoursRow.baselineOffset + shadowItem.rowAdvance - baselineOffset + text: time.minutes + lightFirst: true + } + } + } + + component ClockDigit: StyledText { + id: digit + + readonly property rect inkBounds: metrics.tightBoundingRect + readonly property real advanceWidth: metrics.advanceWidth + + font.pixelSize: shadowItem.digitSize + font.family: root.fontFamily !== "" ? root.fontFamily : Theme.defaultFontFamily + font.weight: shadowItem.variableFont ? Font.Normal : Math.min(Font.Black, shadowItem.weight) + font.variableAxes: shadowItem.variableFont ? { + "wght": shadowItem.weight, + "ROND": 0, + "opsz": 18 + } : ({}) + font.features: ({ + "tnum": 1 + }) + textFormat: Text.PlainText + wrapMode: Text.NoWrap + elide: Text.ElideNone + verticalAlignment: Text.AlignTop + + TextMetrics { + id: metrics + font: digit.font + text: digit.text + renderType: digit.renderType + } + } + + component ShadowRow: Item { + id: shadowRow + + property string text: "" + property bool lightFirst: false + readonly property real firstAdvance: firstDigit.advanceWidth + readonly property real secondAdvance: secondDigit.advanceWidth + readonly property real cell: shadowItem.cellWidth + readonly property real topBearing: Math.min(firstDigit.inkBounds.y, secondDigit.inkBounds.y) + readonly property real bottomBearing: Math.max(firstDigit.inkBounds.y + firstDigit.inkBounds.height, secondDigit.inkBounds.y + secondDigit.inkBounds.height) + + implicitWidth: cell * 2 + shadowItem.tracking + implicitHeight: bottomBearing - topBearing + baselineOffset: -topBearing + + ClockDigit { + id: firstDigit + x: (shadowRow.cell - advanceWidth) / 2 + y: shadowRow.baselineOffset - baselineOffset + text: shadowRow.text[0] ?? "" + color: shadowRow.lightFirst ? root.shadowFaceColor : root.shadowColor + } + + ClockDigit { + id: secondDigit + x: shadowRow.cell + shadowItem.tracking + (shadowRow.cell - advanceWidth) / 2 + y: shadowRow.baselineOffset - baselineOffset + text: shadowRow.text[1] ?? "" + color: shadowRow.lightFirst ? root.shadowColor : root.shadowFaceColor + } + } + + Component { + id: expressiveFace + + DankClockFace { + readonly property bool tallBox: height > width * 0.6 + + hours: time.hours + minutes: time.minutes + seconds: time.hasSeconds && !tallBox ? time.seconds : "" + stacked: tallBox + color: root.contentColor + minutesColor: root.minutesColor + } + } + + Component { + id: analogFace + + DankAnalogClock { + hours: systemClock.date?.getHours() ?? 0 + minutes: systemClock.date?.getMinutes() ?? 0 + seconds: systemClock.date?.getSeconds() ?? 0 + showSeconds: root.cfg.showAnalogSeconds ?? true + showNumbers: root.cfg.showAnalogNumbers ?? false + color: root.contentColor + backgroundColor: "transparent" + } + } +} diff --git a/quickshell/Modules/Lock/Widgets/LockDateWidget.qml b/quickshell/Modules/Lock/Widgets/LockDateWidget.qml new file mode 100644 index 000000000..1b0a07f45 --- /dev/null +++ b/quickshell/Modules/Lock/Widgets/LockDateWidget.qml @@ -0,0 +1,40 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Widgets + +Item { + id: root + + property var instanceData: null + property var lockHost: null + readonly property var cfg: instanceData?.config ?? ({}) + readonly property color contentColor: lockHost?.contentColor(cfg.colorMode ?? "default", cfg.customColor ?? "#ffffff") ?? Theme.lockScreenContentColor + readonly property string fontFamily: SettingsData.lockScreenFontFamily + readonly property bool resizable: true + readonly property real minWidth: implicitWidth / 2 + readonly property real minHeight: implicitHeight / 2 + readonly property real fitScale: implicitWidth > 0 && implicitHeight > 0 ? Math.min(width / implicitWidth, height / implicitHeight) : 1 + + implicitWidth: dateText.implicitWidth + implicitHeight: dateText.implicitHeight + + SystemClock { + id: systemClock + precision: SystemClock.Minutes + } + + StyledText { + id: dateText + anchors.centerIn: parent + scale: root.fitScale + text: { + if (SettingsData.lockDateFormat && SettingsData.lockDateFormat.length > 0) + return systemClock.date.toLocaleDateString(I18n.locale(), SettingsData.lockDateFormat); + return systemClock.date.toLocaleDateString(I18n.locale(), (root.cfg.format ?? "long") === "short" ? "ddd, d MMM" : Locale.LongFormat); + } + font.pixelSize: Theme.fontSizeXLarge + font.family: root.fontFamily !== "" ? root.fontFamily : resolvedFontFamily + color: root.contentColor + } +} diff --git a/quickshell/Modules/Lock/Widgets/LockNotificationsWidget.qml b/quickshell/Modules/Lock/Widgets/LockNotificationsWidget.qml new file mode 100644 index 000000000..6586254f5 --- /dev/null +++ b/quickshell/Modules/Lock/Widgets/LockNotificationsWidget.qml @@ -0,0 +1,254 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.Notifications as Notifications +import qs.Services +import qs.Widgets +import qs.DankCommon.Session + +Item { + id: root + + property var instanceData: null + property var lockHost: null + readonly property var cfg: instanceData?.config ?? ({}) + readonly property int notificationMode: cfg.mode ?? 1 + readonly property bool resizable: true + readonly property real minWidth: Theme.listItemTwoLineHeight * 2 + readonly property real minHeight: Theme.listItemHeight + + readonly property var notifications: NotificationService.groupedNotifications + readonly property int totalCount: { + let count = 0; + for (const group of notifications) + count += group.count || 0; + return count; + } + readonly property bool hasNotifications: totalCount > 0 + readonly property var appNameGroups: { + const groups = {}; + for (const group of notifications) { + const appName = (group.appName || "Unknown").toLowerCase(); + if (!groups[appName]) { + groups[appName] = { + appName: group.appName || I18n.tr("Unknown"), + count: 0, + latestNotification: group.latestNotification + }; + } + groups[appName].count += group.count || 0; + if (group.latestNotification && (!groups[appName].latestNotification || group.latestNotification.time > groups[appName].latestNotification.time)) + groups[appName].latestNotification = group.latestNotification; + } + return Object.values(groups).sort((a, b) => b.count - a.count); + } + + readonly property bool editPlaceholder: !hasNotifications && (lockHost?.demoMode ?? false) + readonly property bool chips: notificationMode === 2 && !editPlaceholder + readonly property var visibleGroups: appNameGroups.slice(0, LockMetrics.notificationLimit) + readonly property int hiddenGroups: Math.max(0, appNameGroups.length - LockMetrics.notificationLimit) + + implicitWidth: chips ? contentLoader.implicitWidth : LockMetrics.notificationCardWidth + implicitHeight: hasNotifications || editPlaceholder ? contentLoader.implicitHeight : 0 + clip: true + + Loader { + id: contentLoader + anchors.left: parent.left + anchors.right: root.chips ? undefined : parent.right + anchors.top: parent.top + active: root.hasNotifications || root.editPlaceholder + sourceComponent: { + if (root.notificationMode === 1 || root.editPlaceholder) + return countOnlyComponent; + return root.chips ? chipRowComponent : notificationListComponent; + } + } + + Component { + id: chipRowComponent + + Row { + spacing: Theme.spacingS + + Repeater { + model: root.visibleGroups + + Rectangle { + id: chip + + required property var modelData + readonly property string appIcon: NotificationService.notificationAppIcon(modelData.latestNotification?.appIcon || "", modelData.latestNotification?.desktopEntry || "") + + height: Theme.buttonHeightXS + width: chipRow.implicitWidth + Theme.spacingM * 2 + radius: Theme.cornerRadiusM + color: Theme.notificationFloatingSurface + border.width: Theme.layerOutlineWidth + border.color: Theme.outlineVariant + Accessible.name: modelData.appName + + Row { + id: chipRow + anchors.centerIn: parent + spacing: Theme.spacingXS + + Item { + width: Theme.iconSizeSmall + height: width + anchors.verticalCenter: parent.verticalCenter + + Image { + id: appImage + anchors.fill: parent + asynchronous: true + smooth: true + fillMode: Image.PreserveAspectFit + sourceSize: Qt.size(width * 2, height * 2) + source: NotificationService.notificationImageSource("", chip.appIcon) + visible: status === Image.Ready + } + + AppIconRenderer { + anchors.fill: parent + visible: !appImage.visible + iconValue: NotificationService.notificationFallbackIcon("", chip.appIcon) + iconSize: Theme.iconSizeSmall + iconColor: Theme.onSurfaceVariant + colorOverride: "transparent" + fallbackText: (chip.modelData.appName || "?").charAt(0).toUpperCase() + fallbackRadius: Theme.fullRadius(width, height) + fallbackBackgroundColor: Theme.secondaryContainer + fallbackTextColor: Theme.onSecondaryContainer + } + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: chip.modelData.appName + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightMedium + color: Theme.onSurface + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + visible: chip.modelData.count > 1 + width: Math.max(height, countText.implicitWidth + Theme.spacingXS * 2) + height: Theme.iconSizeSmall + radius: Theme.fullRadius(width, height) + color: Theme.primary + + StyledText { + id: countText + anchors.centerIn: parent + text: chip.modelData.count + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightMedium + color: Theme.onPrimary + } + } + } + } + } + + Rectangle { + visible: root.hiddenGroups > 0 + height: Theme.buttonHeightXS + width: moreText.implicitWidth + Theme.spacingM * 2 + radius: Theme.cornerRadiusM + color: Theme.notificationFloatingSurface + border.width: Theme.layerOutlineWidth + border.color: Theme.outlineVariant + + StyledText { + id: moreText + anchors.centerIn: parent + text: I18n.tr("+ %1 more", "lock screen notification list overflow, %1 is a count of hidden apps").arg(root.hiddenGroups) + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightMedium + color: Theme.onSurfaceVariant + } + } + } + } + + Component { + id: countOnlyComponent + + LockNotificationCard { + implicitHeight: Theme.listItemHeight + color: Theme.notificationFloatingSurface + Accessible.name: countLabel.text + + Row { + anchors.centerIn: parent + spacing: Theme.spacingS + + DankIcon { + name: "notifications" + size: Theme.iconSize + color: Theme.onSurfaceVariant + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + id: countLabel + text: root.editPlaceholder ? I18n.tr("Notifications") : root.totalCount === 1 ? I18n.tr("1 notification") : I18n.tr("%1 notifications").arg(root.totalCount) + font.pixelSize: Theme.fontSizeMedium + color: Theme.onSurface + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + + Component { + id: notificationListComponent + + DankFlickable { + implicitHeight: Math.min(notificationColumn.implicitHeight, LockMetrics.notificationMaxHeight, Math.max(0, (root.lockHost?.height ?? 0) - (root.parent?.y ?? 0) - Theme.spacingXL)) + height: Math.min(notificationColumn.implicitHeight, root.height) + contentHeight: notificationColumn.implicitHeight + clip: true + + Column { + id: notificationColumn + width: parent.width + spacing: Theme.groupedListGap + + Repeater { + id: notificationRepeater + model: root.appNameGroups.slice(0, LockMetrics.notificationLimit) + + Notifications.NotificationCard { + required property var modelData + required property int index + width: notificationColumn.width + notificationData: modelData.latestNotification + groupCount: modelData.count + interactive: false + headerOnly: false + showActions: false + showDismiss: false + showClose: false + showTime: !headerOnly + animateHeight: false + firstInGroup: index === 0 + lastInGroup: index === notificationRepeater.count - 1 + } + } + + StyledText { + width: parent.width + visible: root.appNameGroups.length > LockMetrics.notificationLimit + text: I18n.tr("+ %1 more", "lock screen notification list overflow, %1 is a count of hidden apps").arg(root.appNameGroups.length - LockMetrics.notificationLimit) + font.pixelSize: Theme.fontSizeSmall + color: Theme.lockScreenContentColor + horizontalAlignment: Text.AlignHCenter + } + } + } + } +} diff --git a/quickshell/Modules/Lock/Widgets/LockPowerWidget.qml b/quickshell/Modules/Lock/Widgets/LockPowerWidget.qml new file mode 100644 index 000000000..210112d0c --- /dev/null +++ b/quickshell/Modules/Lock/Widgets/LockPowerWidget.qml @@ -0,0 +1,47 @@ +import QtQuick +import qs.Common +import qs.Widgets +import qs.DankCommon.Session + +Item { + id: root + + property var instanceData: null + property var lockHost: null + readonly property var cfg: instanceData?.config ?? ({}) + readonly property string shape: cfg.shape ?? "round" + readonly property bool round: shape === "round" + readonly property bool resizable: false + readonly property real minWidth: implicitWidth + readonly property real minHeight: implicitHeight + + implicitWidth: powerButton.width + implicitHeight: powerButton.height + + DankMaterialShape { + anchors.fill: powerButton + visible: !root.round + shape: root.shape + color: Theme.secondaryContainer + scale: powerButton.pressed ? 0.9 : 1 + + Behavior on scale { + NumberAnimation { + duration: LockMetrics.shakeDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial + } + } + } + + LockActionButton { + id: powerButton + Accessible.name: I18n.tr("Power Options") + iconName: "power_settings_new" + iconColor: Theme.onSecondaryContainer + backgroundColor: root.round ? Theme.secondaryContainer : "transparent" + radius: pressed ? Theme.cornerRadiusS : Theme.fullRadius(width, height) + buttonSize: Theme.buttonHeightM + onClicked: root.lockHost?.showPowerMenu() + } +} diff --git a/quickshell/Modules/Lock/Widgets/LockStatusWidget.qml b/quickshell/Modules/Lock/Widgets/LockStatusWidget.qml new file mode 100644 index 000000000..d5cb87501 --- /dev/null +++ b/quickshell/Modules/Lock/Widgets/LockStatusWidget.qml @@ -0,0 +1,39 @@ +import QtQuick +import qs.Common +import qs.Modules.Lock + +Item { + id: root + + property var instanceData: null + property var lockHost: null + readonly property var cfg: instanceData?.config ?? ({}) + readonly property bool background: cfg.background ?? false + readonly property real pad: background ? Theme.spacingM : 0 + readonly property bool resizable: false + readonly property real minWidth: implicitWidth + readonly property real minHeight: implicitHeight + + implicitWidth: statusRow.implicitWidth + pad * 2 + implicitHeight: statusRow.implicitHeight + pad * 2 + + Rectangle { + anchors.fill: parent + visible: root.background + radius: Theme.fullRadius(width, height) + color: Theme.readableSurface + border.width: Theme.layerOutlineWidth + border.color: Theme.outlineMedium + } + + LockStatusRow { + id: statusRow + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: root.pad + interactive: !(root.lockHost?.demoMode ?? true) + showMediaPlayer: root.cfg.showMediaPlayer ?? true + showWeather: root.cfg.showWeather ?? true + useFahrenheit: SettingsData.useFahrenheit + } +} diff --git a/quickshell/Modules/Plugins/DesktopPluginComponent.qml b/quickshell/Modules/Plugins/DesktopPluginComponent.qml index 4651008d5..f0b0c067f 100644 --- a/quickshell/Modules/Plugins/DesktopPluginComponent.qml +++ b/quickshell/Modules/Plugins/DesktopPluginComponent.qml @@ -8,6 +8,7 @@ Item { property string pluginId: "" property string instanceId: "" property var instanceData: null + property bool lockScreen: false property real widgetWidth: 200 property real widgetHeight: 200 diff --git a/quickshell/Modules/Plugins/DesktopPluginWrapper.qml b/quickshell/Modules/Plugins/DesktopPluginWrapper.qml index f5d4385a4..66599dae6 100644 --- a/quickshell/Modules/Plugins/DesktopPluginWrapper.qml +++ b/quickshell/Modules/Plugins/DesktopPluginWrapper.qml @@ -13,27 +13,20 @@ Item { required property string pluginId required property var screen - property var builtinComponent: null - property var pluginService: null property string instanceId: "" property var instanceData: null - onInstanceDataChanged: contentLoader.updateInstanceData() property bool widgetEnabled: true - readonly property bool isBuiltin: pluginId === "desktopClock" || pluginId === "systemMonitor" - readonly property var activeComponent: isBuiltin ? builtinComponent : PluginService.pluginDesktopComponents[pluginId] ?? null - readonly property bool showOnOverlay: instanceData?.config?.showOnOverlay ?? false readonly property bool showOnOverview: instanceData?.config?.showOnOverview ?? false readonly property bool showOnOverviewOnly: instanceData?.config?.showOnOverviewOnly ?? false readonly property bool overviewActive: CompositorService.isNiri && NiriService.inOverview readonly property bool clickThrough: instanceData?.config?.clickThrough ?? false - readonly property bool syncPositionAcrossScreens: instanceData?.config?.syncPositionAcrossScreens ?? false // Unmapping with the widget still in the last buffer leaves a stale image in // Hyprland's blur cache behind transparent tiled windows (#2955), so hide the // content, present a transparent frame, then unmap. - readonly property bool contentShowing: widgetEnabled && activeComponent !== null && (!showOnOverviewOnly || overviewActive) + readonly property bool contentShowing: widgetEnabled && content.activeComponent !== null && (!showOnOverviewOnly || overviewActive) property bool surfaceLingering: false onContentShowingChanged: { @@ -52,157 +45,25 @@ Item { onTriggered: root.surfaceLingering = false } - readonly property string settingsKey: instanceId ? instanceId : pluginId - readonly property bool isInstance: instanceId !== "" && instanceData !== null - - QtObject { - id: instanceScopedPluginService - - readonly property var availablePlugins: PluginService.availablePlugins - readonly property var loadedPlugins: PluginService.loadedPlugins - readonly property var pluginDesktopComponents: PluginService.pluginDesktopComponents - - signal pluginDataChanged(string pluginId) - signal pluginLoaded(string pluginId) - signal pluginUnloaded(string pluginId) - - function loadPluginData(pluginId, key, defaultValue) { - const cfg = root.instanceData?.config; - if (cfg && key in cfg) - return cfg[key]; - return SettingsData.getPluginSetting(pluginId, key, defaultValue); - } - - function savePluginData(pluginId, key, value) { - if (!root.instanceId) - return false; - var updates = {}; - updates[key] = value; - SettingsData.updateDesktopWidgetInstanceConfig(root.instanceId, updates); - Qt.callLater(() => pluginDataChanged(pluginId)); - return true; - } - - function getPluginVariants(pluginId) { - return PluginService.getPluginVariants(pluginId); - } - - function isPluginLoaded(pluginId) { - return PluginService.isPluginLoaded(pluginId); - } + DesktopWidgetGeometry { + id: geometry + instanceId: root.instanceId + instanceData: root.instanceData + screen: root.screen + minWidth: content.contentMinWidth + minHeight: content.contentMinHeight + forceSquare: content.contentForceSquare } - readonly property string screenKey: SettingsData.getScreenDisplayName(screen) - readonly property string positionKey: syncPositionAcrossScreens ? "_synced" : screenKey - readonly property var storedPositions: SessionData.desktopWidgetInstancePositions[instanceId] ?? null - - readonly property int screenWidth: screen?.width ?? 1920 - readonly property int screenHeight: screen?.height ?? 1080 readonly property bool useGhostPreview: !CompositorService.isNiri - property real previewX: widgetX - property real previewY: widgetY - property real previewWidth: widgetWidth - property real previewHeight: widgetHeight + property real previewX: geometry.widgetX + property real previewY: geometry.widgetY + property real previewWidth: geometry.widgetWidth + property real previewHeight: geometry.widgetHeight - function storedGeometry(key) { - if (!isInstance) - return undefined; - return storedPositions?.[positionKey]?.[key]; - } - - function storedCoordinate(key, extent, fallback) { - const val = storedGeometry(key); - if (val === undefined) - return fallback; - return syncPositionAcrossScreens ? val * extent : val; - } - - readonly property bool hasSavedPosition: storedGeometry("x") !== undefined - readonly property bool hasSavedSize: storedGeometry("width") !== undefined - - property real savedX: storedCoordinate("x", screenWidth, screenWidth / 2 - savedWidth / 2) - property real savedY: storedCoordinate("y", screenHeight, screenHeight / 2 - savedHeight / 2) - property real savedWidth: storedGeometry("width") ?? 280 - property real savedHeight: forceSquare ? savedWidth : (storedGeometry("height") ?? 180) - - property real dragOverrideX: -1 - property real dragOverrideY: -1 - property real dragOverrideW: -1 - property real dragOverrideH: -1 - - readonly property real effectiveX: dragOverrideX >= 0 ? dragOverrideX : savedX - readonly property real effectiveY: dragOverrideY >= 0 ? dragOverrideY : savedY - readonly property real effectiveW: dragOverrideW >= 0 ? dragOverrideW : savedWidth - readonly property real effectiveH: dragOverrideH >= 0 ? dragOverrideH : savedHeight - - readonly property real widgetX: Math.max(0, Math.min(effectiveX, screenWidth - widgetWidth)) - readonly property real widgetY: Math.max(0, Math.min(effectiveY, screenHeight - widgetHeight)) - readonly property real widgetWidth: Math.max(minWidth, Math.min(effectiveW, screenWidth)) - readonly property real widgetHeight: Math.max(minHeight, Math.min(effectiveH, screenHeight)) - - function clearDragOverrides() { - dragOverrideX = -1; - dragOverrideY = -1; - dragOverrideW = -1; - dragOverrideH = -1; - } - - function requestResize(width, height) { - if (width < minWidth || height < minHeight) - return; - if (width > screenWidth || height > screenHeight) - return; - dragOverrideW = width; - dragOverrideH = height; - } - - function clearResize() { - dragOverrideW = -1; - dragOverrideH = -1; - } - - property real minWidth: contentLoader.item?.minWidth ?? 100 - property real minHeight: contentLoader.item?.minHeight ?? 100 - property bool forceSquare: contentLoader.item?.forceSquare ?? false - property bool acceptsKeyboardFocus: contentLoader.item?.acceptsKeyboardFocus ?? false property bool isInteracting: dragArea.pressed || resizeArea.pressed - property var _gridSettingsTrigger: SessionData.desktopWidgetGridSettings - readonly property int gridSize: { - void _gridSettingsTrigger; - return SessionData.getDesktopWidgetGridSetting(screenKey, "size", 40); - } - readonly property bool gridEnabled: { - void _gridSettingsTrigger; - return SessionData.getDesktopWidgetGridSetting(screenKey, "enabled", false); - } - - function snapToGrid(value) { - return Math.round(value / gridSize) * gridSize; - } - - function saveGeometry(updates) { - if (!isInstance) - return; - SessionData.updateDesktopWidgetInstancePosition(instanceId, positionKey, updates); - } - - function savePosition(finalX, finalY) { - saveGeometry({ - x: syncPositionAcrossScreens ? finalX / screenWidth : finalX, - y: syncPositionAcrossScreens ? finalY / screenHeight : finalY - }); - } - - function saveSize(finalW, finalH) { - const sizeVal = forceSquare ? Math.max(finalW, finalH) : finalW; - saveGeometry({ - width: sizeVal, - height: forceSquare ? sizeVal : finalH - }); - } - PanelWindow { id: widgetWindow screen: root.screen @@ -234,7 +95,7 @@ Item { return WlrKeyboardFocus.OnDemand; return WlrKeyboardFocus.Exclusive; } - if (root.acceptsKeyboardFocus) + if (content.acceptsKeyboardFocus) return WlrKeyboardFocus.OnDemand; return WlrKeyboardFocus.None; } @@ -253,15 +114,15 @@ Item { return; switch (event.key) { case Qt.Key_G: - SessionData.setDesktopWidgetGridSetting(root.screenKey, "enabled", !root.gridEnabled); + SessionData.setDesktopWidgetGridSetting(geometry.screenKey, "enabled", !geometry.gridEnabled); event.accepted = true; break; case Qt.Key_Z: - SessionData.setDesktopWidgetGridSetting(root.screenKey, "size", Math.max(10, root.gridSize - 10)); + SessionData.setDesktopWidgetGridSetting(geometry.screenKey, "size", Math.max(10, geometry.gridSize - 10)); event.accepted = true; break; case Qt.Key_X: - SessionData.setDesktopWidgetGridSetting(root.screenKey, "size", Math.min(200, root.gridSize + 10)); + SessionData.setDesktopWidgetGridSetting(geometry.screenKey, "size", Math.min(200, geometry.gridSize + 10)); event.accepted = true; break; } @@ -274,75 +135,23 @@ Item { } WlrLayershell.margins { - left: root.widgetX - top: root.widgetY + left: geometry.widgetX + top: geometry.widgetY } - implicitWidth: root.widgetWidth - implicitHeight: root.widgetHeight + implicitWidth: geometry.widgetWidth + implicitHeight: geometry.widgetHeight - Loader { - id: contentLoader + DesktopWidgetContent { + id: content anchors.fill: parent - active: root.widgetEnabled && root.activeComponent !== null + active: root.widgetEnabled visible: root.contentShowing - sourceComponent: root.activeComponent - opacity: 0 - - NumberAnimation { - id: revealFade - target: contentLoader - property: "opacity" - from: 0 - to: 1 - duration: Theme.mediumDuration - easing.type: Theme.standardEasing - } - - function updateInstanceData() { - if (!item || item.instanceData === undefined) - return; - item.instanceData = root.instanceData; - } - - onLoaded: { - if (!item) - return; - - revealFade.restart(); - - if (item.pluginService !== undefined) { - item.pluginService = root.isInstance ? instanceScopedPluginService : root.pluginService; - } - if (item.pluginId !== undefined) - item.pluginId = root.pluginId; - if (item.instanceId !== undefined) - item.instanceId = root.instanceId; - if (item.instanceData !== undefined) - item.instanceData = root.instanceData; - if (!root.hasSavedSize) { - const defW = item.defaultWidth ?? item.widgetWidth ?? 280; - const defH = item.defaultHeight ?? item.widgetHeight ?? 180; - const finalW = Math.max(root.minWidth, Math.min(defW, root.screenWidth)); - const finalH = Math.max(root.minHeight, Math.min(defH, root.screenHeight)); - root.saveSize(finalW, finalH); - } - if (!root.hasSavedPosition) { - const finalX = Math.max(0, Math.min(root.screenWidth / 2 - root.widgetWidth / 2, root.screenWidth - root.widgetWidth)); - const finalY = Math.max(0, Math.min(root.screenHeight / 2 - root.widgetHeight / 2, root.screenHeight - root.widgetHeight)); - root.savePosition(finalX, finalY); - } - if (item.widgetWidth !== undefined) - item.widgetWidth = Qt.binding(() => contentLoader.width); - if (item.widgetHeight !== undefined) - item.widgetHeight = Qt.binding(() => contentLoader.height); - if (item.screen !== undefined) - item.screen = Qt.binding(() => root.screen); - if (item.requestResize !== undefined) - item.requestResize = root.requestResize; - if (item.clearResize !== undefined) - item.clearResize = root.clearResize; - } + pluginId: root.pluginId + instanceId: root.instanceId + instanceData: root.instanceData + screen: root.screen + geometry: geometry } Rectangle { @@ -380,38 +189,33 @@ Item { onPressed: mouse => { startPos = root.useGhostPreview ? Qt.point(mouse.x, mouse.y) : mapToGlobal(mouse.x, mouse.y); - startX = root.widgetX; - startY = root.widgetY; - root.previewX = root.widgetX; - root.previewY = root.widgetY; - root.dragOverrideX = root.widgetX; - root.dragOverrideY = root.widgetY; + startX = geometry.widgetX; + startY = geometry.widgetY; + root.previewX = geometry.widgetX; + root.previewY = geometry.widgetY; + geometry.dragOverrideX = geometry.widgetX; + geometry.dragOverrideY = geometry.widgetY; } onPositionChanged: mouse => { if (!pressed) return; const currentPos = root.useGhostPreview ? Qt.point(mouse.x, mouse.y) : mapToGlobal(mouse.x, mouse.y); - let newX = Math.max(0, Math.min(startX + currentPos.x - startPos.x, root.screenWidth - root.widgetWidth)); - let newY = Math.max(0, Math.min(startY + currentPos.y - startPos.y, root.screenHeight - root.widgetHeight)); - if (root.gridEnabled) { - newX = Math.max(0, Math.min(root.snapToGrid(newX), root.screenWidth - root.widgetWidth)); - newY = Math.max(0, Math.min(root.snapToGrid(newY), root.screenHeight - root.widgetHeight)); - } + const next = geometry.dragMoveTo(startX, startY, currentPos.x - startPos.x, currentPos.y - startPos.y); if (root.useGhostPreview) { - root.previewX = newX; - root.previewY = newY; + root.previewX = next.x; + root.previewY = next.y; return; } - root.dragOverrideX = newX; - root.dragOverrideY = newY; + geometry.dragOverrideX = next.x; + geometry.dragOverrideY = next.y; } onReleased: { - const finalX = root.useGhostPreview ? root.previewX : root.dragOverrideX; - const finalY = root.useGhostPreview ? root.previewY : root.dragOverrideY; - root.savePosition(finalX, finalY); - root.clearDragOverrides(); + const finalX = root.useGhostPreview ? root.previewX : geometry.dragOverrideX; + const finalY = root.useGhostPreview ? root.previewY : geometry.dragOverrideY; + geometry.savePosition(finalX, finalY); + geometry.clearDragOverrides(); } } @@ -431,43 +235,33 @@ Item { onPressed: mouse => { startPos = root.useGhostPreview ? Qt.point(mouse.x, mouse.y) : mapToGlobal(mouse.x, mouse.y); - startWidth = root.widgetWidth; - startHeight = root.widgetHeight; - root.previewWidth = root.widgetWidth; - root.previewHeight = root.widgetHeight; - root.dragOverrideW = root.widgetWidth; - root.dragOverrideH = root.widgetHeight; + startWidth = geometry.widgetWidth; + startHeight = geometry.widgetHeight; + root.previewWidth = geometry.widgetWidth; + root.previewHeight = geometry.widgetHeight; + geometry.dragOverrideW = geometry.widgetWidth; + geometry.dragOverrideH = geometry.widgetHeight; } onPositionChanged: mouse => { if (!pressed) return; const currentPos = root.useGhostPreview ? Qt.point(mouse.x, mouse.y) : mapToGlobal(mouse.x, mouse.y); - let newW = Math.max(root.minWidth, Math.min(startWidth + currentPos.x - startPos.x, root.screenWidth - root.widgetX)); - let newH = Math.max(root.minHeight, Math.min(startHeight + currentPos.y - startPos.y, root.screenHeight - root.widgetY)); - if (root.gridEnabled) { - newW = Math.max(root.minWidth, root.snapToGrid(newW)); - newH = Math.max(root.minHeight, root.snapToGrid(newH)); - } - if (root.forceSquare) { - const size = Math.max(newW, newH); - newW = Math.min(size, root.screenWidth - root.widgetX); - newH = Math.min(size, root.screenHeight - root.widgetY); - } + const next = geometry.dragResizeTo(startWidth, startHeight, currentPos.x - startPos.x, currentPos.y - startPos.y); if (root.useGhostPreview) { - root.previewWidth = newW; - root.previewHeight = newH; + root.previewWidth = next.width; + root.previewHeight = next.height; return; } - root.dragOverrideW = newW; - root.dragOverrideH = newH; + geometry.dragOverrideW = next.width; + geometry.dragOverrideH = next.height; } onReleased: { - const finalW = root.useGhostPreview ? root.previewWidth : root.dragOverrideW; - const finalH = root.useGhostPreview ? root.previewHeight : root.dragOverrideH; - root.saveSize(finalW, finalH); - root.clearDragOverrides(); + const finalW = root.useGhostPreview ? root.previewWidth : geometry.dragOverrideW; + const finalH = root.useGhostPreview ? root.previewHeight : geometry.dragOverrideH; + geometry.saveSize(finalW, finalH); + geometry.clearDragOverrides(); } } } @@ -494,37 +288,13 @@ Item { WlrLayershell.exclusionMode: ExclusionMode.Ignore WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - Item { + DesktopWidgetGridLines { id: gridOverlay anchors.fill: parent - visible: root.gridEnabled - opacity: 0.3 - - Repeater { - model: Math.ceil(root.screenWidth / root.gridSize) - - Rectangle { - required property int index - x: index * root.gridSize - y: 0 - width: 1 - height: root.screenHeight - color: Theme.primary - } - } - - Repeater { - model: Math.ceil(root.screenHeight / root.gridSize) - - Rectangle { - required property int index - x: 0 - y: index * root.gridSize - width: root.screenWidth - height: 1 - color: Theme.primary - } - } + visible: geometry.gridEnabled + gridSize: geometry.gridSize + extentWidth: geometry.screenWidth + extentHeight: geometry.screenHeight } Rectangle { @@ -554,7 +324,7 @@ Item { } Loader { - active: root.isInteracting && root.gridEnabled && !root.useGhostPreview + active: root.isInteracting && geometry.gridEnabled && !root.useGhostPreview sourceComponent: PanelWindow { screen: root.screen @@ -574,35 +344,11 @@ Item { WlrLayershell.exclusionMode: ExclusionMode.Ignore WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - Item { + DesktopWidgetGridLines { anchors.fill: parent - opacity: 0.3 - - Repeater { - model: Math.ceil(root.screenWidth / root.gridSize) - - Rectangle { - required property int index - x: index * root.gridSize - y: 0 - width: 1 - height: root.screenHeight - color: Theme.primary - } - } - - Repeater { - model: Math.ceil(root.screenHeight / root.gridSize) - - Rectangle { - required property int index - x: 0 - y: index * root.gridSize - width: root.screenWidth - height: 1 - color: Theme.primary - } - } + gridSize: geometry.gridSize + extentWidth: geometry.screenWidth + extentHeight: geometry.screenHeight } } } @@ -651,15 +397,15 @@ Item { DankIcon { name: "grid_on" size: 16 - color: root.gridEnabled ? Theme.primary : Theme.surfaceText + color: geometry.gridEnabled ? Theme.primary : Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } StyledText { - text: root.gridEnabled ? I18n.tr("Grid: ON", "Widget grid snap status") : I18n.tr("Grid: OFF", "Widget grid snap status") + text: geometry.gridEnabled ? I18n.tr("Grid: ON", "Widget grid snap status") : I18n.tr("Grid: OFF", "Widget grid snap status") font.pixelSize: Theme.fontSizeSmall font.family: Theme.fontFamily - color: root.gridEnabled ? Theme.primary : Theme.surfaceText + color: geometry.gridEnabled ? Theme.primary : Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } @@ -686,7 +432,7 @@ Item { } NumericText { - text: root.gridSize + "px" + text: geometry.gridSize + "px" reserveText: "200px" width: Math.ceil(reservedWidth) horizontalAlignment: Text.AlignHCenter diff --git a/quickshell/Modules/Plugins/DesktopWidgetContent.qml b/quickshell/Modules/Plugins/DesktopWidgetContent.qml new file mode 100644 index 000000000..12a99bb72 --- /dev/null +++ b/quickshell/Modules/Plugins/DesktopWidgetContent.qml @@ -0,0 +1,166 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Modules.BuiltinDesktopPlugins +import qs.Modules.Lock.Widgets + +Loader { + id: root + + required property string pluginId + required property DesktopWidgetGeometry geometry + property string instanceId: "" + property var instanceData: null + property var screen: null + property bool lockScreen: false + property var lockHost: null + property bool persistDefaults: true + + readonly property bool isInstance: instanceId !== "" && instanceData !== null + readonly property var activeComponent: { + switch (pluginId) { + case "desktopClock": + return clockComponent; + case "systemMonitor": + return systemMonitorComponent; + case "lockClock": + return lockClockComponent; + case "lockDate": + return lockDateComponent; + case "lockAuth": + return lockAuthComponent; + case "lockNotifications": + return lockNotificationsComponent; + case "lockStatus": + return lockStatusComponent; + case "lockPower": + return lockPowerComponent; + } + return PluginService.pluginDesktopComponents[pluginId] ?? null; + } + readonly property real contentMinWidth: item?.minWidth ?? 100 + readonly property real contentMinHeight: item?.minHeight ?? 100 + readonly property bool contentForceSquare: item?.forceSquare ?? false + readonly property bool contentResizable: item?.resizable ?? true + readonly property real contentDefaultWidth: item?.defaultWidth ?? ((item?.implicitWidth ?? 0) > 0 ? item.implicitWidth : 280) + readonly property real contentDefaultHeight: item?.defaultHeight ?? ((item?.implicitHeight ?? 0) > 0 ? item.implicitHeight : 180) + readonly property bool acceptsKeyboardFocus: item?.acceptsKeyboardFocus ?? false + + property Component clockComponent: Component { + DesktopClockWidget {} + } + + property Component systemMonitorComponent: Component { + SystemMonitorWidget {} + } + + property Component lockClockComponent: Component { + LockClockWidget {} + } + + property Component lockDateComponent: Component { + LockDateWidget {} + } + + property Component lockAuthComponent: Component { + LockAuthWidget {} + } + + property Component lockNotificationsComponent: Component { + LockNotificationsWidget {} + } + + property Component lockStatusComponent: Component { + LockStatusWidget {} + } + + property Component lockPowerComponent: Component { + LockPowerWidget {} + } + + onInstanceDataChanged: { + if (!item || item.instanceData === undefined) + return; + item.instanceData = instanceData; + } + + sourceComponent: activeComponent + opacity: 0 + + NumberAnimation { + id: revealFade + target: root + property: "opacity" + from: 0 + to: 1 + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + + QtObject { + id: instanceScopedPluginService + + readonly property var availablePlugins: PluginService.availablePlugins + readonly property var loadedPlugins: PluginService.loadedPlugins + readonly property var pluginDesktopComponents: PluginService.pluginDesktopComponents + + signal pluginDataChanged(string pluginId) + signal pluginLoaded(string pluginId) + signal pluginUnloaded(string pluginId) + + function loadPluginData(pluginId, key, defaultValue) { + const cfg = root.instanceData?.config; + if (cfg && key in cfg) + return cfg[key]; + return SettingsData.getPluginSetting(pluginId, key, defaultValue); + } + + function savePluginData(pluginId, key, value) { + if (!root.instanceId) + return false; + var updates = {}; + updates[key] = value; + SettingsData.updateDesktopWidgetInstanceConfig(root.instanceId, updates); + Qt.callLater(() => pluginDataChanged(pluginId)); + return true; + } + + function getPluginVariants(pluginId) { + return PluginService.getPluginVariants(pluginId); + } + + function isPluginLoaded(pluginId) { + return PluginService.isPluginLoaded(pluginId); + } + } + + onLoaded: { + if (!item) + return; + revealFade.restart(); + if (item.pluginService !== undefined) + item.pluginService = instanceScopedPluginService; + if (item.pluginId !== undefined) + item.pluginId = root.pluginId; + if (item.instanceId !== undefined) + item.instanceId = root.instanceId; + if (item.instanceData !== undefined) + item.instanceData = root.instanceData; + if (item.lockScreen !== undefined) + item.lockScreen = root.lockScreen; + if (item.lockHost !== undefined) + item.lockHost = root.lockHost; + if (root.persistDefaults) + geometry.saveDefaultGeometry(item.defaultWidth ?? item.widgetWidth ?? 280, item.defaultHeight ?? item.widgetHeight ?? 180); + if (item.widgetWidth !== undefined) + item.widgetWidth = Qt.binding(() => root.width); + if (item.widgetHeight !== undefined) + item.widgetHeight = Qt.binding(() => root.height); + if (item.screen !== undefined) + item.screen = Qt.binding(() => root.screen); + if (item.requestResize !== undefined) + item.requestResize = geometry.requestResize; + if (item.clearResize !== undefined) + item.clearResize = geometry.clearResize; + } +} diff --git a/quickshell/Modules/Plugins/DesktopWidgetGeometry.qml b/quickshell/Modules/Plugins/DesktopWidgetGeometry.qml new file mode 100644 index 000000000..d5a6931b5 --- /dev/null +++ b/quickshell/Modules/Plugins/DesktopWidgetGeometry.qml @@ -0,0 +1,155 @@ +import QtQuick +import qs.Common + +QtObject { + id: root + + property string instanceId: "" + property var instanceData: null + property var screen: null + property real minWidth: 100 + property real minHeight: 100 + property bool forceSquare: false + + readonly property bool isInstance: instanceId !== "" && instanceData !== null + readonly property bool syncPositionAcrossScreens: instanceData?.config?.syncPositionAcrossScreens ?? false + readonly property string screenKey: SettingsData.getScreenDisplayName(screen) + readonly property string positionKey: syncPositionAcrossScreens ? "_synced" : screenKey + readonly property var storedPositions: SessionData.desktopWidgetInstancePositions[instanceId] ?? null + + readonly property int screenWidth: screen?.width ?? 1920 + readonly property int screenHeight: screen?.height ?? 1080 + + function storedGeometry(key) { + if (!isInstance) + return undefined; + return storedPositions?.[positionKey]?.[key]; + } + + function storedCoordinate(key, extent, fallback) { + const val = storedGeometry(key); + if (val === undefined) + return fallback; + return syncPositionAcrossScreens ? val * extent : val; + } + + readonly property bool hasSavedPosition: storedGeometry("x") !== undefined + readonly property bool hasSavedSize: storedGeometry("width") !== undefined + + property real defaultWidth: 280 + property real defaultHeight: 180 + property real defaultX: screenWidth / 2 - savedWidth / 2 + property real defaultY: screenHeight / 2 - savedHeight / 2 + + readonly property real savedX: storedCoordinate("x", screenWidth, defaultX) + readonly property real savedY: storedCoordinate("y", screenHeight, defaultY) + readonly property real savedWidth: storedGeometry("width") ?? defaultWidth + readonly property real savedHeight: forceSquare ? savedWidth : (storedGeometry("height") ?? defaultHeight) + + property real dragOverrideX: -1 + property real dragOverrideY: -1 + property real dragOverrideW: -1 + property real dragOverrideH: -1 + + readonly property real effectiveX: dragOverrideX >= 0 ? dragOverrideX : savedX + readonly property real effectiveY: dragOverrideY >= 0 ? dragOverrideY : savedY + readonly property real effectiveW: dragOverrideW >= 0 ? dragOverrideW : savedWidth + readonly property real effectiveH: dragOverrideH >= 0 ? dragOverrideH : savedHeight + + readonly property real widgetX: Math.max(0, Math.min(effectiveX, screenWidth - widgetWidth)) + readonly property real widgetY: Math.max(0, Math.min(effectiveY, screenHeight - widgetHeight)) + readonly property real widgetWidth: Math.max(minWidth, Math.min(effectiveW, screenWidth)) + readonly property real widgetHeight: Math.max(minHeight, Math.min(effectiveH, screenHeight)) + + property var _gridSettingsTrigger: SessionData.desktopWidgetGridSettings + readonly property int gridSize: { + void _gridSettingsTrigger; + return SessionData.getDesktopWidgetGridSetting(screenKey, "size", 40); + } + readonly property bool gridEnabled: { + void _gridSettingsTrigger; + return SessionData.getDesktopWidgetGridSetting(screenKey, "enabled", false); + } + + function snapToGrid(value) { + return Math.round(value / gridSize) * gridSize; + } + + function clearDragOverrides() { + dragOverrideX = -1; + dragOverrideY = -1; + dragOverrideW = -1; + dragOverrideH = -1; + } + + function requestResize(width, height) { + if (width < minWidth || height < minHeight) + return; + if (width > screenWidth || height > screenHeight) + return; + dragOverrideW = width; + dragOverrideH = height; + } + + function clearResize() { + dragOverrideW = -1; + dragOverrideH = -1; + } + + function saveGeometry(updates) { + if (!isInstance) + return; + SessionData.updateDesktopWidgetInstancePosition(instanceId, positionKey, updates); + } + + function savePosition(finalX, finalY) { + saveGeometry({ + x: syncPositionAcrossScreens ? finalX / screenWidth : finalX, + y: syncPositionAcrossScreens ? finalY / screenHeight : finalY + }); + } + + function saveSize(finalW, finalH) { + const sizeVal = forceSquare ? Math.max(finalW, finalH) : finalW; + saveGeometry({ + width: sizeVal, + height: forceSquare ? sizeVal : finalH + }); + } + + function saveDefaultGeometry(defaultWidth, defaultHeight) { + if (!hasSavedSize) { + const finalW = Math.max(minWidth, Math.min(defaultWidth, screenWidth)); + const finalH = Math.max(minHeight, Math.min(defaultHeight, screenHeight)); + saveSize(finalW, finalH); + } + if (hasSavedPosition) + return; + const finalX = Math.max(0, Math.min(screenWidth / 2 - widgetWidth / 2, screenWidth - widgetWidth)); + const finalY = Math.max(0, Math.min(screenHeight / 2 - widgetHeight / 2, screenHeight - widgetHeight)); + savePosition(finalX, finalY); + } + + function dragMoveTo(startX, startY, dx, dy) { + let newX = Math.max(0, Math.min(startX + dx, screenWidth - widgetWidth)); + let newY = Math.max(0, Math.min(startY + dy, screenHeight - widgetHeight)); + if (!gridEnabled) + return Qt.point(newX, newY); + newX = Math.max(0, Math.min(snapToGrid(newX), screenWidth - widgetWidth)); + newY = Math.max(0, Math.min(snapToGrid(newY), screenHeight - widgetHeight)); + return Qt.point(newX, newY); + } + + function dragResizeTo(startWidth, startHeight, dx, dy) { + let newW = Math.max(minWidth, Math.min(startWidth + dx, screenWidth - widgetX)); + let newH = Math.max(minHeight, Math.min(startHeight + dy, screenHeight - widgetY)); + if (gridEnabled) { + newW = Math.max(minWidth, snapToGrid(newW)); + newH = Math.max(minHeight, snapToGrid(newH)); + } + if (!forceSquare) + return Qt.size(newW, newH); + const size = Math.max(newW, newH); + return Qt.size(Math.min(size, screenWidth - widgetX), Math.min(size, screenHeight - widgetY)); + } +} diff --git a/quickshell/Modules/Plugins/DesktopWidgetGridLines.qml b/quickshell/Modules/Plugins/DesktopWidgetGridLines.qml new file mode 100644 index 000000000..83b16d25a --- /dev/null +++ b/quickshell/Modules/Plugins/DesktopWidgetGridLines.qml @@ -0,0 +1,38 @@ +import QtQuick +import qs.Common + +Item { + id: root + + required property int gridSize + required property real extentWidth + required property real extentHeight + + opacity: 0.3 + + Repeater { + model: Math.ceil(root.extentWidth / root.gridSize) + + Rectangle { + required property int index + x: index * root.gridSize + y: 0 + width: 1 + height: root.extentHeight + color: Theme.primary + } + } + + Repeater { + model: Math.ceil(root.extentHeight / root.gridSize) + + Rectangle { + required property int index + x: 0 + y: index * root.gridSize + width: root.extentWidth + height: 1 + color: Theme.primary + } + } +} diff --git a/quickshell/Modules/Settings/DesktopWidgetBrowser.qml b/quickshell/Modules/Settings/DesktopWidgetBrowser.qml index 3a109baa9..67cc5dcd8 100644 --- a/quickshell/Modules/Settings/DesktopWidgetBrowser.qml +++ b/quickshell/Modules/Settings/DesktopWidgetBrowser.qml @@ -8,20 +8,22 @@ import qs.Widgets WidgetPickerWindow { id: root + property string listKey: "desktopWidgetInstances" + signal widgetAdded(string widgetType) function addWidget(widget) { const widgetType = widget.id; const defaultConfig = DesktopWidgetRegistry.getDefaultConfig(widgetType); const name = widget.name || widgetType; - SettingsData.createDesktopWidgetInstance(widgetType, name, defaultConfig); + SettingsData.createDesktopWidgetInstance(widgetType, name, defaultConfig, listKey); root.widgetAdded(widgetType); root.hide(); } objectName: "desktopWidgetBrowser" title: I18n.tr("Add Desktop Widget") - widgets: DesktopWidgetRegistry.registeredWidgetsList || [] + widgets: (DesktopWidgetRegistry.registeredWidgetsList || []).filter(widget => !widget.lockOnly) featuredFirst: true showEmptyState: true widgetDelegate: tileDelegate diff --git a/quickshell/Modules/Settings/DesktopWidgetInstanceCard.qml b/quickshell/Modules/Settings/DesktopWidgetInstanceCard.qml index 6e5223365..4aa4a615c 100644 --- a/quickshell/Modules/Settings/DesktopWidgetInstanceCard.qml +++ b/quickshell/Modules/Settings/DesktopWidgetInstanceCard.qml @@ -14,7 +14,8 @@ SettingsReorderRow { readonly property string instanceId: instanceData?.id ?? "" readonly property string widgetType: instanceData?.widgetType ?? "" readonly property var widgetDef: DesktopWidgetRegistry.getWidget(widgetType) - readonly property string widgetName: instanceData?.name ?? widgetDef?.name ?? widgetType + readonly property string widgetName: instanceData?.name || widgetDef?.name || widgetType + property bool fixed: false signal configureRequested signal deleteRequested @@ -40,6 +41,7 @@ SettingsReorderRow { }, DankToggle { anchors.verticalCenter: parent.verticalCenter + visible: !root.fixed hideText: true text: root.title checked: root.instanceData?.enabled ?? true @@ -51,6 +53,7 @@ SettingsReorderRow { }, DankActionButton { anchors.verticalCenter: parent.verticalCenter + visible: !root.fixed iconName: "content_copy" tooltipText: I18n.tr("Duplicate", "verb, desktop widget menu action") onClicked: root.duplicateRequested() @@ -58,6 +61,7 @@ SettingsReorderRow { SettingsDeleteButton { id: deleteButton anchors.verticalCenter: parent.verticalCenter + visible: !root.fixed onDeleteRequested: root.deleteRequested() } ] diff --git a/quickshell/Modules/Settings/DesktopWidgetSettings/DesktopWidgetTypeSettings.qml b/quickshell/Modules/Settings/DesktopWidgetSettings/DesktopWidgetTypeSettings.qml new file mode 100644 index 000000000..c0bfdfe42 --- /dev/null +++ b/quickshell/Modules/Settings/DesktopWidgetSettings/DesktopWidgetTypeSettings.qml @@ -0,0 +1,120 @@ +import QtQuick +import qs.Modules.Settings.LockWidgetSettings as LWS + +Loader { + id: root + + property string instanceId: "" + property var instanceData: null + property var widgetDef: null + property var parentModal: null + readonly property string widgetType: instanceData?.widgetType ?? "" + + active: instanceData !== null + sourceComponent: { + switch (widgetType) { + case "desktopClock": + return clockSettings; + case "systemMonitor": + return systemMonitorSettings; + case "lockClock": + return lockClockSettings; + case "lockAuth": + return lockAuthSettings; + case "lockNotifications": + return lockNotificationsSettings; + case "lockStatus": + return lockStatusSettings; + case "lockDate": + return lockDateSettings; + case "lockPower": + return lockPowerSettings; + default: + return pluginSettings; + } + } + + Component { + id: clockSettings + + ClockSettings { + instanceId: root.instanceId + instanceData: root.instanceData + } + } + + Component { + id: systemMonitorSettings + + SystemMonitorSettings { + instanceId: root.instanceId + instanceData: root.instanceData + } + } + + Component { + id: lockClockSettings + + LWS.LockClockSettings { + instanceId: root.instanceId + instanceData: root.instanceData + } + } + + Component { + id: lockAuthSettings + + LWS.LockAuthSettings { + instanceId: root.instanceId + instanceData: root.instanceData + } + } + + Component { + id: lockNotificationsSettings + + LWS.LockNotificationsSettings { + instanceId: root.instanceId + instanceData: root.instanceData + } + } + + Component { + id: lockStatusSettings + + LWS.LockStatusSettings { + instanceId: root.instanceId + instanceData: root.instanceData + parentModal: root.parentModal + } + } + + Component { + id: lockDateSettings + + LWS.LockDateSettings { + instanceId: root.instanceId + instanceData: root.instanceData + } + } + + Component { + id: lockPowerSettings + + LWS.LockPowerSettings { + instanceId: root.instanceId + instanceData: root.instanceData + } + } + + Component { + id: pluginSettings + + PluginDesktopWidgetSettings { + instanceId: root.instanceId + instanceData: root.instanceData + widgetType: root.widgetType + widgetDef: root.widgetDef + } + } +} diff --git a/quickshell/Modules/Settings/DesktopWidgetTab.qml b/quickshell/Modules/Settings/DesktopWidgetTab.qml index a9a48c038..feae90d4c 100644 --- a/quickshell/Modules/Settings/DesktopWidgetTab.qml +++ b/quickshell/Modules/Settings/DesktopWidgetTab.qml @@ -11,11 +11,15 @@ import qs.Modules.Settings.DesktopWidgetSettings as DWS Item { id: root + property var parentModal: null + readonly property string instanceId: SettingsUiState.selectedDesktopWidgetId - readonly property var instanceData: (SettingsData.desktopWidgetInstances || []).find(instance => instance.id === instanceId) ?? null + readonly property bool lockScreenInstance: SettingsData.widgetInstanceListKey(instanceId) === "lockScreenWidgetInstances" + readonly property var instanceData: SettingsData.getDesktopWidgetInstance(instanceId) readonly property string widgetType: instanceData?.widgetType ?? "" readonly property var widgetDef: DesktopWidgetRegistry.getWidget(widgetType) - readonly property string widgetName: instanceData?.name ?? widgetDef?.name ?? widgetType + readonly property string widgetName: instanceData?.name || widgetDef?.name || widgetType + readonly property bool fixed: widgetType === "lockAuth" readonly property var cfg: instanceData?.config ?? {} readonly property string overlayCommand: "dms ipc call desktopWidget toggleOverlay " + instanceId readonly property var groupOptions: [ @@ -36,40 +40,18 @@ Item { onWidgetNameChanged: SettingsUiState.selectedWidgetTitle = widgetName - Component { - id: clockSettings - - DWS.ClockSettings { - instanceId: root.instanceId - instanceData: root.instanceData - } - } - - Component { - id: systemMonitorSettings - - DWS.SystemMonitorSettings { - instanceId: root.instanceId - instanceData: root.instanceData - } - } - - Component { - id: pluginSettings - - DWS.PluginDesktopWidgetSettings { - instanceId: root.instanceId - instanceData: root.instanceData - widgetType: root.widgetType - widgetDef: root.widgetDef - } - } - SettingsPage { visible: root.instanceData !== null SettingsCard { + SettingsRow { + visible: root.fixed + iconName: root.widgetDef?.icon ?? "widgets" + title: root.widgetDef?.name ?? root.widgetType + } + SettingsToggleRow { + visible: !root.fixed iconName: root.widgetDef?.icon ?? "widgets" text: root.widgetDef?.name ?? root.widgetType description: root.widgetDef?.description ?? "" @@ -99,25 +81,19 @@ Item { } } - Loader { + DWS.DesktopWidgetTypeSettings { width: parent.width - active: root.instanceData !== null - sourceComponent: { - switch (root.widgetType) { - case "desktopClock": - return clockSettings; - case "systemMonitor": - return systemMonitorSettings; - default: - return pluginSettings; - } - } + instanceId: root.instanceId + instanceData: root.instanceData + widgetDef: root.widgetDef + parentModal: root.parentModal } SettingsCard { title: I18n.tr("Behavior") SettingsToggleRow { + visible: !root.lockScreenInstance text: I18n.tr("Show on overlay") description: I18n.tr("Keeps the widget above windows instead of below them", "desktop widget show on overlay toggle description") checked: root.cfg.showOnOverlay ?? false @@ -125,20 +101,21 @@ Item { } SettingsToggleRow { - visible: CompositorService.isNiri + visible: CompositorService.isNiri && !root.lockScreenInstance text: I18n.tr("Show on overview") checked: root.cfg.showOnOverview ?? false onToggled: checked => root.updateConfig("showOnOverview", checked) } SettingsToggleRow { - visible: CompositorService.isNiri + visible: CompositorService.isNiri && !root.lockScreenInstance text: I18n.tr("Show on overview only") checked: root.cfg.showOnOverviewOnly ?? false onToggled: checked => root.updateConfig("showOnOverviewOnly", checked) } SettingsToggleRow { + visible: !root.lockScreenInstance text: I18n.tr("Click through") checked: root.cfg.clickThrough ?? false onToggled: checked => root.updateConfig("clickThrough", checked) @@ -156,6 +133,7 @@ Item { } SettingsCard { + visible: !root.lockScreenInstance title: I18n.tr("Command") SettingsRow { diff --git a/quickshell/Modules/Settings/LockScreenTab.qml b/quickshell/Modules/Settings/LockScreenTab.qml index a9be3d9c8..fd0328aec 100644 --- a/quickshell/Modules/Settings/LockScreenTab.qml +++ b/quickshell/Modules/Settings/LockScreenTab.qml @@ -268,109 +268,77 @@ Item { } } + LazyLoader { + id: lockWidgetBrowserLoader + active: false + + DesktopWidgetBrowser { + parentModal: root.parentModal + listKey: "lockScreenWidgetInstances" + title: I18n.tr("Add widget") + onWidgetAdded: ToastService.showInfo(I18n.tr("Widget added")) + } + } + SettingsPage { id: mainColumn SettingsCard { width: parent.width - iconName: "lock" - title: I18n.tr("Layout") - settingKey: "lockLayout" + iconName: "widgets" + title: I18n.tr("Widgets") + settingKey: "lockScreenWidgets" + tags: ["lock", "screen", "widgets", "clock", "plugins"] - SettingsToggleRow { - settingKey: "lockScreenShowPowerActions" - tags: ["lock", "screen", "power", "actions", "shutdown", "reboot"] - text: I18n.tr("Show power actions") - checked: SettingsData.lockScreenShowPowerActions - onToggled: checked => SettingsData.set("lockScreenShowPowerActions", checked) - } + SettingsReorderList { + id: lockWidgetList - SettingsToggleRow { - settingKey: "lockScreenShowSystemIcons" - tags: ["lock", "screen", "system", "icons", "status"] - text: I18n.tr("Show system icons") - checked: SettingsData.lockScreenShowSystemIcons - onToggled: checked => SettingsData.set("lockScreenShowSystemIcons", checked) - } + model: SettingsData.lockScreenWidgetInstances || [] - SettingsToggleRow { - settingKey: "lockScreenShowTime" - tags: ["lock", "screen", "time", "clock", "display"] - text: I18n.tr("Show time") - checked: SettingsData.lockScreenShowTime - onToggled: checked => SettingsData.set("lockScreenShowTime", checked) - } + delegate: DesktopWidgetInstanceCard { + required property var modelData - SettingsButtonGroupRow { - settingKey: "lockScreenClockStyle" - tags: ["lock", "screen", "time", "clock", "style", "vertical"] - text: I18n.tr("Clock style") - visible: SettingsData.lockScreenShowTime - model: [I18n.tr("Horizontal", "lock screen clock style option"), I18n.tr("Vertical", "lock screen clock style option")] - currentIndex: SettingsData.lockScreenClockStyle === "vertical" ? 1 : 0 - onSelectionChanged: (index, selected) => { - if (!selected) - return; - SettingsData.set("lockScreenClockStyle", index === 1 ? "vertical" : "horizontal"); - } - } + reorderList: lockWidgetList + reorderEnabled: false + instanceData: modelData + fixed: modelData.widgetType === "lockAuth" - SettingsToggleRow { - settingKey: "lockScreenShowDate" - tags: ["lock", "screen", "date", "calendar", "display"] - text: I18n.tr("Show date") - checked: SettingsData.lockScreenShowDate - onToggled: checked => SettingsData.set("lockScreenShowDate", checked) - } - - SettingsToggleRow { - settingKey: "lockScreenShowProfileImage" - tags: ["lock", "screen", "profile", "image", "avatar", "picture"] - text: I18n.tr("Show profile image") - checked: SettingsData.lockScreenShowProfileImage - onToggled: checked => SettingsData.set("lockScreenShowProfileImage", checked) - } - - SettingsToggleRow { - settingKey: "lockScreenShowPasswordField" - tags: ["lock", "screen", "password", "field", "input", "visible"] - text: I18n.tr("Show password field") - description: I18n.tr("A hidden field appears as soon as a key is pressed") - checked: SettingsData.lockScreenShowPasswordField - onToggled: checked => SettingsData.set("lockScreenShowPasswordField", checked) - } - - SettingsToggleRow { - settingKey: "lockScreenShowMediaPlayer" - tags: ["lock", "screen", "media", "player", "music", "mpris"] - text: I18n.tr("Show media player") - checked: SettingsData.lockScreenShowMediaPlayer - onToggled: checked => SettingsData.set("lockScreenShowMediaPlayer", checked) - } - - SettingsSplitRow { - settingKey: "lockScreenShowWeather" - tab: "lock_screen" - tags: ["weather", "temperature"] - title: I18n.tr("Weather") - checked: SettingsData.lockScreenShowWeather - onToggled: checked => SettingsData.set("lockScreenShowWeather", checked) - onNavigated: keyboard => root.parentModal?.navigateTo("weather", keyboard) - } - - SettingsDropdownRow { - settingKey: "lockScreenNotificationMode" - tags: ["lock", "screen", "notification", "notifications", "privacy"] - text: I18n.tr("Notifications") - options: [I18n.tr("Disabled", "lock screen notification mode option"), I18n.tr("Count only", "lock screen notification mode option"), I18n.tr("App names", "lock screen notification mode option"), I18n.tr("Full content", "lock screen notification mode option")] - currentValue: options[SettingsData.lockScreenNotificationMode] || options[0] - onValueChanged: value => { - const idx = options.indexOf(value); - if (idx >= 0) { - SettingsData.set("lockScreenNotificationMode", idx); + onConfigureRequested: { + SettingsUiState.selectedDesktopWidgetId = instanceId; + SettingsUiState.selectedWidgetTitle = widgetName; + root.parentModal?.navigateTo("desktop_widget"); + } + onDuplicateRequested: SettingsData.duplicateDesktopWidgetInstance(instanceId) + onDeleteRequested: { + SettingsData.removeDesktopWidgetInstance(instanceId); + ToastService.showInfo(I18n.tr("Widget removed")); } } } + + SettingsRow { + iconName: "add" + title: I18n.tr("Add widget") + clickable: true + onClicked: { + lockWidgetBrowserLoader.active = true; + lockWidgetBrowserLoader.item?.show(); + } + } + + SettingsRow { + iconName: "edit" + title: I18n.tr("Edit widgets") + clickable: true + onClicked: SessionService.lockEditorRequested() + } + + SettingsRow { + iconName: "restart_alt" + title: I18n.tr("Reset to default") + clickable: true + onClicked: SettingsData.resetLockScreenWidgets() + } } SettingsCard { diff --git a/quickshell/Modules/Settings/LockWidgetSettings/LockAuthSettings.qml b/quickshell/Modules/Settings/LockWidgetSettings/LockAuthSettings.qml new file mode 100644 index 000000000..6b45d2a78 --- /dev/null +++ b/quickshell/Modules/Settings/LockWidgetSettings/LockAuthSettings.qml @@ -0,0 +1,51 @@ +import QtQuick +import qs.Common +import qs.Modules.Settings.Widgets +import qs.Modules.Settings.DesktopWidgetSettings + +DesktopWidgetInstanceSettings { + id: root + + readonly property var styles: ["pill", "expressive", "ring", "minimal", "dots"] + readonly property var styleLabels: [I18n.tr("Pill", "lock screen password field style"), I18n.tr("Expressive"), I18n.tr("Ring"), I18n.tr("Minimal", "lock screen password field style"), I18n.tr("Dots", "lock screen password field style")] + readonly property var visibilityModes: ["always", "typing", "never"] + readonly property var visibilityLabels: [I18n.tr("Always show", "lock screen widget visibility"), I18n.tr("Hide until typing", "lock screen widget visibility"), I18n.tr("Never")] + readonly property string profileVisibility: cfg.profileVisibility ?? (cfg.showProfileImage === false ? "never" : "always") + readonly property string passwordVisibility: cfg.passwordVisibility ?? (cfg.showPasswordField === false ? "typing" : "always") + + showAppearance: false + + SettingsDropdownRow { + text: I18n.tr("Style") + options: root.styleLabels + currentValue: root.styleLabels[Math.max(0, root.styles.indexOf(root.cfg.style ?? "expressive"))] + onValueChanged: value => { + const idx = root.styleLabels.indexOf(value); + if (idx >= 0) + root.updateConfig("style", root.styles[idx]); + } + } + + SettingsDropdownRow { + text: I18n.tr("Profile image") + options: root.visibilityLabels + currentValue: root.visibilityLabels[Math.max(0, root.visibilityModes.indexOf(root.profileVisibility))] + onValueChanged: value => { + const index = root.visibilityLabels.indexOf(value); + if (index >= 0) + root.updateConfig("profileVisibility", root.visibilityModes[index]); + } + } + + SettingsDropdownRow { + text: I18n.tr("Password field", "lock screen password field visibility setting") + description: root.passwordVisibility === "typing" ? I18n.tr("Typing reveals the field. Escape hides it again. The editor always shows it.", "lock screen hide until typing behavior") : "" + options: root.visibilityLabels.slice(0, 2) + currentValue: root.visibilityLabels[Math.max(0, root.visibilityModes.indexOf(root.passwordVisibility))] + onValueChanged: value => { + const index = root.visibilityLabels.indexOf(value); + if (index >= 0 && index < 2) + root.updateConfig("passwordVisibility", root.visibilityModes[index]); + } + } +} diff --git a/quickshell/Modules/Settings/LockWidgetSettings/LockClockSettings.qml b/quickshell/Modules/Settings/LockWidgetSettings/LockClockSettings.qml new file mode 100644 index 000000000..d7d290b01 --- /dev/null +++ b/quickshell/Modules/Settings/LockWidgetSettings/LockClockSettings.qml @@ -0,0 +1,92 @@ +import QtQuick +import qs.Common +import qs.Modules.Settings.Widgets +import qs.Modules.Settings.DesktopWidgetSettings + +DesktopWidgetInstanceSettings { + id: root + + readonly property var styles: ["horizontal", "vertical", "expressive", "analog"] + readonly property var styleLabels: [I18n.tr("Horizontal", "lock screen clock style option"), I18n.tr("Vertical", "lock screen clock style option"), I18n.tr("Expressive"), I18n.tr("Analog")] + readonly property string style: cfg.style ?? "expressive" + readonly property bool textStyle: style === "horizontal" || style === "vertical" + + showAppearance: false + + SettingsDropdownRow { + text: I18n.tr("Clock style") + options: root.styleLabels + currentValue: root.styleLabels[Math.max(0, root.styles.indexOf(root.style))] + onValueChanged: value => { + const idx = root.styleLabels.indexOf(value); + if (idx >= 0) + root.updateConfig("style", root.styles[idx]); + } + } + + SettingsSliderRow { + visible: root.textStyle + text: I18n.tr("Weight") + minimum: 0 + maximum: 900 + step: 100 + minimumLabel: I18n.tr("Default") + value: root.cfg.weight ?? 0 + onSliderValueChanged: newValue => root.updateConfig("weight", newValue) + } + + SettingsFontDropdownRow { + visible: root.textStyle + text: I18n.tr("Font") + currentFont: root.cfg.fontFamily ?? "" + defaultFamily: SettingsData.lockScreenFontFamily || Theme.defaultFontFamily + onFontSelected: family => root.updateConfig("fontFamily", family) + } + + SettingsToggleRow { + visible: root.style === "horizontal" + text: I18n.tr("Italic") + checked: root.cfg.italic ?? false + onToggled: checked => root.updateConfig("italic", checked) + } + + SettingsToggleRow { + visible: root.style === "horizontal" || root.style === "expressive" + text: I18n.tr("Accent minutes", "lock screen clock colors") + checked: root.cfg.twoTone ?? true + onToggled: checked => root.updateConfig("twoTone", checked) + } + + SettingsToggleRow { + text: I18n.tr("Automatic placement", "lock screen clock placement") + description: I18n.tr("Choose a clear area of the background. Drag the clock to place it yourself.", "lock screen automatic clock placement") + checked: root.cfg.autoPosition ?? true + onToggled: checked => { + if (checked) + SessionData.resetDesktopWidgetInstanceGeometry(root.instanceId, ["x", "y"]); + root.updateConfig("autoPosition", checked); + } + } + + SettingsToggleRow { + visible: root.style === "analog" + text: I18n.tr("Show hour numbers") + checked: root.cfg.showAnalogNumbers ?? false + onToggled: checked => root.updateConfig("showAnalogNumbers", checked) + } + + SettingsToggleRow { + visible: root.style === "analog" + text: I18n.tr("Show seconds") + checked: root.cfg.showAnalogSeconds ?? true + onToggled: checked => root.updateConfig("showAnalogSeconds", checked) + } + + SettingsColorPicker { + showDefault: true + colorMode: root.cfg.colorMode ?? "default" + customColor: root.cfg.customColor ?? "#ffffff" + onColorModeSelected: mode => root.updateConfig("colorMode", mode) + onCustomColorSelected: selectedColor => root.updateConfig("customColor", selectedColor.toString()) + } +} diff --git a/quickshell/Modules/Settings/LockWidgetSettings/LockDateSettings.qml b/quickshell/Modules/Settings/LockWidgetSettings/LockDateSettings.qml new file mode 100644 index 000000000..897e735d8 --- /dev/null +++ b/quickshell/Modules/Settings/LockWidgetSettings/LockDateSettings.qml @@ -0,0 +1,34 @@ +import QtQuick +import qs.Common +import qs.Modules.Settings.Widgets +import qs.Modules.Settings.DesktopWidgetSettings + +DesktopWidgetInstanceSettings { + id: root + + showAppearance: false + + SettingsToggleRow { + text: I18n.tr("Show date") + checked: root.instanceData?.enabled !== false + onToggled: checked => SettingsData.updateDesktopWidgetInstance(root.instanceId, { + enabled: checked + }) + } + + SettingsDropdownRow { + text: I18n.tr("Date format") + options: [I18n.tr("Short"), I18n.tr("Long")] + currentValue: (root.cfg.format ?? "long") === "short" ? I18n.tr("Short") : I18n.tr("Long") + enabled: SettingsData.lockDateFormat === "" + onValueChanged: value => root.updateConfig("format", value === I18n.tr("Short") ? "short" : "long") + } + + SettingsColorPicker { + showDefault: true + colorMode: root.cfg.colorMode ?? "default" + customColor: root.cfg.customColor ?? "#ffffff" + onColorModeSelected: mode => root.updateConfig("colorMode", mode) + onCustomColorSelected: selectedColor => root.updateConfig("customColor", selectedColor.toString()) + } +} diff --git a/quickshell/Modules/Settings/LockWidgetSettings/LockNotificationsSettings.qml b/quickshell/Modules/Settings/LockWidgetSettings/LockNotificationsSettings.qml new file mode 100644 index 000000000..3f981ce75 --- /dev/null +++ b/quickshell/Modules/Settings/LockWidgetSettings/LockNotificationsSettings.qml @@ -0,0 +1,22 @@ +import QtQuick +import qs.Common +import qs.Modules.Settings.Widgets +import qs.Modules.Settings.DesktopWidgetSettings + +DesktopWidgetInstanceSettings { + id: root + + showAppearance: false + + SettingsDropdownRow { + text: I18n.tr("Lock screen content") + options: [I18n.tr("Count only", "lock screen notification mode option"), I18n.tr("App names", "lock screen notification mode option"), I18n.tr("Full content", "lock screen notification mode option")] + currentValue: options[(root.cfg.mode ?? 1) - 1] ?? options[0] + onValueChanged: value => { + const idx = options.indexOf(value); + if (idx < 0) + return; + root.updateConfig("mode", idx + 1); + } + } +} diff --git a/quickshell/Modules/Settings/LockWidgetSettings/LockPowerSettings.qml b/quickshell/Modules/Settings/LockWidgetSettings/LockPowerSettings.qml new file mode 100644 index 000000000..deeb4d84b --- /dev/null +++ b/quickshell/Modules/Settings/LockWidgetSettings/LockPowerSettings.qml @@ -0,0 +1,66 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets +import qs.Modules.Settings.Widgets +import qs.Modules.Settings.DesktopWidgetSettings + +DesktopWidgetInstanceSettings { + id: root + + readonly property var shapes: ["round", "cookie9", "clover4", "sunny", "softBurst"] + readonly property string shape: cfg.shape ?? "round" + + showAppearance: false + + SettingsRow { + title: I18n.tr("Style") + + body: Row { + width: parent.width + spacing: Theme.spacingS + + Repeater { + model: root.shapes + + Rectangle { + id: swatch + + required property string modelData + readonly property bool selected: root.shape === modelData + + width: (parent.width - Theme.spacingS * (root.shapes.length - 1)) / root.shapes.length + height: Theme.listItemHeight + Theme.spacingXS + radius: Theme.cornerRadius + color: selected ? Theme.primarySelected : SettingsMetrics.controlColor + border.color: selected ? Theme.primary : Theme.withAlpha(Theme.primary, 0) + border.width: Theme.outlineWidthFocused + Accessible.role: Accessible.RadioButton + Accessible.name: modelData + + DankMaterialShape { + anchors.centerIn: parent + width: Theme.iconSizeLarge + height: Theme.iconSizeLarge + shape: swatch.modelData === "round" ? "circle" : swatch.modelData + color: Theme.secondaryContainer + + DankIcon { + anchors.centerIn: parent + name: "power_settings_new" + size: Theme.iconSizeSmall + color: Theme.onSecondaryContainer + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.updateConfig("shape", swatch.modelData) + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/LockWidgetSettings/LockStatusSettings.qml b/quickshell/Modules/Settings/LockWidgetSettings/LockStatusSettings.qml new file mode 100644 index 000000000..7852d8977 --- /dev/null +++ b/quickshell/Modules/Settings/LockWidgetSettings/LockStatusSettings.qml @@ -0,0 +1,31 @@ +import QtQuick +import qs.Common +import qs.Modules.Settings.Widgets +import qs.Modules.Settings.DesktopWidgetSettings + +DesktopWidgetInstanceSettings { + id: root + + property var parentModal: null + + showAppearance: false + + SettingsToggleRow { + text: I18n.tr("Background") + checked: root.cfg.background ?? false + onToggled: checked => root.updateConfig("background", checked) + } + + SettingsToggleRow { + text: I18n.tr("Show media player") + checked: root.cfg.showMediaPlayer ?? true + onToggled: checked => root.updateConfig("showMediaPlayer", checked) + } + + SettingsSplitRow { + title: I18n.tr("Weather") + checked: root.cfg.showWeather ?? true + onToggled: checked => root.updateConfig("showWeather", checked) + onNavigated: keyboard => root.parentModal?.navigateTo("weather", keyboard) + } +} diff --git a/quickshell/Modules/Settings/NotificationsTab.qml b/quickshell/Modules/Settings/NotificationsTab.qml index 998fe1630..e1f37f52e 100644 --- a/quickshell/Modules/Settings/NotificationsTab.qml +++ b/quickshell/Modules/Settings/NotificationsTab.qml @@ -319,16 +319,26 @@ Column { } SettingsDropdownRow { - settingKey: "lockScreenNotificationMode" + readonly property var lockNotifications: SettingsData.lockWidgetInstance("lockNotifications") + readonly property int currentMode: !lockNotifications || lockNotifications.enabled === false ? 0 : (lockNotifications.config?.mode ?? 1) + + settingKey: "lockNotificationsMode" tags: ["lock", "screen", "notification", "notifications", "privacy"] + visible: lockNotifications !== null text: I18n.tr("Lock screen content") options: [I18n.tr("Disabled", "lock screen notification mode option"), I18n.tr("Count only", "lock screen notification mode option"), I18n.tr("App names", "lock screen notification mode option"), I18n.tr("Full content", "lock screen notification mode option")] - currentValue: options[SettingsData.lockScreenNotificationMode] || options[0] + currentValue: options[currentMode] || options[0] onValueChanged: value => { const idx = options.indexOf(value); if (idx < 0) return; - SettingsData.set("lockScreenNotificationMode", idx); + SettingsData.updateDesktopWidgetInstance(lockNotifications.id, { + enabled: idx > 0 + }); + if (idx > 0) + SettingsData.updateDesktopWidgetInstanceConfig(lockNotifications.id, { + mode: idx + }); } } } diff --git a/quickshell/Modules/Settings/WeatherSettingsTab.qml b/quickshell/Modules/Settings/WeatherSettingsTab.qml index 533c930c5..2a16e53d7 100644 --- a/quickshell/Modules/Settings/WeatherSettingsTab.qml +++ b/quickshell/Modules/Settings/WeatherSettingsTab.qml @@ -127,12 +127,17 @@ Item { settingKey: "weatherDisplay" SettingsToggleRow { + readonly property var lockStatus: SettingsData.lockWidgetInstance("lockStatus") + tab: "weather" - settingKey: "lockScreenShowWeather" + settingKey: "lockStatusWeather" tags: ["weather", "lock", "screen"] + visible: lockStatus !== null text: I18n.tr("Lock screen") - checked: SettingsData.lockScreenShowWeather - onToggled: checked => SettingsData.set("lockScreenShowWeather", checked) + checked: lockStatus?.config?.showWeather ?? true + onToggled: checked => SettingsData.updateDesktopWidgetInstanceConfig(lockStatus.id, { + showWeather: checked + }) } SettingsNavRow { diff --git a/quickshell/Modules/Settings/Widgets/SettingsColorPicker.qml b/quickshell/Modules/Settings/Widgets/SettingsColorPicker.qml index 794f66026..f80c646ae 100644 --- a/quickshell/Modules/Settings/Widgets/SettingsColorPicker.qml +++ b/quickshell/Modules/Settings/Widgets/SettingsColorPicker.qml @@ -11,6 +11,8 @@ SettingsRow { property string colorMode: "primary" property color customColor: "#ffffff" property string pickerTitle: I18n.tr("Choose color", "color picker title") + property bool showDefault: false + property color defaultColor: Theme.lockScreenContentColor signal colorModeSelected(string mode) signal customColorSelected(color selectedColor) @@ -22,7 +24,14 @@ SettingsRow { spacing: Theme.spacingS Repeater { - model: [ + id: swatches + model: (root.showDefault ? [ + { + id: "default", + label: I18n.tr("Default"), + color: root.defaultColor + } + ] : []).concat([ { id: "primary", label: I18n.tr("Primary"), @@ -38,13 +47,13 @@ SettingsRow { label: I18n.tr("Custom"), color: root.customColor } - ] + ]) Rectangle { required property var modelData required property int index - width: (parent.width - Theme.spacingS * 2) / 3 + width: (parent.width - Theme.spacingS * (swatches.count - 1)) / swatches.count height: Theme.listItemHeight + Theme.spacingXS radius: Theme.cornerRadius color: root.colorMode === modelData.id ? Theme.primarySelected : SettingsMetrics.controlColor diff --git a/quickshell/Modules/Settings/Widgets/SettingsReorderRow.qml b/quickshell/Modules/Settings/Widgets/SettingsReorderRow.qml index d78642218..00d848949 100644 --- a/quickshell/Modules/Settings/Widgets/SettingsReorderRow.qml +++ b/quickshell/Modules/Settings/Widgets/SettingsReorderRow.qml @@ -41,6 +41,7 @@ SettingsRow { DankDragHandle { id: handle + visible: root.reorderEnabled coordinateItem: root.reorderList upDownKeysMove: false label: root.title diff --git a/quickshell/Services/DesktopWidgetRegistry.qml b/quickshell/Services/DesktopWidgetRegistry.qml index 332ab8e95..d59c9a978 100644 --- a/quickshell/Services/DesktopWidgetRegistry.qml +++ b/quickshell/Services/DesktopWidgetRegistry.qml @@ -4,6 +4,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import qs.Common +import "../Common/settings/SettingsSpec.js" as Spec Singleton { id: root @@ -15,6 +16,7 @@ Singleton { Component.onCompleted: { registerBuiltins(); + registerLockBuiltins(); Qt.callLater(syncPluginWidgets); } @@ -64,6 +66,28 @@ Singleton { }); } + function registerLockBuiltins() { + const lockWidget = (id, name, icon) => registerWidget({ + id: id, + name: name, + icon: icon, + description: "", + type: "builtin", + lockOnly: true, + defaultConfig: Spec.SPEC.lockScreenWidgetInstances.def.find(inst => inst.widgetType === id)?.config ?? {}, + defaultSize: { + width: 200, + height: 200 + } + }); + lockWidget("lockClock", I18n.tr("Clock"), "schedule"); + lockWidget("lockDate", I18n.tr("Date"), "event"); + lockWidget("lockAuth", I18n.tr("Password"), "lock"); + lockWidget("lockNotifications", I18n.tr("Notifications"), "notifications"); + lockWidget("lockStatus", I18n.tr("Status"), "wifi"); + lockWidget("lockPower", I18n.tr("Power"), "power_settings_new"); + } + function getDefaultClockConfig() { return { style: "analog", @@ -133,18 +157,24 @@ Singleton { if (!widget) return {}; - if (widget.type === "builtin") { - switch (widgetType) { - case "desktopClock": - return getDefaultClockConfig(); - case "systemMonitor": - return getDefaultSystemMonitorConfig(); - default: - return widget.defaultConfig ?? {}; - } + switch (widgetType) { + case "desktopClock": + return getDefaultClockConfig(); + case "systemMonitor": + return getDefaultSystemMonitorConfig(); } + return JSON.parse(JSON.stringify(widget.defaultConfig ?? {})); + } - return widget.defaultConfig ?? {}; + function showsOnScreen(prefs, screen) { + if (!Array.isArray(prefs) || prefs.length === 0 || prefs.includes("all")) + return true; + const screenKey = SettingsData.getScreenDisplayName(screen); + return prefs.some(p => { + if (typeof p === "string") + return p === screenKey || p === screen?.name; + return p?.name === screen?.name || p === screenKey; + }); } function getDefaultSize(widgetType) { @@ -218,7 +248,11 @@ Singleton { } function getBuiltinWidgets() { - return registeredWidgetsList.filter(w => w.type === "builtin"); + return registeredWidgetsList.filter(w => w.type === "builtin" && !w.lockOnly); + } + + function getDesktopWidgets() { + return registeredWidgetsList.filter(w => !w.lockOnly); } function getPluginWidgets() { diff --git a/quickshell/Services/LockPlacementService.qml b/quickshell/Services/LockPlacementService.qml new file mode 100644 index 000000000..1234027d2 --- /dev/null +++ b/quickshell/Services/LockPlacementService.qml @@ -0,0 +1,84 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Common + +Singleton { + id: root + + property var decodedSamples: ({}) + property var layouts: ({}) + + function neededFor(screen) { + return SettingsData.lockScreenWidgetInstances.some(instance => { + if (instance.widgetType !== "lockClock" || instance.enabled === false || instance.config?.autoPosition === false || !DesktopWidgetRegistry.showsOnScreen(instance.config?.displayPreferences, screen)) + return false; + const positionKey = instance.config?.syncPositionAcrossScreens ? "_synced" : SettingsData.getScreenDisplayName(screen); + return SessionData.desktopWidgetInstancePositions[instance.id]?.[positionKey]?.x === undefined; + }); + } + + function backgroundKey(screenName, width, height) { + const custom = SettingsData.lockScreenWallpaperPath !== ""; + const source = custom ? SettingsData.lockScreenWallpaperPath : SessionData.getMonitorWallpaper(screenName); + const backdrop = !source || source.startsWith("#"); + const fill = SettingsData.lockScreenWallpaperFillMode || (custom ? "Fill" : SessionData.getMonitorWallpaperFillMode(screenName)); + return JSON.stringify([1, source, width, height, fill, fill === "Scrolling" ? SessionData.getMonitorScrollPosition(screenName) : null, backdrop ? SessionData.getMonitorMaterialWallpaper(screenName) : null, backdrop ? Theme.wallpaperPalette() : null, Theme.lockScreenBlur, Theme.lockScreenBlurMax, Theme.lockScreenScrimAlpha, Theme.screenOffColor.toString(), SettingsData.effectiveWallpaperBackgroundColor.toString()]); + } + + function sampleFor(screenName, key) { + const sample = CacheData.lockScreenPlacementSamples[screenName]; + if (!sample || sample.key !== key || typeof sample.key !== "string" || !Number.isInteger(sample.width) || !Number.isInteger(sample.height) || sample.width < 1 || sample.height < 1 || sample.width > 128 || sample.height > 128 || typeof sample.pixels !== "string" || sample.pixels.length !== sample.width * sample.height * 2) + return null; + return sample; + } + + function hasSample(screenName, key) { + return sampleFor(screenName, key) !== null; + } + + function storeSample(screenName, key, luminances, width, height) { + const pixels = luminances.map(value => Math.round(Math.max(0, Math.min(1, value)) * 255).toString(16).padStart(2, "0")).join(""); + delete decodedSamples[screenName]; + delete layouts[screenName]; + CacheData.set("lockScreenPlacementSamples", Object.assign({}, CacheData.lockScreenPlacementSamples, { + [screenName]: { + key, + pixels, + width, + height + } + })); + } + + function layoutFor(screenName, sample, key, calculate) { + if (!sample) + return {}; + const cached = layouts[screenName]; + if (cached?.sampleKey === sample.key && cached.key === key) + return cached.positions; + let decoded = decodedSamples[screenName]; + if (decoded?.key !== sample.key) { + const luminances = []; + for (let i = 0; i < sample.pixels.length; i += 2) { + const value = parseInt(sample.pixels.slice(i, i + 2), 16); + if (!Number.isFinite(value)) + return {}; + luminances.push(value / 255); + } + decoded = { + key: sample.key, + luminances + }; + decodedSamples[screenName] = decoded; + } + const positions = calculate(decoded.luminances, sample.width, sample.height); + layouts[screenName] = { + sampleKey: sample.key, + key, + positions + }; + return positions; + } +} diff --git a/quickshell/Services/SessionService.qml b/quickshell/Services/SessionService.qml index ad16a62fd..d1ba5b8df 100644 --- a/quickshell/Services/SessionService.qml +++ b/quickshell/Services/SessionService.qml @@ -43,6 +43,7 @@ Singleton { signal sessionLocked signal sessionUnlocked signal sessionResumed + signal lockEditorRequested signal lidOpened signal loginctlStateChanged diff --git a/quickshell/shell.qml b/quickshell/shell.qml index f11419268..768c1b0e9 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -12,6 +12,7 @@ import Quickshell import qs.Common import qs.DankCommon.Common as DC import qs.Modules +import qs.Modules.Lock import qs.Services ShellRoot { @@ -49,6 +50,7 @@ ShellRoot { sourceComponent: Scope { WallpaperBackground {} + LockPlacementPreparer {} Loader { active: SettingsData.blurredWallpaperLayer && CompositorService.isNiri diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 8409c7881..267012cbd 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -1516,6 +1516,30 @@ "page": "personalization", "description": "Requires restart" }, + { + "section": "lockNotificationsMode", + "label": "Lock screen content", + "tabIndex": -1, + "category": "Notifications", + "keywords": [ + "alert", + "alerts", + "content", + "lock", + "lockscreen", + "login", + "messages", + "notif", + "notification", + "notifications", + "notifs", + "privacy", + "screen", + "security", + "toast" + ], + "page": "notifications" + }, { "section": "notificationHistorySaveLow", "label": "Low", @@ -7088,26 +7112,6 @@ "parentLabel": "Security", "icon": "lock" }, - { - "section": "lockScreenClockStyle", - "label": "Clock style", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "clock", - "lock", - "lockscreen", - "login", - "password", - "screen", - "security", - "style", - "time", - "vertical", - "watch" - ], - "parentLabel": "Security" - }, { "section": "lockDisplay", "label": "Displays", @@ -7174,33 +7178,6 @@ ], "parentLabel": "Security" }, - { - "section": "lockLayout", - "label": "Layout", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "actions", - "appears", - "field", - "hidden", - "layout", - "lock", - "lockscreen", - "login", - "password", - "power", - "pressed", - "reboot", - "screen", - "security", - "shutdown", - "soon" - ], - "parentLabel": "Security", - "icon": "lock", - "description": "A hidden field appears as soon as a key is pressed" - }, { "section": "lockAtStartup", "label": "Lock at startup", @@ -7260,28 +7237,6 @@ "parentLabel": "Security", "description": "Layout, authentication, screensaver" }, - { - "section": "lockScreenNotificationMode", - "label": "Notifications", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "alert", - "alerts", - "lock", - "lockscreen", - "login", - "notif", - "notification", - "notifications", - "notifs", - "password", - "privacy", - "screen", - "security" - ], - "parentLabel": "Security" - }, { "section": "lockScreenVideoEnabled", "label": "Play video on lock", @@ -7431,153 +7386,6 @@ ], "parentLabel": "Security" }, - { - "section": "lockScreenShowDate", - "label": "Show date", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "calendar", - "date", - "display", - "lock", - "lockscreen", - "login", - "password", - "screen", - "security", - "show" - ], - "parentLabel": "Security" - }, - { - "section": "lockScreenShowMediaPlayer", - "label": "Show media player", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "audio", - "lock", - "lockscreen", - "login", - "media", - "mpris", - "music", - "password", - "playback", - "player", - "screen", - "security", - "show" - ], - "parentLabel": "Security" - }, - { - "section": "lockScreenShowPasswordField", - "label": "Show password field", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "appears", - "field", - "hidden", - "input", - "lock", - "lockscreen", - "login", - "password", - "pressed", - "screen", - "security", - "show", - "soon", - "visible" - ], - "parentLabel": "Security", - "description": "A hidden field appears as soon as a key is pressed" - }, - { - "section": "lockScreenShowPowerActions", - "label": "Show power actions", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "actions", - "hibernate", - "lock", - "lockscreen", - "login", - "password", - "power", - "reboot", - "restart", - "screen", - "security", - "show", - "shutdown", - "sleep", - "suspend" - ], - "parentLabel": "Security" - }, - { - "section": "lockScreenShowProfileImage", - "label": "Show profile image", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "avatar", - "image", - "lock", - "lockscreen", - "login", - "password", - "picture", - "profile", - "screen", - "security", - "show" - ], - "parentLabel": "Security" - }, - { - "section": "lockScreenShowSystemIcons", - "label": "Show system icons", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "icons", - "lock", - "lockscreen", - "login", - "password", - "screen", - "security", - "show", - "status", - "system" - ], - "parentLabel": "Security" - }, - { - "section": "lockScreenShowTime", - "label": "Show time", - "tabIndex": 11, - "category": "Lock screen", - "keywords": [ - "clock", - "display", - "lock", - "lockscreen", - "login", - "password", - "screen", - "security", - "show", - "time" - ], - "parentLabel": "Security" - }, { "section": "lockPamExternallyManaged", "label": "Use system PAM authentication", @@ -7643,23 +7451,25 @@ "parentLabel": "Security" }, { - "section": "lockScreenShowWeather", - "label": "Weather", + "section": "lockScreenWidgets", + "label": "Widgets", "tabIndex": 11, "category": "Lock screen", "keywords": [ - "climate", - "forecast", + "clock", + "components", "lock", "lockscreen", "login", + "modules", "password", + "plugins", "screen", "security", - "temperature", - "weather" + "widgets" ], - "parentLabel": "Security" + "parentLabel": "Security", + "icon": "widgets" }, { "section": "loginctlLockIntegration", @@ -16145,6 +15955,27 @@ ], "parentLabel": "Date, time & region" }, + { + "section": "lockStatusWeather", + "label": "Lock screen", + "tabIndex": 56, + "category": "Weather", + "keywords": [ + "climate", + "date,", + "forecast", + "lock", + "lockscreen", + "login", + "region", + "screen", + "security", + "temperature", + "time", + "weather" + ], + "parentLabel": "Date, time & region" + }, { "section": "weatherEnabled", "label": "Weather",