diff --git a/.config/quickshell/GlobalStates.qml b/.config/quickshell/GlobalStates.qml index e50f7b7b..674d586c 100644 --- a/.config/quickshell/GlobalStates.qml +++ b/.config/quickshell/GlobalStates.qml @@ -5,4 +5,5 @@ pragma ComponentBehavior: Bound Singleton { property int sidebarRightOpenCount: 0 + property bool overviewOpen: false } \ No newline at end of file diff --git a/.config/quickshell/modules/bar/Bar.qml b/.config/quickshell/modules/bar/Bar.qml index d6dc5459..5ab29e8e 100644 --- a/.config/quickshell/modules/bar/Bar.qml +++ b/.config/quickshell/modules/bar/Bar.qml @@ -30,6 +30,10 @@ Scope { id: hideOsdVolume command: ["qs", "ipc", "call", "osdVolume", "hide"] } + Process { + id: toggleOverview + command: ["qs", "ipc", "call", "overview", "toggle"] + } Variants { // For each monitor model: Quickshell.screens @@ -220,6 +224,19 @@ Scope { } } + MouseArea { // Middle: right-click to toggle overview + id: barMiddleMouseArea + anchors.fill: middleSection + acceptedButtons: Qt.RightButton + + onPressed: (event) => { + if (event.button === Qt.RightButton) { + toggleOverview.running = true; + } + } + + } + MouseArea { // Right side: scroll to change volume id: barRightSideMouseArea property bool hovered: false @@ -250,7 +267,7 @@ Scope { if (event.angleDelta.y < 0) Audio.sink.audio.volume -= step; else if (event.angleDelta.y > 0) - Audio.sink.audio.volume += step; + Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step); // Store the mouse position and start tracking barRightSideMouseArea.lastScrollX = event.x; barRightSideMouseArea.lastScrollY = event.y; diff --git a/.config/quickshell/modules/common/Appearance.qml b/.config/quickshell/modules/common/Appearance.qml index 82915bfc..284aca6d 100644 --- a/.config/quickshell/modules/common/Appearance.qml +++ b/.config/quickshell/modules/common/Appearance.qml @@ -145,6 +145,7 @@ Singleton { property int large: 25 property int full: 9999 property int screenRounding: large + property int windowRounding: 20 } font: QtObject { diff --git a/.config/quickshell/modules/common/ConfigOptions.qml b/.config/quickshell/modules/common/ConfigOptions.qml index d30be607..cddaf8f4 100644 --- a/.config/quickshell/modules/common/ConfigOptions.qml +++ b/.config/quickshell/modules/common/ConfigOptions.qml @@ -30,6 +30,12 @@ Singleton { property int timeout: 1000 } + property QtObject overview: QtObject { + property real scale: 0.18 // Relative to screen size + property real numOfRows: 2 + property real numOfCols: 5 + } + property QtObject resources: QtObject { property int updateInterval: 3000 } diff --git a/.config/quickshell/modules/common/widgets/NotificationWidget.qml b/.config/quickshell/modules/common/widgets/NotificationWidget.qml index 85da673f..bd53316a 100644 --- a/.config/quickshell/modules/common/widgets/NotificationWidget.qml +++ b/.config/quickshell/modules/common/widgets/NotificationWidget.qml @@ -131,7 +131,6 @@ Item { if (mouse.button === Qt.LeftButton) { copyNotificationBody.running = true notificationSummaryText.text = `${notificationObject.summary} (copied)` - console.log(notificationSummaryText.text) } } onDragStartedChanged: () => { diff --git a/.config/quickshell/modules/overview/Overview.qml b/.config/quickshell/modules/overview/Overview.qml new file mode 100644 index 00000000..4ff3d11f --- /dev/null +++ b/.config/quickshell/modules/overview/Overview.qml @@ -0,0 +1,105 @@ +import "root:/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: overview + + Variants { + model: Quickshell.screens + + PanelWindow { + id: root + property var modelData + screen: modelData + visible: GlobalStates.overviewOpen + + WlrLayershell.namespace: "quickshell:overview" + WlrLayershell.layer: WlrLayer.Overlay + color: "transparent" + + mask: Region { + item: columnLayout + } + + anchors { + top: true + left: true + right: true + bottom: true + } + + HyprlandFocusGrab { + id: grab + windows: [ root ] + active: false + onCleared: () => { + if (!active) GlobalStates.overviewOpen = false + } + } + + Connections { + target: root + function onVisibleChanged() { + delayedGrabTimer.start() + } + } + + Timer { + id: delayedGrabTimer + interval: ConfigOptions.hacks.arbitraryRaceConditionDelay + repeat: false + onTriggered: { + grab.active = root.visible + } + } + + width: columnLayout.width + height: columnLayout.height + + ColumnLayout { + id: columnLayout + anchors.horizontalCenter: parent.horizontalCenter + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Escape) { + sessionRoot.visible = false; + } + } + + Item { + height: 1 // Prevent Wayland protocol error + width: 1 // Prevent Wayland protocol error + } + + OverviewWidget { + bar: root + } + } + + } + + } + + IpcHandler { + target: "overview" + + function toggle() { + GlobalStates.overviewOpen = !GlobalStates.overviewOpen + } + function close() { + GlobalStates.overviewOpen = false + } + function open() { + GlobalStates.overviewOpen = true + } + } + +} diff --git a/.config/quickshell/modules/overview/OverviewWidget.qml b/.config/quickshell/modules/overview/OverviewWidget.qml new file mode 100644 index 00000000..db63369f --- /dev/null +++ b/.config/quickshell/modules/overview/OverviewWidget.qml @@ -0,0 +1,114 @@ +import "root:/services/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Hyprland +import "./icons.js" as Icons + +Item { + id: root + required property var bar + readonly property HyprlandMonitor monitor: Hyprland.monitorFor(bar.screen) + readonly property var toplevels: ToplevelManager.toplevels + readonly property int workspacesShown: ConfigOptions.overview.numOfRows * ConfigOptions.overview.numOfCols + readonly property int workspaceGroup: Math.floor((monitor.activeWorkspace?.id - 1) / workspacesShown) + property var windows: HyprlandData.windowList + property var windowByAddress: HyprlandData.windowByAddress + property var windowAddresses: HyprlandData.addresses + property var monitorData: HyprlandData.monitors.find(m => m.id === root.monitor.id) + property real scale: ConfigOptions.overview.scale + + property real workspaceNumberMargin: 80 + property real workspaceNumberSize: 80 + + implicitWidth: overviewBackground.implicitWidth + Appearance.sizes.elevationMargin * 2 + implicitHeight: overviewBackground.implicitHeight + Appearance.sizes.elevationMargin * 2 + + property Component windowComponent: OverviewWindow {} + property list windowWidgets: [] + + // onWindowsChanged: { + // console.log("Windows changed") + // } + + Rectangle { + id: overviewBackground + + anchors.fill: parent + + implicitWidth: columnLayout.implicitWidth + 5 * 2 + implicitHeight: columnLayout.implicitHeight + 5 * 2 + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.screenRounding * root.scale + 5 * 2 + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + spacing: 5 + + Repeater { + model: ConfigOptions.overview.numOfRows + delegate: RowLayout { + id: row + property int rowIndex: index + + Repeater { // Workspace repeater + model: ConfigOptions.overview.numOfCols + Rectangle { // Workspace + id: workspace + property int colIndex: index + property int workspaceValue: root.workspaceGroup * workspacesShown + rowIndex * ConfigOptions.overview.numOfCols + colIndex + 1 + + implicitWidth: (monitor.width - monitorData?.reserved[0] - monitorData?.reserved[2]) * root.scale + implicitHeight: (monitor.height - monitorData?.reserved[1] - monitorData?.reserved[3]) * root.scale + color: Appearance.colors.colLayer1 // TODO: reconsider this color for a cleaner look + radius: Appearance.rounding.screenRounding * root.scale + + StyledText { + z: 9999 + anchors.left: parent.left + anchors.top: parent.top + anchors.leftMargin: root.workspaceNumberMargin * root.scale + anchors.topMargin: root.workspaceNumberMargin * root.scale + font.pixelSize: root.workspaceNumberSize * root.scale + color: Appearance.colors.colSubtext + text: workspaceValue + } + + Repeater { // Window repeater + model: ScriptModel { + values: windowAddresses.filter((address) => { + var win = windowByAddress[address] + return (win?.workspace?.id === workspace.workspaceValue) + }) + } + delegate: OverviewWindow { + windowData: windowByAddress[modelData] + monitorData: root.monitorData + scale: root.scale + availableWorkspaceWidth: workspace.implicitWidth + availableWorkspaceHeight: workspace.implicitHeight + } + } + } + } + } + } + } + } + + DropShadow { + anchors.fill: overviewBackground + horizontalOffset: 0 + verticalOffset: 2 + radius: Appearance.sizes.elevationMargin + samples: radius * 2 + 1 // Ideally should be 2 * radius + 1, see qt docs + color: Appearance.colors.colShadow + source: overviewBackground + } +} diff --git a/.config/quickshell/modules/overview/OverviewWindow.qml b/.config/quickshell/modules/overview/OverviewWindow.qml new file mode 100644 index 00000000..34a062eb --- /dev/null +++ b/.config/quickshell/modules/overview/OverviewWindow.qml @@ -0,0 +1,107 @@ +import "root:/services/" +import "root:/modules/common" +import "root:/modules/common/widgets" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Io +import Quickshell.Hyprland +import "./icons.js" as Icons + +Rectangle { // Window + id: root + + property var windowData + property var monitorData + property var scale + property var availableWorkspaceWidth + property var availableWorkspaceHeight + + property var iconToWindowRatio: 0.35 + property var iconToWindowRatioCompact: 0.6 + property var iconPath: Quickshell.iconPath(Icons.noKnowledgeIconGuess(windowData?.class)) + property bool compactMode: Appearance.font.pixelSize.smaller * 4 > root.height || Appearance.font.pixelSize.smaller * 4 > root.width + + z: 1 + x: Math.max((windowData?.at[0] - monitorData?.reserved[0]) * root.scale, 0) + y: Math.max((windowData?.at[1] - monitorData?.reserved[1]) * root.scale, 0) + width: Math.min(windowData?.size[0] * root.scale, availableWorkspaceWidth - x) + height: Math.min(windowData?.size[1] * root.scale, availableWorkspaceHeight - y) + + radius: Appearance.rounding.windowRounding * root.scale + color: Appearance.colors.colLayer2 + border.color : Appearance.transparentize(Appearance.m3colors.m3outline, 0.9) + border.pixelAligned : false + border.width : 1 + + Behavior on x { + NumberAnimation { + duration: Appearance.animation.elementDecel.duration + easing.type: Appearance.animation.elementDecel.type + } + } + Behavior on y { + NumberAnimation { + duration: Appearance.animation.elementDecel.duration + easing.type: Appearance.animation.elementDecel.type + } + } + Behavior on width { + NumberAnimation { + duration: Appearance.animation.elementDecel.duration + easing.type: Appearance.animation.elementDecel.type + } + } + Behavior on height { + NumberAnimation { + duration: Appearance.animation.elementDecel.duration + easing.type: Appearance.animation.elementDecel.type + } + } + + Process { + id: closeOverview + command: ["bash", "-c", "qs ipc call overview close &"] // Somehow has to by async to work? + } + + MouseArea { + id: mouseArea + anchors.fill: parent + onClicked: { + if (windowData) { + closeOverview.running = true + Hyprland.dispatch(`focuswindow address:${windowData.address}`) + } + } + } + + ColumnLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.font.pixelSize.smaller * 0.5 + + IconImage { + id: windowIcon + Layout.alignment: Qt.AlignHCenter + source: root.iconPath + width: root.width * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) + height: root.height * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) + } + + StyledText { + Layout.leftMargin: 10 + Layout.rightMargin: 10 + visible: !compactMode + Layout.fillWidth: true + Layout.fillHeight: true + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.smaller + elide: Text.ElideRight + // wrapMode: Text.Wrap + text: windowData?.title ?? "" + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/overview/icons.js b/.config/quickshell/modules/overview/icons.js new file mode 100644 index 00000000..3eeeab2b --- /dev/null +++ b/.config/quickshell/modules/overview/icons.js @@ -0,0 +1,71 @@ +const substitutions = { + "code-url-handler": "visual-studio-code", + "Code": "visual-studio-code", + "GitHub Desktop": "github-desktop", + "Minecraft* 1.20.1": "minecraft", + "gnome-tweaks": "org.gnome.tweaks", + "pavucontrol-qt": "pavucontrol", + "wps": "wps-office2019-kprometheus", + "wpsoffice": "wps-office2019-kprometheus", + "footclient": "foot", + "": "image-missing" +} +const regexSubstitutions = [ + { + "regex": "/^steam_app_(\\d+)$/", + "replace": "steam_icon_$1" + } +] + + +function iconExists(iconName) { + return false; // TODO: Make this work without Gtk +} + +function substitute(str) { + // Normal substitutions + if (substitutions[str]) + return substitutions[str]; + + // Regex substitutions + for (let i = 0; i < regexSubstitutions.length; i++) { + const substitution = regexSubstitutions[i]; + const replacedName = str.replace( + substitution.regex, + substitution.replace, + ); + if (replacedName != str) return replacedName; + } + + // Guess: convert to kebab case + if (!iconExists(str)) str = str.toLowerCase().replace(/\s+/g, "-"); + + // Original string + return str; +} + +function noKnowledgeIconGuess(str) { + if (!str) return "image-missing"; + + // Normal substitutions + if (substitutions[str]) + return substitutions[str]; + + // Regex substitutions + for (let i = 0; i < regexSubstitutions.length; i++) { + const substitution = regexSubstitutions[i]; + const replacedName = str.replace( + substitution.regex, + substitution.replace, + ); + if (replacedName != str) return replacedName; + } + + // Guess: convert to kebab case if it's not reverse domain name notation + if (!str.includes('.')) { + str = str.toLowerCase().replace(/\s+/g, "-"); + } + + // Original string + return str; +} \ No newline at end of file diff --git a/.config/quickshell/modules/session/Session.qml b/.config/quickshell/modules/session/Session.qml index f2d666cf..50389c82 100644 --- a/.config/quickshell/modules/session/Session.qml +++ b/.config/quickshell/modules/session/Session.qml @@ -187,7 +187,7 @@ Scope { SessionActionButton { id: sessionFirmwareReboot focus: sessionRoot.visible - buttonIcon: "reset_wrench" + buttonIcon: "settings_applications" buttonText: "Reboot to firmware settings" onClicked: { firmwareReboot.running = true; sessionRoot.visible = false } onFocusChanged: { if (focus) sessionRoot.subtitle = buttonText } diff --git a/.config/quickshell/modules/session/SessionActionButton.qml b/.config/quickshell/modules/session/SessionActionButton.qml index 545cfab5..c0ff0a95 100644 --- a/.config/quickshell/modules/session/SessionActionButton.qml +++ b/.config/quickshell/modules/session/SessionActionButton.qml @@ -31,10 +31,6 @@ Button { } } - onClicked: { - console.log("Button clicked:", buttonText) - } - background: Rectangle { anchors.fill: parent radius: Appearance.rounding.full diff --git a/.config/quickshell/services/HyprlandData.qml b/.config/quickshell/services/HyprlandData.qml new file mode 100644 index 00000000..bc129465 --- /dev/null +++ b/.config/quickshell/services/HyprlandData.qml @@ -0,0 +1,65 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland + +Singleton { + id: root + property var windowList: [] + property var addresses: [] + property var windowByAddress: {} + property var monitors: [] + + function updateWindowList() { + getClients.running = true + getMonitors.running = true + } + + Component.onCompleted: { + updateWindowList() + } + + Connections { + target: Hyprland + + function onRawEvent(event) { + // Filter out redundant old v1 events for the same thing + if(event in [ + "activewindow", "focusedmon", "monitoradded", + "createworkspace", "destroyworkspace", "moveworkspace", + "activespecial", "movewindow", "windowtitle" + ]) return ; + updateWindowList() + } + } + + Process { + id: getClients + command: ["bash", "-c", "hyprctl clients -j | jq -c"] + stdout: SplitParser { + onRead: (data) => { + root.windowList = JSON.parse(data) + root.windowByAddress = {} + for (var i = 0; i < root.windowList.length; ++i) { + var win = root.windowList[i] + root.windowByAddress[win.address] = win + } + root.addresses = root.windowList.map((win) => win.address) + } + } + } + Process { + id: getMonitors + command: ["bash", "-c", "hyprctl monitors -j | jq -c"] + stdout: SplitParser { + onRead: (data) => { + root.monitors = JSON.parse(data) + } + } + } +} + diff --git a/.config/quickshell/shell.qml b/.config/quickshell/shell.qml index 002dad7d..fdc1f8b1 100644 --- a/.config/quickshell/shell.qml +++ b/.config/quickshell/shell.qml @@ -3,6 +3,7 @@ import "./modules/bar/" import "./modules/notificationPopup/" import "./modules/onScreenDisplay/" +import "./modules/overview/" import "./modules/screenCorners/" import "./modules/session/" import "./modules/sidebarRight/" @@ -17,6 +18,7 @@ ShellRoot { NotificationPopup {} OnScreenDisplayBrightness {} OnScreenDisplayVolume {} + Overview {} ReloadPopup {} ScreenCorners {} Session {}