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 <noreply@anthropic.com>
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-17 16:27:31 -05:00
commit d867b3591f
8 changed files with 603 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.qmlc
*.jsc
.directory
*~

17
LICENSE Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.

189
README.md Normal file
View File

@ -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 '<id>' 'New Name'
# Create desktop
qdbus org.kde.KWin /VirtualDesktopManager createDesktop <position> 'Name'
# Remove desktop
qdbus org.kde.KWin /VirtualDesktopManager removeDesktop '<id>'
```
## 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)

View File

@ -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"
}
}

20
contents/config/main.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name=""/>
<group name="General">
<entry name="showWindowPreviews" type="Bool">
<default>true</default>
</entry>
<entry name="showWindowIcons" type="Bool">
<default>true</default>
</entry>
<entry name="previewSize" type="Int">
<default>80</default>
<min>40</min>
<max>150</max>
</entry>
</group>
</kcfg>

View File

@ -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"
}
}
}

310
contents/ui/main.qml Normal file
View File

@ -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)
}
}
}

15
metadata.desktop Normal file
View File

@ -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