Preview task-group selections until Meta is released

This commit is contained in:
ajp_anton
2026-09-11 10:59:54 +00:00
parent ccd0161398
commit 0ec628e786
18 changed files with 867 additions and 21 deletions
+7 -1
View File
@@ -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 follow the physical number-row keys for every keyboard layout configured in
KWin, including layouts where those keys do not produce digits without Shift. 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 With **Keep launchers separate** disabled, a pinned launcher and its running
application share one shortcut position. When enabled, the launcher is its own application share one shortcut position. When enabled, the launcher is its own
position and the application's windows form another. **Hide launcher when position and the application's windows form another. **Hide launcher when
application starts** determines whether that separate launcher remains while application starts** determines whether that separate launcher remains while
the application is running. Other groups retain the Task Manager's sort order. 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. Fedora Tools settings. Changes take effect with the next shortcut press.
If application grouping is disabled—or an application is excluded from If application grouping is disabled—or an application is excluded from
+28 -1
View File
@@ -14,17 +14,31 @@ list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
include(GNUInstallDirs) include(GNUInstallDirs)
include(CTest) 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(KF6 REQUIRED COMPONENTS Config GlobalAccel Service)
find_package(LibTaskManager REQUIRED) find_package(LibTaskManager REQUIRED)
find_package(Plasma REQUIRED)
find_package(PlasmaWaylandProtocols REQUIRED) find_package(PlasmaWaylandProtocols REQUIRED)
find_package(XKB 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 add_executable(plasma-task-group-shortcuts
keyboardlayout.cpp keyboardlayout.cpp
main.cpp main.cpp
pendingselection.cpp
previewplacement.cpp
taskbargeometry.cpp
taskselection.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 qt6_generate_wayland_protocol_client_sources(plasma-task-group-shortcuts
PRIVATE_CODE PRIVATE_CODE
FILES ${PLASMA_WAYLAND_PROTOCOLS_DIR}/keystate.xml 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 target_link_libraries(plasma-task-group-shortcuts PRIVATE
Qt6::Gui Qt6::Gui
Qt6::DBus Qt6::DBus
Qt6::Quick
Qt6::WaylandClient Qt6::WaylandClient
KF6::ConfigCore KF6::ConfigCore
KF6::GlobalAccel KF6::GlobalAccel
KF6::Service KF6::Service
PW::LibTaskManager PW::LibTaskManager
Plasma::Plasma
XKB::XKB XKB::XKB
) )
install(TARGETS plasma-task-group-shortcuts DESTINATION ${CMAKE_INSTALL_BINDIR}) 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 install(FILES se.ajpanton.plasma-task-group-shortcuts.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
install(FILES se.ajpanton.plasma-task-group-shortcuts-autostart.desktop install(FILES se.ajpanton.plasma-task-group-shortcuts-autostart.desktop
@@ -71,4 +88,14 @@ if(BUILD_TESTING)
XKB::XKB XKB::XKB
) )
add_test(NAME keyboardlayout COMMAND test-keyboardlayout) 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() endif()
+72
View File
@@ -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
}
}
}
@@ -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", [], "()")
}
+78 -15
View File
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
#include "keyboardlayout.h" #include "keyboardlayout.h"
#include "pendingselection.h"
#include "taskbargeometry.h"
#include "taskselection.h" #include "taskselection.h"
#include "windowpreview.h"
#include <QAction> #include <QAction>
#include <QCommandLineOption> #include <QCommandLineOption>
@@ -29,6 +32,7 @@
#include <abstracttasksmodel.h> #include <abstracttasksmodel.h>
#include <activityinfo.h> #include <activityinfo.h>
#include <tasksmodel.h> #include <tasksmodel.h>
#include <plasma/plasma.h>
namespace namespace
{ {
@@ -109,7 +113,7 @@ void rememberDisplacedShortcuts(int number, const QList<QKeySequence> &sequences
void restoreDisplacedShortcuts() void restoreDisplacedShortcuts()
{ {
for (int number = 1; number <= 9; ++number) { for (int number = 0; number <= 9; ++number) {
KConfigGroup group = displacedShortcutGroup(number); KConfigGroup group = displacedShortcutGroup(number);
if (!group.readEntry("Saved", false)) { if (!group.readEntry("Saved", false)) {
continue; continue;
@@ -137,10 +141,15 @@ void restoreDisplacedShortcuts()
KGlobalAccel::cleanComponent(QString::fromLatin1(componentId)); 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")); const KConfigGroup containments(config, QStringLiteral("Containments"));
KConfigGroup fallback; PanelConfig fallback;
for (const QString &containmentId : containments.groupList()) { for (const QString &containmentId : containments.groupList()) {
const KConfigGroup containment = containments.group(containmentId); const KConfigGroup containment = containments.group(containmentId);
const KConfigGroup applets = containment.group(QStringLiteral("Applets")); const KConfigGroup applets = containment.group(QStringLiteral("Applets"));
@@ -148,11 +157,12 @@ KConfigGroup taskManagerConfig(const KSharedConfig::Ptr &config)
const KConfigGroup applet = applets.group(appletId); const KConfigGroup applet = applets.group(appletId);
if (applet.readEntry("plugin") == QLatin1String("org.kde.plasma.taskmanager")) { if (applet.readEntry("plugin") == QLatin1String("org.kde.plasma.taskmanager")) {
const KConfigGroup general = applet.group(QStringLiteral("Configuration")).group(QStringLiteral("General")); 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) { if (containment.readEntry("lastScreen", -1) == 0) {
return general; return panel;
} }
if (!fallback.isValid()) { if (!fallback.general.isValid()) {
fallback = general; fallback = panel;
} }
} }
} }
@@ -183,6 +193,11 @@ public:
return m_shiftPressed; return m_shiftPressed;
} }
bool metaPressed() const
{
return isActive() && m_metaPressed;
}
Q_SIGNALS: Q_SIGNALS:
void released(); void released();
@@ -218,20 +233,50 @@ public:
reloadConfig(); reloadConfig();
connect(&m_activityInfo, &TaskManager::ActivityInfo::currentActivityChanged, this, [this] { connect(&m_activityInfo, &TaskManager::ActivityInfo::currentActivityChanged, this, [this] {
m_lastShortcut = -1;
m_selection.cancel();
m_model.setActivity(m_activityInfo.currentActivity()); m_model.setActivity(m_activityInfo.currentActivity());
}); });
connect(&m_model, &QAbstractItemModel::modelReset, this, [this] {
m_lastShortcut = -1;
});
connect(qGuiApp, &QGuiApplication::primaryScreenChanged, this, &ShortcutManager::updateScreen); connect(qGuiApp, &QGuiApplication::primaryScreenChanged, this, &ShortcutManager::updateScreen);
connect(&m_modifierState, &ModifierState::released, this, [this] { connect(&m_modifierState, &ModifierState::released, this, [this] {
m_lastShortcut = -1; 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(); updateScreen();
for (int number = 1; number <= 9; ++number) { for (int number = 0; number <= 9; ++number) {
auto *action = new QAction(this); auto *action = new QAction(this);
action->setObjectName(QStringLiteral("activate task group %1").arg(number)); action->setObjectName(number == 0 ? QStringLiteral("cancel task group selection")
action->setText(QStringLiteral("Activate Task Group %1").arg(number)); : 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<QKeySequence> sequences = numberRowShortcuts(number); const QList<QKeySequence> sequences = number == 0
? QList<QKeySequence>{QKeySequence(Qt::META | Qt::Key_Escape),
QKeySequence(Qt::META | Qt::SHIFT | Qt::Key_Escape)}
: numberRowShortcuts(number);
rememberDisplacedShortcuts(number, sequences); rememberDisplacedShortcuts(number, sequences);
for (const QKeySequence &sequence : sequences) { for (const QKeySequence &sequence : sequences) {
KGlobalAccel::stealShortcutSystemwide(sequence); KGlobalAccel::stealShortcutSystemwide(sequence);
@@ -240,7 +285,12 @@ public:
KGlobalAccel::self()->setShortcut(action, sequences, KGlobalAccel::NoAutoloading); KGlobalAccel::self()->setShortcut(action, sequences, KGlobalAccel::NoAutoloading);
connect(action, &QAction::triggered, this, [this, number] { connect(action, &QAction::triggered, this, [this, number] {
if (number == 0) {
m_lastShortcut = -1;
m_selection.cancel();
} else {
useShortcut(number - 1, m_modifierState.shiftPressed()); useShortcut(number - 1, m_modifierState.shiftPressed());
}
}); });
} }
} }
@@ -259,11 +309,13 @@ private:
void reloadConfig() void reloadConfig()
{ {
m_config->reparseConfiguration(); m_config->reparseConfiguration();
const KConfigGroup config = taskManagerConfig(m_config); const PanelConfig panel = taskManagerConfig(m_config);
const KConfigGroup config = panel.general;
if (!config.isValid()) { if (!config.isValid()) {
qWarning("No Plasma Task Manager configuration found"); qWarning("No Plasma Task Manager configuration found");
return; return;
} }
m_panelLocation = panel.location;
m_model.setLauncherList(config.readEntry("launchers", QStringList())); m_model.setLauncherList(config.readEntry("launchers", QStringList()));
m_model.setFilterByCurrentVirtualDesktop(config.readEntry("showOnlyCurrentDesktop", true)); m_model.setFilterByCurrentVirtualDesktop(config.readEntry("showOnlyCurrentDesktop", true));
@@ -286,6 +338,8 @@ private:
void updateScreen() void updateScreen()
{ {
m_lastShortcut = -1;
m_selection.cancel();
if (const QScreen *screen = QGuiApplication::primaryScreen()) { if (const QScreen *screen = QGuiApplication::primaryScreen()) {
m_model.setScreenGeometry(screen->geometry()); m_model.setScreenGeometry(screen->geometry());
} }
@@ -296,6 +350,8 @@ private:
reloadConfig(); reloadConfig();
const QVector<int> tasks = logicalTaskRows(m_model, m_separateLaunchers); const QVector<int> tasks = logicalTaskRows(m_model, m_separateLaunchers);
if (row >= tasks.size()) { if (row >= tasks.size()) {
m_selection.cancel();
m_lastShortcut = -1;
return; return;
} }
@@ -310,23 +366,29 @@ private:
} else { } else {
m_cyclePosition += shifted && settings.shiftCyclesBackward ? -1 : 1; 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) { if (shifted && !continuing && settings.initialShiftOpensNewInstance) {
m_selection.cancel();
m_model.requestNewInstance(m_model.index(tasks.at(row), 0)); m_model.requestNewInstance(m_model.index(tasks.at(row), 0));
return; return;
} }
const QModelIndex task = taskToActivate(m_model, tasks.at(row), m_cyclePosition); const QModelIndex task = taskToActivate(m_model, tasks.at(row), m_cyclePosition);
if (task.isValid()) { // A quick key release can reach us before the shortcut's D-Bus signal.
m_model.requestActivate(task); // 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; KSharedConfig::Ptr m_config;
TaskManager::ActivityInfo m_activityInfo; TaskManager::ActivityInfo m_activityInfo;
TaskManager::TasksModel m_model; TaskManager::TasksModel m_model;
PendingSelection m_selection{m_model};
WindowPreview m_preview;
TaskbarGeometry m_taskbarGeometry;
ModifierState m_modifierState; ModifierState m_modifierState;
int m_panelLocation = Plasma::Types::BottomEdge;
bool m_separateLaunchers = true; bool m_separateLaunchers = true;
int m_lastShortcut = -1; int m_lastShortcut = -1;
int m_cyclePosition = 0; int m_cyclePosition = 0;
@@ -342,6 +404,7 @@ int main(int argc, char **argv)
} }
QGuiApplication application(argc, argv); QGuiApplication application(argc, argv);
application.setQuitOnLastWindowClosed(false);
QCoreApplication::setQuitLockEnabled(false); QCoreApplication::setQuitLockEnabled(false);
QCoreApplication::setApplicationName(QString::fromLatin1(componentId)); QCoreApplication::setApplicationName(QString::fromLatin1(componentId));
QGuiApplication::setApplicationDisplayName(QStringLiteral("Plasma Task Group Shortcuts")); QGuiApplication::setApplicationDisplayName(QStringLiteral("Plasma Task Group Shortcuts"));
@@ -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();
}
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QAbstractItemModel>
#include <QPersistentModelIndex>
// 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;
};
@@ -1,6 +1,6 @@
Name: plasma-task-group-shortcuts Name: plasma-task-group-shortcuts
Version: 0.1.0 Version: 0.1.0
Release: 15%{?dist} Release: 20%{?dist}
Summary: Windows-like application-group shortcuts for the Plasma Task Manager Summary: Windows-like application-group shortcuts for the Plasma Task Manager
License: MIT License: MIT
@@ -17,11 +17,18 @@ BuildRequires: kf6-kglobalaccel-devel
BuildRequires: kf6-kitemmodels-devel BuildRequires: kf6-kitemmodels-devel
BuildRequires: kf6-kservice-devel BuildRequires: kf6-kservice-devel
BuildRequires: libxkbcommon-devel BuildRequires: libxkbcommon-devel
BuildRequires: libplasma-devel
BuildRequires: plasma-wayland-protocols-devel BuildRequires: plasma-wayland-protocols-devel
BuildRequires: plasma-workspace-devel >= 6.7 BuildRequires: plasma-workspace-devel >= 6.7
BuildRequires: qt6-qtbase-devel BuildRequires: qt6-qtbase-devel
BuildRequires: qt6-qtdeclarative-devel
BuildRequires: qt6-qtwayland-devel BuildRequires: qt6-qtwayland-devel
Requires: plasma-workspace >= 6.7 Requires: plasma-workspace >= 6.7
Requires: kpipewire
Requires: kf6-kirigami
Requires: libplasma
BuildRequires: kpipewire
BuildRequires: kf6-kirigami
Provides: fedora-tools-tool Provides: fedora-tools-tool
%description %description
@@ -50,15 +57,32 @@ install -Dpm 0644 %{SOURCE2} \
%license %{_licensedir}/%{name}/LICENSE %license %{_licensedir}/%{name}/LICENSE
%doc %{_docdir}/%{name}/README.md %doc %{_docdir}/%{name}/README.md
%{_bindir}/plasma-task-group-shortcuts %{_bindir}/plasma-task-group-shortcuts
%{_datadir}/plasma-task-group-shortcuts/
%{_datadir}/applications/se.ajpanton.plasma-task-group-shortcuts.desktop %{_datadir}/applications/se.ajpanton.plasma-task-group-shortcuts.desktop
%{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop %{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop
%changelog %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 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-15
- Clarify the package summary - Clarify the package summary
* Sat Sep 05 2026 fedora-tools contributors - 0.1.0-14 * 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 * Sat Sep 05 2026 fedora-tools contributors - 0.1.0-13
- Advertise the tool to Fedora Tools settings - Advertise the tool to Fedora Tools settings
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
#include "previewplacement.h"
#include <plasma/plasma.h>
#include <algorithm>
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;
}
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QPoint>
#include <QRect>
#include <QSize>
QPoint previewPosition(const QRect &button, const QRect &screen, const QSize &preview, int panelLocation);
@@ -5,4 +5,4 @@ Comment=Activate taskbar application groups with Meta and a number
Exec=/usr/bin/plasma-task-group-shortcuts Exec=/usr/bin/plasma-task-group-shortcuts
Icon=preferences-system-windows Icon=preferences-system-windows
NoDisplay=true 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
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: MIT
#include "taskbargeometry.h"
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusReply>
#include <QDebug>
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<int> 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<void> 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));
}
}
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QObject>
#include <QRectF>
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;
};
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: MIT
#include "../pendingselection.h"
#include <QSignalSpy>
#include <QStandardItemModel>
#include <QTest>
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<QModelIndex>(), 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<QModelIndex>(), 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<QModelIndex>(), 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<QModelIndex>(), 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<QModelIndex>().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"
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: MIT
#include <QQmlComponent>
#include <QQmlEngine>
#include <QQuickWindow>
#include <QQuickItem>
#include <QTest>
#include <memory>
#include <QDBusConnection>
#include <QSignalSpy>
#include <plasma/plasma.h>
#include "../previewplacement.h"
#include "../taskbargeometry.h"
#include <abstracttasksmodel.h>
#include <tasksmodel.h>
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<QObject> object(component.create());
QVERIFY2(object, qPrintable(component.errorString()));
auto *window = qobject_cast<QQuickWindow *>(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<QObject> object(component.create());
QVERIFY2(object, qPrintable(component.errorString()));
auto *preview = qobject_cast<QQuickWindow *>(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"
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MIT
#include "windowpreview.h"
#include "previewplacement.h"
#include <QGuiApplication>
#include <QQmlComponent>
#include <QScreen>
#include <abstracttasksmodel.h>
WindowPreview::WindowPreview()
{
QQmlComponent component(&m_engine, QUrl(QStringLiteral("qrc:/Preview.qml")));
m_window.reset(qobject_cast<QQuickWindow *>(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());
}
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QModelIndex>
#include <QQmlEngine>
#include <QQuickWindow>
#include <memory>
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<QQuickWindow> m_window;
int m_panelLocation = 0;
};
@@ -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.cpp" "$source_dir/keyboardlayout.cpp"
install -m 0644 "$tool_dir/keyboardlayout.h" "$source_dir/keyboardlayout.h" 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/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.cpp" "$source_dir/taskselection.cpp"
install -m 0644 "$tool_dir/taskselection.h" "$source_dir/taskselection.h" install -m 0644 "$tool_dir/taskselection.h" "$source_dir/taskselection.h"
install -m 0644 "$tool_dir/tests/test-taskselection.cpp" \ install -m 0644 "$tool_dir/tests/test-taskselection.cpp" \