commit d867b3591f35a2d0547c7779589d12bc636fc8da Author: Andrés Eduardo García Márquez Date: Sat Jan 17 16:27:31 2026 -0500 Initial release: Virtual Desktop Switcher for KDE Plasma Features: - Real-time desktop name display in panel - Hover to open popup with desktop grid - Real window geometry using native PagerModel - Window icons in previews - Proper screen aspect ratio - Desktop management (add, rename, delete) - Scroll wheel navigation - High performance native implementation Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2609758 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.qmlc +*.jsc +.directory +*~ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b983222 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2025 Andres Garcia + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..760760b --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +# Virtual Desktop Switcher + +A KDE Plasma 5 widget that displays the current virtual desktop name and provides a visual overview of all desktops with real window positions. + +![Plasma 5.27+](https://img.shields.io/badge/Plasma-5.27%2B-blue) +![License GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-green) +![QML](https://img.shields.io/badge/QML-Qt%205.15-orange) + +## Features + +- **Real-time desktop name** in panel - Shows active virtual desktop name +- **Hover to open** - Popup appears automatically when hovering (80ms delay) +- **Real window geometry** - Windows displayed in actual positions using native KDE pager API +- **Window icons** - Application icons shown inside window previews +- **Proper aspect ratio** - Desktop previews match your screen proportions +- **Grid layout** - Automatically arranges desktops in optimal columns/rows +- **Desktop management**: + - Click to switch desktop + - Right-click to rename or delete + - Add new desktops with "Add" button + - Scroll wheel to cycle through desktops +- **High performance** - Uses native `PagerModel` instead of shell commands + +## Screenshots + +The widget shows a grid of desktop previews with: +- Window outlines in real positions +- Application icons inside windows +- Desktop number and name badge +- Active desktop highlighted + +## Installation + +### Manual Installation + +```bash +# Clone the repository +git clone https://github.com/andresgarcia0313/plasma-virtual-desktop-switcher.git + +# Copy to Plasma plasmoids directory +cp -r plasma-virtual-desktop-switcher ~/.local/share/plasma/plasmoids/org.kde.virtualdesktopswitcher + +# Restart Plasma +kquitapp5 plasmashell && kstart5 plasmashell +``` + +### Add to Panel + +1. Right-click on your panel → **Add Widgets** +2. Search for **"Virtual Desktop Switcher"** +3. Drag to your panel + +## Configuration + +Right-click on the widget → **Configure Virtual Desktop Switcher** + +| Option | Description | +|--------|-------------| +| Show window previews | Display window outlines inside desktop previews | +| Show window icons | Display application icons on window rectangles | +| Preview size | Adjust the size of preview area (40-150px) | + +## Usage + +| Action | Result | +|--------|--------| +| **Hover** | Opens desktop overview popup | +| **Click** on desktop | Switches to that desktop | +| **Right-click** on desktop | Context menu (Rename, Delete) | +| **Scroll wheel** | Cycles through desktops | +| **Add button** | Creates new virtual desktop | + +## Requirements + +- KDE Plasma 5.27 or later +- KWin window manager +- Qt 5.15+ + +## Technical Details + +### Architecture + +The widget uses the native KDE pager plugin (`org.kde.plasma.private.pager`) which provides: + +- `PagerModel` - Virtual desktop model with real-time updates +- `TasksModel` - Window information including geometry +- `pagerItemSize` - Screen dimensions for aspect ratio + +### Key Files + +``` +org.kde.virtualdesktopswitcher/ +├── metadata.desktop # Plugin metadata +├── contents/ +│ ├── config/ +│ │ ├── main.xml # Configuration schema +│ │ └── config.qml # Config page registration +│ └── ui/ +│ ├── main.qml # Main widget code +│ └── configGeneral.qml # Settings UI +``` + +### QML Imports Used + +```qml +import org.kde.plasma.plasmoid 2.0 +import org.kde.plasma.core 2.0 as PlasmaCore +import org.kde.plasma.components 3.0 as PlasmaComponents +import org.kde.plasma.private.pager 2.0 // Native pager API +``` + +### Window Geometry Scaling + +Windows are scaled from screen coordinates to preview size: + +```qml +readonly property real scaleX: deskW / pagerModel.pagerItemSize.width +readonly property real scaleY: deskH / pagerModel.pagerItemSize.height + +x: Math.round(model.Geometry.x * scaleX) +y: Math.round(model.Geometry.y * scaleY) +``` + +## Development + +### Testing Changes + +```bash +# Edit files in +~/.local/share/plasma/plasmoids/org.kde.virtualdesktopswitcher/ + +# Restart Plasma to see changes +kquitapp5 plasmashell && kstart5 plasmashell +``` + +### Debugging + +```bash +# View Plasma logs +journalctl -f | grep -i plasma + +# Check QML errors +QT_LOGGING_RULES="qml.debug=true" plasmashell +``` + +### KWin D-Bus API + +```bash +# List virtual desktops +gdbus call --session --dest org.kde.KWin \ + --object-path /VirtualDesktopManager \ + --method org.freedesktop.DBus.Properties.Get \ + org.kde.KWin.VirtualDesktopManager desktops + +# Get current desktop +qdbus org.kde.KWin /VirtualDesktopManager current + +# Rename desktop +qdbus org.kde.KWin /VirtualDesktopManager setDesktopName '' 'New Name' + +# Create desktop +qdbus org.kde.KWin /VirtualDesktopManager createDesktop 'Name' + +# Remove desktop +qdbus org.kde.KWin /VirtualDesktopManager removeDesktop '' +``` + +## Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test thoroughly +5. Submit a pull request + +## License + +This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. + +## Credits + +- Uses KDE Plasma's native pager plugin for window geometry +- Inspired by the official KDE Pager widget + +## Author + +**Andres Garcia** - [GitHub](https://github.com/andresgarcia0313) diff --git a/contents/config/config.qml b/contents/config/config.qml new file mode 100644 index 0000000..cf8b3d2 --- /dev/null +++ b/contents/config/config.qml @@ -0,0 +1,10 @@ +import QtQuick 2.15 +import org.kde.plasma.configuration 2.0 + +ConfigModel { + ConfigCategory { + name: i18n("General") + icon: "preferences-desktop-virtual" + source: "configGeneral.qml" + } +} diff --git a/contents/config/main.xml b/contents/config/main.xml new file mode 100644 index 0000000..2a4bda5 --- /dev/null +++ b/contents/config/main.xml @@ -0,0 +1,20 @@ + + + + + + true + + + true + + + 80 + 40 + 150 + + + diff --git a/contents/ui/configGeneral.qml b/contents/ui/configGeneral.qml new file mode 100644 index 0000000..4e43224 --- /dev/null +++ b/contents/ui/configGeneral.qml @@ -0,0 +1,38 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import org.kde.kirigami 2.19 as Kirigami + +Kirigami.FormLayout { + id: page + + property alias cfg_showWindowPreviews: showWindowPreviews.checked + property alias cfg_showWindowIcons: showWindowIcons.checked + property alias cfg_previewSize: previewSize.value + + CheckBox { + id: showWindowPreviews + Kirigami.FormData.label: i18n("Window Previews:") + text: i18n("Show window outlines in popup") + } + + CheckBox { + id: showWindowIcons + Kirigami.FormData.label: i18n("Window Icons:") + text: i18n("Show application icons on windows") + enabled: showWindowPreviews.checked + } + + SpinBox { + id: previewSize + Kirigami.FormData.label: i18n("Preview Size:") + from: 40 + to: 150 + stepSize: 10 + enabled: showWindowPreviews.checked + + textFromValue: function(value) { + return value + " px" + } + } +} diff --git a/contents/ui/main.qml b/contents/ui/main.qml new file mode 100644 index 0000000..f1756ce --- /dev/null +++ b/contents/ui/main.qml @@ -0,0 +1,310 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import org.kde.plasma.plasmoid 2.0 +import org.kde.plasma.core 2.0 as PlasmaCore +import org.kde.plasma.components 3.0 as PlasmaComponents +import org.kde.plasma.private.pager 2.0 + +Item { + id: root + Plasmoid.preferredRepresentation: Plasmoid.compactRepresentation + + // ───────────────────────────────────────────────────────────────────────── + // CONFIGURATION + // ───────────────────────────────────────────────────────────────────────── + readonly property bool showPreviews: plasmoid.configuration.showWindowPreviews + readonly property bool showIcons: plasmoid.configuration.showWindowIcons + + // ───────────────────────────────────────────────────────────────────────── + // PAGER MODEL (native KDE plugin with real window geometry) + // ───────────────────────────────────────────────────────────────────────── + PagerModel { + id: pagerModel + enabled: root.visible + showDesktop: false + pagerType: PagerModel.VirtualDesktops + } + + // ───────────────────────────────────────────────────────────────────────── + // HELPER FUNCTIONS + // ───────────────────────────────────────────────────────────────────────── + function currentDesktopName() { + if (pagerModel.count > 0 && pagerModel.currentPage >= 0) { + var idx = pagerModel.index(pagerModel.currentPage, 0) + var name = pagerModel.data(idx, Qt.DisplayRole) + return name || ("Desktop " + (pagerModel.currentPage + 1)) + } + return "Desktop" + } + + function addDesktop() { pagerModel.addDesktop() } + function removeDesktop() { pagerModel.removeDesktop() } + + // ───────────────────────────────────────────────────────────────────────── + // HOVER TIMERS + // ───────────────────────────────────────────────────────────────────────── + property bool hoverCompact: false + property bool hoverPopup: false + Timer { id: openTimer; interval: 80; onTriggered: plasmoid.expanded = true } + Timer { id: closeTimer; interval: 300; onTriggered: if (!hoverCompact && !hoverPopup) plasmoid.expanded = false } + + // ───────────────────────────────────────────────────────────────────────── + // COMPACT REPRESENTATION + // ───────────────────────────────────────────────────────────────────────── + Plasmoid.compactRepresentation: MouseArea { + id: compactArea + Layout.minimumWidth: compactLabel.implicitWidth + 16 + hoverEnabled: true + + Rectangle { + anchors.fill: parent + color: parent.containsMouse ? PlasmaCore.Theme.highlightColor : "transparent" + opacity: 0.2; radius: 3 + } + + PlasmaComponents.Label { + id: compactLabel + anchors.centerIn: parent + text: currentDesktopName() + font.bold: true + } + + onEntered: { hoverCompact = true; closeTimer.stop(); openTimer.start() } + onExited: { hoverCompact = false; openTimer.stop(); if (!hoverPopup) closeTimer.start() } + onClicked: { openTimer.stop(); closeTimer.stop(); plasmoid.expanded = !plasmoid.expanded } + onWheel: { + var next = wheel.angleDelta.y > 0 + ? (pagerModel.currentPage - 1 + pagerModel.count) % pagerModel.count + : (pagerModel.currentPage + 1) % pagerModel.count + pagerModel.changePage(next) + } + } + + // ───────────────────────────────────────────────────────────────────────── + // FULL REPRESENTATION + // ───────────────────────────────────────────────────────────────────────── + Plasmoid.fullRepresentation: Item { + id: popup + + readonly property int cols: Math.max(1, Math.ceil(Math.sqrt(pagerModel.count))) + readonly property int rows: Math.max(1, Math.ceil(pagerModel.count / cols)) + readonly property real deskW: 130 + readonly property real deskH: pagerModel.pagerItemSize.height > 0 + ? deskW * pagerModel.pagerItemSize.height / pagerModel.pagerItemSize.width + : deskW * 9 / 16 + readonly property real scaleX: deskW / Math.max(1, pagerModel.pagerItemSize.width) + readonly property real scaleY: deskH / Math.max(1, pagerModel.pagerItemSize.height) + + Layout.preferredWidth: cols * (deskW + 8) + 32 + Layout.preferredHeight: rows * (deskH + 8) + 56 + Layout.minimumWidth: 200 + Layout.minimumHeight: 120 + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: { hoverPopup = true; closeTimer.stop() } + onExited: { hoverPopup = false; if (!hoverCompact) closeTimer.start() } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + // ───────────────────────────────────────────────────────────── + // DESKTOP GRID + // ───────────────────────────────────────────────────────────── + Grid { + id: desktopGrid + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter + columns: popup.cols + spacing: 6 + + Repeater { + id: repeater + model: pagerModel + + Rectangle { + id: desktop + width: popup.deskW + height: popup.deskH + + readonly property string desktopName: model.display || ("Desktop " + (index + 1)) + readonly property bool isActive: index === pagerModel.currentPage + + color: isActive ? Qt.darker(PlasmaCore.Theme.highlightColor, 1.3) : PlasmaCore.Theme.backgroundColor + border.width: isActive ? 2 : 1 + border.color: isActive ? PlasmaCore.Theme.highlightColor : PlasmaCore.Theme.disabledTextColor + radius: 4 + clip: true + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + onEntered: { hoverPopup = true; closeTimer.stop(); parent.opacity = 1 } + onExited: parent.opacity = 0.92 + onClicked: mouse.button === Qt.RightButton ? ctxMenu.popup() : (pagerModel.changePage(index), plasmoid.expanded = false) + } + + opacity: 0.92 + + // ───────────────────────────────────────────────── + // WINDOWS with real geometry + // ───────────────────────────────────────────────── + Item { + anchors.fill: parent + anchors.margins: 2 + clip: true + visible: showPreviews + + Repeater { + model: TasksModel + + Rectangle { + readonly property rect geo: model.Geometry + readonly property bool minimized: model.IsMinimized === true + + x: Math.round(geo.x * popup.scaleX) + y: Math.round(geo.y * popup.scaleY) + width: Math.max(8, Math.round(geo.width * popup.scaleX)) + height: Math.max(6, Math.round(geo.height * popup.scaleY)) + visible: !minimized + + color: model.IsActive ? Qt.rgba(1,1,1,0.4) : Qt.rgba(1,1,1,0.2) + border.width: 1 + border.color: model.IsActive ? PlasmaCore.Theme.highlightColor : PlasmaCore.Theme.textColor + radius: 2 + + PlasmaCore.IconItem { + visible: showIcons && parent.width > 16 && parent.height > 12 + anchors.centerIn: parent + width: Math.min(parent.width - 4, parent.height - 4, 20) + height: width + source: model.decoration || "application-x-executable" + usesPlasmaTheme: false + } + } + } + } + + // ───────────────────────────────────────────────── + // DESKTOP LABEL (centered) + // ───────────────────────────────────────────────── + Column { + anchors.centerIn: parent + visible: !showPreviews + PlasmaComponents.Label { + anchors.horizontalCenter: parent.horizontalCenter + text: index + 1 + font.bold: true; font.pixelSize: 18 + color: desktop.isActive ? PlasmaCore.Theme.highlightedTextColor : PlasmaCore.Theme.textColor + } + PlasmaComponents.Label { + anchors.horizontalCenter: parent.horizontalCenter + text: desktop.desktopName + font.pixelSize: 10 + color: desktop.isActive ? PlasmaCore.Theme.highlightedTextColor : PlasmaCore.Theme.textColor + width: popup.deskW - 10 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } + } + + // Badge when previews enabled + Rectangle { + visible: showPreviews + anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter; bottomMargin: 3 } + width: badgeLbl.implicitWidth + 12; height: badgeLbl.implicitHeight + 4 + color: Qt.rgba(0,0,0,0.7); radius: 3 + PlasmaComponents.Label { + id: badgeLbl; anchors.centerIn: parent + text: (index + 1) + " " + desktop.desktopName + font.pixelSize: 11; font.bold: true; color: "white" + } + } + + // Context menu + Menu { + id: ctxMenu + MenuItem { + text: "Rename..." + icon.name: "edit-rename" + onTriggered: { renameDlg.idx = index; renameDlg.open(); renameField.text = desktop.desktopName; renameField.selectAll() } + } + MenuItem { + text: "Delete" + icon.name: "edit-delete" + enabled: pagerModel.count > 1 + onTriggered: pagerModel.removeDesktop() + } + } + } + } + } + + // ───────────────────────────────────────────────────────────── + // BOTTOM BAR + // ───────────────────────────────────────────────────────────── + MouseArea { + Layout.fillWidth: true + Layout.preferredHeight: 36 + hoverEnabled: true + onEntered: { hoverPopup = true; closeTimer.stop() } + onExited: { hoverPopup = false; if (!hoverCompact) closeTimer.start() } + + RowLayout { + anchors.fill: parent + spacing: 8 + PlasmaComponents.Button { + icon.name: "list-add"; text: "Add" + onClicked: pagerModel.addDesktop() + } + Item { Layout.fillWidth: true } + PlasmaComponents.Label { + text: pagerModel.count + " desktops" + opacity: 0.6; font.pixelSize: 11 + } + } + } + } + + // ───────────────────────────────────────────────────────────────── + // RENAME DIALOG + // ───────────────────────────────────────────────────────────────── + Dialog { + id: renameDlg + property int idx: 0 + title: "Rename Desktop" + standardButtons: Dialog.Ok | Dialog.Cancel + anchors.centerIn: parent + contentItem: PlasmaComponents.TextField { + id: renameField + Layout.preferredWidth: 180 + onAccepted: renameDlg.accept() + } + onAccepted: { + if (renameField.text.trim()) { + // Use KWin DBus to rename + var desktopItem = repeater.itemAt(idx) + if (desktopItem) { + execSource.connectSource("qdbus org.kde.KWin /VirtualDesktopManager setDesktopName '" + + pagerModel.data(pagerModel.index(idx, 0), 0x0100 + 1) + "' '" + + renameField.text.trim().replace(/'/g, "'\\''") + "'") + } + pagerModel.refresh() + } + } + } + + PlasmaCore.DataSource { + id: execSource + engine: "executable" + onNewData: disconnectSource(sourceName) + } + } +} diff --git a/metadata.desktop b/metadata.desktop new file mode 100644 index 0000000..4819048 --- /dev/null +++ b/metadata.desktop @@ -0,0 +1,15 @@ +[Desktop Entry] +Name=Virtual Desktop Switcher +Comment=Shows current virtual desktop and allows switching with window previews +Icon=preferences-desktop-virtual +Type=Service +X-KDE-ServiceTypes=Plasma/Applet +X-Plasma-API=declarativeappletscript +X-Plasma-MainScript=ui/main.qml +X-Plasma-ConfigPlugins=org.kde.virtualdesktopswitcher +X-KDE-PluginInfo-Name=org.kde.virtualdesktopswitcher +X-KDE-PluginInfo-Version=1.1 +X-KDE-PluginInfo-Author=Andres Garcia +X-KDE-PluginInfo-License=GPL-3.0 +X-KDE-PluginInfo-Category=Windows and Tasks +X-KDE-FormFactors=desktop,panel