From db903801c3d2c73fe94f59701ce31acdcb273023 Mon Sep 17 00:00:00 2001 From: bbedward Date: Tue, 29 Sep 2026 23:31:28 -0400 Subject: [PATCH] osd: more position options, hookup sheets to some fixes #3629 --- dank-qml-common | 2 +- flake.lock | 6 +- quickshell/Common/SettingsData.qml | 36 ++ quickshell/Common/settings/SettingsSpec.js | 3 + .../Modals/Clipboard/ClipboardContent.qml | 6 +- .../Modals/Clipboard/ClipboardEditor.qml | 6 +- quickshell/Modules/OSD/AudioOutputOSD.qml | 2 + quickshell/Modules/OSD/BrightnessOSD.qml | 13 + quickshell/Modules/OSD/CapsLockOSD.qml | 2 + quickshell/Modules/OSD/IdleInhibitorOSD.qml | 2 + quickshell/Modules/OSD/LevelOSD.qml | 55 +++ quickshell/Modules/OSD/MediaPlaybackOSD.qml | 2 + quickshell/Modules/OSD/MediaVolumeOSD.qml | 2 + quickshell/Modules/OSD/MicVolumeOSD.qml | 10 + quickshell/Modules/OSD/OsdDetailSheet.qml | 104 ++++++ quickshell/Modules/OSD/OsdLevelRow.qml | 23 +- quickshell/Modules/OSD/PowerProfileOSD.qml | 2 + quickshell/Modules/OSD/VolumeOSD.qml | 10 + quickshell/Modules/OSD/WorkspaceOSD.qml | 2 + quickshell/Modules/Settings/OSDTab.qml | 139 +++++--- .../Settings/Widgets/SettingsMetrics.qml | 1 + .../Widgets/SettingsScreenPositionPicker.qml | 211 ++++++++++++ quickshell/Widgets/DankOSD.qml | 118 +++++-- quickshell/Widgets/DankOSDSheet.qml | 316 ++++++++++++++++++ 24 files changed, 983 insertions(+), 90 deletions(-) create mode 100644 quickshell/Modules/OSD/OsdDetailSheet.qml create mode 100644 quickshell/Modules/Settings/Widgets/SettingsScreenPositionPicker.qml create mode 100644 quickshell/Widgets/DankOSDSheet.qml diff --git a/dank-qml-common b/dank-qml-common index 2d206c20d..fc42c683c 160000 --- a/dank-qml-common +++ b/dank-qml-common @@ -1 +1 @@ -Subproject commit 2d206c20d102fb77edf35a41b098f9cc086d1acf +Subproject commit fc42c683c7fd16e7583536acabae54a5fd678fc3 diff --git a/flake.lock b/flake.lock index c33dad0dd..0a24f8763 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "dank-qml-common": { "flake": false, "locked": { - "lastModified": 1790715373, - "narHash": "sha256-hiykGTIlWoRLntC4BWTZ/HIfTQ1aABmAvDyu9t3Sb+A=", + "lastModified": 1790735794, + "narHash": "sha256-+N/tmoRkCCQng+JgZ3Qrb/vrjFihjw9kQgLTus7LwoA=", "owner": "AvengeMedia", "repo": "dank-qml-common", - "rev": "2d206c20d102fb77edf35a41b098f9cc086d1acf", + "rev": "fc42c683c7fd16e7583536acabae54a5fd678fc3", "type": "github" }, "original": { diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index efb6f01f2..9c8bae348 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -1050,6 +1050,7 @@ Singleton { property bool osdAlwaysShowValue: Spec.SPEC.osdAlwaysShowValue.def property int osdPosition: SettingsData.Position.BottomCenter + property var osdPositionOverrides: Spec.SPEC.osdPositionOverrides.def property bool osdVolumeEnabled: Spec.SPEC.osdVolumeEnabled.def property bool osdMediaVolumeEnabled: Spec.SPEC.osdMediaVolumeEnabled.def property bool osdMediaPlaybackEnabled: Spec.SPEC.osdMediaPlaybackEnabled.def @@ -1586,6 +1587,41 @@ Singleton { } } + function osdPositionFor(kind) { + const override = osdPositionOverrides?.[kind]; + return typeof override === "number" ? override : osdPosition; + } + + function hasOsdPositionOverride(kind) { + return typeof osdPositionOverrides?.[kind] === "number"; + } + + function setOsdPosition(kind, position) { + if (!kind) { + set("osdPosition", position); + return; + } + if (position === osdPosition) { + resetOsdPosition(kind); + return; + } + set("osdPositionOverrides", Object.assign({}, osdPositionOverrides, { + [kind]: position + })); + } + + function resetOsdPosition(kind) { + if (!kind) { + resetToDefault(["osdPosition"]); + return; + } + if (!hasOsdPositionOverride(kind)) + return; + const next = Object.assign({}, osdPositionOverrides); + delete next[kind]; + set("osdPositionOverrides", next); + } + function barConfigDefault(field) { return Spec.SPEC.barConfigs.def[0][field]; } diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index d0f2b016d..14de78aa9 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -1241,6 +1241,9 @@ var LOCAL_SPEC = { osdPosition: { def: 5 }, + osdPositionOverrides: { + def: {} + }, osdVolumeEnabled: { def: true }, diff --git a/quickshell/Modals/Clipboard/ClipboardContent.qml b/quickshell/Modals/Clipboard/ClipboardContent.qml index 800103eee..7b84de918 100644 --- a/quickshell/Modals/Clipboard/ClipboardContent.qml +++ b/quickshell/Modals/Clipboard/ClipboardContent.qml @@ -211,10 +211,10 @@ Item { anchors.topMargin: PopoutMetrics.contentGap anchors.left: parent.left anchors.right: parent.right - anchors.bottom: parent.bottom + anchors.bottom: keyboardHintsLoader.top anchors.leftMargin: PopoutMetrics.contentPadding anchors.rightMargin: PopoutMetrics.contentPadding - anchors.bottomMargin: (keyboardHintsLoader.item ? keyboardHintsLoader.item.implicitHeight + PopoutMetrics.contentPadding * 2 : 0) + Theme.spacingXS + anchors.bottomMargin: keyboardHintsLoader.active ? PopoutMetrics.contentGap : 0 clip: true DankListView { @@ -297,7 +297,7 @@ Item { anchors.right: parent.right anchors.leftMargin: PopoutMetrics.contentPadding anchors.rightMargin: PopoutMetrics.contentPadding - anchors.bottomMargin: active ? PopoutMetrics.contentPadding : 0 + anchors.bottomMargin: PopoutMetrics.contentPadding active: modal.showKeyboardHints height: item ? item.implicitHeight : 0 diff --git a/quickshell/Modals/Clipboard/ClipboardEditor.qml b/quickshell/Modals/Clipboard/ClipboardEditor.qml index 555164062..a168091ae 100644 --- a/quickshell/Modals/Clipboard/ClipboardEditor.qml +++ b/quickshell/Modals/Clipboard/ClipboardEditor.qml @@ -255,8 +255,8 @@ Item { Column { anchors.fill: parent - anchors.margins: Theme.spacingM - spacing: Theme.spacingM + anchors.margins: PopoutMetrics.contentPadding + spacing: PopoutMetrics.contentGap Item { id: editorHeader @@ -295,7 +295,7 @@ Item { DankTextEdit { id: editField width: parent.width - height: Math.max(Theme.fontSizeMedium * 8, parent.height - editorHeader.height - editorActions.height - Theme.spacingM * 2) + height: Math.max(Theme.fontSizeMedium * 8, parent.height - editorHeader.height - editorActions.height - PopoutMetrics.contentGap * 2) leftIconName: "edit" placeholderText: I18n.tr("Edit clipboard text") backgroundColor: Theme.floatingWindowFieldColor diff --git a/quickshell/Modules/OSD/AudioOutputOSD.qml b/quickshell/Modules/OSD/AudioOutputOSD.qml index e45fc6f35..5f9a8e041 100644 --- a/quickshell/Modules/OSD/AudioOutputOSD.qml +++ b/quickshell/Modules/OSD/AudioOutputOSD.qml @@ -6,6 +6,8 @@ import qs.Widgets DankOSD { id: root + osdKind: "audioOutput" + property string deviceName: "" property string deviceIcon: "speaker" diff --git a/quickshell/Modules/OSD/BrightnessOSD.qml b/quickshell/Modules/OSD/BrightnessOSD.qml index e2627a775..76244f2b0 100644 --- a/quickshell/Modules/OSD/BrightnessOSD.qml +++ b/quickshell/Modules/OSD/BrightnessOSD.qml @@ -1,10 +1,13 @@ import QtQuick import qs.Common +import qs.Modules.ControlCenter.Details import qs.Services LevelOSD { id: root + osdKind: "brightness" + readonly property var device: BrightnessService.getCurrentDeviceInfo() property int displayLevel: BrightnessService.brightnessLevel @@ -19,6 +22,7 @@ LevelOSD { return BrightnessService.brightnessIconName(device); } } + endIconLabel: I18n.tr("Brightness") iconColor: Theme.onPrimary value: displayLevel minimum: BrightnessService.brightnessMinimum(device) @@ -26,6 +30,15 @@ LevelOSD { unit: BrightnessService.brightnessUnit(device) available: BrightnessService.brightnessAvailable + sheet: OsdDetailSheet { + BrightnessDetail { + anchors.fill: parent + initialDeviceName: root.device?.name ?? "" + screenName: root.screen?.name ?? "" + screenModel: root.screen?.model ?? "" + } + } + onLevelRequested: level => { displayLevel = level; BrightnessService.setBrightness(level, BrightnessService.lastIpcDevice, true); diff --git a/quickshell/Modules/OSD/CapsLockOSD.qml b/quickshell/Modules/OSD/CapsLockOSD.qml index 69b97cbe5..a6830cec3 100644 --- a/quickshell/Modules/OSD/CapsLockOSD.qml +++ b/quickshell/Modules/OSD/CapsLockOSD.qml @@ -6,6 +6,8 @@ import qs.Widgets DankOSD { id: root + osdKind: "capsLock" + osdWidth: Theme.osdHeight osdHeight: Theme.osdHeight autoHideInterval: 2000 diff --git a/quickshell/Modules/OSD/IdleInhibitorOSD.qml b/quickshell/Modules/OSD/IdleInhibitorOSD.qml index a3b45b3a1..de4292b72 100644 --- a/quickshell/Modules/OSD/IdleInhibitorOSD.qml +++ b/quickshell/Modules/OSD/IdleInhibitorOSD.qml @@ -6,6 +6,8 @@ import qs.Widgets DankOSD { id: root + osdKind: "idleInhibitor" + osdWidth: Theme.osdHeight osdHeight: Theme.osdHeight autoHideInterval: 2000 diff --git a/quickshell/Modules/OSD/LevelOSD.qml b/quickshell/Modules/OSD/LevelOSD.qml index c46620dca..57eff4f4d 100644 --- a/quickshell/Modules/OSD/LevelOSD.qml +++ b/quickshell/Modules/OSD/LevelOSD.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import qs.Common +import qs.Modules.ControlCenter import qs.Widgets DankOSD { @@ -10,6 +11,7 @@ DankOSD { property string iconName: "" property string insetIconName: "" property string endIconName: "" + property string endIconLabel: "" property bool iconInteractive: false property string iconLabel: "" property color iconColor: Theme.onPrimary @@ -26,6 +28,40 @@ DankOSD { readonly property real osdValueReserve: endIconName.length > 0 || SettingsData.osdAlwaysShowValue ? Theme.buttonHeightM : 0 + function showEndIconTooltip(item) { + endIconTooltip.active = true; + const tip = endIconTooltip.item; + if (!tip) + return; + const pos = item.mapToItem(null, 0, 0); + const left = windowX + pos.x; + const top = windowY + pos.y; + const gap = Theme.spacingS; + tip.text = endIconLabel; + switch (alignY) { + case 1: + tip.show(endIconLabel, left + item.width / 2, top - gap - tip.implicitHeight, screen); + return; + case -1: + tip.show(endIconLabel, left + item.width / 2, top + item.height + gap, screen); + return; + } + if (alignX === 1) + tip.show(endIconLabel, left - gap, top + item.height / 2, screen, false, true); + else + tip.show(endIconLabel, left + item.width + gap, top + item.height / 2, screen, true, false); + } + + function hideEndIconTooltip() { + endIconTooltip.item?.hide(); + endIconTooltip.active = false; + } + + onVisibleChanged: { + if (!visible) + hideEndIconTooltip(); + } + function requestLevel(level) { if (!available) return; @@ -36,14 +72,23 @@ DankOSD { osdWidth: isVerticalLayout ? Theme.osdHeight : Math.min(Theme.osdLevelWidth + osdValueReserve, screenWidth - Theme.spacingM * 2) osdHeight: isVerticalLayout ? Math.min(Theme.osdLevelVerticalHeight, screenHeight - Theme.spacingM * 2) : Theme.buttonHeightS + Theme.spacingS * 2 autoHideInterval: 3000 + sheetWidth: CcMetrics.sheetWidthFor(CcMetrics.minimumColumns) enableMouseInteraction: true + Loader { + id: endIconTooltip + active: false + sourceComponent: DankTooltip {} + } + content: OsdLevelRow { vertical: root.isVerticalLayout sliderSize: root.isVerticalLayout ? "m" : "s" iconName: root.iconName insetIconName: root.insetIconName endIconName: root.endIconName + endIconInteractive: root.sheet !== null + endIconLabel: root.endIconLabel iconInteractive: root.iconInteractive iconLabel: root.iconLabel iconColor: root.iconColor @@ -56,6 +101,16 @@ DankOSD { displayText: root.displayText sliderEnabled: root.available onIconClicked: root.iconClicked() + onEndIconClicked: { + root.hideEndIconTooltip(); + root.expand(); + } + onEndIconHoveredChanged: { + if (endIconHovered && root.sheet) + root.showEndIconTooltip(endIconItem); + else + root.hideEndIconTooltip(); + } onHoverChanged: hovered => root.setChildHovered(hovered) onSliderValueChanged: newValue => root.requestLevel(newValue) } diff --git a/quickshell/Modules/OSD/MediaPlaybackOSD.qml b/quickshell/Modules/OSD/MediaPlaybackOSD.qml index b0801fde6..fc285e787 100644 --- a/quickshell/Modules/OSD/MediaPlaybackOSD.qml +++ b/quickshell/Modules/OSD/MediaPlaybackOSD.qml @@ -9,6 +9,8 @@ import Quickshell.Widgets DankOSD { id: root + osdKind: "mediaPlayback" + readonly property bool useVertical: isVerticalLayout readonly property bool playing: player?.isPlaying ?? false readonly property var player: MprisController.activePlayer diff --git a/quickshell/Modules/OSD/MediaVolumeOSD.qml b/quickshell/Modules/OSD/MediaVolumeOSD.qml index 6eb0c3f6c..5d5d9e129 100644 --- a/quickshell/Modules/OSD/MediaVolumeOSD.qml +++ b/quickshell/Modules/OSD/MediaVolumeOSD.qml @@ -5,6 +5,8 @@ import qs.Services LevelOSD { id: root + osdKind: "mediaVolume" + readonly property var player: MprisController.activePlayer readonly property bool volumeSupported: player?.volumeSupported ?? false readonly property real playerVolume: player?.volume ?? 0 diff --git a/quickshell/Modules/OSD/MicVolumeOSD.qml b/quickshell/Modules/OSD/MicVolumeOSD.qml index 3ba462f6c..7a7bedd2f 100644 --- a/quickshell/Modules/OSD/MicVolumeOSD.qml +++ b/quickshell/Modules/OSD/MicVolumeOSD.qml @@ -1,16 +1,20 @@ import QtQuick import qs.Common +import qs.Modules.ControlCenter.Details import qs.Services LevelOSD { id: root + osdKind: "mic" + readonly property var audio: AudioService.source?.audio ?? null readonly property bool muted: audio?.muted ?? false iconName: muted ? "mic_off" : "mic" insetIconName: "mic" endIconName: "graphic_eq" + endIconLabel: I18n.tr("Audio Input") iconInteractive: true iconLabel: muted ? I18n.tr("Unmute") : I18n.tr("Mute") iconColor: muted ? Theme.onErrorContainer : Theme.onPrimary @@ -19,6 +23,12 @@ LevelOSD { available: !!audio displayText: muted ? I18n.tr("Muted") : "" + sheet: OsdDetailSheet { + AudioInputDetail { + anchors.fill: parent + } + } + onIconClicked: AudioService.toggleMicMute() onLevelRequested: level => { SessionData.suppressOSDTemporarily(); diff --git a/quickshell/Modules/OSD/OsdDetailSheet.qml b/quickshell/Modules/OSD/OsdDetailSheet.qml new file mode 100644 index 000000000..9625a1341 --- /dev/null +++ b/quickshell/Modules/OSD/OsdDetailSheet.qml @@ -0,0 +1,104 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.ControlCenter +import qs.Modules.ControlCenter.Details +import qs.Services +import qs.Widgets + +FocusScope { + id: root + + default property alias detail: body.data + readonly property Item page: body.children[0] ?? null + readonly property string settingsTab: page?.headerActions?.settingsTab ?? "" + + signal closeRequested + + implicitHeight: CcMetrics.detailDialogPadding * 2 + header.height + (page?.implicitHeight ?? 0) + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + function releasePortSelector() { + if (portSelectorLoader.item?.shown) + return; + portSelectorLoader.active = false; + } + + Item { + id: header + + x: CcMetrics.detailDialogPadding + y: CcMetrics.detailDialogPadding + width: parent.width - CcMetrics.detailDialogPadding * 2 + height: CcMetrics.pageHeaderHeight + + StyledText { + anchors.left: parent.left + anchors.leftMargin: CcMetrics.rowPaddingH + anchors.right: settingsButton.visible ? settingsButton.left : parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + text: root.page?.title ?? "" + font.pixelSize: CcMetrics.pageTitleSize + color: Theme.surfaceText + elide: Text.ElideRight + horizontalAlignment: Text.AlignLeft + } + + DankActionButton { + id: settingsButton + + anchors.right: parent.right + anchors.rightMargin: CcMetrics.headerEdgeInset + anchors.verticalCenter: parent.verticalCenter + visible: root.settingsTab !== "" + buttonSize: CcMetrics.headerActionSize + iconSize: CcMetrics.headerActionIconSize + iconName: "settings" + iconColor: Theme.surfaceText + Accessible.name: I18n.tr("Settings") + onClicked: { + root.closeRequested(); + PopoutService.openSettingsWithTab(root.settingsTab); + } + } + } + + Item { + id: body + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + anchors.bottom: parent.bottom + anchors.leftMargin: CcMetrics.detailDialogPadding + anchors.rightMargin: CcMetrics.detailDialogPadding + anchors.bottomMargin: CcMetrics.detailDialogPadding + focus: true + } + + Connections { + target: root.page + ignoreUnknownSignals: true + + function onShowPortSelector(node) { + portSelectorLoader.active = true; + portSelectorLoader.item?.show(node); + if (!portSelectorLoader.item?.shown) + portSelectorLoader.active = false; + } + } + + Loader { + id: portSelectorLoader + + anchors.fill: parent + z: CcMetrics.overlayZ + active: false + sourceComponent: AudioPortSelector { + onDismissed: Qt.callLater(root.releasePortSelector) + } + } +} diff --git a/quickshell/Modules/OSD/OsdLevelRow.qml b/quickshell/Modules/OSD/OsdLevelRow.qml index e726b8827..ac3a0f982 100644 --- a/quickshell/Modules/OSD/OsdLevelRow.qml +++ b/quickshell/Modules/OSD/OsdLevelRow.qml @@ -10,6 +10,8 @@ Item { property string iconName: "volume_up" property string insetIconName: "" property string endIconName: "" + property bool endIconInteractive: false + property string endIconLabel: "" property string iconLabel: "" property bool iconInteractive: false property bool tonalIcon: true @@ -27,7 +29,9 @@ Item { property color thumbOutlineColor: Theme.hostSurface property real horizontalPadding: -1 - readonly property bool containsMouse: levelSlider.containsMouse || icon.hovered + readonly property bool containsMouse: levelSlider.containsMouse || icon.hovered || endIcon.hovered + readonly property bool endIconHovered: endIcon.hovered + readonly property Item endIconItem: endIcon readonly property bool showValueColumn: SettingsData.osdAlwaysShowValue readonly property bool showEndIcon: endIconName.length > 0 readonly property alias isDragging: levelSlider.isDragging @@ -41,6 +45,7 @@ Item { signal sliderValueChanged(int newValue) signal iconClicked + signal endIconClicked signal hoverChanged(bool hovered) onContainsMouseChanged: root.hoverChanged(root.containsMouse) @@ -126,12 +131,18 @@ Item { visible: root.showValueColumn } - DankIcon { - anchors.centerIn: parent - name: root.endIconName - size: Theme.iconSize - color: root.sliderEnabled ? root.fillColor : Theme.onSurface_38 + OsdIcon { + id: endIcon + + anchors.fill: parent + iconName: root.endIconName + iconColor: root.fillColor + tonal: false + label: root.endIconLabel + interactive: root.endIconInteractive + available: root.sliderEnabled visible: !root.showValueColumn + onClicked: root.endIconClicked() } } } diff --git a/quickshell/Modules/OSD/PowerProfileOSD.qml b/quickshell/Modules/OSD/PowerProfileOSD.qml index d355a3354..d117ad58b 100644 --- a/quickshell/Modules/OSD/PowerProfileOSD.qml +++ b/quickshell/Modules/OSD/PowerProfileOSD.qml @@ -6,6 +6,8 @@ import qs.Widgets DankOSD { id: root + osdKind: "powerProfile" + property int currentProfile: 0 property string profileIcon: "settings" diff --git a/quickshell/Modules/OSD/VolumeOSD.qml b/quickshell/Modules/OSD/VolumeOSD.qml index aacce34aa..736bc7ebb 100644 --- a/quickshell/Modules/OSD/VolumeOSD.qml +++ b/quickshell/Modules/OSD/VolumeOSD.qml @@ -1,15 +1,19 @@ import QtQuick import qs.Common +import qs.Modules.ControlCenter.Details import qs.Services LevelOSD { id: root + osdKind: "volume" + readonly property var audio: AudioService.sink?.audio ?? null iconName: AudioService.sinkVolumeIconName insetIconName: "music_note" endIconName: AudioService.sinkIcon(AudioService.sink) + endIconLabel: I18n.tr("Audio Output") iconInteractive: true iconLabel: audio?.muted ? I18n.tr("Unmute", "verb, button to unmute audio or a muted app") : I18n.tr("Mute") value: AudioService.sinkVolumePercent @@ -17,6 +21,12 @@ LevelOSD { available: !!audio displayText: audio?.muted ? I18n.tr("Muted") : "" + sheet: OsdDetailSheet { + AudioOutputDetail { + anchors.fill: parent + } + } + onIconClicked: AudioService.toggleMute() onLevelRequested: level => { SessionData.suppressOSDTemporarily(); diff --git a/quickshell/Modules/OSD/WorkspaceOSD.qml b/quickshell/Modules/OSD/WorkspaceOSD.qml index 8dcaf54bb..85b14802c 100644 --- a/quickshell/Modules/OSD/WorkspaceOSD.qml +++ b/quickshell/Modules/OSD/WorkspaceOSD.qml @@ -9,6 +9,8 @@ import qs.Widgets DankOSD { id: root + osdKind: "workspace" + property string workspaceLabel: "" property var lastActiveWorkspaceId: null diff --git a/quickshell/Modules/Settings/OSDTab.qml b/quickshell/Modules/Settings/OSDTab.qml index 20e1c6619..f83ee9d6d 100644 --- a/quickshell/Modules/Settings/OSDTab.qml +++ b/quickshell/Modules/Settings/OSDTab.qml @@ -6,6 +6,75 @@ import qs.Modules.Settings.Widgets Item { id: root + property string positionScope: "" + readonly property var positionScopes: [ + { + "value": "", + "label": I18n.tr("Default"), + "shown": true + }, + { + "value": "volume", + "label": I18n.tr("Volume"), + "shown": SettingsData.osdVolumeEnabled + }, + { + "value": "mediaVolume", + "label": I18n.tr("Media volume"), + "shown": SettingsData.osdMediaVolumeEnabled + }, + { + "value": "mediaPlayback", + "label": I18n.tr("Media playback"), + "shown": SettingsData.osdMediaPlaybackEnabled + }, + { + "value": "brightness", + "label": I18n.tr("Brightness"), + "shown": SettingsData.osdBrightnessEnabled + }, + { + "value": "idleInhibitor", + "label": I18n.tr("Idle inhibitor", "feature that keeps the session from going idle"), + "shown": SettingsData.osdIdleInhibitorEnabled + }, + { + "value": "mic", + "label": I18n.tr("Microphone"), + "shown": SettingsData.osdMicMuteEnabled || SettingsData.osdMicVolumeEnabled + }, + { + "value": "capsLock", + "label": I18n.tr("Caps lock"), + "shown": SettingsData.osdCapsLockEnabled + }, + { + "value": "powerProfile", + "label": I18n.tr("Power profile"), + "shown": SettingsData.osdPowerProfileEnabled + }, + { + "value": "audioOutput", + "label": I18n.tr("Audio output switch"), + "shown": SettingsData.osdAudioOutputEnabled + }, + { + "value": "workspace", + "label": I18n.tr("Workspace Switch", "toggle label for workspace change OSD"), + "shown": SettingsData.osdWorkspaceEnabled + } + ].filter(scope => scope.shown).map(scope => ({ + "value": scope.value, + "label": scope.label, + "icon": SettingsData.hasOsdPositionOverride(scope.value) ? "pin_drop" : "", + "tooltip": SettingsData.hasOsdPositionOverride(scope.value) ? I18n.tr("Custom") : "" + })) + + onPositionScopesChanged: { + if (!positionScopes.some(scope => scope.value === positionScope)) + positionScope = ""; + } + SettingsPage { id: mainColumn @@ -13,51 +82,37 @@ Item { width: parent.width settingKey: "osd" - SettingsDropdownRow { + SettingsRow { + id: positionRow + + readonly property bool overridden: root.positionScope !== "" && SettingsData.hasOsdPositionOverride(root.positionScope) + settingKey: "osdPosition" - text: I18n.tr("Position") - currentValue: { - switch (SettingsData.osdPosition) { - case SettingsData.Position.Top: - return I18n.tr("Top Right", "screen position option"); - case SettingsData.Position.Left: - return I18n.tr("Top Left", "screen position option"); - case SettingsData.Position.TopCenter: - return I18n.tr("Top Center", "screen position option"); - case SettingsData.Position.Right: - return I18n.tr("Bottom Right", "screen position option"); - case SettingsData.Position.Bottom: - return I18n.tr("Bottom Left", "screen position option"); - case SettingsData.Position.BottomCenter: - return I18n.tr("Bottom Center", "screen position option"); - case SettingsData.Position.LeftCenter: - return I18n.tr("Left Center", "screen position option"); - case SettingsData.Position.RightCenter: - return I18n.tr("Right Center", "screen position option"); - default: - return I18n.tr("Bottom Center", "screen position option"); - } + title: I18n.tr("Position") + subtitle: { + const label = positionPicker.labelFor(SettingsData.osdPositionFor(root.positionScope)); + return root.positionScope !== "" && !overridden ? I18n.tr("Default") + " ยท " + label : label; } - options: [I18n.tr("Top Right", "screen position option"), I18n.tr("Top Left", "screen position option"), I18n.tr("Top Center", "screen position option"), I18n.tr("Bottom Right", "screen position option"), I18n.tr("Bottom Left", "screen position option"), I18n.tr("Bottom Center", "screen position option"), I18n.tr("Left Center", "screen position option"), I18n.tr("Right Center", "screen position option")] - onValueChanged: value => { - if (value === I18n.tr("Top Right", "screen position option")) { - SettingsData.set("osdPosition", SettingsData.Position.Top); - } else if (value === I18n.tr("Top Left", "screen position option")) { - SettingsData.set("osdPosition", SettingsData.Position.Left); - } else if (value === I18n.tr("Top Center", "screen position option")) { - SettingsData.set("osdPosition", SettingsData.Position.TopCenter); - } else if (value === I18n.tr("Bottom Right", "screen position option")) { - SettingsData.set("osdPosition", SettingsData.Position.Right); - } else if (value === I18n.tr("Bottom Left", "screen position option")) { - SettingsData.set("osdPosition", SettingsData.Position.Bottom); - } else if (value === I18n.tr("Bottom Center", "screen position option")) { - SettingsData.set("osdPosition", SettingsData.Position.BottomCenter); - } else if (value === I18n.tr("Left Center", "screen position option")) { - SettingsData.set("osdPosition", SettingsData.Position.LeftCenter); - } else if (value === I18n.tr("Right Center", "screen position option")) { - SettingsData.set("osdPosition", SettingsData.Position.RightCenter); + modified: root.positionScope === "" ? !SettingsData.isDefault(["osdPosition"]) : overridden + resetByKeys: false + onResetRequested: SettingsData.resetOsdPosition(root.positionScope) + + body: [ + DankFilterChips { + model: root.positionScopes + Binding on currentIndex { + value: Math.max(0, root.positionScopes.findIndex(scope => scope.value === root.positionScope)) + restoreMode: Binding.RestoreNone + } + showCounts: false + onSelectionChanged: index => root.positionScope = root.positionScopes[index].value + }, + SettingsScreenPositionPicker { + id: positionPicker + selected: SettingsData.osdPositionFor(root.positionScope) + onPicked: position => SettingsData.setOsdPosition(root.positionScope, position) } - } + ] } SettingsToggleRow { diff --git a/quickshell/Modules/Settings/Widgets/SettingsMetrics.qml b/quickshell/Modules/Settings/Widgets/SettingsMetrics.qml index 2486f11d9..d27f3ea94 100644 --- a/quickshell/Modules/Settings/Widgets/SettingsMetrics.qml +++ b/quickshell/Modules/Settings/Widgets/SettingsMetrics.qml @@ -31,6 +31,7 @@ Singleton { readonly property real splitDividerHeight: Theme.iconSize readonly property real buttonGroupCompactThreshold: 200 readonly property real choiceCardPreviewRatio: 10 / 16 + readonly property real positionPickerMaxWidth: 360 readonly property real wallpaperThumbRatio: 10 / 16 readonly property int wallpaperThumbCache: 1024 readonly property real wallpaperHeroStackWidth: 640 diff --git a/quickshell/Modules/Settings/Widgets/SettingsScreenPositionPicker.qml b/quickshell/Modules/Settings/Widgets/SettingsScreenPositionPicker.qml new file mode 100644 index 000000000..9d531ed93 --- /dev/null +++ b/quickshell/Modules/Settings/Widgets/SettingsScreenPositionPicker.qml @@ -0,0 +1,211 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: root + + property int selected: SettingsData.Position.BottomCenter + readonly property var positions: [SettingsData.Position.Left, SettingsData.Position.TopCenter, SettingsData.Position.Top, SettingsData.Position.LeftCenter, SettingsData.Position.RightCenter, SettingsData.Position.Bottom, SettingsData.Position.BottomCenter, SettingsData.Position.Right] + readonly property real edgeSlotLength: 0.26 + readonly property real sideSlotLength: 0.42 + readonly property real slotThickness: 0.1 + readonly property rect selectedArea: slotRect(selected) + property int placedPosition: -1 + + signal picked(int position) + + width: parent?.width ?? 0 + implicitHeight: screenMock.height + height: implicitHeight + LayoutMirroring.enabled: false + LayoutMirroring.childrenInherit: true + + onSelectedAreaChanged: placeIndicator() + Component.onCompleted: placeIndicator() + + function labelFor(position) { + switch (position) { + case SettingsData.Position.Top: + return I18n.tr("Top Right", "screen position option"); + case SettingsData.Position.Left: + return I18n.tr("Top Left", "screen position option"); + case SettingsData.Position.TopCenter: + return I18n.tr("Top Center", "screen position option"); + case SettingsData.Position.Right: + return I18n.tr("Bottom Right", "screen position option"); + case SettingsData.Position.Bottom: + return I18n.tr("Bottom Left", "screen position option"); + case SettingsData.Position.LeftCenter: + return I18n.tr("Left Center", "screen position option"); + case SettingsData.Position.RightCenter: + return I18n.tr("Right Center", "screen position option"); + default: + return I18n.tr("Bottom Center", "screen position option"); + } + } + + function cellFor(position) { + switch (position) { + case SettingsData.Position.Left: + return Qt.point(0, 0); + case SettingsData.Position.TopCenter: + return Qt.point(1, 0); + case SettingsData.Position.Top: + return Qt.point(2, 0); + case SettingsData.Position.LeftCenter: + return Qt.point(0, 1); + case SettingsData.Position.RightCenter: + return Qt.point(2, 1); + case SettingsData.Position.Bottom: + return Qt.point(0, 2); + case SettingsData.Position.Right: + return Qt.point(2, 2); + default: + return Qt.point(1, 2); + } + } + + function slotRect(position) { + const cell = cellFor(position); + const side = cell.y === 1; + const inset = screenMock.inset; + const w = Math.round(side ? screenMock.height * slotThickness : screenMock.width * edgeSlotLength); + const h = Math.round(screenMock.height * (side ? sideSlotLength : slotThickness)); + const x = cell.x === 0 ? inset : cell.x === 2 ? screenMock.width - w - inset : Math.round((screenMock.width - w) / 2); + const y = cell.y === 0 ? inset : cell.y === 2 ? screenMock.height - h - inset : Math.round((screenMock.height - h) / 2); + return Qt.rect(x, y, w, h); + } + + function neighbor(position, dx, dy) { + const from = cellFor(position); + let best = -1; + let bestCost = Infinity; + for (const candidate of positions) { + const cell = cellFor(candidate); + const along = dx !== 0 ? (cell.x - from.x) * dx : (cell.y - from.y) * dy; + if (along <= 0) + continue; + const cost = along + 2 * (dx !== 0 ? Math.abs(cell.y - from.y) : Math.abs(cell.x - from.x)); + if (cost >= bestCost) + continue; + best = candidate; + bestCost = cost; + } + return best; + } + + function step(from, dx, dy) { + const next = neighbor(from, dx, dy); + if (next < 0) + return; + picked(next); + slotRepeater.itemAt(positions.indexOf(next))?.forceActiveFocus(Qt.TabFocusReason); + } + + function placeIndicator() { + const animate = placedPosition >= 0 && placedPosition !== selected; + placedPosition = selected; + if (animate) + indicatorSpring.retarget(selectedArea); + else + indicatorSpring.snapTo(selectedArea); + } + + RectSpringMotion { + id: indicatorSpring + enabled: !Theme.springMotionDisabled + reducedMotion: SettingsData.reduceMotion + stiffness: Theme.springPreset("fast", Theme.expressiveDurations.expressiveFastSpatial).stiffness + damping: Theme.springPreset("fast", Theme.expressiveDurations.expressiveFastSpatial).damping + } + + Rectangle { + id: screenMock + + readonly property real inset: Theme.spacingS + + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(root.width, SettingsMetrics.positionPickerMaxWidth) + height: Math.round(width * SettingsMetrics.choiceCardPreviewRatio) + radius: Theme.cornerRadiusM + color: Theme.surfaceContainerHighest + border.width: Theme.outlineWidth + border.color: Theme.outlineVariant + + Repeater { + model: root.positions + + Rectangle { + required property int modelData + readonly property rect area: root.slotRect(modelData) + + x: area.x + y: area.y + width: area.width + height: area.height + radius: Theme.fullRadius(width, height) + color: root.enabled ? Theme.outlineVariant : Theme.onSurface_12 + } + } + + Rectangle { + readonly property real inset: screenMock.inset + + width: indicatorSpring.value.width + height: indicatorSpring.value.height + x: Math.max(inset, Math.min(indicatorSpring.value.x, screenMock.width - width - inset)) + y: Math.max(inset, Math.min(indicatorSpring.value.y, screenMock.height - height - inset)) + radius: Theme.fullRadius(width, height) + color: root.enabled ? Theme.primary : Theme.onSurface_38 + } + + Repeater { + id: slotRepeater + model: root.positions + + StyledButton { + id: slot + + required property int modelData + readonly property rect area: root.slotRect(modelData) + readonly property bool isSelected: modelData === root.selected + readonly property real touchPad: Math.max(0, (Theme.buttonHeightS - Math.min(area.width, area.height)) / 2) + + x: area.x - touchPad + y: area.y - touchPad + width: area.width + touchPad * 2 + height: area.height + touchPad * 2 + focusPolicy: isSelected || activeFocus ? Qt.StrongFocus : Qt.ClickFocus + Accessible.role: Accessible.RadioButton + Accessible.name: root.labelFor(modelData) + Accessible.checked: isSelected + onClicked: root.picked(modelData) + Keys.onLeftPressed: root.step(modelData, -1, 0) + Keys.onRightPressed: root.step(modelData, 1, 0) + Keys.onUpPressed: root.step(modelData, 0, -1) + Keys.onDownPressed: root.step(modelData, 0, 1) + + Rectangle { + anchors.centerIn: parent + width: slot.area.width + height: slot.area.height + radius: Theme.fullRadius(width, height) + color: "transparent" + + StateLayer { + control: slot + disabled: !slot.enabled + stateColor: slot.isSelected ? Theme.onPrimary : Theme.onSurface + } + + FocusRing { + visible: slot.visualFocus + } + } + } + } + } +} diff --git a/quickshell/Widgets/DankOSD.qml b/quickshell/Widgets/DankOSD.qml index 8efc75c63..faa06411a 100644 --- a/quickshell/Widgets/DankOSD.qml +++ b/quickshell/Widgets/DankOSD.qml @@ -14,6 +14,8 @@ PanelWindow { property alias content: contentLoader.sourceComponent property alias contentLoader: contentLoader property var modelData + property string osdKind: "" + readonly property int osdPosition: SettingsData.osdPositionFor(osdKind) property bool shouldBeVisible: false property bool _surfaceFrameReady: false readonly property bool presented: shouldBeVisible && _surfaceFrameReady @@ -25,12 +27,15 @@ PanelWindow { property real surfaceRadius: Theme.fullRadius(alignedWidth, alignedHeight) property int animationDuration: Theme.mediumDuration property var animationEasing: Theme.emphasizedEasing + property Component sheet: null + property real sheetWidth: 0 + property bool expanded: false signal osdShown signal osdHidden function show() { - if (SessionData.suppressOSD) + if (SessionData.suppressOSD || expanded) return; if (shouldBeVisible) { hideTimer.restart(); @@ -45,10 +50,38 @@ PanelWindow { } function hide() { + if (expanded) { + closeSheet(); + return; + } shouldBeVisible = false; closeTimer.restart(); } + function expand() { + if (!sheet || !shouldBeVisible || expanded) + return; + hideTimer.stop(); + expanded = true; + } + + function closeSheet() { + if (sheetLoader.item) { + sheetLoader.item.close(); + return; + } + finishSheet(); + } + + function finishSheet() { + expanded = false; + shouldBeVisible = false; + hideTimer.stop(); + closeTimer.stop(); + visible = false; + osdHidden(); + } + function resetHideTimer() { if (shouldBeVisible) { hideTimer.restart(); @@ -101,6 +134,7 @@ PanelWindow { if (Quickshell.screens[i].name === currentScreenName) return; } + expanded = false; shouldBeVisible = false; visible = false; hideTimer.stop(); @@ -135,7 +169,7 @@ PanelWindow { readonly property real alignedWidth: Theme.px(osdWidth, dpr) readonly property real alignedHeight: Theme.px(osdHeight, dpr) - readonly property bool isVerticalLayout: SettingsData.osdPosition === SettingsData.Position.LeftCenter || SettingsData.osdPosition === SettingsData.Position.RightCenter + readonly property bool isVerticalLayout: osdPosition === SettingsData.Position.LeftCenter || osdPosition === SettingsData.Position.RightCenter readonly property var barEdgeOffsets: { const offsets = { @@ -187,58 +221,65 @@ PanelWindow { return SettingsData.dockReservationForEdge(screen, side); } - readonly property real alignedX: { - const margin = Theme.spacingM; - const centerX = (screenWidth - alignedWidth) / 2; + function edgeInset(side) { + return Theme.spacingM + Math.max(barEdgeOffsets[side], dockOffsetForEdge(side)); + } - switch (SettingsData.osdPosition) { - case SettingsData.Position.Left: - case SettingsData.Position.Bottom: - case SettingsData.Position.LeftCenter: - const leftDockOffset = root.dockOffsetForEdge("left"); - return Theme.snap(margin + Math.max(barEdgeOffsets.left, leftDockOffset), dpr); - case SettingsData.Position.Top: - case SettingsData.Position.Right: - case SettingsData.Position.RightCenter: - const rightDockOffset = root.dockOffsetForEdge("right"); - return Theme.snap(screenWidth - alignedWidth - margin - Math.max(barEdgeOffsets.right, rightDockOffset), dpr); - case SettingsData.Position.TopCenter: - case SettingsData.Position.BottomCenter: + function placeAlong(align, extent, size, startSide, endSide) { + switch (align) { + case -1: + return Theme.snap(edgeInset(startSide), dpr); + case 1: + return Theme.snap(extent - size - edgeInset(endSide), dpr); default: - return Theme.snap(centerX, dpr); + return Theme.snap((extent - size) / 2, dpr); } } - readonly property real alignedY: { - const margin = Theme.spacingM; - const centerY = (screenHeight - alignedHeight) / 2; + readonly property int alignX: { + switch (osdPosition) { + case SettingsData.Position.Left: + case SettingsData.Position.Bottom: + case SettingsData.Position.LeftCenter: + return -1; + case SettingsData.Position.Top: + case SettingsData.Position.Right: + case SettingsData.Position.RightCenter: + return 1; + default: + return 0; + } + } - switch (SettingsData.osdPosition) { + readonly property int alignY: { + switch (osdPosition) { case SettingsData.Position.Top: case SettingsData.Position.Left: case SettingsData.Position.TopCenter: - const topDockOffset = root.dockOffsetForEdge("top"); - return Theme.snap(margin + Math.max(barEdgeOffsets.top, topDockOffset), dpr); + return -1; case SettingsData.Position.Right: case SettingsData.Position.Bottom: case SettingsData.Position.BottomCenter: - const bottomDockOffset = root.dockOffsetForEdge("bottom"); - return Theme.snap(screenHeight - alignedHeight - margin - Math.max(barEdgeOffsets.bottom, bottomDockOffset), dpr); - case SettingsData.Position.LeftCenter: - case SettingsData.Position.RightCenter: + return 1; default: - return Theme.snap(centerY, dpr); + return 0; } } + readonly property real alignedX: placeAlong(alignX, screenWidth, alignedWidth, "left", "right") + readonly property real alignedY: placeAlong(alignY, screenHeight, alignedHeight, "top", "bottom") + anchors { top: true left: true } + readonly property real windowX: Math.max(0, Theme.snap(alignedX - shadowBuffer, dpr)) + readonly property real windowY: Math.max(0, Theme.snap(alignedY - shadowBuffer, dpr)) + WlrLayershell.margins { - left: Math.max(0, Theme.snap(alignedX - shadowBuffer, dpr)) - top: Math.max(0, Theme.snap(alignedY - shadowBuffer, dpr)) + left: root.windowX + top: root.windowY } implicitWidth: alignedWidth + (shadowBuffer * 2) @@ -266,6 +307,8 @@ PanelWindow { interval: autoHideInterval repeat: false onTriggered: { + if (expanded) + return; if (!enableMouseInteraction || !mouseArea.containsMouse) { hide(); } else { @@ -340,4 +383,15 @@ PanelWindow { mask: Region { item: bgShadowLayer } + + LazyLoader { + id: sheetLoader + active: root.expanded + + DankOSDSheet { + host: root + onPresented: root.visible = false + onDismissed: root.finishSheet() + } + } } diff --git a/quickshell/Widgets/DankOSDSheet.qml b/quickshell/Widgets/DankOSDSheet.qml new file mode 100644 index 000000000..1108820b7 --- /dev/null +++ b/quickshell/Widgets/DankOSDSheet.qml @@ -0,0 +1,316 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services +import "../Common/FluidGeometry.js" as FluidGeometry + +PanelWindow { + id: root + + required property var host + + property bool opened: false + property bool closing: false + property real shownSheetHeight: sheetHeight + + Behavior on shownSheetHeight { + enabled: root.opened && !root.closing && !morph.running && root.animationDuration > 0 && !SettingsData.reduceMotion + NumberAnimation { + duration: root.animationDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.variantPopoutResizeCurve + } + } + + readonly property real dpr: host.dpr + readonly property real shadowBuffer: host.shadowBuffer + readonly property int alignX: host.alignX + readonly property int alignY: host.alignY + readonly property rect compact: Qt.rect(host.alignedX, host.alignedY, host.alignedWidth, host.alignedHeight) + readonly property real insetTop: host.edgeInset("top") + readonly property real insetBottom: host.edgeInset("bottom") + readonly property real insetLeft: host.edgeInset("left") + readonly property real insetRight: host.edgeInset("right") + + readonly property real sheetWidth: Theme.px(Math.min(host.sheetWidth, host.screenWidth - insetLeft - insetRight), dpr) + readonly property real maxSheetHeight: { + switch (alignY) { + case -1: + return host.screenHeight - compact.y - insetBottom; + case 1: + return compact.y + compact.height - insetTop; + default: + return 2 * Math.min(compact.y + compact.height / 2 - insetTop, host.screenHeight - insetBottom - compact.y - compact.height / 2); + } + } + readonly property real sheetHeight: Theme.px(Math.min(maxSheetHeight, sheetBody.item?.implicitHeight ?? compact.height), dpr) + readonly property real sheetX: Math.max(insetLeft, Math.min(alongFrom(alignX, compact.x, compact.width, sheetWidth), host.screenWidth - insetRight - sheetWidth)) + readonly property real sheetY: alongFrom(alignY, compact.y, compact.height, shownSheetHeight) + + readonly property real bandHeight: Math.max(compact.height, maxSheetHeight) + readonly property real bandX: Math.min(compact.x, sheetX) + readonly property real bandY: alongFrom(alignY, compact.y, compact.height, bandHeight) + readonly property real bandRight: Math.max(compact.x + compact.width, sheetX + sheetWidth) + readonly property real originX: Math.max(0, Theme.snap(bandX - shadowBuffer, dpr)) + readonly property real originY: Math.max(0, Theme.snap(bandY - shadowBuffer, dpr)) + + readonly property rect compactLocal: Qt.rect(compact.x - originX, compact.y - originY, compact.width, compact.height) + readonly property rect sheetLocal: Qt.rect(Theme.snap(sheetX - originX, dpr), Theme.snap(sheetY - originY, dpr), sheetWidth, shownSheetHeight) + + readonly property int animationDuration: Theme.popoutAnimationDuration + readonly property var springParams: Theme.springPreset("default", animationDuration) + readonly property real morphTravelPx: Math.max(1, Math.abs(sheetLocal.x - compactLocal.x), Math.abs(sheetLocal.y - compactLocal.y), Math.abs(sheetLocal.width - compactLocal.width), Math.abs(sheetLocal.height - compactLocal.height)) + readonly property bool morphSettled: !morph.running || Math.abs(morph.value - morph.target) < Theme.morphLayerEpsilon + // A fully transparent surface renders no frames, so the spring would never advance to settle. + readonly property bool closeFinished: closing && (morphSettled || FluidGeometry.chromeOpacity(morph.value) <= 0) + readonly property real morphProgress: morphSettled ? morph.target : morph.value + readonly property real progress: Math.max(0, Math.min(1, morphProgress)) + readonly property rect body: FluidGeometry.limitOvershoot(morphRect(compactLocal, sheetLocal, morphProgress), sheetLocal, Theme.fluidOvershootLimit) + readonly property real surfaceRadius: host.surfaceRadius + (Theme.windowRadius - host.surfaceRadius) * progress + + signal presented + signal dismissed + + function alongFrom(align, start, size, target) { + switch (align) { + case -1: + return start; + case 1: + return start + size - target; + default: + return start + (size - target) / 2; + } + } + + function alignIn(align, outer, inner) { + switch (align) { + case -1: + return 0; + case 1: + return outer - inner; + default: + return (outer - inner) / 2; + } + } + + // FluidGeometry only bounds growth; a shrinking axis overshooting its target would crush the fixed-size content. + function morphRect(from, to, value) { + const grown = FluidGeometry.interpolate(from, to, value); + const held = FluidGeometry.interpolate(from, to, Math.min(1, value)); + const holdX = to.width < from.width; + const holdY = to.height < from.height; + return Qt.rect(holdX ? held.x : grown.x, holdY ? held.y : grown.y, holdX ? held.width : grown.width, holdY ? held.height : grown.height); + } + + function present() { + opened = true; + presented(); + morph.retarget(1); + keyScope.forceActiveFocus(); + } + + function close() { + if (closing) + return; + morph.retarget(0); + closing = true; + } + + onCloseFinishedChanged: { + if (closeFinished) + dismissed(); + } + + screen: host.screen + color: "transparent" + exclusiveZone: -1 + WlrLayershell.namespace: host.blurNamespace + WlrLayershell.layer: host.WlrLayershell.layer + WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(opened && !closing) + + anchors { + top: true + left: true + } + + WlrLayershell.margins { + left: root.originX + top: root.originY + } + + implicitWidth: Math.min(host.screenWidth - originX, Theme.snap(bandRight + shadowBuffer, dpr) - originX) + implicitHeight: Math.min(host.screenHeight - originY, Theme.snap(bandY + bandHeight + shadowBuffer, dpr) - originY) + + SpringMotion { + id: morph + enabled: !Theme.springMotionDisabled + reducedMotion: root.animationDuration <= 0 || SettingsData.reduceMotion + positionEpsilon: Math.max(0.001, 0.25 / root.dpr / root.morphTravelPx) + velocityEpsilon: positionEpsilon * damping / Math.max(0.001, 2 * mass) + stiffness: root.springParams.stiffness + damping: root.springParams.damping + + Component.onCompleted: snapTo(0) + } + + WindowBlur { + targetWindow: root + surfaceColor: Theme.withAlpha(root.host.surfaceColor, Theme.popupTransparency) + blurX: surface.x + blurY: surface.y + blurWidth: surface.opacity < 1 ? 0 : surface.width + blurHeight: surface.opacity < 1 ? 0 : surface.height + blurRadius: root.surfaceRadius + } + + DankFocusGrab { + windows: [root, dismissWindow].concat(KeyboardFocus.barWindows) + wanted: KeyboardFocus.wantsGrab(root.opened && !root.closing) + onCleared: root.close() + } + + Connections { + target: surface.Window.window + enabled: !root.opened + + function onFrameSwapped() { + root.present(); + } + } + + FocusScope { + id: keyScope + + anchors.fill: parent + Keys.onEscapePressed: root.close() + + MouseArea { + anchors.fill: parent + enabled: root.opened + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: root.close() + } + + Item { + id: surface + + x: root.body.x + y: root.body.y + width: root.body.width + height: root.body.height + opacity: root.closing ? FluidGeometry.chromeOpacity(morph.value) : 1 + + ElevationShadow { + anchors.fill: parent + z: -2 + level: Theme.elevationLevel2 + fallbackOffset: Theme.spacingXS + targetRadius: root.surfaceRadius + targetColor: Theme.withAlpha(root.host.surfaceColor, Theme.popupTransparency) + borderColor: Theme.outlineVariant + borderWidth: 0 + shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.AllButtons + onWheel: wheel => wheel.accepted = true + } + + Item { + anchors.fill: parent + clip: true + + Loader { + x: root.alignIn(root.alignX, parent.width, width) + y: root.alignIn(root.alignY, parent.height, height) + width: root.compact.width + height: root.compact.height + sourceComponent: root.host.content + opacity: root.closing ? 0 : 1 - FluidGeometry.chromeOpacity(morph.value) + visible: opacity > 0 + enabled: !root.opened + } + + Loader { + id: sheetBody + + x: root.alignIn(root.alignX, parent.width, width) + y: root.alignIn(root.alignY, parent.height, height) + width: root.sheetWidth + height: root.sheetHeight + sourceComponent: root.host.sheet + opacity: FluidGeometry.contentOpacity(root.body, root.sheetLocal) + visible: opacity > 0 + focus: true + } + } + } + } + + Connections { + target: sheetBody.item + ignoreUnknownSignals: true + + function onCloseRequested() { + root.close(); + } + } + + PanelWindow { + id: dismissWindow + + property bool mapped: false + + screen: root.screen + visible: root.opened && !root.closing + color: "transparent" + exclusiveZone: -1 + // Input only: after the first transparent frame maps it, repainting would damage the whole screen. + updatesEnabled: !mapped + WlrLayershell.namespace: root.host.blurNamespace + ":dismiss" + WlrLayershell.layer: root.WlrLayershell.layer + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + bottom: true + left: true + right: true + } + + mask: Region { + width: dismissWindow.width + height: dismissWindow.height + + Region { + x: root.originX + y: root.originY + width: root.width + height: root.height + intersection: Intersection.Subtract + } + } + + Connections { + target: dismissSurface.Window.window + enabled: !dismissWindow.mapped + + function onFrameSwapped() { + dismissWindow.mapped = true; + } + } + + MouseArea { + id: dismissSurface + + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: root.close() + } + } +}