diff --git a/README.md b/README.md index 27bdba2..8a3f9d4 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,19 @@ Meta—or pressing a different number—ends the current cycle. The shortcuts follow the physical number-row keys for every keyboard layout configured in KWin, including layouts where those keys do not produce digits without Shift. +A small thumbnail above the selected taskbar button shows the selection while +Meta is held, even for single-window groups. Only releasing Meta activates the +selected window, so Alt+Tab can return to the application used before the cycle. +Esc cancels the selection; another number press starts a fresh cycle without +releasing Meta. Launcher and new-instance actions still run immediately. + With **Keep launchers separate** disabled, a pinned launcher and its running application share one shortcut position. When enabled, the launcher is its own position and the application's windows form another. **Hide launcher when application starts** determines whether that separate launcher remains while the application is running. Other groups retain the Task Manager's sort order. -The first-window behavior and both Shift behaviors can be changed through +The first-window behaviour and both Shift behaviours can be changed through Fedora Tools settings. Changes take effect with the next shortcut press. If application grouping is disabled—or an application is excluded from diff --git a/plasma-task-group-shortcuts/CMakeLists.txt b/plasma-task-group-shortcuts/CMakeLists.txt index 736976b..f9c6d7a 100644 --- a/plasma-task-group-shortcuts/CMakeLists.txt +++ b/plasma-task-group-shortcuts/CMakeLists.txt @@ -14,17 +14,31 @@ list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) include(GNUInstallDirs) include(CTest) -find_package(Qt6 REQUIRED COMPONENTS DBus Gui WaylandClient) +find_package(Qt6 REQUIRED COMPONENTS DBus Gui Quick WaylandClient) find_package(KF6 REQUIRED COMPONENTS Config GlobalAccel Service) find_package(LibTaskManager REQUIRED) +find_package(Plasma REQUIRED) find_package(PlasmaWaylandProtocols REQUIRED) find_package(XKB REQUIRED) +# KWin caches both QML components and directory listings for its whole session. +# Changed helper code needs a new directory, not just a new filename. +file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/TaskbarGeometry.qml" geometry_hash) +set(geometry_dir "plasma-task-group-shortcuts/${geometry_hash}") +configure_file(TaskbarGeometry.qml "qml/${geometry_hash}/TaskbarGeometry.qml" COPYONLY) + add_executable(plasma-task-group-shortcuts keyboardlayout.cpp main.cpp + pendingselection.cpp + previewplacement.cpp + taskbargeometry.cpp taskselection.cpp + windowpreview.cpp ) +target_compile_definitions(plasma-task-group-shortcuts PRIVATE + GEOMETRY_SCRIPT_PATH="${CMAKE_INSTALL_FULL_DATADIR}/${geometry_dir}/TaskbarGeometry.qml") +qt_add_resources(plasma-task-group-shortcuts preview PREFIX "/" FILES Preview.qml) qt6_generate_wayland_protocol_client_sources(plasma-task-group-shortcuts PRIVATE_CODE FILES ${PLASMA_WAYLAND_PROTOCOLS_DIR}/keystate.xml @@ -32,15 +46,18 @@ qt6_generate_wayland_protocol_client_sources(plasma-task-group-shortcuts target_link_libraries(plasma-task-group-shortcuts PRIVATE Qt6::Gui Qt6::DBus + Qt6::Quick Qt6::WaylandClient KF6::ConfigCore KF6::GlobalAccel KF6::Service PW::LibTaskManager + Plasma::Plasma XKB::XKB ) install(TARGETS plasma-task-group-shortcuts DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(FILES TaskbarGeometry.qml DESTINATION "${CMAKE_INSTALL_DATADIR}/${geometry_dir}") install(FILES se.ajpanton.plasma-task-group-shortcuts.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) install(FILES se.ajpanton.plasma-task-group-shortcuts-autostart.desktop @@ -71,4 +88,14 @@ if(BUILD_TESTING) XKB::XKB ) add_test(NAME keyboardlayout COMMAND test-keyboardlayout) + + add_executable(test-pendingselection tests/test-pendingselection.cpp pendingselection.cpp) + target_link_libraries(test-pendingselection PRIVATE Qt6::Test Qt6::Gui) + add_test(NAME pendingselection COMMAND test-pendingselection) + + add_executable(test-preview tests/test-preview.cpp taskbargeometry.cpp previewplacement.cpp) + target_compile_definitions(test-preview PRIVATE GEOMETRY_SCRIPT_PATH="${CMAKE_CURRENT_BINARY_DIR}/qml/${geometry_hash}/TaskbarGeometry.qml") + qt_add_resources(test-preview preview PREFIX "/" FILES Preview.qml) + target_link_libraries(test-preview PRIVATE Qt6::Test Qt6::Quick Qt6::DBus Plasma::Plasma PW::LibTaskManager KF6::Service) + add_test(NAME preview COMMAND test-preview -platform offscreen) endif() diff --git a/plasma-task-group-shortcuts/Preview.qml b/plasma-task-group-shortcuts/Preview.qml new file mode 100644 index 0000000..fd752bf --- /dev/null +++ b/plasma-task-group-shortcuts/Preview.qml @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT + +import QtQuick +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components as PlasmaComponents +import org.kde.taskmanager as TaskManager +import org.kde.pipewire as PipeWire + +PlasmaCore.Dialog { + id: root + + property string windowId: "" + property string windowTitle: "" + property int position: 0 + property int count: 0 + + visible: false + type: PlasmaCore.Dialog.Tooltip + // A Qt tooltip needs a parent surface; this standalone helper has none. + // Plasma's tooltip role still keeps it out of the taskbar and focus history. + flags: Qt.Tool | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus | Qt.WindowTransparentForInput + hideOnWindowDeactivate: false + + mainItem: ColumnLayout { + width: Kirigami.Units.gridUnit * 18 + height: implicitHeight + spacing: Kirigami.Units.smallSpacing + + Item { + Layout.fillWidth: true + Layout.preferredHeight: width * 9 / 16 + + Loader { + id: thumbnail + anchors.fill: parent + active: root.visible && root.windowId !== "" + sourceComponent: PipeWire.PipeWireSourceItem { + nodeId: request.nodeId + objectSerial: request.objectSerial + + TaskManager.ScreencastingRequest { + id: request + uuid: root.windowId + } + } + } + + PlasmaComponents.Label { + anchors.centerIn: parent + visible: !thumbnail.item || !thumbnail.item.ready + text: qsTr("Window preview unavailable") + opacity: 0.7 + } + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: root.windowTitle + elide: Text.ElideRight + maximumLineCount: 1 + } + + PlasmaComponents.Label { + Layout.fillWidth: true + text: qsTr("%1 of %2 · Release Meta to switch · Esc to cancel").arg(root.position).arg(root.count) + wrapMode: Text.WordWrap + opacity: 0.7 + } + } +} diff --git a/plasma-task-group-shortcuts/TaskbarGeometry.qml b/plasma-task-group-shortcuts/TaskbarGeometry.qml new file mode 100644 index 0000000..f84a4e0 --- /dev/null +++ b/plasma-task-group-shortcuts/TaskbarGeometry.qml @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +import QtQuick +import org.kde.kwin as KWin +import org.kde.plasma.workspace.dbus as DBus + +// KWin knows the actual task-button geometry published by Plasma, including +// panel placement and scaling. No windows or Plasma settings are changed here. +DBus.SignalWatcher { + service: "se.ajpanton.plasma-task-group-shortcuts" + path: "/TaskbarGeometry" + iface: "se.ajpanton.PlasmaTaskGroupShortcuts.TaskbarGeometry" + + function dbuslookup(serial, uuid) { + // D-Bus supplies a boxed string; normalize before strict comparison. + const windowId = String(uuid); + const window = KWin.Workspace.windows.find(w => String(w.internalId) === windowId); + const rect = window ? window.iconGeometry : Qt.rect(0, 0, 0, 0); + send("complete", [serial, rect.x, rect.y, rect.width, rect.height], "(idddd)"); + } + + function send(member, args, signature) { + DBus.SessionBus.asyncCall({ + service, path, iface, member, signature, arguments: args + }, () => {}, result => console.warn("Taskbar geometry:", result.error.message)); + } + + Component.onCompleted: send("bridgeReady", [], "()") +} diff --git a/plasma-task-group-shortcuts/main.cpp b/plasma-task-group-shortcuts/main.cpp index 33eba8c..ea7f295 100644 --- a/plasma-task-group-shortcuts/main.cpp +++ b/plasma-task-group-shortcuts/main.cpp @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT #include "keyboardlayout.h" +#include "pendingselection.h" +#include "taskbargeometry.h" #include "taskselection.h" +#include "windowpreview.h" #include #include @@ -29,6 +32,7 @@ #include #include #include +#include namespace { @@ -109,7 +113,7 @@ void rememberDisplacedShortcuts(int number, const QList &sequences void restoreDisplacedShortcuts() { - for (int number = 1; number <= 9; ++number) { + for (int number = 0; number <= 9; ++number) { KConfigGroup group = displacedShortcutGroup(number); if (!group.readEntry("Saved", false)) { continue; @@ -137,10 +141,15 @@ void restoreDisplacedShortcuts() KGlobalAccel::cleanComponent(QString::fromLatin1(componentId)); } -KConfigGroup taskManagerConfig(const KSharedConfig::Ptr &config) +struct PanelConfig { + KConfigGroup general; + int location = Plasma::Types::BottomEdge; +}; + +PanelConfig taskManagerConfig(const KSharedConfig::Ptr &config) { const KConfigGroup containments(config, QStringLiteral("Containments")); - KConfigGroup fallback; + PanelConfig fallback; for (const QString &containmentId : containments.groupList()) { const KConfigGroup containment = containments.group(containmentId); const KConfigGroup applets = containment.group(QStringLiteral("Applets")); @@ -148,11 +157,12 @@ KConfigGroup taskManagerConfig(const KSharedConfig::Ptr &config) const KConfigGroup applet = applets.group(appletId); if (applet.readEntry("plugin") == QLatin1String("org.kde.plasma.taskmanager")) { const KConfigGroup general = applet.group(QStringLiteral("Configuration")).group(QStringLiteral("General")); + const PanelConfig panel{general, containment.readEntry("location", int(Plasma::Types::BottomEdge))}; if (containment.readEntry("lastScreen", -1) == 0) { - return general; + return panel; } - if (!fallback.isValid()) { - fallback = general; + if (!fallback.general.isValid()) { + fallback = panel; } } } @@ -183,6 +193,11 @@ public: return m_shiftPressed; } + bool metaPressed() const + { + return isActive() && m_metaPressed; + } + Q_SIGNALS: void released(); @@ -218,20 +233,50 @@ public: reloadConfig(); connect(&m_activityInfo, &TaskManager::ActivityInfo::currentActivityChanged, this, [this] { + m_lastShortcut = -1; + m_selection.cancel(); m_model.setActivity(m_activityInfo.currentActivity()); }); + connect(&m_model, &QAbstractItemModel::modelReset, this, [this] { + m_lastShortcut = -1; + }); connect(qGuiApp, &QGuiApplication::primaryScreenChanged, this, &ShortcutManager::updateScreen); connect(&m_modifierState, &ModifierState::released, this, [this] { m_lastShortcut = -1; + m_selection.finish(); + }); + connect(&m_modifierState, &QWaylandClientExtension::activeChanged, this, [this] { + if (!m_modifierState.isActive()) { + m_lastShortcut = -1; + m_selection.cancel(); + } + }); + connect(&m_selection, &PendingSelection::activationRequested, + &m_model, &TaskManager::TasksModel::requestActivate); + connect(&m_selection, &PendingSelection::previewRequested, this, [this](const QModelIndex &task) { + m_preview.show(task, m_panelLocation); + m_taskbarGeometry.request(task.data(TaskManager::AbstractTasksModel::WinIdList).toList().value(0).toString()); + }); + connect(&m_selection, &PendingSelection::previewHidden, this, [this] { + m_taskbarGeometry.cancel(); + m_preview.hide(); + }); + connect(&m_taskbarGeometry, &TaskbarGeometry::received, this, [this](const QRectF &geometry) { + m_preview.setAnchor(geometry.toAlignedRect()); }); updateScreen(); - for (int number = 1; number <= 9; ++number) { + for (int number = 0; number <= 9; ++number) { auto *action = new QAction(this); - action->setObjectName(QStringLiteral("activate task group %1").arg(number)); - action->setText(QStringLiteral("Activate Task Group %1").arg(number)); + action->setObjectName(number == 0 ? QStringLiteral("cancel task group selection") + : QStringLiteral("activate task group %1").arg(number)); + action->setText(number == 0 ? QStringLiteral("Cancel Task Group Selection") + : QStringLiteral("Activate Task Group %1").arg(number)); - const QList sequences = numberRowShortcuts(number); + const QList sequences = number == 0 + ? QList{QKeySequence(Qt::META | Qt::Key_Escape), + QKeySequence(Qt::META | Qt::SHIFT | Qt::Key_Escape)} + : numberRowShortcuts(number); rememberDisplacedShortcuts(number, sequences); for (const QKeySequence &sequence : sequences) { KGlobalAccel::stealShortcutSystemwide(sequence); @@ -240,7 +285,12 @@ public: KGlobalAccel::self()->setShortcut(action, sequences, KGlobalAccel::NoAutoloading); connect(action, &QAction::triggered, this, [this, number] { - useShortcut(number - 1, m_modifierState.shiftPressed()); + if (number == 0) { + m_lastShortcut = -1; + m_selection.cancel(); + } else { + useShortcut(number - 1, m_modifierState.shiftPressed()); + } }); } } @@ -259,11 +309,13 @@ private: void reloadConfig() { m_config->reparseConfiguration(); - const KConfigGroup config = taskManagerConfig(m_config); + const PanelConfig panel = taskManagerConfig(m_config); + const KConfigGroup config = panel.general; if (!config.isValid()) { qWarning("No Plasma Task Manager configuration found"); return; } + m_panelLocation = panel.location; m_model.setLauncherList(config.readEntry("launchers", QStringList())); m_model.setFilterByCurrentVirtualDesktop(config.readEntry("showOnlyCurrentDesktop", true)); @@ -286,6 +338,8 @@ private: void updateScreen() { + m_lastShortcut = -1; + m_selection.cancel(); if (const QScreen *screen = QGuiApplication::primaryScreen()) { m_model.setScreenGeometry(screen->geometry()); } @@ -296,6 +350,8 @@ private: reloadConfig(); const QVector tasks = logicalTaskRows(m_model, m_separateLaunchers); if (row >= tasks.size()) { + m_selection.cancel(); + m_lastShortcut = -1; return; } @@ -310,23 +366,29 @@ private: } else { m_cyclePosition += shifted && settings.shiftCyclesBackward ? -1 : 1; } - m_lastShortcut = row; + const bool held = m_modifierState.metaPressed(); + m_lastShortcut = held ? row : -1; if (shifted && !continuing && settings.initialShiftOpensNewInstance) { + m_selection.cancel(); m_model.requestNewInstance(m_model.index(tasks.at(row), 0)); return; } const QModelIndex task = taskToActivate(m_model, tasks.at(row), m_cyclePosition); - if (task.isValid()) { - m_model.requestActivate(task); - } + // A quick key release can reach us before the shortcut's D-Bus signal. + // In that case there is no held cycle left to preview. + m_selection.select(task, task.data(TaskManager::AbstractTasksModel::IsWindow).toBool() && held); } KSharedConfig::Ptr m_config; TaskManager::ActivityInfo m_activityInfo; TaskManager::TasksModel m_model; + PendingSelection m_selection{m_model}; + WindowPreview m_preview; + TaskbarGeometry m_taskbarGeometry; ModifierState m_modifierState; + int m_panelLocation = Plasma::Types::BottomEdge; bool m_separateLaunchers = true; int m_lastShortcut = -1; int m_cyclePosition = 0; @@ -342,6 +404,7 @@ int main(int argc, char **argv) } QGuiApplication application(argc, argv); + application.setQuitOnLastWindowClosed(false); QCoreApplication::setQuitLockEnabled(false); QCoreApplication::setApplicationName(QString::fromLatin1(componentId)); QGuiApplication::setApplicationDisplayName(QStringLiteral("Plasma Task Group Shortcuts")); diff --git a/plasma-task-group-shortcuts/pendingselection.cpp b/plasma-task-group-shortcuts/pendingselection.cpp new file mode 100644 index 0000000..9790197 --- /dev/null +++ b/plasma-task-group-shortcuts/pendingselection.cpp @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +#include "pendingselection.h" + +PendingSelection::PendingSelection(QAbstractItemModel &model) +{ + connect(&model, &QAbstractItemModel::modelReset, this, &PendingSelection::cancel); + connect(&model, &QAbstractItemModel::rowsRemoved, this, [this] { + if (!m_task.isValid()) { + cancel(); + } + }); +} + +void PendingSelection::select(const QModelIndex &task, bool defer) +{ + m_task = task; + if (defer && task.isValid()) { + Q_EMIT previewRequested(task); + } else { + finish(); + } +} + +void PendingSelection::finish() +{ + const QPersistentModelIndex task = m_task; + cancel(); + if (task.isValid()) { + Q_EMIT activationRequested(task); + } +} + +void PendingSelection::cancel() +{ + m_task = QPersistentModelIndex(); + Q_EMIT previewHidden(); +} diff --git a/plasma-task-group-shortcuts/pendingselection.h b/plasma-task-group-shortcuts/pendingselection.h new file mode 100644 index 0000000..10c4b55 --- /dev/null +++ b/plasma-task-group-shortcuts/pendingselection.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +// A preview is not an activation: only the final selection enters focus history. +class PendingSelection : public QObject +{ + Q_OBJECT + +public: + explicit PendingSelection(QAbstractItemModel &model); + void select(const QModelIndex &task, bool defer); + void finish(); + void cancel(); + +Q_SIGNALS: + void previewRequested(const QModelIndex &task); + void previewHidden(); + void activationRequested(const QModelIndex &task); + +private: + QPersistentModelIndex m_task; +}; diff --git a/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec b/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec index 5160727..7240714 100644 --- a/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec +++ b/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec @@ -1,6 +1,6 @@ Name: plasma-task-group-shortcuts Version: 0.1.0 -Release: 15%{?dist} +Release: 20%{?dist} Summary: Windows-like application-group shortcuts for the Plasma Task Manager License: MIT @@ -17,11 +17,18 @@ BuildRequires: kf6-kglobalaccel-devel BuildRequires: kf6-kitemmodels-devel BuildRequires: kf6-kservice-devel BuildRequires: libxkbcommon-devel +BuildRequires: libplasma-devel BuildRequires: plasma-wayland-protocols-devel BuildRequires: plasma-workspace-devel >= 6.7 BuildRequires: qt6-qtbase-devel +BuildRequires: qt6-qtdeclarative-devel BuildRequires: qt6-qtwayland-devel Requires: plasma-workspace >= 6.7 +Requires: kpipewire +Requires: kf6-kirigami +Requires: libplasma +BuildRequires: kpipewire +BuildRequires: kf6-kirigami Provides: fedora-tools-tool %description @@ -50,15 +57,32 @@ install -Dpm 0644 %{SOURCE2} \ %license %{_licensedir}/%{name}/LICENSE %doc %{_docdir}/%{name}/README.md %{_bindir}/plasma-task-group-shortcuts +%{_datadir}/plasma-task-group-shortcuts/ %{_datadir}/applications/se.ajpanton.plasma-task-group-shortcuts.desktop %{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop %changelog +* Thu Sep 10 2026 fedora-tools contributors - 0.1.0-20 +- Load updated geometry helpers without reusing KWin's cached QML + +* Thu Sep 10 2026 fedora-tools contributors - 0.1.0-19 +- Normalize D-Bus window IDs when locating the selected taskbar button + +* Thu Sep 10 2026 fedora-tools contributors - 0.1.0-18 +- Anchor previews to task buttons and defer single-window selection +- Cancel and reset the held cycle with Meta+Esc + +* Thu Sep 10 2026 fedora-tools contributors - 0.1.0-17 +- Show the standalone selection preview without requiring a parent surface + +* Wed Sep 09 2026 fedora-tools contributors - 0.1.0-16 +- Preview multi-window selections and activate only when Meta is released + * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-15 - Clarify the package summary * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-14 -- Add configurable cycle start and Shift behavior +- Add configurable cycle start and Shift behaviour * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-13 - Advertise the tool to Fedora Tools settings diff --git a/plasma-task-group-shortcuts/previewplacement.cpp b/plasma-task-group-shortcuts/previewplacement.cpp new file mode 100644 index 0000000..75acb0f --- /dev/null +++ b/plasma-task-group-shortcuts/previewplacement.cpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT + +#include "previewplacement.h" + +#include +#include + +QPoint previewPosition(const QRect &button, const QRect &screen, const QSize &preview, int panelLocation) +{ + const QRect anchor = button.isValid() ? button : screen; + const int centerX = anchor.x() + (anchor.width() - preview.width()) / 2; + const int centerY = anchor.y() + (anchor.height() - preview.height()) / 2; + QPoint result; + switch (panelLocation) { + case Plasma::Types::TopEdge: + result = {centerX, button.isValid() ? button.bottom() + 1 : screen.top()}; + break; + case Plasma::Types::LeftEdge: + result = {button.isValid() ? button.right() + 1 : screen.left(), centerY}; + break; + case Plasma::Types::RightEdge: + result = {button.isValid() ? button.left() - preview.width() : screen.right() + 1 - preview.width(), centerY}; + break; + default: + result = {centerX, button.isValid() ? button.top() - preview.height() : screen.bottom() + 1 - preview.height()}; + break; + } + result.setX(std::clamp(result.x(), screen.left(), std::max(screen.left(), screen.right() + 1 - preview.width()))); + result.setY(std::clamp(result.y(), screen.top(), std::max(screen.top(), screen.bottom() + 1 - preview.height()))); + return result; +} diff --git a/plasma-task-group-shortcuts/previewplacement.h b/plasma-task-group-shortcuts/previewplacement.h new file mode 100644 index 0000000..218eff7 --- /dev/null +++ b/plasma-task-group-shortcuts/previewplacement.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +QPoint previewPosition(const QRect &button, const QRect &screen, const QSize &preview, int panelLocation); diff --git a/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts.desktop b/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts.desktop index eeadb66..ca84e2a 100644 --- a/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts.desktop +++ b/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts.desktop @@ -5,4 +5,4 @@ Comment=Activate taskbar application groups with Meta and a number Exec=/usr/bin/plasma-task-group-shortcuts Icon=preferences-system-windows NoDisplay=true -X-KDE-Wayland-Interfaces=org_kde_plasma_window_management,org_kde_kwin_keystate +X-KDE-Wayland-Interfaces=org_kde_plasma_window_management,org_kde_kwin_keystate,org_kde_plasma_shell,zkde_screencast_unstable_v1 diff --git a/plasma-task-group-shortcuts/taskbargeometry.cpp b/plasma-task-group-shortcuts/taskbargeometry.cpp new file mode 100644 index 0000000..94a3968 --- /dev/null +++ b/plasma-task-group-shortcuts/taskbargeometry.cpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT + +#include "taskbargeometry.h" + +#include +#include +#include +#include + +namespace { +constexpr auto scriptName = "se.ajpanton.task-group-shortcuts.geometry"; +constexpr auto objectPath = "/TaskbarGeometry"; +} + +TaskbarGeometry::TaskbarGeometry(QObject *parent) + : QObject(parent) +{ + auto bus = QDBusConnection::sessionBus(); + if (!bus.registerObject(QString::fromLatin1(objectPath), this, QDBusConnection::ExportScriptableContents)) { + qFatal("Cannot register the taskbar geometry interface"); + } + QDBusInterface scripting(QStringLiteral("org.kde.KWin"), QStringLiteral("/Scripting"), + QStringLiteral("org.kde.kwin.Scripting")); + // Replace any copy left behind by an earlier process that was terminated. + scripting.call(QStringLiteral("unloadScript"), QString::fromLatin1(scriptName)); + const QDBusReply loaded = scripting.call(QStringLiteral("loadDeclarativeScript"), + QStringLiteral(GEOMETRY_SCRIPT_PATH), QString::fromLatin1(scriptName)); + if (!loaded.isValid() || loaded.value() < 0) { + qWarning() << "Cannot load taskbar geometry script:" << loaded.error().message(); + return; + } + m_loaded = true; + QDBusInterface script(QStringLiteral("org.kde.KWin"), + QStringLiteral("/Scripting/Script%1").arg(loaded.value()), QStringLiteral("org.kde.kwin.Script")); + const QDBusReply started = script.call(QStringLiteral("run")); + if (!started.isValid()) { + qWarning() << "Cannot start taskbar geometry script:" << started.error().message(); + m_loaded = false; + } +} + +TaskbarGeometry::~TaskbarGeometry() +{ + QDBusInterface scripting(QStringLiteral("org.kde.KWin"), QStringLiteral("/Scripting"), + QStringLiteral("org.kde.kwin.Scripting")); + scripting.call(QStringLiteral("unloadScript"), QString::fromLatin1(scriptName)); + QDBusConnection::sessionBus().unregisterObject(QString::fromLatin1(objectPath)); +} + +void TaskbarGeometry::request(const QString &uuid) +{ + ++m_serial; + m_uuid = uuid; + if (m_ready) { + Q_EMIT lookup(m_serial, uuid); + } else if (!m_loaded) { + Q_EMIT received({}); + } +} + +void TaskbarGeometry::cancel() +{ + ++m_serial; + m_uuid.clear(); +} + +void TaskbarGeometry::bridgeReady() +{ + m_ready = true; + if (!m_uuid.isEmpty()) { + Q_EMIT lookup(m_serial, m_uuid); + } +} + +void TaskbarGeometry::complete(int serial, double x, double y, double width, double height) +{ + if (serial == m_serial && !m_uuid.isEmpty()) { + Q_EMIT received(QRectF(x, y, width, height)); + } +} diff --git a/plasma-task-group-shortcuts/taskbargeometry.h b/plasma-task-group-shortcuts/taskbargeometry.h new file mode 100644 index 0000000..67f2dd2 --- /dev/null +++ b/plasma-task-group-shortcuts/taskbargeometry.h @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +class TaskbarGeometry : public QObject +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "se.ajpanton.PlasmaTaskGroupShortcuts.TaskbarGeometry") + +public: + explicit TaskbarGeometry(QObject *parent = nullptr); + ~TaskbarGeometry() override; + void request(const QString &uuid); + void cancel(); + +Q_SIGNALS: + Q_SCRIPTABLE void lookup(int serial, const QString &uuid); + void received(const QRectF &geometry); + +public Q_SLOTS: + Q_SCRIPTABLE void bridgeReady(); + Q_SCRIPTABLE void complete(int serial, double x, double y, double width, double height); + +private: + int m_serial = 0; + bool m_ready = false; + bool m_loaded = false; + QString m_uuid; +}; diff --git a/plasma-task-group-shortcuts/tests/test-pendingselection.cpp b/plasma-task-group-shortcuts/tests/test-pendingselection.cpp new file mode 100644 index 0000000..9535a50 --- /dev/null +++ b/plasma-task-group-shortcuts/tests/test-pendingselection.cpp @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT + +#include "../pendingselection.h" + +#include +#include +#include + +class PendingSelectionTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void singleWindowWaitsForRelease() + { + QStandardItemModel model(1, 1); + PendingSelection selection(model); + QSignalSpy activated(&selection, &PendingSelection::activationRequested); + QSignalSpy previewed(&selection, &PendingSelection::previewRequested); + selection.select(model.index(0, 0), true); + QCOMPARE(previewed.count(), 1); + QCOMPARE(activated.count(), 0); + selection.finish(); + QCOMPARE(activated.count(), 1); + } + + void cancellationAllowsAnotherSelectionBeforeRelease() + { + QStandardItemModel model(2, 1); + PendingSelection selection(model); + QSignalSpy activated(&selection, &PendingSelection::activationRequested); + selection.select(model.index(1, 0), true); + selection.cancel(); + selection.select(model.index(0, 0), true); + QCOMPARE(activated.count(), 0); + selection.finish(); + QCOMPARE(activated.count(), 1); + QCOMPARE(activated.first().first().value(), model.index(0, 0)); + } + + void activatesOnlyFinalSelection() + { + QStandardItemModel model(3, 1); + PendingSelection selection(model); + QSignalSpy activated(&selection, &PendingSelection::activationRequested); + QSignalSpy previewed(&selection, &PendingSelection::previewRequested); + QSignalSpy hidden(&selection, &PendingSelection::previewHidden); + + selection.select(model.index(0, 0), true); + selection.select(model.index(1, 0), true); + selection.select(model.index(2, 0), true); + QCOMPARE(previewed.count(), 3); + QCOMPARE(activated.count(), 0); + selection.finish(); + QCOMPARE(hidden.count(), 1); + QCOMPARE(activated.count(), 1); + QCOMPARE(activated.first().first().value(), model.index(2, 0)); + selection.finish(); + QCOMPARE(activated.count(), 1); + } + + void switchingGroupsAbandonsPreviousSelection() + { + QStandardItemModel model; + for (const auto &name : {"Dolphin", "Firefox"}) { + auto *group = new QStandardItem(QString::fromLatin1(name)); + group->appendRow(new QStandardItem(QStringLiteral("first"))); + group->appendRow(new QStandardItem(QStringLiteral("second"))); + model.appendRow(group); + } + PendingSelection selection(model); + QSignalSpy activated(&selection, &PendingSelection::activationRequested); + selection.select(model.index(1, 0, model.index(0, 0)), true); + const QModelIndex firefox = model.index(0, 0, model.index(1, 0)); + selection.select(firefox, true); + QCOMPARE(activated.count(), 0); + selection.finish(); + QCOMPARE(activated.count(), 1); + QCOMPARE(activated.first().first().value(), firefox); + } + + void immediateActionReplacesPreview() + { + QStandardItemModel model(2, 1); + PendingSelection selection(model); + QSignalSpy activated(&selection, &PendingSelection::activationRequested); + selection.select(model.index(0, 0), true); + selection.select(model.index(1, 0), false); + QCOMPARE(activated.count(), 1); + QCOMPARE(activated.first().first().value(), model.index(1, 0)); + selection.finish(); + QCOMPARE(activated.count(), 1); + } + + void cancellationDoesNotActivate() + { + QStandardItemModel model(1, 1); + PendingSelection selection(model); + QSignalSpy activated(&selection, &PendingSelection::activationRequested); + selection.select(model.index(0, 0), true); + selection.cancel(); + selection.finish(); + selection.select(model.index(0, 0), true); + selection.select({}, true); + selection.finish(); + QCOMPARE(activated.count(), 0); + } + + void selectedWindowClosingCancels() + { + QStandardItemModel model(2, 1); + PendingSelection selection(model); + QSignalSpy activated(&selection, &PendingSelection::activationRequested); + QSignalSpy hidden(&selection, &PendingSelection::previewHidden); + selection.select(model.index(0, 0), true); + model.removeRow(0); + QVERIFY(!hidden.isEmpty()); + selection.finish(); + QCOMPARE(activated.count(), 0); + } + + void rowChangeKeepsSelectedWindow() + { + QStandardItemModel model(3, 1); + model.setData(model.index(2, 0), QStringLiteral("selected")); + PendingSelection selection(model); + QSignalSpy activated(&selection, &PendingSelection::activationRequested); + selection.select(model.index(2, 0), true); + model.removeRow(0); + selection.finish(); + QCOMPARE(activated.count(), 1); + QCOMPARE(activated.first().first().value().data().toString(), QStringLiteral("selected")); + } + + void modelResetCancels() + { + QStandardItemModel model(1, 1); + PendingSelection selection(model); + QSignalSpy activated(&selection, &PendingSelection::activationRequested); + selection.select(model.index(0, 0), true); + model.clear(); + model.setRowCount(1); + selection.finish(); + QCOMPARE(activated.count(), 0); + } +}; + +QTEST_GUILESS_MAIN(PendingSelectionTest) +#include "test-pendingselection.moc" diff --git a/plasma-task-group-shortcuts/tests/test-preview.cpp b/plasma-task-group-shortcuts/tests/test-preview.cpp new file mode 100644 index 0000000..ec95675 --- /dev/null +++ b/plasma-task-group-shortcuts/tests/test-preview.cpp @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../previewplacement.h" +#include "../taskbargeometry.h" +#include +#include + +class PreviewTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void placementTracksButtonAndScreenEdges() + { + const QRect screen(0, 0, 1000, 800); + const QSize size(300, 200); + QCOMPARE(previewPosition(QRect(400, 760, 100, 40), screen, size, Plasma::Types::BottomEdge), QPoint(300, 560)); + QCOMPARE(previewPosition(QRect(0, 760, 100, 40), screen, size, Plasma::Types::BottomEdge), QPoint(0, 560)); + QCOMPARE(previewPosition(QRect(900, 760, 100, 40), screen, size, Plasma::Types::BottomEdge), QPoint(700, 560)); + QCOMPARE(previewPosition(QRect(400, 0, 100, 40), screen, size, Plasma::Types::TopEdge), QPoint(300, 40)); + QCOMPARE(previewPosition(QRect(0, 300, 40, 100), screen, size, Plasma::Types::LeftEdge), QPoint(40, 250)); + QCOMPARE(previewPosition(QRect(960, 300, 40, 100), screen, size, Plasma::Types::RightEdge), QPoint(660, 250)); + QCOMPARE(previewPosition(QRect(-1000, 760, 100, 40), QRect(-1000, 0, 1000, 800), size, + Plasma::Types::BottomEdge), QPoint(-1000, 560)); + QCOMPARE(previewPosition({}, screen, size, Plasma::Types::BottomEdge), QPoint(350, 600)); + } + + void geometryBridgeOnWayland() + { + if (QGuiApplication::platformName() != QLatin1String("wayland")) { + QSKIP("Requires KWin's Wayland compositor"); + } + auto bus = QDBusConnection::sessionBus(); + const QString service = QStringLiteral("se.ajpanton.plasma-task-group-shortcuts"); + QVERIFY(bus.registerService(service)); + { + TaskbarGeometry geometry; + QSignalSpy results(&geometry, &TaskbarGeometry::received); + QSignalSpy lookups(&geometry, &TaskbarGeometry::lookup); + // The script must respond even when a window has disappeared. + geometry.request(QStringLiteral("nonexistent-window")); + QTRY_COMPARE(lookups.count(), 1); + QTRY_COMPARE(results.count(), 1); + QVERIFY(results.first().first().toRectF().isEmpty()); + geometry.request(QStringLiteral("another-window")); + geometry.cancel(); + QTest::qWait(100); + QCOMPARE(results.count(), 1); + } + QVERIFY(bus.unregisterService(service)); + } + + void findsPublishedButtonGeometryOnWayland() + { + if (QGuiApplication::platformName() != QLatin1String("wayland")) { + QSKIP("Requires KWin and the plasma-window-management protocol"); + } + TaskManager::TasksModel model; + model.setGroupMode(TaskManager::TasksModel::GroupDisabled); + QQuickWindow target; + target.setTitle(QStringLiteral("Task group geometry test")); + target.resize(400, 300); + target.show(); + QTRY_VERIFY(target.isExposed()); + const auto matching = [&] { + return model.match(model.index(0, 0), Qt::DisplayRole, target.title()); + }; + QTRY_VERIFY(!matching().isEmpty()); + const QModelIndex index = matching().first(); + const QString uuid = index.data(TaskManager::AbstractTasksModel::WinIdList).toList().first().toString(); + QVERIFY(!uuid.isEmpty()); + + QQuickWindow panel; + panel.setTitle(QStringLiteral("Task group geometry panel")); + panel.resize(1000, 40); + panel.show(); + QTRY_VERIFY(panel.isExposed()); + const auto panelTask = [&] { + return model.match(model.index(0, 0), Qt::DisplayRole, panel.title()); + }; + QTRY_VERIFY(!panelTask().isEmpty()); + QTRY_VERIFY(panelTask().first().data(TaskManager::AbstractTasksModel::Geometry).toRect().isValid()); + const QPoint origin = panelTask().first().data(TaskManager::AbstractTasksModel::Geometry).toRect().topLeft(); + const QRect button(origin + QPoint(120, 0), QSize(180, 40)); + QQuickItem delegate(panel.contentItem()); + delegate.setPosition(QPointF(120, 0)); + delegate.setSize(QSizeF(180, 40)); + model.requestPublishDelegateGeometry(index, button, &delegate); + QTest::qWait(100); + + auto bus = QDBusConnection::sessionBus(); + const QString service = QStringLiteral("se.ajpanton.plasma-task-group-shortcuts"); + QVERIFY(bus.registerService(service)); + { + TaskbarGeometry geometry; + QSignalSpy results(&geometry, &TaskbarGeometry::received); + geometry.request(uuid); + QTRY_COMPARE(results.count(), 1); + QCOMPARE(results.first().first().toRectF(), QRectF(button)); + } + QVERIFY(bus.unregisterService(service)); + } + + void loadsWithoutTakingFocus() + { + QTest::failOnWarning(QRegularExpression(QStringLiteral(".*Failed to create .*popup.*"))); + QQmlEngine engine; + QQmlComponent component(&engine, QUrl(QStringLiteral("qrc:/Preview.qml"))); + std::unique_ptr object(component.create()); + QVERIFY2(object, qPrintable(component.errorString())); + auto *window = qobject_cast(object.get()); + QVERIFY(window); + QVERIFY(!window->isVisible()); + QCOMPARE(window->type(), Qt::Tool); + QVERIFY(!window->transientParent()); + QVERIFY(window->flags().testFlag(Qt::FramelessWindowHint)); + QVERIFY(window->flags().testFlag(Qt::WindowDoesNotAcceptFocus)); + QVERIFY(window->flags().testFlag(Qt::WindowTransparentForInput)); + window->show(); + QTRY_VERIFY(window->isExposed()); + // Wayland can reject a window with an asynchronously delivered close. + QTest::qWait(100); + QVERIFY(window->isVisible()); + QVERIFY(window->isExposed()); + QTRY_VERIFY(window->width() > 0); + QTRY_VERIFY(window->height() > 0); + QVERIFY(window->flags().testFlag(Qt::WindowDoesNotAcceptFocus)); + window->setProperty("windowTitle", QStringLiteral("Test window")); + window->setProperty("position", 2); + window->setProperty("count", 3); + QCOMPARE(window->property("windowTitle").toString(), QStringLiteral("Test window")); + window->hide(); + } + + void preservesFocusOnWayland() + { + if (QGuiApplication::platformName() != QLatin1String("wayland")) { + QSKIP("Requires a Wayland compositor"); + } + QQuickWindow original; + original.resize(400, 300); + original.show(); + QTRY_VERIFY(original.isActive()); + + QQmlEngine engine; + QQmlComponent component(&engine, QUrl(QStringLiteral("qrc:/Preview.qml"))); + std::unique_ptr object(component.create()); + QVERIFY2(object, qPrintable(component.errorString())); + auto *preview = qobject_cast(object.get()); + QVERIFY(preview); + preview->show(); + QTRY_VERIFY(preview->isExposed()); + QTest::qWait(100); + QVERIFY(preview->isVisible()); + QVERIFY(!preview->isActive()); + QVERIFY(original.isActive()); + preview->hide(); + QTest::qWait(100); + QVERIFY(original.isActive()); + } +}; + +QTEST_MAIN(PreviewTest) +#include "test-preview.moc" diff --git a/plasma-task-group-shortcuts/windowpreview.cpp b/plasma-task-group-shortcuts/windowpreview.cpp new file mode 100644 index 0000000..701119e --- /dev/null +++ b/plasma-task-group-shortcuts/windowpreview.cpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT + +#include "windowpreview.h" +#include "previewplacement.h" + +#include +#include +#include +#include + +WindowPreview::WindowPreview() +{ + QQmlComponent component(&m_engine, QUrl(QStringLiteral("qrc:/Preview.qml"))); + m_window.reset(qobject_cast(component.create())); + if (!m_window) { + qFatal("Cannot create window preview: %s", qPrintable(component.errorString())); + } +} + +void WindowPreview::show(const QModelIndex &task, int panelLocation) +{ + const QVariantList ids = task.data(TaskManager::AbstractTasksModel::WinIdList).toList(); + m_window->setProperty("windowId", ids.isEmpty() ? QString() : ids.first().toString()); + m_window->setProperty("windowTitle", task.data(Qt::DisplayRole).toString()); + m_window->setProperty("position", task.parent().isValid() ? task.row() + 1 : 1); + m_window->setProperty("count", task.parent().isValid() ? task.model()->rowCount(task.parent()) : 1); + m_panelLocation = panelLocation; + // Plasma sizes the dialog from mainItem when it becomes visible. + m_window->show(); + + setAnchor({}); +} + +void WindowPreview::setAnchor(const QRect &button) +{ + if (!m_window->isVisible()) { + return; + } + QScreen *screen = button.isValid() ? QGuiApplication::screenAt(button.center()) : nullptr; + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + if (screen) { + m_window->setScreen(screen); + m_window->setPosition(previewPosition(button, screen->availableGeometry(), m_window->size(), m_panelLocation)); + } +} + +void WindowPreview::hide() +{ + m_window->hide(); + m_window->setProperty("windowId", QString()); +} diff --git a/plasma-task-group-shortcuts/windowpreview.h b/plasma-task-group-shortcuts/windowpreview.h new file mode 100644 index 0000000..0f9828a --- /dev/null +++ b/plasma-task-group-shortcuts/windowpreview.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include + +class WindowPreview +{ +public: + WindowPreview(); + void show(const QModelIndex &task, int panelLocation); + void setAnchor(const QRect &button); + void hide(); + +private: + QQmlEngine m_engine; + std::unique_ptr m_window; + int m_panelLocation = 0; +}; diff --git a/scripts/build-plasma-task-group-shortcuts-rpm b/scripts/build-plasma-task-group-shortcuts-rpm index ddf8bf4..05666eb 100755 --- a/scripts/build-plasma-task-group-shortcuts-rpm +++ b/scripts/build-plasma-task-group-shortcuts-rpm @@ -15,6 +15,18 @@ install -m 0644 "$tool_dir/CMakeLists.txt" "$source_dir/CMakeLists.txt" install -m 0644 "$tool_dir/keyboardlayout.cpp" "$source_dir/keyboardlayout.cpp" install -m 0644 "$tool_dir/keyboardlayout.h" "$source_dir/keyboardlayout.h" install -m 0644 "$tool_dir/main.cpp" "$source_dir/main.cpp" +install -m 0644 "$tool_dir/pendingselection.cpp" "$source_dir/pendingselection.cpp" +install -m 0644 "$tool_dir/pendingselection.h" "$source_dir/pendingselection.h" +install -m 0644 "$tool_dir/windowpreview.cpp" "$source_dir/windowpreview.cpp" +install -m 0644 "$tool_dir/windowpreview.h" "$source_dir/windowpreview.h" +install -m 0644 "$tool_dir/Preview.qml" "$source_dir/Preview.qml" +install -m 0644 "$tool_dir/TaskbarGeometry.qml" "$source_dir/TaskbarGeometry.qml" +install -m 0644 "$tool_dir/taskbargeometry.cpp" "$source_dir/taskbargeometry.cpp" +install -m 0644 "$tool_dir/taskbargeometry.h" "$source_dir/taskbargeometry.h" +install -m 0644 "$tool_dir/previewplacement.cpp" "$source_dir/previewplacement.cpp" +install -m 0644 "$tool_dir/previewplacement.h" "$source_dir/previewplacement.h" +install -m 0644 "$tool_dir/tests/test-pendingselection.cpp" "$source_dir/tests/test-pendingselection.cpp" +install -m 0644 "$tool_dir/tests/test-preview.cpp" "$source_dir/tests/test-preview.cpp" install -m 0644 "$tool_dir/taskselection.cpp" "$source_dir/taskselection.cpp" install -m 0644 "$tool_dir/taskselection.h" "$source_dir/taskselection.h" install -m 0644 "$tool_dir/tests/test-taskselection.cpp" \