refactor: modularize main.qml into components, dialogs and JS modules

Split monolithic main.qml into separate files for better maintainability:
- CompactRepresentation.qml, FullRepresentation.qml
- DesktopDelegate.qml, Translations.qml
- DesktopLogic.js, DesktopManager.js
- components/DragRect.qml
- dialogs/RenameDialog.qml, NewDesktopDialog.qml
- Added unit tests for JS modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andrés Eduardo García Márquez 2026-02-10 12:12:45 -05:00
parent 4d13665893
commit 1823a3c4eb
14 changed files with 2066 additions and 300 deletions

View File

@ -0,0 +1,44 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.plasma.components 3.0 as PlasmaComponents
import "." as Local
MouseArea {
id: root
property var pagerModel
property string currentName: ""
signal requestOpen()
signal requestClose()
signal requestToggle()
signal wheelUp()
signal wheelDown()
Layout.minimumWidth: label.implicitWidth + 16
hoverEnabled: true
Rectangle {
anchors.fill: parent
color: parent.containsMouse ? PlasmaCore.Theme.highlightColor : "transparent"
opacity: 0.2
radius: 3
}
PlasmaComponents.Label {
id: label
anchors.centerIn: parent
text: currentName || Local.Translations.t.desktop
font.bold: true
}
onEntered: requestOpen()
onExited: requestClose()
onClicked: requestToggle()
onWheel: function(wheel) {
if (wheel.angleDelta.y > 0) wheelUp()
else wheelDown()
}
}

View File

@ -0,0 +1,129 @@
import QtQuick 2.15
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
import "." as Local
Item {
id: root
property int desktopIndex: 0
property string desktopName: ""
property bool isActive: false
property bool showPreviews: true
property bool showIcons: true
property real scaleX: 1
property real scaleY: 1
property bool isDragTarget: false
property bool isDragSource: false
property bool canDelete: true
signal clicked()
signal rightClicked()
signal deleteRequested()
signal dragStarted(int index, string name)
signal dragMoved(real mouseX, real mouseY)
signal dragEnded()
property bool isHovered: mouseArea.containsMouse || deleteBtn.containsMouse
Rectangle {
id: desktop
anchors.fill: parent
anchors.margins: root.isDragTarget ? 0 : 2
color: root.isActive ? Qt.darker(PlasmaCore.Theme.highlightColor, 1.3) : PlasmaCore.Theme.backgroundColor
border.width: root.isDragTarget ? 3 : (root.isActive ? 2 : 1)
border.color: root.isDragTarget ? "#3498db" : (root.isActive ? PlasmaCore.Theme.highlightColor : PlasmaCore.Theme.disabledTextColor)
radius: 4; clip: true
opacity: root.isDragSource ? 0.5 : (root.isHovered ? 1 : 0.92)
Behavior on opacity { NumberAnimation { duration: 100 } }
Behavior on anchors.margins { NumberAnimation { duration: 100 } }
// Windows
Item {
anchors.fill: parent; anchors.margins: 2; clip: true
visible: root.showPreviews
Repeater {
model: TasksModel
Rectangle {
readonly property rect geo: model.Geometry
x: Math.round(geo.x * root.scaleX); y: Math.round(geo.y * root.scaleY)
width: Math.max(8, Math.round(geo.width * root.scaleX))
height: Math.max(6, Math.round(geo.height * root.scaleY))
visible: model.IsMinimized !== true
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: root.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
}
}
}
}
// Label (when no previews)
Column {
anchors.centerIn: parent; visible: !root.showPreviews
PlasmaComponents.Label {
anchors.horizontalCenter: parent.horizontalCenter
text: root.desktopIndex + 1; font.bold: true; font.pixelSize: 18
color: root.isActive ? PlasmaCore.Theme.highlightedTextColor : PlasmaCore.Theme.textColor
}
PlasmaComponents.Label {
anchors.horizontalCenter: parent.horizontalCenter
text: root.desktopName; font.pixelSize: 10; width: root.width - 14
elide: Text.ElideRight; horizontalAlignment: Text.AlignHCenter
color: root.isActive ? PlasmaCore.Theme.highlightedTextColor : PlasmaCore.Theme.textColor
}
}
// Badge (when previews enabled)
Rectangle {
visible: root.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: (root.desktopIndex + 1) + " " + root.desktopName
font.pixelSize: 11; font.bold: true; color: "white"
}
}
}
// Delete button
Rectangle {
id: deleteBtn
visible: root.isHovered && root.canDelete && !root.isDragSource
anchors { top: parent.top; right: parent.right; margins: 4 }
width: Math.min(parent.width * 0.25, 36); height: width; radius: width / 2
color: deleteArea.containsMouse ? "#e74c3c" : Qt.rgba(0,0,0,0.7)
property bool containsMouse: deleteArea.containsMouse
Behavior on color { ColorAnimation { duration: 100 } }
PlasmaCore.IconItem { anchors.centerIn: parent; width: parent.width * 0.6; height: width; source: "edit-delete" }
MouseArea { id: deleteArea; anchors.fill: parent; hoverEnabled: true; onClicked: root.deleteRequested() }
}
// Main mouse area
MouseArea {
id: mouseArea
anchors.fill: parent; hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
property real startX: 0; property real startY: 0; property bool dragging: false
onPressed: function(mouse) { if (mouse.button === Qt.LeftButton) { startX = mouse.x; startY = mouse.y; dragging = false } }
onPositionChanged: function(mouse) {
if (!(mouse.buttons & Qt.LeftButton)) return
var dist = Math.sqrt(Math.pow(mouse.x - startX, 2) + Math.pow(mouse.y - startY, 2))
if (!dragging && dist > 10) { dragging = true; root.dragStarted(root.desktopIndex, root.desktopName) }
if (dragging) root.dragMoved(mouse.x, mouse.y)
}
onReleased: function(mouse) { if (mouse.button === Qt.LeftButton && dragging) root.dragEnded(); dragging = false }
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) root.rightClicked()
else if (!dragging) root.clicked()
}
}
}

257
contents/ui/DesktopLogic.js Normal file
View File

