diff --git a/modules/user/modules/quickshell/README.md b/modules/user/modules/quickshell/README.md new file mode 100644 index 0000000..26ade30 --- /dev/null +++ b/modules/user/modules/quickshell/README.md @@ -0,0 +1,13 @@ +## DO NOT STEAL BLINDLY + +You think the author's configuration is well formed and a good place to learn from? + +**Think Again.** + +This config has evolved over the lifetime of quickshell itself and my own learning of QML with +very little maintenance. If you are going to steal something please pay close attention to what +it is doing and why, because it may be a wrong or inefficient way of solving the problem. + +This will eventually undergo a refactor to be made presentable but it currently is not, and +should not be treated as a good place to learn from, which is why it isn't linked on any +quickshell pages. diff --git a/modules/user/modules/quickshell/default.nix b/modules/user/modules/quickshell/default.nix index 2fdd685..567db05 100644 --- a/modules/user/modules/quickshell/default.nix +++ b/modules/user/modules/quickshell/default.nix @@ -3,6 +3,7 @@ in { home.packages = with pkgs; [ qt6.qtimageformats # amog + qt6.qt5compat # shader fx quickshell.packages.${system}.default pamtester # lockscreen grim imagemagick # screenshot diff --git a/modules/user/modules/quickshell/shell/ReloadPopup.qml b/modules/user/modules/quickshell/shell/ReloadPopup.qml new file mode 100644 index 0000000..f1146ef --- /dev/null +++ b/modules/user/modules/quickshell/shell/ReloadPopup.qml @@ -0,0 +1,126 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell + +Scope { + id: root + property bool failed; + property string errorString; + + // Connect to the Quickshell global to listen for the reload signals. + Connections { + target: Quickshell + + function onReloadCompleted() { + root.failed = false; + popupLoader.loading = true; + } + + function onReloadFailed(error: string) { + // Close any existing popup before making a new one. + popupLoader.active = false; + + root.failed = true; + root.errorString = error; + popupLoader.loading = true; + } + } + + // Keep the popup in a loader because it isn't needed most of the timeand will take up + // memory that could be used for something else. + LazyLoader { + id: popupLoader + + PanelWindow { + id: popup + + anchors { + top: true + left: true + } + + margins { + top: 25 + left: 25 + } + + width: rect.width + height: rect.height + + // color blending is a bit odd as detailed in the type reference. + color: "transparent" + + Rectangle { + id: rect + color: failed ? "#40802020" : "#40009020" + + implicitHeight: layout.implicitHeight + 50 + implicitWidth: layout.implicitWidth + 30 + + // Fills the whole area of the rectangle, making any clicks go to it, + // which dismiss the popup. + MouseArea { + id: mouseArea + anchors.fill: parent + onClicked: popupLoader.active = false + + // makes the mouse area track mouse hovering, so the hide animation + // can be paused when hovering. + hoverEnabled: true + } + + ColumnLayout { + id: layout + anchors { + top: parent.top + topMargin: 20 + horizontalCenter: parent.horizontalCenter + } + + Text { + text: root.failed ? "Reload failed." : "Reloaded completed!" + color: "white" + } + + Text { + text: root.errorString + color: "white" + // When visible is false, it also takes up no space. + visible: root.errorString != "" + } + } + + // A progress bar on the bottom of the screen, showing how long until the + // popup is removed. + Rectangle { + id: bar + color: "#20ffffff" + anchors.bottom: parent.bottom + anchors.left: parent.left + height: 20 + + PropertyAnimation { + id: anim + target: bar + property: "width" + from: rect.width + to: 0 + duration: failed ? 10000 : 800 + onFinished: popupLoader.active = false + + // Pause the animation when the mouse is hovering over the popup, + // so it stays onscreen while reading. This updates reactively + // when the mouse moves on and off the popup. + paused: mouseArea.containsMouse + } + } + + // We could set `running: true` inside the animation, but the width of the + // rectangle might not be calculated yet, due to the layout. + // In the `Component.onCompleted` event handler, all of the component's + // properties and children have been initialized. + Component.onCompleted: anim.start() + } + } + } +} diff --git a/modules/user/modules/quickshell/shell/ShellGlobals.qml b/modules/user/modules/quickshell/shell/ShellGlobals.qml index 21822c4..030322b 100644 --- a/modules/user/modules/quickshell/shell/ShellGlobals.qml +++ b/modules/user/modules/quickshell/shell/ShellGlobals.qml @@ -7,11 +7,13 @@ Singleton { readonly property string rtpath: "/run/user/1000/quickshell" readonly property var colors: QtObject { - readonly property var bar: "#30c0ffff"; - readonly property var barOutline: "#50ffffff"; - readonly property var widget: "#40ceffff"; - readonly property var widgetOutline: "#60ffffff"; - readonly property var separator: "#60ffffff"; + readonly property color bar: "#30c0ffff"; + readonly property color barOutline: "#50ffffff"; + readonly property color widget: "#25ceffff"; + readonly property color widgetActive: "#80ceffff"; + readonly property color widgetOutline: "#40ffffff"; + readonly property color widgetOutlineSeparate: "#20ffffff"; + readonly property color separator: "#60ffffff"; } readonly property var popoutXCurve: EasingCurve { @@ -31,4 +33,9 @@ Singleton { onTriggered: time = new Date() } + + function interpolateColors(x: real, a: color, b: color): color { + const xa = 1.0 - x; + return Qt.rgba(a.r * xa + b.r * x, a.g * xa + b.g * x, a.b * xa + b.b * x, a.a * xa + b.a * x); + } } diff --git a/modules/user/modules/quickshell/shell/Shortcut.qml b/modules/user/modules/quickshell/shell/Shortcut.qml new file mode 100644 index 0000000..aea3c7a --- /dev/null +++ b/modules/user/modules/quickshell/shell/Shortcut.qml @@ -0,0 +1,5 @@ +import Quickshell.Hyprland + +GlobalShortcut { + appid: "shell" +} diff --git a/modules/user/modules/quickshell/shell/bar/Bar.qml b/modules/user/modules/quickshell/shell/bar/Bar.qml index 70e06a3..99acc26 100644 --- a/modules/user/modules/quickshell/shell/bar/Bar.qml +++ b/modules/user/modules/quickshell/shell/bar/Bar.qml @@ -1,16 +1,46 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls +import Quickshell import "systray" as SysTray +import "audio" as Audio +import "mpris" as Mpris +import "workspaces" as Workspaces BarContainment { id: root + + property bool isSoleBar: Quickshell.screens.length == 1; + ColumnLayout { anchors { left: parent.left right: parent.right top: parent.top } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Loader { + active: isSoleBar + Layout.preferredHeight: active ? implicitHeight : 0; + Layout.fillWidth: true + + sourceComponent: Workspaces.Widget { + bar: root + wsBaseIndex: 1 + } + } + + Workspaces.Widget { + bar: root + Layout.fillWidth: true + wsBaseIndex: root.screen.name == "eDP-1" ? 11 : 1; + hideWhenEmpty: isSoleBar + } + } } ColumnLayout { @@ -20,10 +50,19 @@ BarContainment { bottom: parent.bottom } + Mpris.Players { + bar: root + Layout.fillWidth: true + } + + Audio.AudioControls { + bar: root + Layout.fillWidth: true + } + SysTray.SysTray { bar: root Layout.fillWidth: true - //width: 24 } ClockWidget { diff --git a/modules/user/modules/quickshell/shell/bar/BarContainment.qml b/modules/user/modules/quickshell/shell/bar/BarContainment.qml index 758c621..cb4343f 100644 --- a/modules/user/modules/quickshell/shell/bar/BarContainment.qml +++ b/modules/user/modules/quickshell/shell/bar/BarContainment.qml @@ -1,14 +1,13 @@ import QtQuick import Quickshell import Quickshell.Wayland -import ".." +import "root:." +import "root:lock" as Lock PanelWindow { id: root - default property list widgetSurfaceData; - readonly property var widgetSurface: widgetSurface; - property list overlays: []; + default property alias barItems: containment.data; anchors { left: true @@ -17,9 +16,25 @@ PanelWindow { } width: 70 + margins.left: Lock.Controller.locked ? -width : 0 + exclusiveZone: width - margins.left + color: "transparent" WlrLayershell.namespace: "shell:bar" + + readonly property var tooltip: tooltip; + Tooltip { + id: tooltip + bar: root + } + + readonly property real tooltipXOffset: root.width + 2; + + function boundedY(targetY: real, height: real): real { + return Math.max(barRect.anchors.topMargin + height, Math.min(barRect.height + barRect.anchors.topMargin - height, targetY)) + } + Rectangle { id: barRect @@ -43,72 +58,4 @@ PanelWindow { } } } - - // note: must be above the widgetSurface due to reload order - PersistentProperties { - id: persist - reloadableId: "persist" - - property bool visible: false - } - - onBackingWindowVisibleChanged: { - persist.visible = Qt.binding(() => backingWindowVisible); - } - - PanelWindow { - id: widgetSurface - reloadableId: "widgetSurface" - - visible: persist.visible - anchors: root.anchors - screen: root.screen - exclusionMode: ExclusionMode.Ignore - WlrLayershell.namespace: "shell:bar" - WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand - color: "transparent" - - width: { - const extents = overlays - .filter(overlay => !overlay.fullyCollapsed) - .map(overlay => overlayXOffset + overlay.expandedWidth); - - return Math.max(root.width, ...extents); - } - - readonly property real overlayXOffset: root.width + 10; - readonly property real tooltipXOffset: root.width + 2; - - function overlayRect(targetY: real, size: rect): rect { - const y = Math.max(barRect.y, Math.min((barRect.y + barRect.height) - size.height, targetY)); - return Qt.rect(overlayXOffset, y, size.width, size.height); - } - - function boundedY(targetY: real, height: real): real { - return Math.max(0, Math.min(barRect.height - height, targetY)) - } - - Item { - id: contentArea - data: widgetSurfaceData - } - - readonly property var tooltip: tooltip; - Tooltip { - id: tooltip - bar: root - } - - function repositionContentArea() { - // abusing the knowledge that both bars are in the same position onscreen - const contentRect = containment.mapToItem(root.contentItem, 0, 0, containment.width, containment.height) - - contentArea.x = contentRect.x - contentArea.y = contentRect.y - contentArea.width = contentRect.width - contentArea.height = contentRect.height - } - } - - onWindowTransformChanged: widgetSurface.repositionContentArea() } diff --git a/modules/user/modules/quickshell/shell/bar/Tooltip.qml b/modules/user/modules/quickshell/shell/bar/Tooltip.qml index 91b4a22..31c7171 100644 --- a/modules/user/modules/quickshell/shell/bar/Tooltip.qml +++ b/modules/user/modules/quickshell/shell/bar/Tooltip.qml @@ -12,9 +12,21 @@ Scope { readonly property TooltipItem activeItem: activeMenu ?? activeTooltip; property TooltipItem lastActiveItem: null; + property Item tooltipItem: null; + onActiveItemChanged: { - if (activeItem != null) activeItem.visible = true; - if (lastActiveItem != null) lastActiveItem.visible = false; + if (activeItem != null) { + activeItem.targetVisible = true; + + if (tooltipItem) { + activeItem.parent = tooltipItem; + } + } + + if (lastActiveItem != null && lastActiveItem != activeItem) { + lastActiveItem.targetVisible = false; + } + lastActiveItem = activeItem; } @@ -40,21 +52,22 @@ Scope { PopupWindow { id: popup - parentWindow: bar.widgetSurface - relativeX: bar.widgetSurface.tooltipXOffset + parentWindow: bar + relativeX: bar.tooltipXOffset relativeY: 0 - height: bar.widgetSurface.height - width: tooltipItem.width + height: bar.height + width: 1000//Math.max(1, widthAnim.running ? Math.max(tooltipItem.targetWidth, tooltipItem.lastTargetWidth) : tooltipItem.targetWidth) visible: true color: "transparent" + //color: "#20000000" mask: Region { - item: (activeItem?.isMenu ?? false) ? tooltipItem : null + item: (activeItem?.hoverable ?? false) ? tooltipItem : null } HyprlandFocusGrab { active: activeItem?.isMenu ?? false - windows: [ popup, bar.widgetSurface ] + windows: [ popup, bar ] onActiveChanged: { if (!active && activeItem?.isMenu) { activeMenu.close() @@ -62,19 +75,34 @@ Scope { } } - BarWidgetInner { + Item { id: tooltipItem + Component.onCompleted: { + root.tooltipItem = this; + if (root.activeItem) { + root.activeItem.parent = this; + } + } - readonly property var targetWidth: activeItem?.implicitWidth ?? 10; - readonly property var targetHeight: (activeItem?.implicitHeight ?? 0) + 10; + readonly property var targetWidth: activeItem?.implicitWidth ?? 0; + readonly property var targetHeight: activeItem?.implicitHeight ?? 0; + + property var lastTargetWidthTracker: 0; + property var lastTargetWidth: 0; + + onTargetWidthChanged: { + lastTargetWidth = lastTargetWidthTracker; + lastTargetWidthTracker = targetWidth; + } readonly property real targetY: { if (activeItem == null) return 0; - const target = bar.widgetSurface.contentItem.mapFromItem(activeItem.owner, 0, activeItem.targetRelativeY).y; - return bar.widgetSurface.boundedY(target, activeItem.implicitHeight / 2); + const target = bar.contentItem.mapFromItem(activeItem.owner, 0, activeItem.targetRelativeY).y; + return bar.boundedY(target, activeItem.implicitHeight / 2); } - width: targetWidth + 10 + property var w: -1 + width: Math.max(1, w) property var y1: -1 property var y2: -1 @@ -112,13 +140,19 @@ Scope { } } - Item { - clip: true - children: [ activeItem ] - - anchors { - fill: parent - margins: 5 + SmoothedAnimation { + id: widthAnim + target: tooltipItem + property: "w" + to: tooltipItem.targetWidth; + onToChanged: { + if (tooltipItem.w == -1) { + stop(); + tooltipItem.w = to; + } else { + velocity = (Math.max(tooltipItem.width, to) - Math.min(tooltipItem.width, to)) * 5; + restart(); + } } } } diff --git a/modules/user/modules/quickshell/shell/bar/TooltipItem.qml b/modules/user/modules/quickshell/shell/bar/TooltipItem.qml index 1733d5d..aadb179 100644 --- a/modules/user/modules/quickshell/shell/bar/TooltipItem.qml +++ b/modules/user/modules/quickshell/shell/bar/TooltipItem.qml @@ -1,19 +1,29 @@ import QtQuick import Quickshell +import "root:/" Item { id: root required property var tooltip; required property Item owner; property bool isMenu: false; + property bool hoverable: isMenu; property bool animateSize: true; property bool show: false; + property bool preloadBackground: root.visible; property real targetRelativeY: owner.height / 2; property real hangTime: isMenu ? 0 : 200; signal close(); + default property alias data: contentItem.data; + + property Component backgroundComponent: BarWidgetInner { + color: ShellGlobals.colors.bar + anchors.fill: parent + } + onShowChanged: { if (show) { hangTimer.stop(); @@ -28,4 +38,60 @@ Item { interval: hangTime onTriggered: tooltip.removeItem(root); } + + property bool targetVisible: false + property real targetOpacity: 0 + opacity: targetOpacity / 1000 + + Behavior on targetOpacity { + id: opacityAnimation + SmoothedAnimation { velocity: 5000 } + } + + function snapOpacity(opacity: real) { + opacityAnimation.enabled = false; + targetOpacity = opacity * 1000 + opacityAnimation.enabled = true; + } + + onTargetVisibleChanged: { + if (targetVisible) { + visible = true; + targetOpacity = 1000; + } else { + close() + targetOpacity = 0; + } + } + + onTargetOpacityChanged: { + if (!targetVisible && targetOpacity == 0) { + visible = false; + this.parent = null; + } + } + + anchors.fill: parent + visible: false + clip: true + implicitHeight: contentItem.implicitHeight + 10 + implicitWidth: contentItem.implicitWidth + 10 + + readonly property Item item: contentItem; + + Loader { + anchors.fill: parent + active: root.visible || root.preloadBackground + asynchronous: !root.visible && root.preloadBackground + sourceComponent: backgroundComponent + } + + Item { + id: contentItem + anchors.fill: parent + anchors.margins: 5 + + implicitHeight: childrenRect.height + implicitWidth: childrenRect.width + } } diff --git a/modules/user/modules/quickshell/shell/bar/audio/AudioControl.qml b/modules/user/modules/quickshell/shell/bar/audio/AudioControl.qml new file mode 100644 index 0000000..525b895 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/audio/AudioControl.qml @@ -0,0 +1,78 @@ +import QtQuick +import Quickshell.Services.Pipewire +import ".." + +ClickableIcon { + id: root + required property var bar; + required property PwNode node; + property bool mixerOpen: false; + + PwObjectTracker { objects: [ node ] } + + implicitHeight: width; + acceptedButtons: Qt.LeftButton | Qt.RightButton; + showPressed: mixerOpen + + onClicked: event => { + event.accepted = true; + if (event.button === Qt.LeftButton) { + node.audio.muted = !node.audio.muted; + } else if (event.button === Qt.RightButton) { + mixerOpen = !mixerOpen; + } + } + + onWheel: event => { + event.accepted = true; + node.audio.volume += (event.angleDelta.y / 120) * 0.05 + } + + property var tooltip: TooltipItem { + tooltip: bar.tooltip + owner: root + + show: root.containsMouse || mouseArea.containsMouse + hoverable: true + + MouseArea { + id: mouseArea + hoverEnabled: true + acceptedButtons: Qt.NoButton + + implicitWidth: childrenRect.width + implicitHeight: childrenRect.height + + VolumeSlider { + implicitWidth: 200 + implicitHeight: root.height + + //enabled: !node.audio.muted + value: node.audio.volume + onValueChanged: node.audio.volume = value + } + } + } + + property var rightclickMenu: TooltipItem { + tooltip: bar.tooltip + owner: root + + isMenu: true + show: mixerOpen + + onClose: mixerOpen = false + /*onVisibleChanged: { + if (!visible) mixerOpen = false; + }*/ + + Loader { + active: rightclickMenu.visible + sourceComponent: Mixer { + width: 550 + trackedNode: node + nodeImage: root.image + } + } + } +} diff --git a/modules/user/modules/quickshell/shell/bar/audio/AudioControls.qml b/modules/user/modules/quickshell/shell/bar/audio/AudioControls.qml new file mode 100644 index 0000000..3b1bd27 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/audio/AudioControls.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import ".." + +BarWidgetInner { + id: root + required property var bar; + implicitHeight: column.implicitHeight + 10; + + ColumnLayout { + anchors { + fill: parent; + margins: 5; + } + + id: column; + implicitHeight: childrenRect.height; + spacing: 5; + + Loader { + Layout.fillWidth: true; + active: Pipewire.defaultAudioSink != null; + + sourceComponent: AudioControl { + bar: root.bar; + node: Pipewire.defaultAudioSink; + image: `image://icon/${node.audio.muted ? "audio-volume-muted-symbolic" : "audio-volume-high-symbolic"}` + } + } + + Loader { + Layout.fillWidth: true; + active: Pipewire.defaultAudioSource != null; + + sourceComponent: AudioControl { + bar: root.bar; + node: Pipewire.defaultAudioSource; + image: `image://icon/${node.audio.muted ? "microphone-sensitivity-muted-symbolic" : "microphone-sensitivity-high-symbolic"}` + } + } + } +} diff --git a/modules/user/modules/quickshell/shell/bar/audio/Mixer.qml b/modules/user/modules/quickshell/shell/bar/audio/Mixer.qml new file mode 100644 index 0000000..ab11b22 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/audio/Mixer.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Pipewire +import ".." +import "../.." + +ColumnLayout { + required property PwNode trackedNode; + required property string nodeImage; + + PwNodeLinkTracker { + id: linkTracker + node: trackedNode + } + + PwObjectTracker { objects: [ trackedNode, ...linkTracker.linkGroups ] } + + MixerEntry { + id: nodeEntry + node: trackedNode + image: nodeImage + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + visible: linkTracker.linkGroups.length > 0 + + color: ShellGlobals.colors.separator + } + + Repeater { + model: linkTracker.linkGroups + + MixerEntry { + required property PwLinkGroup modelData; + node: trackedNode.isSink ? modelData.source : modelData.target; + state: modelData.state; + + image: { + let icon = ""; + let props = node.properties; + if (props["application.icon-name"] != undefined) { + icon = props["application.icon-name"]; + } else if (props["application.process.binary"] != undefined) { + icon = props["application.process.binary"]; + } + + // special cases :( + if (icon == "firefox") icon = "firefox-devedition"; + + return `image://icon/${icon}` + } + } + } +} diff --git a/modules/user/modules/quickshell/shell/bar/audio/MixerEntry.qml b/modules/user/modules/quickshell/shell/bar/audio/MixerEntry.qml new file mode 100644 index 0000000..ec78ec0 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/audio/MixerEntry.qml @@ -0,0 +1,51 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Pipewire +import ".." + +RowLayout { + id: root + required property PwNode node; + required property string image; + property int state: PwLinkState.Unlinked; + + PwObjectTracker { objects: [ node ] } + + ClickableIcon { + image: root.image + asynchronous: true + implicitHeight: 40 + implicitWidth: height + } + + ColumnLayout { + RowLayout { + Item { + implicitHeight: title.implicitHeight + Layout.fillWidth: true + + Text { + id: title + color: "white" + anchors.fill: parent + elide: Text.ElideRight + text: { + const name = node.properties["application.name"] ?? (node.description == "" ? node.name : node.description); + const mediaName = node.properties["media.name"]; + + return mediaName != undefined ? `${name} - ${mediaName}` : name; + } + } + } + } + + VolumeSlider { + //Layout.fillHeight: true + Layout.fillWidth: true + implicitWidth: 200 + + value: node.audio.volume + onValueChanged: node.audio.volume = value + } + } +} diff --git a/modules/user/modules/quickshell/shell/bar/audio/VolumeSlider.qml b/modules/user/modules/quickshell/shell/bar/audio/VolumeSlider.qml new file mode 100644 index 0000000..1e22f73 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/audio/VolumeSlider.qml @@ -0,0 +1,109 @@ +import QtQuick +import QtQuick.Shapes + +Item { + id: root + property real from: 0.0 + property real to: 1.5 + property real warning: 1.0 + property real value: 0.0 + + implicitWidth: groove.implicitWidth + implicitHeight: 20 + + property real __valueOffset: ((value - from) / (to - from)) * groove.width + property real __wheelValue: -1 + + MouseArea { + id: mouseArea + anchors.fill: parent + + Rectangle { + id: grooveWarning + + anchors { + left: groove.left + leftMargin: ((warning - from) / (to - from)) * groove.width + right: groove.right + top: groove.top + bottom: groove.bottom + } + + color: "#60ffa800" + topRightRadius: 5 + bottomRightRadius: 5 + } + + Rectangle { + anchors { + top: groove.bottom + horizontalCenter: grooveWarning.left + } + + color: "#60eeffff" + width: 1 + height: groove.height + } + + Rectangle { + id: grooveFill + + anchors { + left: groove.left + top: groove.top + bottom: groove.bottom + } + + radius: 5 + color: "#80ceffff" + width: __valueOffset + } + + Rectangle { + id: groove + + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + + implicitHeight: 7 + color: "transparent" + border.color: "#20050505" + border.width: 1 + radius: 5 + } + + Rectangle { + id: handle + anchors.verticalCenter: groove.verticalCenter + height: 15 + width: height + radius: height * 0.5 + x: __valueOffset - width * 0.5 + } + + onWheel: event => { + event.accepted = true; + __wheelValue = value + (event.angleDelta.y / 120) * 0.05 + __wheelValue = -1 + } + } + + Binding { + when: mouseArea.pressed + target: root + property: "value" + value: (mouseArea.mouseX / width) * (to - from) + from + restoreMode: Binding.RestoreBinding + } + + Binding { + when: __wheelValue != -1 + target: root + property: "value" + value: __wheelValue + restoreMode: Binding.RestoreBinding + } +} diff --git a/modules/user/modules/quickshell/shell/bar/audio/volume_off.svg b/modules/user/modules/quickshell/shell/bar/audio/volume_off.svg new file mode 100644 index 0000000..aeff292 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/audio/volume_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/user/modules/quickshell/shell/bar/audio/volume_up.svg b/modules/user/modules/quickshell/shell/bar/audio/volume_up.svg new file mode 100644 index 0000000..0d394c0 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/audio/volume_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/user/modules/quickshell/shell/bar/mpris/BackgroundArt.qml b/modules/user/modules/quickshell/shell/bar/mpris/BackgroundArt.qml new file mode 100644 index 0000000..b80994e --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/mpris/BackgroundArt.qml @@ -0,0 +1,161 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import ".." +import "../.." + +BarWidgetInner { + id: root + border.color: "transparent" + + property real renderWidth: width + property real renderHeight: height + + property real blurRadius: 20; + property real blurSamples: 41; + + property bool reverse: false; + + function setArt(art: string, reverse: bool, immediate: bool) { + this.reverse = reverse; + + if (art.length == 0) { + stack.replace(null); + } else { + stack.replace(component, { uri: art }, immediate) + } + } + + property var component: Component { + Item { + id: componentRoot + property var uri: null; + readonly property bool svReady: image.status === Image.Ready; + + Image { + id: image + anchors.centerIn: parent; + source: uri; + cache: false; + asynchronous: true; + + fillMode: Image.PreserveAspectCrop; + sourceSize.width: width; + sourceSize.height: height; + width: stack.width + blurRadius * 2; + height: stack.height + blurRadius * 2; + } + + property Component blurComponent: Item { + id: blur + //parent: blurContainment + // blur into the neighboring elements if applicable + x: componentRoot.x - blurRadius * 4 + y: componentRoot.y + image.y + width: componentRoot.width + blurRadius * 8 + height: image.height + + onVisibleChanged: { + if (visible) blurSource.scheduleUpdate(); + } + + Connections { + target: image + function onStatusChanged() { + if (image.status == Image.Ready) { + blurSource.scheduleUpdate(); + } + } + } + + ShaderEffectSource { + id: blurSource + sourceItem: stack + sourceRect: Qt.rect(blur.x, blur.y, blur.width, blur.height); + live: false + anchors.fill: parent + visible: false + } + + Item { + x: blurRadius + width: blur.width - blurRadius * 2 + height: blur.height + clip: true + GaussianBlur { + source: blurSource + x: -parent.x + width: blur.width + height: blur.height + radius: root.blurRadius + samples: root.blurSamples + visible: true + } + } + } + + // Weird crash if the blur is not owned by its visual parent, + // so it has to be a component. + property Item blur: blurComponent.createObject(blurContainment); + Component.onDestruction: blur.destroy(); + } + } + + SlideView { + id: stack; + height: renderHeight + width: renderWidth + anchors.centerIn: parent; + visible: false; + animate: root.visible; + + readonly property real fromPos: (stack.width + blurRadius * 2) * (reverse ? -1 : 1); + + enterTransition: PropertyAnimation { + property: "x" + from: stack.fromPos + to: 0 + duration: 400 + easing.type: Easing.OutExpo; + } + + exitTransition: PropertyAnimation { + property: "x" + to: -stack.fromPos + duration: 400 + easing.type: Easing.OutExpo; + } + } + + Item { + id: blurContainment + x: stack.x + y: stack.y + width: stack.width + height: stack.height + } + + readonly property Rectangle overlay: overlayItem; + Rectangle { + id: overlayItem + visible: false + anchors.fill: parent + border.color: ShellGlobals.colors.widgetOutlineSeparate + border.width: 0//1 + radius: 0//root.radius + color: "transparent" + } + + // slightly offset on the corners :/ + layer.enabled: true + layer.effect: ShaderEffect { + fragmentShader: "radial_clip.frag.qsb" + // +1 seems to match Rectangle + property real radius: root.radius + 1 + property size size: Qt.size(root.width, root.height) + property real borderWidth: 1//.5 + property color borderColor: ShellGlobals.colors.widgetOutlineSeparate//"#ffff0000" + property color tint: overlayItem.color + } +} diff --git a/modules/user/modules/quickshell/shell/bar/mpris/MediaSlider.qml b/modules/user/modules/quickshell/shell/bar/mpris/MediaSlider.qml new file mode 100644 index 0000000..93f1162 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/mpris/MediaSlider.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Templates as T + +T.Slider { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitHandleWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + implicitHandleHeight + topPadding + bottomPadding) + + background: Rectangle { + x: control.leftPadding + y: control.topPadding + control.availableHeight / 2 - height / 2 + implicitWidth: 200 + implicitHeight: 7 + width: control.availableWidth + height: implicitHeight + + radius: 5 + color: "#30ceffff" + border.width: 0 + + Rectangle { + anchors { + top: parent.top + bottom: parent.bottom + } + + width: control.handle.x + (control.handle.width / 2) - parent.x + radius: parent.radius + color: "#80ceffff" + } + } + + handle: Rectangle { + x: control.leftPadding + control.visualPosition * (control.availableWidth - width) + y: control.topPadding + control.availableHeight / 2 - height / 2 + implicitWidth: 16 + implicitHeight: 16 + radius: 8 + } +} diff --git a/modules/user/modules/quickshell/shell/bar/mpris/MprisController.qml b/modules/user/modules/quickshell/shell/bar/mpris/MprisController.qml new file mode 100644 index 0000000..4a54ac3 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/mpris/MprisController.qml @@ -0,0 +1,168 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Services.Mpris +import Quickshell.Hyprland +import "../.." + +Singleton { + id: root; + property MprisPlayer trackedPlayer: null; + property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null; + signal trackChanged(reverse: bool); + + property bool __reverse: false; + + property var activeTrack; + Component.onCompleted: { + for (const player of Mpris.players.values) { + if (player.playbackState == MprisPlaybackState.Playing) { + if (root.trackedPlayer == null) { + root.trackedPlayer = player; + } + } + + player.playbackStateChanged.connect(() => { + if (root.trackedPlayer !== player) root.trackedPlayer = player; + }); + } + } + + Connections { + target: activePlayer + + function onTrackChanged() { + root.updateTrack(); + } + } + + // Change the tracked player when one changes playback state or is created in a playing state. + Connections { + target: Mpris.players; + + function onObjectInsertedPost(player: MprisPlayer) { + if (player.playbackState === MprisPlaybackState.Playing) { + if (root.trackedPlayer !== player) root.trackedPlayer = player; + } + + player.playbackStateChanged.connect(() => { + if (root.trackedPlayer !== player) root.trackedPlayer = player; + }); + } + + function onObjectRemovedPre() { + console.log(`trackedPlayer: ${root.trackedPlayer}`) + if (root.trackedPlayer == null) { + for (const player of Mpris.players.values) { + if (player.playbackState === MprisPlaybackState.Playing) { + root.trackedPlayer = player; + break; + } + } + } + } + } + + onActivePlayerChanged: this.updateTrack(); + + function updateTrack() { + const metadata = this.activePlayer?.metadata ?? {}; + + this.activeTrack = { + artUrl: metadata["mpris:artUrl"] ?? "", + title: metadata["xesam:title"] ?? "", + artist: metadata["xesam:artist"] ?? "", + }; + + this.trackChanged(__reverse); + this.__reverse = false; + } + + property bool isPlaying: this.activePlayer && this.activePlayer.playbackState == MprisPlaybackState.Playing; + property bool canPlay: this.activePlayer?.canPlay ?? false; + function play() { + if (this.canPlay) this.activePlayer.playbackState = MprisPlaybackState.Playing; + } + + property bool canPause: this.activePlayer?.canPause ?? false; + function pause() { + if (this.canPause) this.activePlayer.playbackState = MprisPlaybackState.Paused; + } + + property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false; + function previous() { + if (this.canGoPrevious) { + this.__reverse = true; + this.activePlayer.previous(); + } + } + + property bool canGoNext: this.activePlayer?.canGoNext ?? false; + function next() { + if (this.canGoNext) { + this.__reverse = false; + this.activePlayer.next(); + } + } + + property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl; + + property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl; + property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None; + function setLoopState(loopState: var) { + if (this.loopSupported) { + this.activePlayer.loopState = loopState; + } + } + + property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl; + property bool hasShuffle: this.activePlayer?.shuffle ?? false; + function setShuffle(shuffle: bool) { + if (this.shuffleSupported) { + this.activePlayer.shuffle = shuffle; + } + } + + function setActivePlayer(player: MprisPlayer) { + const targetPlayer = player ?? MprisPlayer.players[0]; + console.log(`setactive: ${targetPlayer} from ${activePlayer}`) + + if (targetPlayer && this.activePlayer) { + this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer); + } else { + // always animate forward if going to null + this.__reverse = false; + } + + this.trackedPlayer = targetPlayer; + } + + Shortcut { + name: "music-pauseall"; + onPressed: { + for (let i = 0; i < Mpris.players.length; i++) { + const player = Mpris.players[i]; + if (player.canPause) player.playbackState = MprisPlaybackState.Paused; + } + } + } + + Shortcut { + name: "music-playpause"; + onPressed: { + if (root.isPlaying) root.pause(); + else root.play(); + } + } + + Shortcut { + name: "music-previous"; + onPressed: root.previous(); + } + + Shortcut { + name: "music-next"; + onPressed: root.next(); + } +} diff --git a/modules/user/modules/quickshell/shell/bar/mpris/Player.qml b/modules/user/modules/quickshell/shell/bar/mpris/Player.qml new file mode 100644 index 0000000..4f771ea --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/mpris/Player.qml @@ -0,0 +1,7 @@ +import Quickshell +import Quickshell.Services.Mpris + +Scope { + required property MprisPlayer player; + +} diff --git a/modules/user/modules/quickshell/shell/bar/mpris/PlayerPopup.qml b/modules/user/modules/quickshell/shell/bar/mpris/PlayerPopup.qml new file mode 100644 index 0000000..de82804 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/mpris/PlayerPopup.qml @@ -0,0 +1,2 @@ +import QtQuick +import diff --git a/modules/user/modules/quickshell/shell/bar/mpris/Players.qml b/modules/user/modules/quickshell/shell/bar/mpris/Players.qml new file mode 100644 index 0000000..fc914e6 --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/mpris/Players.qml @@ -0,0 +1,527 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Services.Mpris +import ".." +import "../.." + +MouseArea { + id: root + hoverEnabled: true + + required property var bar; + implicitHeight: column.implicitHeight + 10 + + PersistentProperties { + id: persist + reloadableId: "MusicWidget"; + property bool widgetOpen: false; + + onReloaded: { + rightclickMenu.snapOpacity(widgetOpen ? 1.0 : 0.0) + } + } + + property alias widgetOpen: persist.widgetOpen; + + acceptedButtons: Qt.RightButton + onClicked: widgetOpen = !widgetOpen + + onWheel: event => { + event.accepted = true; + if (MprisController.canChangeVolume) { + this.activePlayer.volume = Math.max(0, Math.min(1, this.activePlayer.volume + (event.angleDelta.y / 120) * 0.05)); + } + } + + readonly property var activePlayer: MprisController.activePlayer + + Item { + id: widget + anchors.fill: parent + + property real scaleMul: root.pressed || widgetOpen ? 100 : 1 + Behavior on scaleMul { SmoothedAnimation { velocity: 600 } } + scale: scaleCurve.interpolate(scaleMul / 100, 1, (width - 6) / width) + + EasingCurve { + id: scaleCurve + curve.type: Easing.Linear + } + + implicitHeight: column.implicitHeight + 10 + + BackgroundArt { + id: bkg + anchors.fill: parent + + function updateArt(reverse: bool) { + this.setArt(MprisController.activeTrack.artUrl, reverse, false) + } + + Component.onCompleted: this.updateArt(false); + + Connections { + target: MprisController + + function onTrackChanged(reverse: bool) { + bkg.updateArt(reverse); + } + } + } + + ColumnLayout { + id: column + + anchors { + fill: parent + margins: 5; + } + + ClickableIcon { + Layout.fillWidth: true + image: "root:icons/rewind.svg" + implicitHeight: width + scaleIcon: false + baseMargin: 3 + enabled: MprisController.canGoPrevious; + onClicked: MprisController.previous(); + } + + ClickableIcon { + Layout.fillWidth: true + image: `root:icons/${MprisController.isPlaying ? "pause" : "play"}.svg`; + implicitHeight: width + scaleIcon: false + enabled: MprisController.isPlaying ? MprisController.canPause : MprisController.canPlay; + onClicked: { + if (MprisController.isPlaying) MprisController.pause(); + else MprisController.play(); + } + } + + ClickableIcon { + Layout.fillWidth: true + image: "root:icons/fast-forward.svg" + implicitHeight: width + scaleIcon: false + baseMargin: 3 + enabled: MprisController.canGoNext; + onClicked: MprisController.next(); + } + } + + property var tooltip: TooltipItem { + id: tooltip + tooltip: bar.tooltip + owner: root + + show: root.containsMouse && (activePlayer?.metadata["mpris:trackid"] ?? false) + + //implicitHeight: root.height - 10 + //implicitWidth: childrenRect.width + + Item { + implicitWidth: 200 + implicitHeight: 100 + } + + /*Loader { + active: tooltip.visible + + sourceComponent: ColumnLayout { + height: root.height - 10 + RowLayout { + Image { + Layout.fillHeight: true + source: mainPlayer.metadata["mpris:artUrl"] ?? "" + + cache: false + fillMode: Image.PreserveAspectCrop + sourceSize.width: height + sourceSize.height: height + } + Label { + text: mainPlayer.identity + } + } + + Slider { + Layout.fillWidth: true + } + } + }*/ + } + + property var rightclickMenu: TooltipItem { + id: rightclickMenu + tooltip: bar.tooltip + owner: root + + isMenu: true + show: widgetOpen + onClose: widgetOpen = false + + // some very large covers take a sec to appear in the background, + // so we'll try to preload them. + preloadBackground: root.containsMouse + + backgroundComponent: BackgroundArt { + id: popupBkg + anchors.fill: parent + renderHeight: rightclickMenu.implicitHeight + renderWidth: rightclickMenu.implicitWidth + blurRadius: 100 + blurSamples: 201 + + overlay.color: "#80000000" + + Connections { + target: MprisController + + function onTrackChanged(reverse: bool) { + popupBkg.setArt(MprisController.activeTrack.artUrl, reverse, false); + } + } + + Component.onCompleted: { + setArt(MprisController.activeTrack.artUrl, false, true); + } + } + + Loader { + width: 500 + height: 650 + active: rightclickMenu.visible + + sourceComponent: ColumnLayout { + property var player: activePlayer; + anchors.fill: parent; + + property int position: 0; + property int length: 0; + + FrameAnimation { + id: posTracker; + running: player.playbackState == MprisPlaybackState.Playing && widgetOpen; + onTriggered: player.positionChanged(); + } + + Connections { + target: player + + function onPositionChanged() { + const newPosition = Math.floor(player.position); + if (newPosition != position) position = newPosition; + } + + function onLengthChanged() { + const newLength = Math.floor(player.length); + if (newLength != length) length = newLength; + } + } + + Connections { + target: MprisController + + function onTrackChanged(reverse: bool) { + trackStack.updateTrack(reverse, false); + length = Math.floor(player.length); + } + } + + Component.onCompleted: { + position = Math.floor(player.position); + length = Math.floor(player.length); + } + + function timeStr(time: int): string { + const seconds = time % 60; + const minutes = Math.floor(time / 60); + + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } + + Item { + id: playerSelectorContainment + Layout.fillWidth: true + implicitHeight: playerSelector.implicitHeight + 20 + implicitWidth: playerSelector.implicitWidth + + ScrollView { + id: playerSelector + anchors.centerIn: parent + width: Math.min(implicitWidth, playerSelectorContainment.width) + + RowLayout { + Repeater { + model: Mpris.players + + MouseArea { + required property MprisPlayer modelData; + readonly property bool selected: modelData == player; + + implicitWidth: childrenRect.width + implicitHeight: childrenRect.height + + onClicked: MprisController.setActivePlayer(modelData); + + Rectangle { + implicitWidth: 50 + implicitHeight: 50 + radius: 5 + color: selected ? "#20ceffff" : "transparent" + + Image { + anchors.fill: parent + anchors.margins: 5 + // lazy and wont always work, but good enough until a desktop entry impl. + source: { + const entry = DesktopEntries.byId(modelData.desktopEntry); + console.log(`ent ${entry} id ${modelData.desktopEntry}`) + if (!entry) return "image://icon/"; + return `image://icon/${entry.icon}`; + } + //asynchronous: true + + sourceSize.width: 50 + sourceSize.height: 50 + cache: false + } + } + } + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.bottomMargin: 10 + + Label { + anchors.centerIn: parent + text: activePlayer.identity + } + } + + SlideView { + id: trackStack + Layout.fillWidth: true + implicitHeight: 400 + + property bool reverse: false; + + Component.onCompleted: updateTrack(false, true); + + function updateTrack(reverse: bool, immediate: bool) { + this.reverse = reverse; + this.replace( + trackComponent, + { track: MprisController.activeTrack }, + immediate + ) + } + + property var trackComponent: Component { + Flickable { + id: flickable + required property var track; + // in most cases this is ready around the same time as the background, + // but may take longer if the image is huge. + readonly property bool svReady: img.status === Image.Ready; + contentWidth: width + 1 + onDragEnded: { + return; + console.log(`dragend ${contentX}`) + if (Math.abs(contentX) > 75) { + if (contentX < 0) MprisController.previous(); + else if (contentX > 0) MprisController.next(); + } + } + ColumnLayout { + id: trackContent + width: flickable.width + height: flickable.height + + Item { + Layout.fillWidth: true + implicitHeight: 300//img.implicitHeight + implicitWidth: img.implicitWidth + + Image { + id: img; + anchors.centerIn: parent; + source: track.artUrl ?? ""; + //height: 300 + //fillMode: Image.PreserveAspectFit + cache: false + asynchronous: true + + sourceSize.height: 300 + sourceSize.width: 300 + } + } + + Item { + Layout.fillWidth: true + Layout.topMargin: 20 + + Label { + anchors.centerIn: parent + text: track.title + } + } + + Item { + Layout.fillWidth: true + Layout.topMargin: 20 + + Label { + anchors.centerIn: parent + text: track.artist + } + } + + Item { Layout.fillHeight: true } + } + } + } + + readonly property real fromPos: trackStack.width * (trackStack.reverse ? -1 : 1); + + // intentionally slightly faster than the background + enterTransition: PropertyAnimation { + property: "x" + from: trackStack.fromPos + to: 0; + duration: 350; + easing.type: Easing.OutExpo; + } + + exitTransition: PropertyAnimation { + property: "x" + to: target.x - trackStack.fromPos; + duration: 350; + easing.type: Easing.OutExpo; + } + } + + Item { Layout.fillHeight: true } + + Item { + Layout.fillWidth: true + implicitHeight: controlsRow.implicitHeight + + RowLayout { + id: controlsRow + anchors.centerIn: parent + + ClickableIcon { + image: { + switch (MprisController.loopState) { + case MprisLoopState.None: return "root:icons/repeat-none.svg"; + case MprisLoopState.Playlist: return "root:icons/repeat-all.svg"; + case MprisLoopState.Track: return "root:icons/repeat-once.svg"; + } + } + + implicitWidth: 50 + implicitHeight: width + scaleIcon: false + baseMargin: 3 + enabled: MprisController.loopSupported; + onClicked: { + let target = MprisLoopState.None; + switch (MprisController.loopState) { + case MprisLoopState.None: target = MprisLoopState.Playlist; break; + case MprisLoopState.Playlist: target = MprisLoopState.Track; break; + case MprisLoopState.Track: target = MprisLoopState.None; break; + } + + MprisController.setLoopState(target); + } + } + + ClickableIcon { + image: "root:icons/rewind.svg" + implicitWidth: 60 + implicitHeight: width + scaleIcon: false + baseMargin: 3 + enabled: MprisController.canGoPrevious; + onClicked: MprisController.previous(); + } + + ClickableIcon { + image: `root:icons/${MprisController.isPlaying ? "pause" : "play"}.svg`; + Layout.leftMargin: -10 + Layout.rightMargin: -10 + implicitWidth: 80 + implicitHeight: width + scaleIcon: false + enabled: MprisController.isPlaying ? MprisController.canPause : MprisController.canPlay + onClicked: { + if (MprisController.isPlaying) MprisController.pause(); + else MprisController.play(); + } + } + + ClickableIcon { + image: "root:icons/fast-forward.svg" + implicitWidth: 60 + implicitHeight: width + scaleIcon: false + baseMargin: 3 + enabled: MprisController.canGoNext; + onClicked: MprisController.next(); + } + + ClickableIcon { + image: `root:icons/${MprisController.hasShuffle ? "shuffle" : "shuffle-off"}.svg` + implicitWidth: 50 + implicitHeight: width + scaleIcon: false + enabled: MprisController.shuffleSupported; + onClicked: MprisController.setShuffle(!MprisController.hasShuffle); + } + } + } + + RowLayout { + Label { + Layout.preferredWidth: lengthLabel.implicitWidth + text: timeStr(position) + } + + MediaSlider { + id: slider + Layout.fillWidth: true + property var bindSlider: true; + enabled: player.canSeek + from: 0 + to: player.length + + onPressedChanged: { + if (!pressed) player.position = value; + bindSlider = !pressed; + } + + Binding { + when: slider.bindSlider + slider.value: player.position + } + } + + Label { + id: lengthLabel + text: timeStr(length) + } + } + } + } + } + } +} diff --git a/modules/user/modules/quickshell/shell/bar/systray/SysTray.qml b/modules/user/modules/quickshell/shell/bar/systray/SysTray.qml index 3cbc6cb..b243080 100644 --- a/modules/user/modules/quickshell/shell/bar/systray/SysTray.qml +++ b/modules/user/modules/quickshell/shell/bar/systray/SysTray.qml @@ -41,11 +41,8 @@ OverlayWidget { Layout.fillWidth: true implicitHeight: width - Behavior on implicitHeight { - SmoothedAnimation { velocity: 50 } - } - MouseArea { + ClickableIcon { id: mouseArea anchors { top: parent.top @@ -54,31 +51,10 @@ OverlayWidget { } width: height - hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - Image { - id: image - anchors.fill: parent - - anchors.margins: mouseArea.pressed || targetMenuOpen ? 3 : 0 - Behavior on anchors.margins { SmoothedAnimation { velocity: 30 } } - - source: modelData.icon - sourceSize.width: width - sourceSize.height: height - cache: false - visible: false - } - - MultiEffect { - anchors.fill: image - source: image - - property real targetBrightness: mouseArea.pressed || targetMenuOpen ? -25 : mouseArea.containsMouse ? 75 : 0 - Behavior on targetBrightness { SmoothedAnimation { velocity: 600 } } - brightness: targetBrightness / 1000 - } + image: modelData.icon + showPressed: targetMenuOpen onClicked: event => { event.accepted = true; @@ -99,42 +75,30 @@ OverlayWidget { } property var tooltip: TooltipItem { - anchors.fill: parent - tooltip: bar.widgetSurface.tooltip - owner: image + tooltip: bar.tooltip + owner: mouseArea show: mouseArea.containsMouse - implicitWidth: tooltipText.implicitWidth - implicitHeight: tooltipText.implicitHeight Text { id: tooltipText - anchors.fill: parent text: modelData.tooltipTitle != "" ? modelData.tooltipTitle : modelData.id color: "white" } } property var rightclickMenu: TooltipItem { - anchors.fill: parent - tooltip: bar.widgetSurface.tooltip - owner: image + tooltip: bar.tooltip + owner: mouseArea isMenu: true show: targetMenuOpen && menu.showChildren animateSize: !rightclickItems.animating - implicitHeight: rightclickItems.implicitHeight - implicitWidth: rightclickItems.implicitWidth - - onVisibleChanged: { - if (!visible) targetMenuOpen = false; - } onClose: targetMenuOpen = false; MenuItemList { id: rightclickItems - anchors.fill: parent items: menu == null ? [] : menu.children onClose: targetMenuOpen = false; } diff --git a/modules/user/modules/quickshell/shell/bar/workspaces/Widget.qml b/modules/user/modules/quickshell/shell/bar/workspaces/Widget.qml new file mode 100644 index 0000000..c52d21b --- /dev/null +++ b/modules/user/modules/quickshell/shell/bar/workspaces/Widget.qml @@ -0,0 +1,114 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland +import ".." +import "root:." + +MouseArea { + id: root + required property var bar; + required property int wsBaseIndex; + property int wsCount: 10; + property bool hideWhenEmpty: false; + implicitHeight: column.implicitHeight + 10; + + acceptedButtons: Qt.NoButton + + onWheel: event => { + event.accepted = true; + const step = -Math.sign(event.angleDelta.y); + const targetWs = currentIndex + step; + + if (targetWs >= wsBaseIndex && targetWs < wsBaseIndex + wsCount) { + Hyprland.dispatch(`workspace ${targetWs}`) + } + } + + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(bar.screen); + property int currentIndex: 0; + property int existsCount: 0; + visible: !hideWhenEmpty || existsCount > 0; + + property real animPos: 0; + Behavior on animPos { SmoothedAnimation { velocity: 100 } } + + // destructor takes care of nulling + signal workspaceAdded(workspace: HyprlandWorkspace); + + ColumnLayout { + id: column + spacing: 0 + anchors { + fill: parent; + topMargin: 0; + margins: 5; + } + + Repeater { + model: 10 + + MouseArea { + id: wsItem + onPressed: Hyprland.dispatch(`workspace ${wsIndex}`); + + Layout.fillWidth: true + implicitHeight: 15 + + required property int index; + property int wsIndex: wsBaseIndex + index; + property HyprlandWorkspace workspace: null; + property bool exists: workspace != null; + property bool active: (monitor?.activeWorkspace ?? false) && monitor.activeWorkspace == workspace; + + onActiveChanged: { + if (active) root.currentIndex = wsIndex; + } + + onExistsChanged: { + root.existsCount += exists ? 1 : -1; + } + + Connections { + target: root + + function onWorkspaceAdded(workspace: HyprlandWorkspace) { + if (workspace.id == wsItem.wsIndex) { + wsItem.workspace = workspace; + } + } + } + + property real animActive: active ? 100 : 0 + Behavior on animActive { NumberAnimation { duration: 100 } } + + property real animExists: exists ? 100 : 0 + Behavior on animExists { NumberAnimation { duration: 100 } } + + Rectangle { + anchors.centerIn: parent + height: 10 + width: parent.width + scale: 1 + animActive * 0.003 + radius: height / 2 + border.color: ShellGlobals.colors.widgetOutline + border.width: 1 + color: ShellGlobals.interpolateColors(animExists * 0.01, ShellGlobals.colors.widget, ShellGlobals.colors.widgetActive); + } + } + } + } + + Connections { + target: Hyprland.workspaces + + function onObjectInsertedPost(workspace) { + root.workspaceAdded(workspace); + } + } + + Component.onCompleted: { + Hyprland.workspaces.values.forEach(workspace => { + root.workspaceAdded(workspace) + }); + } +} diff --git a/modules/user/modules/quickshell/shell/icons/fast-forward.svg b/modules/user/modules/quickshell/shell/icons/fast-forward.svg new file mode 100644 index 0000000..1745658 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/fast-forward.svg @@ -0,0 +1 @@ + diff --git a/modules/user/modules/quickshell/shell/icons/icons.txt b/modules/user/modules/quickshell/shell/icons/icons.txt new file mode 100644 index 0000000..6053745 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/icons.txt @@ -0,0 +1,4 @@ +phosphor icons +shuffle-off is an edit of shuffle. +play is edited. +repeat-none is an edit of repeat. diff --git a/modules/user/modules/quickshell/shell/icons/monitor.svg b/modules/user/modules/quickshell/shell/icons/monitor.svg new file mode 100644 index 0000000..2f51e19 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/monitor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/user/modules/quickshell/shell/icons/pause.svg b/modules/user/modules/quickshell/shell/icons/pause.svg new file mode 100644 index 0000000..d83a876 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/pause.svg @@ -0,0 +1 @@ + diff --git a/modules/user/modules/quickshell/shell/icons/play.svg b/modules/user/modules/quickshell/shell/icons/play.svg new file mode 100644 index 0000000..3927ce5 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/play.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/modules/user/modules/quickshell/shell/icons/repeat-all.svg b/modules/user/modules/quickshell/shell/icons/repeat-all.svg new file mode 100644 index 0000000..8aeba52 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/repeat-all.svg @@ -0,0 +1 @@ + diff --git a/modules/user/modules/quickshell/shell/icons/repeat-none.svg b/modules/user/modules/quickshell/shell/icons/repeat-none.svg new file mode 100644 index 0000000..17b0b9a --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/repeat-none.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/modules/user/modules/quickshell/shell/icons/repeat-once.svg b/modules/user/modules/quickshell/shell/icons/repeat-once.svg new file mode 100644 index 0000000..0e26339 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/repeat-once.svg @@ -0,0 +1 @@ + diff --git a/modules/user/modules/quickshell/shell/icons/rewind.svg b/modules/user/modules/quickshell/shell/icons/rewind.svg new file mode 100644 index 0000000..7810ae5 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/rewind.svg @@ -0,0 +1 @@ + diff --git a/modules/user/modules/quickshell/shell/icons/shuffle-off.svg b/modules/user/modules/quickshell/shell/icons/shuffle-off.svg new file mode 100644 index 0000000..f0b25da --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/shuffle-off.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/modules/user/modules/quickshell/shell/icons/shuffle.svg b/modules/user/modules/quickshell/shell/icons/shuffle.svg new file mode 100644 index 0000000..c6bb6b6 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/shuffle.svg @@ -0,0 +1 @@ + diff --git a/modules/user/modules/quickshell/shell/icons/skip-back.svg b/modules/user/modules/quickshell/shell/icons/skip-back.svg new file mode 100644 index 0000000..ae5bb3a --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/skip-back.svg @@ -0,0 +1 @@ + diff --git a/modules/user/modules/quickshell/shell/icons/skip-forward.svg b/modules/user/modules/quickshell/shell/icons/skip-forward.svg new file mode 100644 index 0000000..70d3c49 --- /dev/null +++ b/modules/user/modules/quickshell/shell/icons/skip-forward.svg @@ -0,0 +1 @@ + diff --git a/modules/user/modules/quickshell/shell/lock/Controller.qml b/modules/user/modules/quickshell/shell/lock/Controller.qml new file mode 100644 index 0000000..715772b --- /dev/null +++ b/modules/user/modules/quickshell/shell/lock/Controller.qml @@ -0,0 +1,149 @@ +pragma Singleton + +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import ".." +import "../.." + +Singleton { + id: root + function init() {} + + property bool locked: false; + onLockedChanged: { + if (locked) { + lockContextLoader.active = true; + lock.locked = true; + } else { + lockClearTimer.start(); + workspaceUnlockAnimation(); + } + } + + Timer { + id: lockClearTimer + interval: 600 + onTriggered: { + lock.locked = false; + lockContextLoader.active = false; + } + } + + property var oldWorkspaces: ({}); + + function workspaceLockAnimation() { + const focusedMonitor = Hyprland.focusedMonitor.id; + + Hyprland.monitors.values.forEach(monitor => { + if (monitor.activeWorkspace) { + root.oldWorkspaces[monitor.id] = monitor.activeWorkspace.id; + } + + Hyprland.dispatch(`workspace name:lock_${monitor.name}`); + }); + + Hyprland.dispatch(`focusmonitor ${focusedMonitor}`); + } + + function workspaceUnlockAnimation() { + const focusedMonitor = Hyprland.focusedMonitor.id; + + Hyprland.monitors.values.forEach(monitor => { + const workspace = root.oldWorkspaces[monitor.id]; + if (workspace) Hyprland.dispatch(`workspace ${workspace}`); + }); + + Hyprland.dispatch(`focusmonitor ${focusedMonitor}`); + + root.oldWorkspaces = ({}); + } + + Shortcut { + name: "lock" + onPressed: { + if (root.locked) root.locked = false; + else root.locked = true; + } + } + + LazyLoader { + id: lockContextLoader + + LockContext { + onUnlocked: root.locked = false; + } + } + + WlSessionLock { + id: lock + + onSecureChanged: { + if (secure) { + Qt.callLater(() => root.workspaceLockAnimation()); + } + } + + WlSessionLockSurface { + id: lockSurface + color: "transparent" + + // Ensure nothing spawns in the workspace behind the transparent lock + // by filling in the background after animations complete. + Rectangle { + anchors.fill: parent + color: "gray" + visible: backgroundImage.visible + } + + BackgroundImage { + id: backgroundImage + anchors.fill: parent + screen: lockSurface.screen + visible: !lockAnim.running + asynchronous: true + } + + LockContent { + id: lockContent + context: lockContextLoader.item; + + visible: false + width: lockSurface.width + height: lockSurface.height + } + + NumberAnimation { + id: lockAnim + target: lockContent + property: "y" + to: 0 + duration: 600 + easing.type: Easing.BezierSpline + easing.bezierCurve: [0.0, 0.75, 0.15, 1.0, 1.0, 1.0] + } + + onVisibleChanged: { + if (visible) { + lockContent.y = -lockSurface.height + console.log(`y ${lockContent.y}`) + lockContent.visible = true; + lockAnim.running = true; + } + } + + Connections { + target: root + + function onLockedChanged() { + if (!locked) { + lockAnim.to = -lockSurface.height + lockAnim.running = true; + } + } + } + } + } +} diff --git a/modules/user/modules/quickshell/shell/lock/LockButton.qml b/modules/user/modules/quickshell/shell/lock/LockButton.qml new file mode 100644 index 0000000..af07509 --- /dev/null +++ b/modules/user/modules/quickshell/shell/lock/LockButton.qml @@ -0,0 +1,50 @@ +import QtQuick +import "root:." + +Item { + id: root + implicitHeight: 75 + implicitWidth: showProgress * 0.1 + + signal clicked(); + property string icon; + property bool show: true; + + property int showProgress: show ? 1000 : 0 + Behavior on showProgress { + NumberAnimation { + duration: 100 + easing.type: Easing.OutQuad + } + } + + MouseArea { + id: mouseArea + implicitWidth: 75 + implicitHeight: 75 + hoverEnabled: true + + y: -(height + 30) * (1.0 - showProgress * 0.001) + x: 12.5 - 50 * (1.0 - showProgress * 0.001) + + Component.onCompleted: clicked.connect(root.clicked); + + Rectangle { + anchors.fill: parent + radius: 5 + color: ShellGlobals.interpolateColors(hoverColorInterp * 0.001, ShellGlobals.colors.widget, ShellGlobals.colors.widgetActive); + border.width: 1 + border.color: ShellGlobals.colors.widgetOutline + + property int hoverColorInterp: mouseArea.containsMouse || mouseArea.pressed ? 1000 : 0; + Behavior on hoverColorInterp { SmoothedAnimation { velocity: 10000 } } + + Image { + anchors.fill: parent + anchors.margins: 15 + + source: root.icon + } + } + } +} diff --git a/modules/user/modules/quickshell/shell/lock/LockContent.qml b/modules/user/modules/quickshell/shell/lock/LockContent.qml new file mode 100644 index 0000000..bfce2cc --- /dev/null +++ b/modules/user/modules/quickshell/shell/lock/LockContent.qml @@ -0,0 +1,113 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import ".." + +Item { + id: root + required property LockContext context; + + property real focusAnim: focusAnimInternal * 0.001 + property int focusAnimInternal: Window.active ? 1000 : 0 + Behavior on focusAnimInternal { SmoothedAnimation { velocity: 5000 } } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: parent.height / 2 + textBox.height + id: sep + + implicitHeight: 6 + implicitWidth: 800 + radius: height / 2 + color: ShellGlobals.colors.widget + } + + ColumnLayout { + implicitWidth: sep.implicitWidth + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: sep.top + spacing: 0 + + Text { + id: timeText + Layout.alignment: Qt.AlignHCenter + + font { + pointSize: 120 + hintingPreference: Font.PreferFullHinting + family: "Noto Sans" + } + + color: "white" + renderType: Text.NativeRendering + + text: { + const hours = ShellGlobals.time.getHours().toString().padStart(2, '0'); + const minutes = ShellGlobals.time.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + } + } + + Item { + Layout.alignment: Qt.AlignHCenter + implicitHeight: childrenRect.height * focusAnim + implicitWidth: sep.implicitWidth + clip: true + + TextInput { + id: textBox + focus: true + width: parent.width + + color: enabled ? + root.context.failed ? "#ffa0a0" : "white" + : "#80ffffff"; + + font.pointSize: 24 + horizontalAlignment: TextInput.AlignHCenter + echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + + onTextChanged: root.context.currentText = text; + + Window.onActiveChanged: { + if (Window.active) { + text = root.context.currentText; + } + } + + onAccepted: { + if (text != "") root.context.tryUnlock(); + } + + enabled: !root.context.isUnlocking; + } + } + } + + Item { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: sep.bottom + implicitHeight: (childrenRect.height + 30) * focusAnim + implicitWidth: sep.implicitWidth + clip: true + + RowLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.topMargin: 50 + spacing: 0 + + LockButton { + icon: "root:icons/monitor.svg" + onClicked: root.context.dpms(); + } + + LockButton { + icon: "root:icons/pause.svg" + show: context.mediaPlaying; + onClicked: root.context.pauseMedia(); + } + } + } +} diff --git a/modules/user/modules/quickshell/shell/lock/LockContext.qml b/modules/user/modules/quickshell/shell/lock/LockContext.qml new file mode 100644 index 0000000..d8c1a87 --- /dev/null +++ b/modules/user/modules/quickshell/shell/lock/LockContext.qml @@ -0,0 +1,57 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import Quickshell.Services.Mpris + +Scope { + id: root + signal unlocked(); + property string currentText: ""; + readonly property alias isUnlocking: pamtester.running; + property bool failed: false; + + onCurrentTextChanged: failed = false; + + readonly property bool mediaPlaying: Mpris.players.values.some(player => { + return player.playbackState === MprisPlaybackState.Playing && player.canPause; + }); + + function pauseMedia() { + Mpris.players.values.forEach(player => { + if (player.playbackState === MprisPlaybackState.Playing && player.canPause) { + player.playbackState = MprisPlaybackState.Paused; + } + }); + } + + function dpms() { + Hyprland.dispatch(`dpms`); + } + + Process { + id: pamtester + property bool failed: true + + command: ["pamtester", "login", Quickshell.env("USER"), "authenticate"] + + onStarted: this.write(`${currentText}\n`) + + stdout: SplitParser { + // fails go to stderr + onRead: pamtester.failed = false + } + + onExited: { + if (failed) { + root.failed = true; + } else { + root.unlocked(); + } + } + } + + function tryUnlock() { + pamtester.running = true; + } +} diff --git a/modules/user/modules/quickshell/shell/screenshot/Controller.qml b/modules/user/modules/quickshell/shell/screenshot/Controller.qml index e02e6a4..2f54761 100644 --- a/modules/user/modules/quickshell/shell/screenshot/Controller.qml +++ b/modules/user/modules/quickshell/shell/screenshot/Controller.qml @@ -99,6 +99,7 @@ Scope { exclusionMode: ExclusionMode.Ignore WlrLayershell.namespace: "shell:screenshot" WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive anchors { top: true