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
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
+28 -1
View File
@@ -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()
+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
#include "keyboardlayout.h"
#include "pendingselection.h"
#include "taskbargeometry.h"
#include "taskselection.h"
#include "windowpreview.h"
#include <QAction>
#include <QCommandLineOption>
@@ -29,6 +32,7 @@
#include <abstracttasksmodel.h>
#include <activityinfo.h>
#include <tasksmodel.h>
#include <plasma/plasma.h>
namespace
{
@@ -109,7 +113,7 @@ void rememberDisplacedShortcuts(int number, const QList<QKeySequence> &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<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);
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] {
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<int> 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"));
@@ -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
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
@@ -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
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
@@ -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.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" \