@ -0,0 +1,257 @@
// DesktopLogic.js - Pure business logic (testable with Node.js)
// All functions are pure - no side effects, same input = same output
.pragma library
// ============================================================================
// COMMAND BUILDERS - Generate shell commands for KWin D-Bus operations
// ============================================================================
/**
* Build command to remove a desktop
* @param {string} desktopId - The desktop UUID
* @returns {string|null} - Command string or null if invalid
*/
function buildRemoveCommand(desktopId) {
if (!desktopId || typeof desktopId !== 'string' || desktopId.trim() === '') {
return null
}
return "qdbus org.kde.KWin /VirtualDesktopManager removeDesktop '" + desktopId + "'"
}
/**
* Build command to rename a desktop
* @param {string} desktopId - The desktop UUID
* @param {string} newName - The new name
* @returns {string|null} - Command string or null if invalid
*/
function buildRenameCommand(desktopId, newName) {
if (!desktopId || typeof desktopId !== 'string' || desktopId.trim() === '') {
return null
}
if (!newName || typeof newName !== 'string' || newName.trim() === '') {
return null
}
var escaped = escapeShell(newName.trim())
return "qdbus org.kde.KWin /VirtualDesktopManager setDesktopName '" + desktopId + "' '" + escaped + "'"
}
/**
* Build command to create a new desktop
* @param {number} position - Position index for new desktop
* @param {string} name - Name for new desktop
* @returns {string} - Command string
*/
function buildCreateCommand(position, name) {
var pos = Math.max(0, Math.floor(position))
var escaped = escapeShell(name ? name.trim() : 'Desktop')
return "qdbus org.kde.KWin /VirtualDesktopManager createDesktop " + pos + " '" + escaped + "'"
}
/**
* Build bash command to swap windows between two desktops using wmctrl
* @param {number} indexA - First desktop index (0-based)
* @param {number} indexB - Second desktop index (0-based)
* @returns {string} - Bash command string
*/
function buildSwapWindowsCommand(indexA, indexB) {
var a = Math.floor(indexA)
var b = Math.floor(indexB)
return "bash -c 'wins_a=$(wmctrl -l | awk \"\\$2==" + a + " {print \\$1}\"); " +
"wins_b=$(wmctrl -l | awk \"\\$2==" + b + " {print \\$1}\"); " +
"for w in $wins_a; do wmctrl -i -r $w -t " + b + "; done; " +
"for w in $wins_b; do wmctrl -i -r $w -t " + a + "; done'"
}
// ============================================================================
// STRING UTILITIES
// ============================================================================
/**
* Escape string for safe use in shell single quotes
* @param {string} str - Input string
* @returns {string} - Escaped string
*/
function escapeShell(str) {
if (!str || typeof str !== 'string') return ''
return str.replace(/'/g, "'\\''")
}
// ============================================================================
// GRID CALCULATIONS - Pure math functions for layout
// ============================================================================
/**
* Calculate optimal grid dimensions for N items
* Uses square root to create balanced grid
* @param {number} count - Number of items
* @returns {{cols: number, rows: number}} - Grid dimensions
*/
function calculateGrid(count) {
var n = Math.max(1, Math.floor(count))
var cols = Math.max(1, Math.ceil(Math.sqrt(n)))
var rows = Math.max(1, Math.ceil(n / cols))
return { cols: cols, rows: rows }
}
/**
* Calculate preview height maintaining aspect ratio
* @param {number} width - Preview width
* @param {number} screenWidth - Screen width
* @param {number} screenHeight - Screen height
* @returns {number} - Calculated height
*/
function calculatePreviewHeight(width, screenWidth, screenHeight) {
if (!screenWidth || screenWidth <= 0 || !screenHeight || screenHeight <= 0) {
return width * 9 / 16 // Default 16:9
}
return width * screenHeight / screenWidth
}
/**
* Calculate scale factors for positioning windows in preview
* @param {number} previewWidth - Preview width
* @param {number} previewHeight - Preview height
* @param {number} screenWidth - Screen width
* @param {number} screenHeight - Screen height
* @returns {{x: number, y: number}} - Scale factors
*/
function calculateScale(previewWidth, previewHeight, screenWidth, screenHeight) {
return {
x: previewWidth / Math.max(1, screenWidth),
y: previewHeight / Math.max(1, screenHeight)
}
}
// ============================================================================
// NAVIGATION - Desktop switching logic
// ============================================================================
/**
* Calculate next desktop index with wrap-around
* @param {number} current - Current desktop index
* @param {number} count - Total number of desktops
* @param {number} direction - Direction: positive = forward, negative = backward
* @returns {number} - Next desktop index
*/
function nextDesktop(current, count, direction) {
if (count <= 0) return 0
var c = Math.max(0, Math.floor(current))
var n = Math.max(1, Math.floor(count))
if (direction > 0) {
return (c + 1) % n
} else {
return (c - 1 + n) % n
}
}
// ============================================================================
// DRAG AND DROP - Validation and detection
// ============================================================================
/**
* Check if swap operation is valid
* @param {number} indexA - Source index
* @param {number} indexB - Target index
* @param {number} count - Total count
* @returns {boolean} - True if swap is valid
*/
function canSwap(indexA, indexB, count) {
if (typeof indexA !== 'number' || typeof indexB !== 'number' || typeof count !== 'number') {
return false
}
return indexA >= 0 && indexB >= 0 &&
indexA < count && indexB < count &&
indexA !== indexB
}
/**
* Find drop target index from mouse position
* This function needs QML objects, returns -1 for pure JS testing
* @param {object} repeater - QML Repeater
* @param {object} grid - QML Grid
* @param {number} mouseX - Mouse X in grid coordinates
* @param {number} mouseY - Mouse Y in grid coordinates
* @returns {number} - Target index or -1
*/
function findDropTarget(repeater, grid, mouseX, mouseY) {
if (!repeater || !grid || typeof repeater.count !== 'number') {
return -1
}
for (var i = 0; i < repeater.count; i++) {
var item = repeater.itemAt(i)
if (!item) continue
var itemPos = grid.mapFromItem(item, 0, 0)
if (mouseX >= itemPos.x && mouseX < itemPos.x + item.width &&
mouseY >= itemPos.y && mouseY < itemPos.y + item.height) {
return i
}
}
return -1
}
/**
* Calculate distance between two points
* @param {number} x1 - First point X
* @param {number} y1 - First point Y
* @param {number} x2 - Second point X
* @param {number} y2 - Second point Y
* @returns {number} - Distance
*/
function distance(x1, y1, x2, y2) {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
}
/**
* Check if drag threshold is exceeded
* @param {number} startX - Start X
* @param {number} startY - Start Y
* @param {number} currentX - Current X
* @param {number} currentY - Current Y
* @param {number} threshold - Drag threshold (default 10)
* @returns {boolean} - True if threshold exceeded
*/
function isDragStarted(startX, startY, currentX, currentY, threshold) {
var t = threshold || 10
return distance(startX, startY, currentX, currentY) > t
}
// ============================================================================
// VALIDATION - Input validation helpers
// ============================================================================
/**
* Validate desktop index
* @param {number} index - Index to validate
* @param {number} count - Total count
* @returns {boolean} - True if valid
*/
function isValidIndex(index, count) {
return typeof index === 'number' &&
typeof count === 'number' &&
index >= 0 &&
index < count &&
Number.isInteger(index)
}
/**
* Validate desktop name
* @param {string} name - Name to validate
* @returns {boolean} - True if valid
*/
function isValidName(name) {
return typeof name === 'string' && name.trim().length > 0
}
/**
* Clamp value between min and max
* @param {number} value - Value to clamp
* @param {number} min - Minimum
* @param {number} max - Maximum
* @returns {number} - Clamped value
*/
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value))
}

View File

