From 1823a3c4eb13933948ae8b62ca4a7061dcde1020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Eduardo=20Garc=C3=ADa=20M=C3=A1rquez?= Date: Tue, 10 Feb 2026 12:12:45 -0500 Subject: [PATCH] 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 --- contents/ui/CompactRepresentation.qml | 44 ++ contents/ui/DesktopDelegate.qml | 129 +++++ contents/ui/DesktopLogic.js | 257 +++++++++ contents/ui/DesktopManager.js | 72 +++ contents/ui/FullRepresentation.qml | 255 +++++++++ contents/ui/Translations.qml | 53 ++ contents/ui/components/DragRect.qml | 23 + contents/ui/dialogs/NewDesktopDialog.qml | 70 +++ contents/ui/dialogs/RenameDialog.qml | 76 +++ contents/ui/main.qml | 629 ++++++++++++----------- contents/ui/qmldir | 1 + tests/run_all_tests.sh | 38 ++ tests/test_DesktopLogic.js | 587 +++++++++++++++++++++ tests/test_DesktopManager.js | 132 +++++ 14 files changed, 2066 insertions(+), 300 deletions(-) create mode 100644 contents/ui/CompactRepresentation.qml create mode 100644 contents/ui/DesktopDelegate.qml create mode 100644 contents/ui/DesktopLogic.js create mode 100644 contents/ui/DesktopManager.js create mode 100644 contents/ui/FullRepresentation.qml create mode 100644 contents/ui/Translations.qml create mode 100644 contents/ui/components/DragRect.qml create mode 100644 contents/ui/dialogs/NewDesktopDialog.qml create mode 100644 contents/ui/dialogs/RenameDialog.qml create mode 100644 contents/ui/qmldir create mode 100755 tests/run_all_tests.sh create mode 100644 tests/test_DesktopLogic.js create mode 100644 tests/test_DesktopManager.js diff --git a/contents/ui/CompactRepresentation.qml b/contents/ui/CompactRepresentation.qml new file mode 100644 index 0000000..0e0dcca --- /dev/null +++ b/contents/ui/CompactRepresentation.qml @@ -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() + } +} diff --git a/contents/ui/DesktopDelegate.qml b/contents/ui/DesktopDelegate.qml new file mode 100644 index 0000000..3a4f240 --- /dev/null +++ b/contents/ui/DesktopDelegate.qml @@ -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() + } + } +} diff --git a/contents/ui/DesktopLogic.js b/contents/ui/DesktopLogic.js new file mode 100644 index 0000000..9144e23 --- /dev/null +++ b/contents/ui/DesktopLogic.js @@ -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)) +} diff --git a/contents/ui/DesktopManager.js b/contents/ui/DesktopManager.js new file mode 100644 index 0000000..0aa7732 --- /dev/null +++ b/contents/ui/DesktopManager.js @@ -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 + } +} diff --git a/contents/ui/FullRepresentation.qml b/contents/ui/FullRepresentation.qml new file mode 100644 index 0000000..2b154fc --- /dev/null +++ b/contents/ui/FullRepresentation.qml @@ -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)) + } + } +} diff --git a/contents/ui/Translations.qml b/contents/ui/Translations.qml new file mode 100644 index 0000000..5ef6ad0 --- /dev/null +++ b/contents/ui/Translations.qml @@ -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"] +} diff --git a/contents/ui/components/DragRect.qml b/contents/ui/components/DragRect.qml new file mode 100644 index 0000000..cd08efc --- /dev/null +++ b/contents/ui/components/DragRect.qml @@ -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 + } +} diff --git a/contents/ui/dialogs/NewDesktopDialog.qml b/contents/ui/dialogs/NewDesktopDialog.qml new file mode 100644 index 0000000..37c833e --- /dev/null +++ b/contents/ui/dialogs/NewDesktopDialog.qml @@ -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() } + } + } + } + } +} diff --git a/contents/ui/dialogs/RenameDialog.qml b/contents/ui/dialogs/RenameDialog.qml new file mode 100644 index 0000000..81db71e --- /dev/null +++ b/contents/ui/dialogs/RenameDialog.qml @@ -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() + } + } + } + } +} diff --git a/contents/ui/main.qml b/contents/ui/main.qml index 0b4fcd3..5c636a7 100644 --- a/contents/ui/main.qml +++ b/contents/ui/main.qml @@ -5,87 +5,93 @@ 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 +import "DesktopLogic.js" as Logic Item { id: root 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 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 { id: pagerModel - enabled: root.visible + enabled: true showDesktop: false pagerType: PagerModel.VirtualDesktops } - // ───────────────────────────────────────────────────────────────────────── - // HELPER FUNCTIONS - // ───────────────────────────────────────────────────────────────────────── + // Store desktop IDs fetched from D-Bus + 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() { - if (pagerModel.count > 0 && pagerModel.currentPage >= 0) { - var idx = pagerModel.index(pagerModel.currentPage, 0) - var name = pagerModel.data(idx, Qt.DisplayRole) - return name || (t.desktop + " " + (pagerModel.currentPage + 1)) - } - return t.desktop + if (pagerModel.count > 0 && pagerModel.currentPage >= 0) + return getDesktopName(pagerModel.currentPage) + return "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 { - id: compactArea Layout.minimumWidth: compactLabel.implicitWidth + 16 hoverEnabled: true @@ -105,39 +111,127 @@ Item { 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) + onWheel: function(wheel) { + pagerModel.changePage(Logic.nextDesktop(pagerModel.currentPage, pagerModel.count, wheel.angleDelta.y > 0 ? -1 : 1)) } } - // ───────────────────────────────────────────────────────────────────────── - // 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 var gridDims: Logic.calculateGrid(pagerModel.count) readonly property real deskW: previewSize - readonly property real deskH: pagerModel.pagerItemSize.height > 0 - ? deskW * pagerModel.pagerItemSize.height / pagerModel.pagerItemSize.width - : deskW * 9 / 16 + readonly property real deskH: Logic.calculatePreviewHeight(previewSize, pagerModel.pagerItemSize.width, pagerModel.pagerItemSize.height) 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) + 70 + Layout.preferredWidth: gridDims.cols * (deskW + 8) + 32 + Layout.preferredHeight: gridDims.rows * (deskH + 8) + 70 Layout.minimumWidth: 200 Layout.minimumHeight: 120 - MouseArea { - anchors.fill: parent - hoverEnabled: true - onEntered: { hoverPopup = true; closeTimer.stop() } - onExited: { hoverPopup = false; if (!hoverCompact) closeTimer.start() } + property int dragSource: -1 + property int dropTarget: -1 + + Timer { + 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 { @@ -145,15 +239,12 @@ Item { anchors.margins: 10 spacing: 8 - // ───────────────────────────────────────────────────────────── - // DESKTOP GRID - // ───────────────────────────────────────────────────────────── Grid { - id: desktopGrid + id: grid Layout.fillWidth: true Layout.fillHeight: true Layout.alignment: Qt.AlignHCenter - columns: popup.cols + columns: popup.gridDims.cols spacing: 6 Repeater { @@ -161,70 +252,24 @@ Item { model: pagerModel Rectangle { - id: desktop + id: desktopItem width: popup.deskW height: popup.deskH - - readonly property string desktopName: model.display || (t.desktop + " " + (index + 1)) - readonly property bool isActive: index === pagerModel.currentPage - property bool isHovered: false - - 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 + color: index === pagerModel.currentPage + ? Qt.darker(PlasmaCore.Theme.highlightColor, 1.3) + : PlasmaCore.Theme.backgroundColor + border.width: popup.dropTarget === index && popup.dragSource !== index ? 3 : (index === pagerModel.currentPage ? 2 : 1) + border.color: popup.dropTarget === index && popup.dragSource !== index + ? "#3498db" + : (index === pagerModel.currentPage ? PlasmaCore.Theme.highlightColor : PlasmaCore.Theme.disabledTextColor) radius: 4 clip: true - opacity: isHovered ? 1 : 0.92 + opacity: popup.dragSource === index ? 0.5 : (desktopMA.containsMouse || deleteMA.containsMouse ? 1 : 0.92) - MouseArea { - anchors.fill: parent - 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 - } - } - } + property string desktopName: model.display || ("Desktop " + (index + 1)) + property bool isHovered: desktopMA.containsMouse || deleteMA.containsMouse - // ───────────────────────────────────────────────── - // 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 - // ───────────────────────────────────────────────── + // Windows Item { anchors.fill: parent anchors.margins: 2 @@ -233,17 +278,13 @@ Item { Repeater { model: TasksModel - Rectangle { - readonly property rect geo: model.Geometry - readonly property bool minimized: model.IsMinimized === true - + property rect geo: model.Geometry 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 - + 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 @@ -261,181 +302,169 @@ Item { } } - // ───────────────────────────────────────────────── - // 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 + // Badge 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 + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottomMargin: 3 + width: badgeLabel.implicitWidth + 12 + height: badgeLabel.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" + id: badgeLabel + anchors.centerIn: parent + 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 { - id: resizeMouseArea + id: desktopMA anchors.fill: parent hoverEnabled: true - cursorShape: Qt.SizeFDiagCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton property real startX: 0 property real startY: 0 - property int startSize: 0 + property bool dragging: false - onPressed: { - startX = mouse.x - startY = mouse.y - startSize = previewSize + onPressed: function(mouse) { + if (mouse.button === Qt.LeftButton) { + startX = mouse.x + startY = mouse.y + dragging = false + } } - onPositionChanged: { - if (pressed) { - var delta = (mouse.x - startX + mouse.y - startY) / 2 - var newSize = Math.max(80, Math.min(200, startSize + delta)) - plasmoid.configuration.previewSize = Math.round(newSize) + + 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 + 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)) } } } } } } - } - // ───────────────────────────────────────────────────────────────── - // CONTEXT MENU - // ───────────────────────────────────────────────────────────────── - Menu { - id: ctxMenu - property int desktopIndex: 0 - property string desktopName: "" + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 36 + spacing: 8 - MenuItem { - text: t.switchTo + " \"" + ctxMenu.desktopName + "\"" - icon.name: "go-jump" - onTriggered: { - pagerModel.changePage(ctxMenu.desktopIndex) - plasmoid.expanded = false + PlasmaComponents.Button { + icon.name: "list-add" + text: "Add" + onClicked: run(Logic.buildCreateCommand(pagerModel.count, "Desktop " + (pagerModel.count + 1))) } - } - 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() - } - } - // ───────────────────────────────────────────────────────────────── - // RENAME DIALOG - // ───────────────────────────────────────────────────────────────── - Dialog { - id: renameDlg - property int idx: 0 - 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() + Item { Layout.fillWidth: true } + + PlasmaComponents.Label { + text: pagerModel.count + " desktops" + opacity: 0.6 + font.pixelSize: 11 } } } - PlasmaCore.DataSource { - id: execSource - engine: "executable" - onNewData: disconnectSource(sourceName) + // Drag rectangle + Rectangle { + id: dragRect + 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 + } } } } diff --git a/contents/ui/qmldir b/contents/ui/qmldir new file mode 100644 index 0000000..1a97797 --- /dev/null +++ b/contents/ui/qmldir @@ -0,0 +1 @@ +singleton Translations 1.0 Translations.qml diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh new file mode 100755 index 0000000..dc51983 --- /dev/null +++ b/tests/run_all_tests.sh @@ -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 diff --git a/tests/test_DesktopLogic.js b/tests/test_DesktopLogic.js new file mode 100644 index 0000000..519a163 --- /dev/null +++ b/tests/test_DesktopLogic.js @@ -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); diff --git a/tests/test_DesktopManager.js b/tests/test_DesktopManager.js new file mode 100644 index 0000000..2751f27 --- /dev/null +++ b/tests/test_DesktopManager.js @@ -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);