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
+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", [], "()")
}
+79 -16
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] {
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<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;
};