@ -0,0 +1,72 @@
// DesktopManager.js - Pure logic functions (testeable)
.pragma library
// Build command to remove a desktop
function buildRemoveCommand(desktopId) {
if (!desktopId) return null
return "qdbus org.kde.KWin /VirtualDesktopManager removeDesktop '" + desktopId + "'"
}
// Build command to rename a desktop
function buildRenameCommand(desktopId, newName) {
if (!desktopId || !newName || !newName.trim()) return null
var escaped = newName.trim().replace(/'/g, "'\\''")
return "qdbus org.kde.KWin /VirtualDesktopManager setDesktopName '" + desktopId + "' '" + escaped + "'"
}
// Build command to create a desktop
function buildCreateCommand(position, name) {
var escaped = name.trim().replace(/'/g, "'\\''")
return "qdbus org.kde.KWin /VirtualDesktopManager createDesktop " + position + " '" + escaped + "'"
}
// Build commands to swap windows between desktops
function buildSwapWindowsCommand(indexA, indexB) {
return "bash -c 'wins_a=$(wmctrl -l | awk \"\\$2==" + indexA + " {print \\$1}\"); " +
"wins_b=$(wmctrl -l | awk \"\\$2==" + indexB + " {print \\$1}\"); " +
"for w in $wins_a; do wmctrl -i -r $w -t " + indexB + "; done; " +
"for w in $wins_b; do wmctrl -i -r $w -t " + indexA + "; done'"
}
// Escape string for shell
function escapeShell(str) {
return str.replace(/'/g, "'\\''")
}
// Calculate grid dimensions
function calculateGrid(count) {
var cols = Math.max(1, Math.ceil(Math.sqrt(count)))
var rows = Math.max(1, Math.ceil(count / cols))
return { cols: cols, rows: rows }
}
// Calculate desktop preview dimensions
function calculatePreviewSize(baseSize, screenWidth, screenHeight) {
var width = baseSize
var height = screenHeight > 0 ? baseSize * screenHeight / screenWidth : baseSize * 9 / 16
return { width: width, height: height }
}
// Calculate scale factors for window positioning
function calculateScale(previewWidth, previewHeight, screenWidth, screenHeight) {
return {
x: previewWidth / Math.max(1, screenWidth),
y: previewHeight / Math.max(1, screenHeight)
}
}
// Validate swap operation
function canSwap(indexA, indexB, count) {
return indexA >= 0 && indexB >= 0 &&
indexA < count && indexB < count &&
indexA !== indexB
}
// Calculate next desktop (for wheel navigation)
function nextDesktop(current, count, direction) {
if (direction > 0) {
return (current + 1) % count
} else {
return (current - 1 + count) % count
}
}

View File

@ -0,0 +1,255 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.plasma.components 3.0 as PlasmaComponents
import "DesktopManager.js" as DM
import "components" as Components
import "dialogs" as Dialogs
import "." as Local
Item {
id: root
property var pagerModel
property bool showPreviews: true
property bool showIcons: true
property int previewSize: 130
signal hoverEntered()
signal hoverExited()
signal desktopClicked(int index)
signal deleteDesktop(int index)
signal createDesktop(string name)
signal renameDesktop(int index, string name)
signal swapDesktops(int indexA, int indexB)
signal sizeCommitted(int size)
signal closeRequested()
property bool dialogOpen: newDlg.visible || renameDlg.visible
// Use JS for calculations
readonly property var gridSize: DM.calculateGrid(pagerModel ? pagerModel.count : 1)
readonly property var previewDims: DM.calculatePreviewSize(
previewSize,
pagerModel ? pagerModel.pagerItemSize.width : 1920,
pagerModel ? pagerModel.pagerItemSize.height : 1080
)
readonly property var scaleFactor: DM.calculateScale(
previewDims.width, previewDims.height,
pagerModel ? pagerModel.pagerItemSize.width : 1920,
pagerModel ? pagerModel.pagerItemSize.height : 1080
)
Layout.preferredWidth: gridSize.cols * (previewDims.width + 8) + 32
Layout.preferredHeight: gridSize.rows * (previewDims.height + 8) + 70
Layout.minimumWidth: 200
Layout.minimumHeight: 120
// Drag state
property int dragSource: -1
property int dropTarget: -1
// Background mouse area
MouseArea {
anchors.fill: parent
hoverEnabled: true
z: -1
onEntered: root.hoverEntered()
onExited: root.hoverExited()
onReleased: {
dragSource = -1
dropTarget = -1
dragRect.active = false
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
// Desktop grid
Grid {
id: grid
Layout.fillWidth: true
Layout.fillHeight: true
Layout.alignment: Qt.AlignHCenter
columns: gridSize.cols
spacing: 6
Repeater {
id: repeater
model: pagerModel
DesktopDelegate {
width: previewDims.width
height: previewDims.height
desktopIndex: index
desktopName: model.display || (Local.Translations.t.desktop + " " + (index + 1))
isActive: pagerModel ? index === pagerModel.currentPage : false
showPreviews: root.showPreviews
showIcons: root.showIcons
scaleX: scaleFactor.x
scaleY: scaleFactor.y
isDragTarget: root.dropTarget === index && root.dragSource !== index
isDragSource: root.dragSource === index
canDelete: pagerModel ? pagerModel.count > 1 : false
onClicked: {
root.desktopClicked(index)
root.closeRequested()
}
onRightClicked: ctxMenu.show(index, desktopName)
onDeleteRequested: root.deleteDesktop(index)
onDragStarted: function(idx, name) {
root.dragSource = idx
dragRect.dragName = name
dragRect.active = true
}
onDragMoved: function(mx, my) {
var pos = mapToItem(root, mx, my)
dragRect.x = pos.x - dragRect.width / 2
dragRect.y = pos.y - dragRect.height / 2
// Find drop target
var gpos = mapToItem(grid, mx, my)
root.dropTarget = -1
for (var i = 0; i < repeater.count; i++) {
var item = repeater.itemAt(i)
if (!item) continue
var ipos = grid.mapFromItem(item, 0, 0)
if (gpos.x >= ipos.x && gpos.x < ipos.x + item.width &&
gpos.y >= ipos.y && gpos.y < ipos.y + item.height) {
root.dropTarget = i
break
}
}
}
onDragEnded: {
if (root.dragSource >= 0 && root.dropTarget >= 0 && root.dragSource !== root.dropTarget) {
root.swapDesktops(root.dragSource, root.dropTarget)
}
root.dragSource = -1
root.dropTarget = -1
dragRect.active = false
}
}
}
}
// Bottom bar
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 36
spacing: 8
PlasmaComponents.Button {
icon.name: "list-add"
text: Local.Translations.t.add
onClicked: newDlg.open(Local.Translations.t.desktop + " " + (pagerModel ? pagerModel.count + 1 : 1))
}
Item { Layout.fillWidth: true }
PlasmaComponents.Label {
text: (pagerModel ? pagerModel.count : 0) + " " + Local.Translations.t.desktops
opacity: 0.6
font.pixelSize: 11
}
// Resize handle
Rectangle {
width: 24
height: 24
color: "transparent"
PlasmaCore.IconItem {
anchors.fill: parent
anchors.margins: 2
source: "transform-scale"
opacity: resizeArea.containsMouse || resizeArea.pressed ? 1 : 0.4
}
MouseArea {
id: resizeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SizeFDiagCursor
property int startY: 0
property int startSize: 0
onPressed: {
startY = mouseY
startSize = root.previewSize
}
onPositionChanged: {
if (pressed) {
root.previewSize = Math.max(80, Math.min(200, Math.round(startSize + (mouseY - startY) * 0.8)))
}
}
onReleased: root.sizeCommitted(root.previewSize)
}
}
}
}
// Drag rectangle
Components.DragRect {
id: dragRect
width: previewDims.width - 4
height: previewDims.height - 4
}
// Dialogs
Dialogs.NewDesktopDialog {
id: newDlg
onAccepted: function(name) { root.createDesktop(name) }
}
Dialogs.RenameDialog {
id: renameDlg
onAccepted: function(idx, name) { root.renameDesktop(idx, name) }
}
// Context menu
Menu {
id: ctxMenu
property int idx: 0
property string name: ""
function show(i, n) {
idx = i
name = n
popup()
}
MenuItem {
text: Local.Translations.t.switchTo + " \"" + ctxMenu.name + "\""
icon.name: "go-jump"
onTriggered: {
root.desktopClicked(ctxMenu.idx)
root.closeRequested()
}
}
MenuSeparator {}
MenuItem {
text: Local.Translations.t.rename
icon.name: "edit-rename"
onTriggered: renameDlg.open(ctxMenu.idx, ctxMenu.name)
}
MenuItem {
text: Local.Translations.t.delete_
icon.name: "edit-delete"
enabled: pagerModel ? pagerModel.count > 1 : false
onTriggered: root.deleteDesktop(ctxMenu.idx)
}
MenuSeparator {}
MenuItem {
text: Local.Translations.t.newDesktop
icon.name: "list-add"
onTriggered: newDlg.open(Local.Translations.t.desktop + " " + (pagerModel ? pagerModel.count + 1 : 1))
}
}
}

View File

@ -0,0 +1,53 @@
pragma Singleton
import QtQuick 2.15
QtObject {
readonly property string lang: Qt.locale().name.substring(0, 2)
readonly property var strings: ({
"en": {
desktop: "Desktop", add: "Add", desktops: "desktops",
rename: "Rename...", delete_: "Delete",
renameTitle: "Rename Desktop", switchTo: "Switch to",
newDesktop: "New Desktop", enterName: "Enter name:",
create: "Create", cancel: "Cancel", save: "Save"
},
"es": {
desktop: "Escritorio", add: "Agregar", desktops: "escritorios",
rename: "Renombrar...", delete_: "Eliminar",
renameTitle: "Renombrar Escritorio", switchTo: "Cambiar a",
newDesktop: "Nuevo Escritorio", enterName: "Ingrese nombre:",
create: "Crear", cancel: "Cancelar", save: "Guardar"
},
"zh": {
desktop: "桌面", add: "添加", desktops: "个桌面",
rename: "重命名...", delete_: "删除",
renameTitle: "重命名桌面", switchTo: "切换到",
newDesktop: "新建桌面", enterName: "输入名称:",
create: "创建", cancel: "取消", save: "保存"
},
"fr": {
desktop: "Bureau", add: "Ajouter", desktops: "bureaux",
rename: "Renommer...", delete_: "Supprimer",
renameTitle: "Renommer le bureau", switchTo: "Basculer vers",
newDesktop: "Nouveau bureau", enterName: "Entrez le nom:",
create: "Créer", cancel: "Annuler", save: "Enregistrer"
},
"de": {
desktop: "Desktop", add: "Hinzufügen", desktops: "Desktops",
rename: "Umbenennen...", delete_: "Löschen",
renameTitle: "Desktop umbenennen", switchTo: "Wechseln zu",
newDesktop: "Neuer Desktop", enterName: "Name eingeben:",
create: "Erstellen", cancel: "Abbrechen", save: "Speichern"
},
"pt": {
desktop: "Área de trabalho", add: "Adicionar", desktops: "áreas",
rename: "Renomear...", delete_: "Excluir",
renameTitle: "Renomear", switchTo: "Mudar para",
newDesktop: "Nova área", enterName: "Digite o nome:",
create: "Criar", cancel: "Cancelar", save: "Salvar"
}
})
readonly property var t: strings[lang] || strings["en"]
}

View File

@ -0,0 +1,23 @@
import QtQuick 2.15
import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.plasma.components 3.0 as PlasmaComponents
Rectangle {
id: root
property string dragName: ""
property bool active: false
visible: active
color: PlasmaCore.Theme.highlightColor
opacity: 0.9
radius: 4
z: 1000
PlasmaComponents.Label {
anchors.centerIn: parent
text: root.dragName
font.bold: true
color: PlasmaCore.Theme.highlightedTextColor
}
}

View File

@ -0,0 +1,70 @@
import QtQuick 2.15
import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.plasma.components 3.0 as PlasmaComponents
import ".." as Local
Rectangle {
id: root
visible: false
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.7)
z: 100
property string defaultName: ""
signal accepted(string name)
signal cancelled()
function open(name) {
defaultName = name
nameField.text = name
visible = true
nameField.forceActiveFocus()
nameField.selectAll()
}
function close() {
visible = false
cancelled()
}
MouseArea { anchors.fill: parent; onClicked: root.close() }
Rectangle {
anchors.centerIn: parent
width: 280; height: 130
color: PlasmaCore.Theme.backgroundColor
border.color: PlasmaCore.Theme.highlightColor
border.width: 1; radius: 8
MouseArea { anchors.fill: parent }
Column {
anchors.fill: parent; anchors.margins: 16; spacing: 12
PlasmaComponents.Label {
text: Local.Translations.t.newDesktop
font.bold: true; font.pixelSize: 14
}
PlasmaComponents.TextField {
id: nameField
width: parent.width
placeholderText: Local.Translations.t.enterName
onAccepted: if (text.trim()) { root.accepted(text); root.close() }
}
Row {
anchors.right: parent.right; spacing: 8
PlasmaComponents.Button {
text: Local.Translations.t.cancel
onClicked: root.close()
}
PlasmaComponents.Button {
text: Local.Translations.t.create
highlighted: true
onClicked: if (nameField.text.trim()) { root.accepted(nameField.text); root.close() }
}
}
}
}
}

View File

@ -0,0 +1,76 @@
import QtQuick 2.15
import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.plasma.components 3.0 as PlasmaComponents
import ".." as Local
Rectangle {
id: root
visible: false
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.7)
z: 100
property int desktopIndex: -1
signal accepted(int index, string name)
signal cancelled()
function open(index, name) {
desktopIndex = index
nameField.text = name
visible = true
nameField.forceActiveFocus()
nameField.selectAll()
}
function close() {
visible = false
cancelled()
}
function doRename() {
if (nameField.text.trim()) {
accepted(desktopIndex, nameField.text)
close()
}
}
MouseArea { anchors.fill: parent; onClicked: root.close() }
Rectangle {
anchors.centerIn: parent
width: 280; height: 130
color: PlasmaCore.Theme.backgroundColor
border.color: PlasmaCore.Theme.highlightColor
border.width: 1; radius: 8
MouseArea { anchors.fill: parent }
Column {
anchors.fill: parent; anchors.margins: 16; spacing: 12
PlasmaComponents.Label {
text: Local.Translations.t.renameTitle
font.bold: true; font.pixelSize: 14
}
PlasmaComponents.TextField {
id: nameField
width: parent.width
onAccepted: root.doRename()
}
Row {
anchors.right: parent.right; spacing: 8
PlasmaComponents.Button {
text: Local.Translations.t.cancel
onClicked: root.close()
}
PlasmaComponents.Button {
text: Local.Translations.t.save
highlighted: true
onClicked: root.doRename()
}
}
}
}
}

View File

@ -5,87 +5,93 @@ import org.kde.plasma.plasmoid 2.0
import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.plasma.components 3.0 as PlasmaComponents import org.kde.plasma.components 3.0 as PlasmaComponents
import org.kde.plasma.private.pager 2.0 import org.kde.plasma.private.pager 2.0
import "DesktopLogic.js" as Logic
Item { Item {
id: root id: root
Plasmoid.preferredRepresentation: Plasmoid.compactRepresentation Plasmoid.preferredRepresentation: Plasmoid.compactRepresentation
//
// INTERNATIONALIZATION (i18n)
//
readonly property string systemLang: Qt.locale().name.substring(0, 2)
readonly property var translations: ({
"en": { desktop: "Desktop", add: "Add", desktops: "desktops", rename: "Rename...", delete_: "Delete",
renameTitle: "Rename Desktop", switchTo: "Switch to", moveWindowHere: "Move window here",
newDesktop: "New Desktop", confirmDelete: "Delete this desktop?" },
"es": { desktop: "Escritorio", add: "Agregar", desktops: "escritorios", rename: "Renombrar...", delete_: "Eliminar",
renameTitle: "Renombrar Escritorio", switchTo: "Cambiar a", moveWindowHere: "Mover ventana aquí",
newDesktop: "Nuevo Escritorio", confirmDelete: "¿Eliminar este escritorio?" },
"zh": { desktop: "桌面", add: "添加", desktops: "个桌面", rename: "重命名...", delete_: "删除",
renameTitle: "重命名桌面", switchTo: "切换到", moveWindowHere: "移动窗口到此处",
newDesktop: "新建桌面", confirmDelete: "删除此桌面?" },
"fr": { desktop: "Bureau", add: "Ajouter", desktops: "bureaux", rename: "Renommer...", delete_: "Supprimer",
renameTitle: "Renommer le bureau", switchTo: "Basculer vers", moveWindowHere: "Déplacer la fenêtre ici",
newDesktop: "Nouveau bureau", confirmDelete: "Supprimer ce bureau ?" },
"de": { desktop: "Desktop", add: "Hinzufügen", desktops: "Desktops", rename: "Umbenennen...", delete_: "Löschen",
renameTitle: "Desktop umbenennen", switchTo: "Wechseln zu", moveWindowHere: "Fenster hierher verschieben",
newDesktop: "Neuer Desktop", confirmDelete: "Diesen Desktop löschen?" },
"pt": { desktop: "Área de trabalho", add: "Adicionar", desktops: "áreas de trabalho", rename: "Renomear...", delete_: "Excluir",
renameTitle: "Renomear área de trabalho", switchTo: "Mudar para", moveWindowHere: "Mover janela para cá",
newDesktop: "Nova área de trabalho", confirmDelete: "Excluir esta área de trabalho?" }
})
readonly property var t: translations[systemLang] || translations["en"]
//
// CONFIGURATION
//
readonly property bool showPreviews: plasmoid.configuration.showWindowPreviews readonly property bool showPreviews: plasmoid.configuration.showWindowPreviews
readonly property bool showIcons: plasmoid.configuration.showWindowIcons readonly property bool showIcons: plasmoid.configuration.showWindowIcons
readonly property int previewSize: plasmoid.configuration.previewSize || 130 property int previewSize: plasmoid.configuration.previewSize || 130
property bool hoverCompact: false
property bool hoverPopup: false
Timer { id: openTimer; interval: 80; onTriggered: plasmoid.expanded = true }
Timer { id: closeTimer; interval: 400; onTriggered: if (!hoverCompact && !hoverPopup) plasmoid.expanded = false }
//
// PAGER MODEL (native KDE plugin with real window geometry)
//
PagerModel { PagerModel {
id: pagerModel id: pagerModel
enabled: root.visible enabled: true
showDesktop: false showDesktop: false
pagerType: PagerModel.VirtualDesktops pagerType: PagerModel.VirtualDesktops
} }
// // Store desktop IDs fetched from D-Bus
// HELPER FUNCTIONS property var desktopIds: ({})
//
PlasmaCore.DataSource {
id: executable
engine: "executable"
onNewData: {
var stdout = data["stdout"] || ""
// Parse desktop IDs from qdbus output
if (sourceName.indexOf("desktops") > -1) {
parseDesktopIds(stdout)
}
disconnectSource(sourceName)
}
Component.onCompleted: refreshDesktopIds()
}
function refreshDesktopIds() {
executable.connectSource("qdbus --literal org.kde.KWin /VirtualDesktopManager org.kde.KWin.VirtualDesktopManager.desktops")
}
function parseDesktopIds(output) {
// Parse: [Argument: (uss) 0, "uuid", "name"], ...
var regex = /\[Argument: \(uss\) (\d+), "([^"]+)", "([^"]+)"\]/g
var match
var ids = {}
while ((match = regex.exec(output)) !== null) {
var idx = parseInt(match[1])
ids[idx] = match[2]
}
desktopIds = ids
}
// Refresh IDs when desktop count changes
Connections {
target: pagerModel
function onCountChanged() {
refreshDesktopIds()
}
}
function run(cmd) {
if (cmd) {
executable.connectSource(cmd)
}
}
function getDesktopId(index) {
if (index < 0 || index >= pagerModel.count) return ""
return desktopIds[index] || ""
}
function getDesktopName(index) {
return pagerModel.data(pagerModel.index(index, 0), Qt.DisplayRole) || ("Desktop " + (index + 1))
}
function currentDesktopName() { function currentDesktopName() {
if (pagerModel.count > 0 && pagerModel.currentPage >= 0) { if (pagerModel.count > 0 && pagerModel.currentPage >= 0)
var idx = pagerModel.index(pagerModel.currentPage, 0) return getDesktopName(pagerModel.currentPage)
var name = pagerModel.data(idx, Qt.DisplayRole) return "Desktop"
return name || (t.desktop + " " + (pagerModel.currentPage + 1))
}
return t.desktop
} }
function addDesktop() { pagerModel.addDesktop() }
function removeDesktop(index) {
if (pagerModel.count > 1) {
pagerModel.changePage(index)
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 { Plasmoid.compactRepresentation: MouseArea {
id: compactArea
Layout.minimumWidth: compactLabel.implicitWidth + 16 Layout.minimumWidth: compactLabel.implicitWidth + 16
hoverEnabled: true hoverEnabled: true
@ -105,39 +111,127 @@ Item {
onEntered: { hoverCompact = true; closeTimer.stop(); openTimer.start() } onEntered: { hoverCompact = true; closeTimer.stop(); openTimer.start() }
onExited: { hoverCompact = false; openTimer.stop(); if (!hoverPopup) closeTimer.start() } onExited: { hoverCompact = false; openTimer.stop(); if (!hoverPopup) closeTimer.start() }
onClicked: { openTimer.stop(); closeTimer.stop(); plasmoid.expanded = !plasmoid.expanded } onClicked: { openTimer.stop(); closeTimer.stop(); plasmoid.expanded = !plasmoid.expanded }
onWheel: { onWheel: function(wheel) {
var next = wheel.angleDelta.y > 0 pagerModel.changePage(Logic.nextDesktop(pagerModel.currentPage, pagerModel.count, wheel.angleDelta.y > 0 ? -1 : 1))
? (pagerModel.currentPage - 1 + pagerModel.count) % pagerModel.count
: (pagerModel.currentPage + 1) % pagerModel.count
pagerModel.changePage(next)
} }
} }
//
// FULL REPRESENTATION
//
Plasmoid.fullRepresentation: Item { Plasmoid.fullRepresentation: Item {
id: popup id: popup
readonly property int cols: Math.max(1, Math.ceil(Math.sqrt(pagerModel.count))) readonly property var gridDims: Logic.calculateGrid(pagerModel.count)
readonly property int rows: Math.max(1, Math.ceil(pagerModel.count / cols))
readonly property real deskW: previewSize readonly property real deskW: previewSize
readonly property real deskH: pagerModel.pagerItemSize.height > 0 readonly property real deskH: Logic.calculatePreviewHeight(previewSize, pagerModel.pagerItemSize.width, pagerModel.pagerItemSize.height)
? deskW * pagerModel.pagerItemSize.height / pagerModel.pagerItemSize.width
: deskW * 9 / 16
readonly property real scaleX: deskW / Math.max(1, pagerModel.pagerItemSize.width) readonly property real scaleX: deskW / Math.max(1, pagerModel.pagerItemSize.width)
readonly property real scaleY: deskH / Math.max(1, pagerModel.pagerItemSize.height) readonly property real scaleY: deskH / Math.max(1, pagerModel.pagerItemSize.height)
Layout.preferredWidth: cols * (deskW + 8) + 32 Layout.preferredWidth: gridDims.cols * (deskW + 8) + 32
Layout.preferredHeight: rows * (deskH + 8) + 70 Layout.preferredHeight: gridDims.rows * (deskH + 8) + 70
Layout.minimumWidth: 200 Layout.minimumWidth: 200
Layout.minimumHeight: 120 Layout.minimumHeight: 120
MouseArea { property int dragSource: -1
anchors.fill: parent property int dropTarget: -1
hoverEnabled: true
onEntered: { hoverPopup = true; closeTimer.stop() } Timer {
onExited: { hoverPopup = false; if (!hoverCompact) closeTimer.start() } id: refreshTimer
interval: 300
onTriggered: pagerModel.refresh()
}
PlasmaComponents.Menu {
id: contextMenu
property int desktopIndex: -1
property string desktopName: ""
property string desktopId: ""
PlasmaComponents.MenuItem {
text: "Switch to"
icon.name: "go-jump"
onClicked: {
pagerModel.changePage(contextMenu.desktopIndex)
plasmoid.expanded = false
}
}
PlasmaComponents.MenuItem {
text: "Rename..."
icon.name: "edit-rename"
onClicked: {
renameDialog.desktopId = contextMenu.desktopId
renameDialog.desktopName = contextMenu.desktopName
renameDialog.open()
}
}
PlasmaComponents.MenuSeparator {}
PlasmaComponents.MenuItem {
text: "Delete"
icon.name: "edit-delete"
enabled: pagerModel.count > 1
onClicked: run(Logic.buildRemoveCommand(contextMenu.desktopId))
}
PlasmaComponents.MenuSeparator {}
PlasmaComponents.MenuItem {
text: "New Desktop"
icon.name: "list-add"
onClicked: run(Logic.buildCreateCommand(pagerModel.count, "Desktop " + (pagerModel.count + 1)))
}
}
Dialog {
id: renameDialog
title: "Rename Desktop"
anchors.centerIn: parent
modal: true
standardButtons: Dialog.Ok | Dialog.Cancel
property string desktopId: ""
property string desktopName: ""
onOpened: {
renameField.text = desktopName
renameField.selectAll()
renameField.forceActiveFocus()
}
onAccepted: {
if (renameField.text.trim()) {
run(Logic.buildRenameCommand(desktopId, renameField.text.trim()))
refreshTimer.start()
}
}
contentItem: ColumnLayout {
spacing: 10
PlasmaComponents.Label {
text: "Enter new name:"
}
PlasmaComponents.TextField {
id: renameField
Layout.fillWidth: true
Layout.preferredWidth: 250
onAccepted: renameDialog.accept()
}
}
}
// Track hover state for entire popup
HoverHandler {
id: popupHover
onHoveredChanged: {
if (hovered) {
hoverPopup = true
closeTimer.stop()
} else {
hoverPopup = false
if (!hoverCompact) closeTimer.start()
}
}
}
// Cleanup drag on release anywhere
TapHandler {
acceptedButtons: Qt.LeftButton
onCanceled: { popup.dragSource = -1; popup.dropTarget = -1 }
} }
ColumnLayout { ColumnLayout {
@ -145,15 +239,12 @@ Item {
anchors.margins: 10 anchors.margins: 10
spacing: 8 spacing: 8
//
// DESKTOP GRID
//
Grid { Grid {
id: desktopGrid id: grid
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
columns: popup.cols columns: popup.gridDims.cols
spacing: 6 spacing: 6
Repeater { Repeater {
@ -161,70 +252,24 @@ Item {
model: pagerModel model: pagerModel
Rectangle { Rectangle {
id: desktop id: desktopItem
width: popup.deskW width: popup.deskW
height: popup.deskH height: popup.deskH
color: index === pagerModel.currentPage
readonly property string desktopName: model.display || (t.desktop + " " + (index + 1)) ? Qt.darker(PlasmaCore.Theme.highlightColor, 1.3)
readonly property bool isActive: index === pagerModel.currentPage : PlasmaCore.Theme.backgroundColor
property bool isHovered: false border.width: popup.dropTarget === index && popup.dragSource !== index ? 3 : (index === pagerModel.currentPage ? 2 : 1)
border.color: popup.dropTarget === index && popup.dragSource !== index
color: isActive ? Qt.darker(PlasmaCore.Theme.highlightColor, 1.3) : PlasmaCore.Theme.backgroundColor ? "#3498db"
border.width: isActive ? 2 : 1 : (index === pagerModel.currentPage ? PlasmaCore.Theme.highlightColor : PlasmaCore.Theme.disabledTextColor)
border.color: isActive ? PlasmaCore.Theme.highlightColor : PlasmaCore.Theme.disabledTextColor
radius: 4 radius: 4
clip: true clip: true
opacity: isHovered ? 1 : 0.92 opacity: popup.dragSource === index ? 0.5 : (desktopMA.containsMouse || deleteMA.containsMouse ? 1 : 0.92)
MouseArea { property string desktopName: model.display || ("Desktop " + (index + 1))
anchors.fill: parent property bool isHovered: desktopMA.containsMouse || deleteMA.containsMouse
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onEntered: { hoverPopup = true; closeTimer.stop(); desktop.isHovered = true }
onExited: { desktop.isHovered = false }
onClicked: {
if (mouse.button === Qt.RightButton) {
ctxMenu.desktopIndex = index
ctxMenu.desktopName = desktop.desktopName
ctxMenu.popup()
} else {
pagerModel.changePage(index)
plasmoid.expanded = false
}
}
}
// // Windows
// DELETE BUTTON (top-right, on hover)
//
Rectangle {
id: deleteBtn
visible: desktop.isHovered && pagerModel.count > 1
anchors { top: parent.top; right: parent.right; margins: 2 }
width: Math.min(parent.width * 0.25, 44)
height: width
radius: width / 2
color: deleteMouseArea.containsMouse ? "#e74c3c" : Qt.rgba(0,0,0,0.6)
PlasmaCore.IconItem {
anchors.centerIn: parent
width: parent.width * 0.6
height: width
source: "edit-delete"
usesPlasmaTheme: false
}
MouseArea {
id: deleteMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: removeDesktop(index)
}
}
//
// WINDOWS with real geometry
//
Item { Item {
anchors.fill: parent anchors.fill: parent
anchors.margins: 2 anchors.margins: 2
@ -233,17 +278,13 @@ Item {
Repeater { Repeater {
model: TasksModel model: TasksModel
Rectangle { Rectangle {
readonly property rect geo: model.Geometry property rect geo: model.Geometry
readonly property bool minimized: model.IsMinimized === true
x: Math.round(geo.x * popup.scaleX) x: Math.round(geo.x * popup.scaleX)
y: Math.round(geo.y * popup.scaleY) y: Math.round(geo.y * popup.scaleY)
width: Math.max(8, Math.round(geo.width * popup.scaleX)) width: Math.max(8, Math.round(geo.width * popup.scaleX))
height: Math.max(6, Math.round(geo.height * popup.scaleY)) height: Math.max(6, Math.round(geo.height * popup.scaleY))
visible: !minimized visible: model.IsMinimized !== true
color: model.IsActive ? Qt.rgba(1,1,1,0.4) : Qt.rgba(1,1,1,0.2) color: model.IsActive ? Qt.rgba(1,1,1,0.4) : Qt.rgba(1,1,1,0.2)
border.width: 1 border.width: 1
border.color: model.IsActive ? PlasmaCore.Theme.highlightColor : PlasmaCore.Theme.textColor border.color: model.IsActive ? PlasmaCore.Theme.highlightColor : PlasmaCore.Theme.textColor
@ -261,181 +302,169 @@ Item {
} }
} }
// // Badge
// DESKTOP LABEL (centered, when no previews)
//
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 { Rectangle {
visible: showPreviews anchors.bottom: parent.bottom
anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter; bottomMargin: 3 } anchors.horizontalCenter: parent.horizontalCenter
width: badgeLbl.implicitWidth + 12; height: badgeLbl.implicitHeight + 4 anchors.bottomMargin: 3
color: Qt.rgba(0,0,0,0.7); radius: 3 width: badgeLabel.implicitWidth + 12
height: badgeLabel.implicitHeight + 4
color: Qt.rgba(0,0,0,0.7)
radius: 3
PlasmaComponents.Label { PlasmaComponents.Label {
id: badgeLbl; anchors.centerIn: parent id: badgeLabel
text: (index + 1) + " " + desktop.desktopName anchors.centerIn: parent
font.pixelSize: 11; font.bold: true; color: "white" text: (index + 1) + " " + desktopItem.desktopName
font.pixelSize: 11
font.bold: true
color: "white"
} }
} }
}
}
}
//
// 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: t.add
onClicked: pagerModel.addDesktop()
}
Item { Layout.fillWidth: true }
PlasmaComponents.Label {
text: pagerModel.count + " " + t.desktops
opacity: 0.6; font.pixelSize: 11
}
//
// RESIZE HANDLE
//
Rectangle {
width: 20; height: 20
color: "transparent"
PlasmaCore.IconItem {
anchors.fill: parent
source: "transform-scale"
opacity: resizeMouseArea.containsMouse ? 1 : 0.5
}
MouseArea { MouseArea {
id: resizeMouseArea id: desktopMA
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.SizeFDiagCursor acceptedButtons: Qt.LeftButton | Qt.RightButton
property real startX: 0 property real startX: 0
property real startY: 0 property real startY: 0
property int startSize: 0 property bool dragging: false
onPressed: { onPressed: function(mouse) {
startX = mouse.x if (mouse.button === Qt.LeftButton) {
startY = mouse.y startX = mouse.x
startSize = previewSize startY = mouse.y
dragging = false
}
} }
onPositionChanged: {
if (pressed) { onPositionChanged: function(mouse) {
var delta = (mouse.x - startX + mouse.y - startY) / 2 if (!(mouse.buttons & Qt.LeftButton)) return
var newSize = Math.max(80, Math.min(200, startSize + delta)) var dist = Math.sqrt(Math.pow(mouse.x - startX, 2) + Math.pow(mouse.y - startY, 2))
plasmoid.configuration.previewSize = Math.round(newSize) if (!dragging && dist > 10) {
dragging = true
popup.dragSource = index
dragRect.dragName = desktopItem.desktopName
}
if (dragging) {
var pos = mapToItem(popup, mouse.x, mouse.y)
dragRect.x = pos.x - dragRect.width / 2
dragRect.y = pos.y - dragRect.height / 2
var gpos = mapToItem(grid, mouse.x, mouse.y)
popup.dropTarget = Logic.findDropTarget(repeater, grid, gpos.x, gpos.y)
}
}
onReleased: function(mouse) {
if (mouse.button === Qt.LeftButton && dragging && Logic.canSwap(popup.dragSource, popup.dropTarget, pagerModel.count)) {
var idxA = popup.dragSource
var idxB = popup.dropTarget
var nameA = getDesktopName(idxA)
var nameB = getDesktopName(idxB)
var idA = getDesktopId(idxA)
var idB = getDesktopId(idxB)
if (idA && idB) {
run(Logic.buildSwapWindowsCommand(idxA, idxB))
run(Logic.buildRenameCommand(idA, nameB))
run(Logic.buildRenameCommand(idB, nameA))
refreshTimer.start()
}
}
dragging = false
popup.dragSource = -1
popup.dropTarget = -1
}
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
contextMenu.desktopIndex = index
contextMenu.desktopName = desktopItem.desktopName
contextMenu.desktopId = getDesktopId(index)
contextMenu.popup()
} else if (!dragging) {
pagerModel.changePage(index)
plasmoid.expanded = false
}
}
}
// Delete button - declared AFTER desktopMA to receive events first
Rectangle {
id: deleteBtn
visible: desktopItem.isHovered && pagerModel.count > 1 && popup.dragSource < 0
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 4
width: Math.min(parent.width * 0.25, 36)
height: width
radius: width / 2
color: deleteMA.containsMouse ? "#e74c3c" : Qt.rgba(0,0,0,0.7)
PlasmaCore.IconItem {
anchors.centerIn: parent
width: parent.width * 0.6
height: width
source: "edit-delete"
}
MouseArea {
id: deleteMA
anchors.fill: parent
hoverEnabled: true
onClicked: {
var id = getDesktopId(index)
if (id) run(Logic.buildRemoveCommand(id))
} }
} }
} }
} }
} }
} }
}
// RowLayout {
// CONTEXT MENU Layout.fillWidth: true
// Layout.preferredHeight: 36
Menu { spacing: 8
id: ctxMenu
property int desktopIndex: 0
property string desktopName: ""
MenuItem { PlasmaComponents.Button {
text: t.switchTo + " \"" + ctxMenu.desktopName + "\"" icon.name: "list-add"
icon.name: "go-jump" text: "Add"
onTriggered: { onClicked: run(Logic.buildCreateCommand(pagerModel.count, "Desktop " + (pagerModel.count + 1)))
pagerModel.changePage(ctxMenu.desktopIndex)
plasmoid.expanded = false
} }
}
MenuSeparator {}
MenuItem {
text: t.rename
icon.name: "edit-rename"
onTriggered: {
renameDlg.idx = ctxMenu.desktopIndex
renameDlg.open()
renameField.text = ctxMenu.desktopName
renameField.selectAll()
}
}
MenuItem {
text: t.delete_
icon.name: "edit-delete"
enabled: pagerModel.count > 1
onTriggered: removeDesktop(ctxMenu.desktopIndex)
}
MenuSeparator {}
MenuItem {
text: t.newDesktop
icon.name: "list-add"
onTriggered: pagerModel.addDesktop()
}
}
// Item { Layout.fillWidth: true }
// RENAME DIALOG
// PlasmaComponents.Label {
Dialog { text: pagerModel.count + " desktops"
id: renameDlg opacity: 0.6
property int idx: 0 font.pixelSize: 11
title: t.renameTitle
standardButtons: Dialog.Ok | Dialog.Cancel
anchors.centerIn: parent
contentItem: PlasmaComponents.TextField {
id: renameField
Layout.preferredWidth: 180
onAccepted: renameDlg.accept()
}
onAccepted: {
if (renameField.text.trim()) {
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 { // Drag rectangle
id: execSource Rectangle {
engine: "executable" id: dragRect
onNewData: disconnectSource(sourceName) visible: popup.dragSource >= 0
width: popup.deskW - 4
height: popup.deskH - 4
color: PlasmaCore.Theme.highlightColor
opacity: 0.9
radius: 4
z: 1000
property string dragName: ""
PlasmaComponents.Label {
anchors.centerIn: parent
text: dragRect.dragName
font.bold: true
color: PlasmaCore.Theme.highlightedTextColor
}
} }
} }
} }

1
contents/ui/qmldir Normal file
View File

@ -0,0 +1 @@
singleton Translations 1.0 Translations.qml

38
tests/run_all_tests.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/bash
# Run all tests for Virtual Desktop Switcher
cd "$(dirname "$0")"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Virtual Desktop Switcher - Test Suite ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
FAILED=0
echo "▶ Running test_DesktopLogic.js..."
if node test_DesktopLogic.js; then
echo ""
else
FAILED=1
fi
echo ""
echo "▶ Running test_DesktopManager.js..."
if node test_DesktopManager.js; then
echo ""
else
FAILED=1
fi
echo ""
if [ $FAILED -eq 0 ]; then
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ✓ ALL TESTS PASSED ║"
echo "╚══════════════════════════════════════════════════════════════╝"
exit 0
else
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ✗ SOME TESTS FAILED ║"
echo "╚══════════════════════════════════════════════════════════════╝"
exit 1
fi

587
tests/test_DesktopLogic.js Normal file
View File

@ -0,0 +1,587 @@
#!/usr/bin/env node
/**
* Test Suite for DesktopLogic.js
* Provides comprehensive coverage of all pure functions
* Run: node test_DesktopLogic.js
*/
const fs = require('fs');
const path = require('path');
// Load and prepare the module
const srcPath = path.join(__dirname, '../contents/ui/DesktopLogic.js');
let code = fs.readFileSync(srcPath, 'utf8');
code = code.replace('.pragma library', '');
code += `
module.exports = {
buildRemoveCommand, buildRenameCommand, buildCreateCommand, buildSwapWindowsCommand,
escapeShell, calculateGrid, calculatePreviewHeight, calculateScale,
nextDesktop, canSwap, findDropTarget, distance, isDragStarted,
isValidIndex, isValidName, clamp
};`;
const tmpPath = '/tmp/DesktopLogic_test.js';
fs.writeFileSync(tmpPath, code);
const Logic = require(tmpPath);
// Test utilities
let passed = 0, failed = 0, total = 0;
const results = { passed: [], failed: [] };
function test(name, fn) {
total++;
try {
fn();
passed++;
results.passed.push(name);
console.log(`${name}`);
} catch (e) {
failed++;
results.failed.push({ name, error: e.message });
console.log(`${name}`);
console.log(` Error: ${e.message}`);
}
}
function assertEqual(actual, expected, msg) {
if (actual !== expected) {
throw new Error(`${msg || 'Assertion failed'}: expected "${expected}", got "${actual}"`);
}
}
function assertDeepEqual(actual, expected, msg) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${msg || 'Assertion failed'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function assertTrue(condition, msg) {
if (!condition) throw new Error(msg || 'Expected true');
}
function assertFalse(condition, msg) {
if (condition) throw new Error(msg || 'Expected false');
}
function assertNull(value, msg) {
if (value !== null) throw new Error(`${msg || 'Expected null'}, got ${value}`);
}
function assertNotNull(value, msg) {
if (value === null) throw new Error(msg || 'Expected non-null');
}
function assertContains(str, substr, msg) {
if (!str.includes(substr)) {
throw new Error(`${msg || 'String does not contain'}: "${substr}" not in "${str}"`);
}
}
function assertApprox(actual, expected, tolerance, msg) {
if (Math.abs(actual - expected) > tolerance) {
throw new Error(`${msg || 'Approximation failed'}: expected ~${expected}, got ${actual}`);
}
}
// ============================================================================
// TEST SUITES
// ============================================================================
console.log('\n╔════════════════════════════════════════════════════════════════╗');
console.log('║ DesktopLogic.js - Comprehensive Test Suite ║');
console.log('╚════════════════════════════════════════════════════════════════╝\n');
// ----------------------------------------------------------------------------
console.log('┌─ buildRemoveCommand ─────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('returns valid command for valid ID', () => {
const result = Logic.buildRemoveCommand('abc-123');
assertEqual(result, "qdbus org.kde.KWin /VirtualDesktopManager removeDesktop 'abc-123'");
});
test('returns null for null ID', () => {
assertNull(Logic.buildRemoveCommand(null));
});
test('returns null for undefined ID', () => {
assertNull(Logic.buildRemoveCommand(undefined));
});
test('returns null for empty string ID', () => {
assertNull(Logic.buildRemoveCommand(''));
});
test('returns null for whitespace-only ID', () => {
assertNull(Logic.buildRemoveCommand(' '));
});
test('returns null for numeric ID', () => {
assertNull(Logic.buildRemoveCommand(123));
});
test('handles UUID format', () => {
const result = Logic.buildRemoveCommand('550e8400-e29b-41d4-a716-446655440000');
assertContains(result, '550e8400-e29b-41d4-a716-446655440000');
});
// ----------------------------------------------------------------------------
console.log('\n┌─ buildRenameCommand ─────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('returns valid command for valid inputs', () => {
const result = Logic.buildRenameCommand('id1', 'Work');
assertEqual(result, "qdbus org.kde.KWin /VirtualDesktopManager setDesktopName 'id1' 'Work'");
});
test('escapes single quotes in name', () => {
const result = Logic.buildRenameCommand('id1', "it's mine");
assertContains(result, "it'\\''s mine");
});
test('trims whitespace from name', () => {
const result = Logic.buildRenameCommand('id1', ' Work ');
assertContains(result, "'Work'");
});
test('returns null for null ID', () => {
assertNull(Logic.buildRenameCommand(null, 'name'));
});
test('returns null for null name', () => {
assertNull(Logic.buildRenameCommand('id', null));
});
test('returns null for empty name', () => {
assertNull(Logic.buildRenameCommand('id', ''));
});
test('returns null for whitespace-only name', () => {
assertNull(Logic.buildRenameCommand('id', ' '));
});
test('handles special characters in name', () => {
const result = Logic.buildRenameCommand('id', 'Dev & Test');
assertContains(result, 'Dev & Test');
});
test('handles unicode in name', () => {
const result = Logic.buildRenameCommand('id', '桌面');
assertContains(result, '桌面');
});
// ----------------------------------------------------------------------------
console.log('\n┌─ buildCreateCommand ───────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('returns valid command for position 0', () => {
const result = Logic.buildCreateCommand(0, 'New Desktop');
assertEqual(result, "qdbus org.kde.KWin /VirtualDesktopManager createDesktop 0 'New Desktop'");
});
test('returns valid command for position 5', () => {
const result = Logic.buildCreateCommand(5, 'Desktop 6');
assertContains(result, 'createDesktop 5');
});
test('handles negative position (clamps to 0)', () => {
const result = Logic.buildCreateCommand(-1, 'Test');
assertContains(result, 'createDesktop 0');
});
test('handles float position (floors)', () => {
const result = Logic.buildCreateCommand(2.7, 'Test');
assertContains(result, 'createDesktop 2');
});
test('escapes single quotes in name', () => {
const result = Logic.buildCreateCommand(0, "John's Desktop");
assertContains(result, "John'\\''s Desktop");
});
test('handles null name (uses default)', () => {
const result = Logic.buildCreateCommand(0, null);
assertContains(result, "'Desktop'");
});
// ----------------------------------------------------------------------------
console.log('\n┌─ buildSwapWindowsCommand ────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('returns bash command with wmctrl', () => {
const result = Logic.buildSwapWindowsCommand(0, 1);
assertContains(result, 'bash -c');
assertContains(result, 'wmctrl');
});
test('includes both desktop indices', () => {
const result = Logic.buildSwapWindowsCommand(2, 5);
assertContains(result, '==2');
assertContains(result, '==5');
assertContains(result, '-t 5');
assertContains(result, '-t 2');
});
test('handles desktop 0', () => {
const result = Logic.buildSwapWindowsCommand(0, 3);
assertContains(result, '==0');
});
test('floors float indices', () => {
const result = Logic.buildSwapWindowsCommand(1.9, 3.1);
assertContains(result, '==1');
assertContains(result, '==3');
});
// ----------------------------------------------------------------------------
console.log('\n┌─ escapeShell ────────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('escapes single quote', () => {
assertEqual(Logic.escapeShell("it's"), "it'\\''s");
});
test('escapes multiple single quotes', () => {
assertEqual(Logic.escapeShell("it's John's"), "it'\\''s John'\\''s");
});
test('returns empty for null', () => {
assertEqual(Logic.escapeShell(null), '');
});
test('returns empty for undefined', () => {
assertEqual(Logic.escapeShell(undefined), '');
});
test('returns empty for number', () => {
assertEqual(Logic.escapeShell(123), '');
});
test('preserves string without quotes', () => {
assertEqual(Logic.escapeShell('hello world'), 'hello world');
});
// ----------------------------------------------------------------------------
console.log('\n┌─ calculateGrid ──────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('1 item = 1x1 grid', () => {
assertDeepEqual(Logic.calculateGrid(1), { cols: 1, rows: 1 });
});
test('2 items = 2x1 grid', () => {
assertDeepEqual(Logic.calculateGrid(2), { cols: 2, rows: 1 });
});
test('3 items = 2x2 grid', () => {
assertDeepEqual(Logic.calculateGrid(3), { cols: 2, rows: 2 });
});
test('4 items = 2x2 grid', () => {
assertDeepEqual(Logic.calculateGrid(4), { cols: 2, rows: 2 });
});
test('5 items = 3x2 grid', () => {
assertDeepEqual(Logic.calculateGrid(5), { cols: 3, rows: 2 });
});
test('9 items = 3x3 grid', () => {
assertDeepEqual(Logic.calculateGrid(9), { cols: 3, rows: 3 });
});
test('16 items = 4x4 grid', () => {
assertDeepEqual(Logic.calculateGrid(16), { cols: 4, rows: 4 });
});
test('0 items = 1x1 grid (minimum)', () => {
assertDeepEqual(Logic.calculateGrid(0), { cols: 1, rows: 1 });
});
test('negative items = 1x1 grid (minimum)', () => {
assertDeepEqual(Logic.calculateGrid(-5), { cols: 1, rows: 1 });
});
test('18 items = 5x4 grid', () => {
const result = Logic.calculateGrid(18);
assertTrue(result.cols * result.rows >= 18);
});
// ----------------------------------------------------------------------------
console.log('\n┌─ calculatePreviewHeight ─────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('maintains 16:9 aspect ratio', () => {
const height = Logic.calculatePreviewHeight(160, 1920, 1080);
assertApprox(height, 90, 0.01);
});
test('maintains 4:3 aspect ratio', () => {
const height = Logic.calculatePreviewHeight(120, 1024, 768);
assertApprox(height, 90, 0.01);
});
test('uses 16:9 default for zero width', () => {
const height = Logic.calculatePreviewHeight(160, 0, 0);
assertApprox(height, 90, 0.01);
});
test('uses 16:9 default for negative dimensions', () => {
const height = Logic.calculatePreviewHeight(160, -100, -100);
assertApprox(height, 90, 0.01);
});
test('handles ultrawide (21:9)', () => {
const height = Logic.calculatePreviewHeight(210, 2560, 1080);
assertApprox(height, 88.59, 0.1);
});
// ----------------------------------------------------------------------------
console.log('\n┌─ calculateScale ─────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('calculates correct scale factors', () => {
const scale = Logic.calculateScale(130, 73, 1920, 1080);
assertApprox(scale.x, 130/1920, 0.0001);
assertApprox(scale.y, 73/1080, 0.0001);
});
test('handles zero screen dimensions', () => {
const scale = Logic.calculateScale(130, 73, 0, 0);
assertEqual(scale.x, 130);
assertEqual(scale.y, 73);
});
// ----------------------------------------------------------------------------
console.log('\n┌─ nextDesktop ────────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('forward from 0 to 1', () => {
assertEqual(Logic.nextDesktop(0, 5, 1), 1);
});
test('forward from 4 wraps to 0', () => {
assertEqual(Logic.nextDesktop(4, 5, 1), 0);
});
test('backward from 0 wraps to 4', () => {
assertEqual(Logic.nextDesktop(0, 5, -1), 4);
});
test('backward from 2 to 1', () => {
assertEqual(Logic.nextDesktop(2, 5, -1), 1);
});
test('handles count of 1', () => {
assertEqual(Logic.nextDesktop(0, 1, 1), 0);
assertEqual(Logic.nextDesktop(0, 1, -1), 0);
});
test('handles count of 0 (returns 0)', () => {
assertEqual(Logic.nextDesktop(0, 0, 1), 0);
});
test('floors float current', () => {
assertEqual(Logic.nextDesktop(1.9, 5, 1), 2);
});
// ----------------------------------------------------------------------------
console.log('\n┌─ canSwap ────────────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('valid swap returns true', () => {
assertTrue(Logic.canSwap(0, 1, 5));
});
test('same index returns false', () => {
assertFalse(Logic.canSwap(2, 2, 5));
});
test('negative indexA returns false', () => {
assertFalse(Logic.canSwap(-1, 1, 5));
});
test('negative indexB returns false', () => {
assertFalse(Logic.canSwap(0, -1, 5));
});
test('indexA >= count returns false', () => {
assertFalse(Logic.canSwap(5, 1, 5));
});
test('indexB >= count returns false', () => {
assertFalse(Logic.canSwap(0, 5, 5));
});
test('non-number indexA returns false', () => {
assertFalse(Logic.canSwap('0', 1, 5));
});
test('non-number indexB returns false', () => {
assertFalse(Logic.canSwap(0, '1', 5));
});
test('non-number count returns false', () => {
assertFalse(Logic.canSwap(0, 1, '5'));
});
test('boundary valid: 0 and count-1', () => {
assertTrue(Logic.canSwap(0, 4, 5));
});
// ----------------------------------------------------------------------------
console.log('\n┌─ findDropTarget ─────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('returns -1 for null repeater', () => {
assertEqual(Logic.findDropTarget(null, {}, 0, 0), -1);
});
test('returns -1 for null grid', () => {
assertEqual(Logic.findDropTarget({count: 1}, null, 0, 0), -1);
});
test('returns -1 for invalid repeater', () => {
assertEqual(Logic.findDropTarget({}, {}, 0, 0), -1);
});
// ----------------------------------------------------------------------------
console.log('\n┌─ distance ─────────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('distance from origin', () => {
assertEqual(Logic.distance(0, 0, 3, 4), 5);
});
test('distance same point is 0', () => {
assertEqual(Logic.distance(5, 5, 5, 5), 0);
});
test('distance negative coordinates', () => {
assertEqual(Logic.distance(-3, 0, 0, 4), 5);
});
test('distance horizontal', () => {
assertEqual(Logic.distance(0, 0, 10, 0), 10);
});
test('distance vertical', () => {
assertEqual(Logic.distance(0, 0, 0, 10), 10);
});
// ----------------------------------------------------------------------------
console.log('\n┌─ isDragStarted ────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('returns false below threshold', () => {
assertFalse(Logic.isDragStarted(0, 0, 5, 5, 10));
});
test('returns true above threshold', () => {
assertTrue(Logic.isDragStarted(0, 0, 15, 0, 10));
});
test('returns false at exact threshold', () => {
assertFalse(Logic.isDragStarted(0, 0, 10, 0, 10));
});
test('uses default threshold of 10', () => {
assertFalse(Logic.isDragStarted(0, 0, 8, 0));
assertTrue(Logic.isDragStarted(0, 0, 12, 0));
});
// ----------------------------------------------------------------------------
console.log('\n┌─ isValidIndex ───────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('valid index returns true', () => {
assertTrue(Logic.isValidIndex(0, 5));
assertTrue(Logic.isValidIndex(4, 5));
});
test('negative index returns false', () => {
assertFalse(Logic.isValidIndex(-1, 5));
});
test('index >= count returns false', () => {
assertFalse(Logic.isValidIndex(5, 5));
});
test('float index returns false', () => {
assertFalse(Logic.isValidIndex(1.5, 5));
});
test('string index returns false', () => {
assertFalse(Logic.isValidIndex('0', 5));
});
// ----------------------------------------------------------------------------
console.log('\n┌─ isValidName ────────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('valid name returns true', () => {
assertTrue(Logic.isValidName('Work'));
});
test('empty string returns false', () => {
assertFalse(Logic.isValidName(''));
});
test('whitespace only returns false', () => {
assertFalse(Logic.isValidName(' '));
});
test('null returns false', () => {
assertFalse(Logic.isValidName(null));
});
test('number returns false', () => {
assertFalse(Logic.isValidName(123));
});
test('name with spaces returns true', () => {
assertTrue(Logic.isValidName('My Work'));
});
// ----------------------------------------------------------------------------
console.log('\n┌─ clamp ──────────────────────────────────────────────────────────');
// ----------------------------------------------------------------------------
test('value within range unchanged', () => {
assertEqual(Logic.clamp(50, 0, 100), 50);
});
test('value below min clamped', () => {
assertEqual(Logic.clamp(-10, 0, 100), 0);
});
test('value above max clamped', () => {
assertEqual(Logic.clamp(150, 0, 100), 100);
});
test('value at min unchanged', () => {
assertEqual(Logic.clamp(0, 0, 100), 0);
});
test('value at max unchanged', () => {
assertEqual(Logic.clamp(100, 0, 100), 100);
});
// ============================================================================
// RESULTS
// ============================================================================
console.log('\n╔════════════════════════════════════════════════════════════════╗');
console.log('║ TEST RESULTS ║');
console.log('╠════════════════════════════════════════════════════════════════╣');
console.log(`║ Total: ${String(total).padStart(3)}`);
console.log(`║ Passed: ${String(passed).padStart(3)} ✓ ║`);
console.log(`║ Failed: ${String(failed).padStart(3)} ${failed > 0 ? '✗' : ' '}`);
console.log(`║ Coverage: ${((passed/total)*100).toFixed(1)}% ║`);
console.log('╚════════════════════════════════════════════════════════════════╝');
if (failed > 0) {
console.log('\n Failed tests:');
results.failed.forEach(f => console.log(` - ${f.name}: ${f.error}`));
}
console.log(failed === 0 ? '\n✓ All tests passed!' : '\n✗ Some tests failed');
process.exit(failed > 0 ? 1 : 0);

