commit b3a976111a1a6f0dc4536d0afce8b4079b6ead79 Author: RarDog Date: Sun Sep 13 18:30:37 2026 +0300 Initial commit: macOS Window Buttons plasmoid for KDE Plasma 6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de7ee4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.directory +*~ +*.swp +*.bak +.DS_Store +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dbd5df --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# macOS Window Buttons for KDE Plasma 6 + +Легковесный плазмоид (виджет панели задач) для **KDE Plasma 6**, реализующий круглые кнопки управления окном в стиле macOS. + +## Особенности +- **Интерфейс в стиле macOS**: аккуратные круглые кнопки (Закрыть, Свернуть, Развернуть) диаметром 13px с правильными цветами и обводкой. +- **Интерактивность**: появление миниатюрных символов (✕, −, ⤢) при наведении курсора на группу кнопок. +- **Умное автоскрытие**: виджет отображается на панели **только тогда, когда активное окно развернуто на весь экран (Maximized / FullScreen)**. +- **Схлопывание в 0px**: когда окно не развернуто, свернуто или активно другое неразвернутое окно, виджет полностью скрывается (`visible: false`) и занимает ровно 0 пикселей на панели. +- **Поддержка режима редактирования Plasma**: при входе в режим настройки панели виджет остается видимым с полупрозрачной рамкой, чтобы его можно было легко перемещать, настраивать или удалять. + +## Установка + +### Вариант 1: Через символическую ссылку (рекомендуется для разработки) +```bash +mkdir -p ~/.local/share/plasma/plasmoids +ln -sfn "$(pwd)" ~/.local/share/plasma/plasmoids/org.kde.plasma.macoswindowbuttons +kbuildsycoca6 +systemctl --user restart plasma-plasmashell.service +``` + +### Вариант 2: Через kpackagetool6 +```bash +kpackagetool6 -t Plasma/Applet -i . +systemctl --user restart plasma-plasmashell.service +``` + +## Скрытие родных рамок у развернутых окон +Чтобы у самих окон при максимизации исчезала родная верхняя полоса с заголовком и кнопками, включите опцию в KWin: +```bash +kwriteconfig6 --file kwinrc --group Windows --key BorderlessMaximizedWindows true +qdbus6 org.kde.KWin /KWin reconfigure +``` + +## Лицензия +GPL-2.0+ diff --git a/contents/ui/main.qml b/contents/ui/main.qml new file mode 100644 index 0000000..b19da32 --- /dev/null +++ b/contents/ui/main.qml @@ -0,0 +1,327 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes + +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.taskmanager as TaskManager + +PlasmoidItem { + id: root + + // Константы геометрии кнопок в стиле macOS + readonly property real buttonSize: 13 + readonly property real buttonSpacing: 8 + readonly property real horizontalPadding: 6 + readonly property real verticalPadding: 4 + + readonly property real contentWidth: (buttonSize * 3) + (buttonSpacing * 2) + (horizontalPadding * 2) + readonly property real contentHeight: buttonSize + (verticalPadding * 2) + + // Состояние текущего активного окна + property bool isWindowMaximized: false + property bool canClose: true + property bool canMinimize: true + property bool canMaximize: true + + // Режим редактирования панели/рабочего стола Plasma + readonly property bool inEditMode: (Plasmoid.containment?.corona?.editMode ?? false) || Plasmoid.userConfiguring + readonly property bool shouldBeVisible: isWindowMaximized || inEditMode + + // Интеграция с панелью Plasma 6 + Plasmoid.backgroundHints: PlasmaCore.Types.NoBackground + Plasmoid.status: shouldBeVisible ? PlasmaCore.Types.ActiveStatus : PlasmaCore.Types.HiddenStatus + + // Видимость и схлопывание виджета в 0px на панели, когда окно не развернуто и не в режиме редактирования + visible: shouldBeVisible + + implicitWidth: shouldBeVisible ? contentWidth : 0 + implicitHeight: shouldBeVisible ? contentHeight : 0 + + Layout.preferredWidth: shouldBeVisible ? contentWidth : 0 + Layout.minimumWidth: shouldBeVisible ? contentWidth : 0 + Layout.maximumWidth: shouldBeVisible ? contentWidth : 0 + Layout.preferredHeight: shouldBeVisible ? contentHeight : 0 + Layout.minimumHeight: shouldBeVisible ? contentHeight : 0 + Layout.maximumHeight: shouldBeVisible ? contentHeight : 0 + + preferredRepresentation: fullRepresentation + compactRepresentation: fullRepresentation + + // Отслеживание виртуальных рабочих столов + TaskManager.VirtualDesktopInfo { + id: virtualDesktopInfo + onCurrentDesktopChanged: root.updateWindowState() + } + + // Отслеживание активностей KDE + TaskManager.ActivityInfo { + id: activityInfo + onCurrentActivityChanged: root.updateWindowState() + } + + // Модель окон и задач + TaskManager.TasksModel { + id: tasksModel + virtualDesktop: virtualDesktopInfo.currentDesktop + activity: activityInfo.currentActivity + filterByVirtualDesktop: true + filterByActivity: true + groupMode: TaskManager.TasksModel.GroupDisabled + + onActiveTaskChanged: root.updateWindowState() + onDataChanged: root.updateWindowState() + onRowsInserted: root.updateWindowState() + onRowsRemoved: root.updateWindowState() + onModelReset: root.updateWindowState() + onCountChanged: root.updateWindowState() + } + + // Получение актуального QModelIndex активного окна + function getActiveTaskIndex() { + let idx = tasksModel.activeTask; + if (idx && idx.valid && tasksModel.data(idx, TaskManager.AbstractTasksModel.IsActive)) { + return idx; + } + for (let i = 0; i < tasksModel.rowCount(); ++i) { + let mIdx = tasksModel.index(i, 0); + if (tasksModel.data(mIdx, TaskManager.AbstractTasksModel.IsActive)) { + return mIdx; + } + } + return null; + } + + // Обновление состояния развернутости и активности окна + function updateWindowState() { + let idx = getActiveTaskIndex(); + if (idx && idx.valid) { + let isLauncher = Boolean(tasksModel.data(idx, TaskManager.AbstractTasksModel.IsLauncher)); + let isStartup = Boolean(tasksModel.data(idx, TaskManager.AbstractTasksModel.IsStartup)); + let isActive = Boolean(tasksModel.data(idx, TaskManager.AbstractTasksModel.IsActive)); + let isMax = Boolean(tasksModel.data(idx, TaskManager.AbstractTasksModel.IsMaximized)); + let isFull = Boolean(tasksModel.data(idx, TaskManager.AbstractTasksModel.IsFullScreen)); + let isMin = Boolean(tasksModel.data(idx, TaskManager.AbstractTasksModel.IsMinimized)); + + let closeable = tasksModel.data(idx, TaskManager.AbstractTasksModel.IsClosable); + let minimizable = tasksModel.data(idx, TaskManager.AbstractTasksModel.IsMinimizable); + let maximizable = tasksModel.data(idx, TaskManager.AbstractTasksModel.IsMaximizable); + + canClose = (closeable !== undefined && closeable !== null) ? Boolean(closeable) : true; + canMinimize = (minimizable !== undefined && minimizable !== null) ? Boolean(minimizable) : true; + canMaximize = (maximizable !== undefined && maximizable !== null) ? Boolean(maximizable) : true; + + // Виджет показывается ТОЛЬКО когда активное окно развернуто на весь экран и не свернуто + isWindowMaximized = (isActive && !isLauncher && !isStartup && (isMax || isFull) && !isMin); + } else { + isWindowMaximized = false; + } + } + + // Действия над окном через TasksModel + function closeWindow() { + let idx = getActiveTaskIndex(); + if (idx && idx.valid) { + tasksModel.requestClose(idx); + } + } + + function minimizeWindow() { + let idx = getActiveTaskIndex(); + if (idx && idx.valid) { + tasksModel.requestToggleMinimized(idx); + } + } + + function toggleMaximizeWindow() { + let idx = getActiveTaskIndex(); + if (idx && idx.valid) { + tasksModel.requestToggleMaximized(idx); + } + } + + Component.onCompleted: { + updateWindowState(); + } + + // Представление кнопок + fullRepresentation: Item { + id: buttonsContainer + + implicitWidth: root.contentWidth + implicitHeight: root.contentHeight + visible: root.shouldBeVisible + + // Подсветка-заглушка в режиме редактирования Plasma, если окно не развернуто + Rectangle { + anchors.fill: parent + visible: root.inEditMode && !root.isWindowMaximized + color: Qt.rgba(1, 1, 1, 0.08) + border.color: Qt.rgba(1, 1, 1, 0.35) + border.width: 1 + radius: 4 + } + + // Отслеживание наведения курсора на группу кнопок + HoverHandler { + id: clusterHoverHandler + } + + readonly property bool isClusterHovered: clusterHoverHandler.hovered + + RowLayout { + id: buttonsRow + anchors.centerIn: parent + spacing: root.buttonSpacing + opacity: root.isWindowMaximized ? 1.0 : (root.inEditMode ? 0.6 : 0.0) + + // Кнопка «Закрыть» (красная) + Rectangle { + id: closeButton + width: root.buttonSize + height: root.buttonSize + radius: root.buttonSize / 2 + color: closeMouseArea.pressed ? "#bf4740" : "#ff5f56" + border.color: "#e0443e" + border.width: 1 + antialiasing: true + opacity: root.canClose ? 1.0 : 0.4 + enabled: root.canClose + + // Символ крестика (✕) + Item { + anchors.centerIn: parent + width: 6 + height: 6 + opacity: buttonsContainer.isClusterHovered ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { duration: 120 } + } + + Rectangle { + anchors.centerIn: parent + width: 7 + height: 1.2 + color: "#4c0002" + radius: 0.6 + rotation: 45 + antialiasing: true + } + Rectangle { + anchors.centerIn: parent + width: 7 + height: 1.2 + color: "#4c0002" + radius: 0.6 + rotation: -45 + antialiasing: true + } + } + + MouseArea { + id: closeMouseArea + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.closeWindow() + } + } + + // Кнопка «Свернуть» (желтая) + Rectangle { + id: minimizeButton + width: root.buttonSize + height: root.buttonSize + radius: root.buttonSize / 2 + color: minMouseArea.pressed ? "#bfa020" : "#ffbd2e" + border.color: "#dea125" + border.width: 1 + antialiasing: true + opacity: root.canMinimize ? 1.0 : 0.4 + enabled: root.canMinimize + + // Символ минуса (−) + Item { + anchors.centerIn: parent + width: 6 + height: 6 + opacity: buttonsContainer.isClusterHovered ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { duration: 120 } + } + + Rectangle { + anchors.centerIn: parent + width: 7 + height: 1.2 + color: "#7d4f00" + radius: 0.6 + antialiasing: true + } + } + + MouseArea { + id: minMouseArea + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.minimizeWindow() + } + } + + // Кнопка «Развернуть / Восстановить» (зеленая) + Rectangle { + id: maximizeButton + width: root.buttonSize + height: root.buttonSize + radius: root.buttonSize / 2 + color: maxMouseArea.pressed ? "#1da030" : "#27c93f" + border.color: "#1aab29" + border.width: 1 + antialiasing: true + opacity: root.canMaximize ? 1.0 : 0.4 + enabled: root.canMaximize + + // Символ стрелок macOS (два диагональных треугольника) + Shape { + anchors.centerIn: parent + width: 6 + height: 6 + opacity: buttonsContainer.isClusterHovered ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { duration: 120 } + } + asynchronous: false + + ShapePath { + strokeWidth: 0 + strokeColor: "transparent" + fillColor: "#0d5516" + startX: 6 + startY: 0 + PathLine { x: 1.8; y: 0 } + PathLine { x: 6; y: 4.2 } + PathLine { x: 6; y: 0 } + } + ShapePath { + strokeWidth: 0 + strokeColor: "transparent" + fillColor: "#0d5516" + startX: 0 + startY: 6 + PathLine { x: 4.2; y: 6 } + PathLine { x: 0; y: 1.8 } + PathLine { x: 0; y: 6 } + } + } + + MouseArea { + id: maxMouseArea + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleMaximizeWindow() + } + } + } + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..1e069ff --- /dev/null +++ b/metadata.json @@ -0,0 +1,23 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Authors": [ + { + "Name": "Plasma Developer", + "Email": "user@example.com" + } + ], + "Category": "Windows and Tasks", + "Description": "Кнопки управления окном в стиле macOS (Закрыть, Свернуть, Развернуть), отображаемые на панели только при развернутом окне.", + "Icon": "window-close", + "Id": "org.kde.plasma.macoswindowbuttons", + "License": "GPL-2.0+", + "Name": "macOS Window Buttons", + "Version": "1.0", + "Website": "https://kde.org" + }, + "X-Plasma-API-Minimum-Version": "6.0", + "X-Plasma-Provides": [ + "org.kde.plasma.windowmanagement" + ] +}