View File

@ -0,0 +1,132 @@
#!/usr/bin/env node
// Test suite for DesktopManager.js
// Run with: node test_DesktopManager.js
// Load the module (remove QML-specific pragma)
const fs = require('fs');
const path = require('path');
const srcPath = path.join(__dirname, '../contents/ui/DesktopManager.js');
let code = fs.readFileSync(srcPath, 'utf8');
code = code.replace('.pragma library', '');
code += '\nmodule.exports = { buildRemoveCommand, buildRenameCommand, buildCreateCommand, buildSwapWindowsCommand, escapeShell, calculateGrid, calculatePreviewSize, calculateScale, canSwap, nextDesktop };';
fs.writeFileSync('/tmp/DM_test.js', code);
const DM = require('/tmp/DM_test.js');
let passed = 0;
let failed = 0;
function test(name, condition) {
if (condition) {
console.log(`${name}`);
passed++;
} else {
console.log(`${name}`);
failed++;
}
}
function testEqual(name, actual, expected) {
if (actual === expected) {
console.log(`${name}`);
passed++;
} else {
console.log(`${name}: expected "${expected}", got "${actual}"`);
failed++;
}
}
console.log('\n=== Testing DesktopManager.js ===\n');
// Test buildRemoveCommand
console.log('--- buildRemoveCommand ---');
testEqual('Remove with valid ID',
DM.buildRemoveCommand('abc-123'),
"qdbus org.kde.KWin /VirtualDesktopManager removeDesktop 'abc-123'"
);
test('Remove with null ID returns null', DM.buildRemoveCommand(null) === null);
test('Remove with empty ID returns null', DM.buildRemoveCommand('') === null);
// Test buildRenameCommand
console.log('\n--- buildRenameCommand ---');
testEqual('Rename simple',
DM.buildRenameCommand('id1', 'Work'),
"qdbus org.kde.KWin /VirtualDesktopManager setDesktopName 'id1' 'Work'"
);
test('Rename with null ID returns null', DM.buildRenameCommand(null, 'name') === null);
test('Rename with empty name returns null', DM.buildRenameCommand('id', '') === null);
test('Rename with whitespace name returns null', DM.buildRenameCommand('id', ' ') === null);
// Test escapeShell
console.log('\n--- escapeShell ---');
testEqual('Escape single quote', DM.escapeShell("it's"), "it'\\''s");
testEqual('No escape needed', DM.escapeShell("hello"), "hello");
// Test buildCreateCommand
console.log('\n--- buildCreateCommand ---');
testEqual('Create at position 5',
DM.buildCreateCommand(5, 'New Desktop'),
"qdbus org.kde.KWin /VirtualDesktopManager createDesktop 5 'New Desktop'"
);
// Test buildSwapWindowsCommand
console.log('\n--- buildSwapWindowsCommand ---');
test('Swap command contains wmctrl',
DM.buildSwapWindowsCommand(0, 1).includes('wmctrl')
);
test('Swap command contains both indices',
DM.buildSwapWindowsCommand(2, 5).includes('2') && DM.buildSwapWindowsCommand(2, 5).includes('5')
);
// Test calculateGrid
console.log('\n--- calculateGrid ---');
let grid1 = DM.calculateGrid(1);
testEqual('Grid for 1: cols', grid1.cols, 1);
testEqual('Grid for 1: rows', grid1.rows, 1);
let grid4 = DM.calculateGrid(4);
testEqual('Grid for 4: cols', grid4.cols, 2);
testEqual('Grid for 4: rows', grid4.rows, 2);
let grid5 = DM.calculateGrid(5);
testEqual('Grid for 5: cols', grid5.cols, 3);
testEqual('Grid for 5: rows', grid5.rows, 2);
let grid9 = DM.calculateGrid(9);
testEqual('Grid for 9: cols', grid9.cols, 3);
testEqual('Grid for 9: rows', grid9.rows, 3);
// Test calculatePreviewSize
console.log('\n--- calculatePreviewSize ---');
let preview = DM.calculatePreviewSize(130, 1920, 1080);
testEqual('Preview width', preview.width, 130);
test('Preview height is proportional', Math.abs(preview.height - 73.125) < 0.01);
// Test calculateScale
console.log('\n--- calculateScale ---');
let scale = DM.calculateScale(130, 73, 1920, 1080);
test('Scale X is positive', scale.x > 0);
test('Scale Y is positive', scale.y > 0);
// Test canSwap
console.log('\n--- canSwap ---');
test('canSwap valid', DM.canSwap(0, 1, 5) === true);
test('canSwap same index', DM.canSwap(2, 2, 5) === false);
test('canSwap negative A', DM.canSwap(-1, 1, 5) === false);
test('canSwap negative B', DM.canSwap(0, -1, 5) === false);
test('canSwap A out of bounds', DM.canSwap(5, 1, 5) === false);
test('canSwap B out of bounds', DM.canSwap(0, 5, 5) === false);
// Test nextDesktop
console.log('\n--- nextDesktop ---');
testEqual('Next from 0 (forward)', DM.nextDesktop(0, 5, 1), 1);
testEqual('Next from 4 (forward, wrap)', DM.nextDesktop(4, 5, 1), 0);
testEqual('Next from 0 (backward, wrap)', DM.nextDesktop(0, 5, -1), 4);
testEqual('Next from 2 (backward)', DM.nextDesktop(2, 5, -1), 1);
// Summary
console.log('\n=== Results ===');
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log(failed === 0 ? '\n✓ All tests passed!' : '\n✗ Some tests failed');
process.exit(failed > 0 ? 1 : 0);