Add Plasma task group shortcuts

This commit is contained in:
ajp_anton
2026-09-04 14:52:45 +00:00
parent 4a78e6b485
commit 088c42bc14
13 changed files with 923 additions and 0 deletions
+32
View File
@@ -16,6 +16,7 @@ sudo dnf config-manager addrepo \
```bash ```bash
sudo dnf install touchpad-hold-tap sudo dnf install touchpad-hold-tap
sudo dnf install plasma-always-show-unlock sudo dnf install plasma-always-show-unlock
sudo dnf install plasma-task-group-shortcuts
``` ```
## Compatibility ## Compatibility
@@ -48,3 +49,34 @@ The package applies a narrow patch to Plasma 6.7's lock-screen QML and reapplies
it after `plasma-desktop` updates. An unknown future layout is left unchanged it after `plasma-desktop` updates. An unknown future layout is left unchanged
instead of being modified speculatively. Removing the package restores Plasma's instead of being modified speculatively. Removing the package restores Plasma's
original behavior. original behavior.
### Plasma task-group shortcuts
The `plasma-task-group-shortcuts` package makes Meta+1 through Meta+9 address
application groups instead of individual taskbar buttons. It follows the
launchers, ordering, filters, and grouping exceptions of the Task Manager on
the primary screen. Pressing a launcher opens the application; pressing an
application group starts with its first window, then advances through its
windows while Meta remains held. Holding Shift after starting a cycle moves
backward instead. Shift on the first press launches a new instance. Releasing
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.
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.
If application grouping is disabled—or an application is excluded from
grouping—each of its windows remains a separate shortcut position. **Only group
when the Task Manager is full** does not alter shortcut groups; that would make
the numbers change merely because the panel changed size.
The utility starts with Plasma and records the shortcuts it replaces. Before
removing it, restore them with:
```bash
plasma-task-group-shortcuts --restore-shortcuts
```
@@ -0,0 +1,74 @@
# SPDX-License-Identifier: MIT
cmake_minimum_required(VERSION 3.22)
project(plasma-task-group-shortcuts VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(ECM REQUIRED NO_MODULE)
list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
include(GNUInstallDirs)
include(CTest)
find_package(Qt6 REQUIRED COMPONENTS DBus Gui WaylandClient)
find_package(KF6 REQUIRED COMPONENTS Config GlobalAccel Service)
find_package(LibTaskManager REQUIRED)
find_package(PlasmaWaylandProtocols REQUIRED)
find_package(XKB REQUIRED)
add_executable(plasma-task-group-shortcuts
keyboardlayout.cpp
main.cpp
taskselection.cpp
)
qt6_generate_wayland_protocol_client_sources(plasma-task-group-shortcuts
PRIVATE_CODE
FILES ${PLASMA_WAYLAND_PROTOCOLS_DIR}/keystate.xml
)
target_link_libraries(plasma-task-group-shortcuts PRIVATE
Qt6::Gui
Qt6::DBus
Qt6::WaylandClient
KF6::ConfigCore
KF6::GlobalAccel
KF6::Service
PW::LibTaskManager
XKB::XKB
)
install(TARGETS plasma-task-group-shortcuts DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES se.ajpanton.plasma-task-group-shortcuts.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
install(FILES se.ajpanton.plasma-task-group-shortcuts-autostart.desktop
DESTINATION ${CMAKE_INSTALL_FULL_SYSCONFDIR}/xdg/autostart)
if(BUILD_TESTING)
find_package(Qt6 REQUIRED COMPONENTS Test)
add_executable(test-taskselection
tests/test-taskselection.cpp
taskselection.cpp
)
target_link_libraries(test-taskselection PRIVATE
Qt6::Test
KF6::ConfigCore
KF6::Service
PW::LibTaskManager
)
add_test(NAME taskselection COMMAND test-taskselection)
add_executable(test-keyboardlayout
tests/test-keyboardlayout.cpp
keyboardlayout.cpp
)
target_link_libraries(test-keyboardlayout PRIVATE
Qt6::Test
Qt6::Gui
Qt6::DBus
XKB::XKB
)
add_test(NAME keyboardlayout COMMAND test-keyboardlayout)
endif()
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: MIT
#include "keyboardlayout.h"
#include <QDBusArgument>
#include <QDBusInterface>
#include <QDBusMetaType>
#include <QDBusReply>
#include <QDebug>
#include <memory>
#include <xkbcommon/xkbcommon.h>
namespace KeyboardLayoutDbus
{
struct Layout
{
QString name;
QString variant;
QString displayName;
};
using Layouts = QList<Layout>;
QDBusArgument &operator<<(QDBusArgument &argument, const Layout &layout)
{
argument.beginStructure();
argument << layout.name << layout.variant << layout.displayName;
argument.endStructure();
return argument;
}
const QDBusArgument &operator>>(const QDBusArgument &argument, Layout &layout)
{
argument.beginStructure();
argument >> layout.name >> layout.variant >> layout.displayName;
argument.endStructure();
return argument;
}
}
Q_DECLARE_METATYPE(KeyboardLayoutDbus::Layout)
Q_DECLARE_METATYPE(KeyboardLayoutDbus::Layouts)
namespace
{
KeyboardLayoutDbus::Layouts configuredLayouts()
{
qDBusRegisterMetaType<KeyboardLayoutDbus::Layout>();
qDBusRegisterMetaType<KeyboardLayoutDbus::Layouts>();
QDBusInterface layouts(QStringLiteral("org.kde.KWin"), QStringLiteral("/Layouts"),
QStringLiteral("org.kde.KeyboardLayouts"));
const QDBusReply<KeyboardLayoutDbus::Layouts> reply = layouts.call(QStringLiteral("getLayoutsList"));
if (!reply.isValid()) {
qWarning() << "Could not read KWin keyboard layouts:" << reply.error().message();
return {};
}
return reply.value();
}
}
std::optional<Qt::Key> numberRowKey(QStringView layout, QStringView variant, int number, bool shifted)
{
if (number < 1 || number > 9) {
return std::nullopt;
}
using Context = std::unique_ptr<xkb_context, decltype(&xkb_context_unref)>;
using Keymap = std::unique_ptr<xkb_keymap, decltype(&xkb_keymap_unref)>;
using State = std::unique_ptr<xkb_state, decltype(&xkb_state_unref)>;
const Context context(xkb_context_new(XKB_CONTEXT_NO_FLAGS), xkb_context_unref);
if (!context) {
return std::nullopt;
}
const QByteArray layoutName = layout.toLatin1();
const QByteArray variantName = variant.toLatin1();
const xkb_rule_names names{
.layout = layoutName.constData(),
.variant = variantName.isEmpty() ? nullptr : variantName.constData(),
};
const Keymap keymap(xkb_keymap_new_from_names(context.get(), &names, XKB_KEYMAP_COMPILE_NO_FLAGS),
xkb_keymap_unref);
if (!keymap) {
return std::nullopt;
}
const State state(xkb_state_new(keymap.get()), xkb_state_unref);
if (!state) {
return std::nullopt;
}
if (shifted) {
const xkb_mod_index_t shift = xkb_keymap_mod_get_index(keymap.get(), XKB_MOD_NAME_SHIFT);
if (shift == XKB_MOD_INVALID) {
return std::nullopt;
}
xkb_state_update_mask(state.get(), xkb_mod_mask_t{1} << shift, 0, 0, 0, 0, 0);
}
const QByteArray keyName = QByteArrayLiteral("AE0") + QByteArray::number(number);
const xkb_keycode_t keycode = xkb_keymap_key_by_name(keymap.get(), keyName.constData());
if (keycode == XKB_KEYCODE_INVALID) {
return std::nullopt;
}
const uint32_t codePoint = xkb_keysym_to_utf32(xkb_state_key_get_one_sym(state.get(), keycode));
if (codePoint == 0) {
return std::nullopt;
}
return static_cast<Qt::Key>(codePoint);
}
QList<QKeySequence> numberRowShortcuts(int number)
{
QList<QKeySequence> shortcuts;
for (const auto &layout : configuredLayouts()) {
for (const bool shifted : {false, true}) {
const auto key = numberRowKey(layout.name, layout.variant, number, shifted);
if (!key) {
qWarning() << "Could not map number-row key" << number << "for keyboard layout" << layout.name;
continue;
}
const QKeySequence sequence(Qt::META | *key);
if (!shortcuts.contains(sequence)) {
shortcuts.append(sequence);
}
}
}
return shortcuts;
}
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QKeySequence>
#include <QStringView>
#include <optional>
std::optional<Qt::Key> numberRowKey(QStringView layout, QStringView variant, int number, bool shifted);
QList<QKeySequence> numberRowShortcuts(int number);
+355
View File
@@ -0,0 +1,355 @@
// SPDX-License-Identifier: MIT
#include "keyboardlayout.h"
#include "taskselection.h"
#include <QAction>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QDBusConnection>
#include <QDBusInterface>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QScreen>
#include <QSet>
#include <QWaylandClientExtensionTemplate>
#include "qwayland-keystate.h"
#include <algorithm>
#include <utility>
#include <KConfigGroup>
#include <KGlobalAccel>
#include <KGlobalShortcutInfo>
#include <KSharedConfig>
#include <abstracttasksmodel.h>
#include <activityinfo.h>
#include <tasksmodel.h>
namespace
{
constexpr auto configFile = "plasma-org.kde.plasma.desktop-appletsrc";
constexpr auto componentId = "se.ajpanton.plasma-task-group-shortcuts";
constexpr auto stateFile = "plasma-task-group-shortcutsrc";
KConfigGroup displacedShortcutGroup(int number)
{
const auto config = KSharedConfig::openConfig(QString::fromLatin1(stateFile));
return KConfigGroup(config, QStringLiteral("Displaced shortcut %1").arg(number));
}
void rememberDisplacedShortcuts(int number, const QList<QKeySequence> &sequences)
{
KConfigGroup group = displacedShortcutGroup(number);
QJsonArray shortcuts = QJsonDocument::fromJson(group.readEntry("Shortcuts", QByteArray())).array();
QSet<QString> savedActions;
for (const QJsonValue &value : std::as_const(shortcuts)) {
const QJsonObject shortcut = value.toObject();
savedActions.insert(shortcut.value(QStringLiteral("component")).toString()
+ QLatin1Char('\n')
+ shortcut.value(QStringLiteral("action")).toString());
}
QStringList capturedSequences = group.readEntry("Sequences", QStringList());
for (const QKeySequence &sequence : sequences) {
const QString sequenceName = sequence.toString(QKeySequence::PortableText);
if (capturedSequences.contains(sequenceName)) {
continue;
}
capturedSequences.append(sequenceName);
for (const KGlobalShortcutInfo &shortcut : KGlobalAccel::globalShortcutsByKey(sequence)) {
const QString actionId = shortcut.componentUniqueName() + QLatin1Char('\n') + shortcut.uniqueName();
if (shortcut.componentUniqueName() == QLatin1String(componentId)
|| savedActions.contains(actionId)) {
continue;
}
QJsonArray keys;
for (const QKeySequence &key : shortcut.keys()) {
keys.append(key.toString(QKeySequence::PortableText));
}
shortcuts.append(QJsonObject{
{QStringLiteral("component"), shortcut.componentUniqueName()},
{QStringLiteral("componentName"), shortcut.componentFriendlyName()},
{QStringLiteral("action"), shortcut.uniqueName()},
{QStringLiteral("actionName"), shortcut.friendlyName()},
{QStringLiteral("keys"), keys},
});
savedActions.insert(actionId);
}
}
group.writeEntry("Saved", true);
group.writeEntry("Sequences", capturedSequences);
group.writeEntry("Shortcuts", QJsonDocument(shortcuts).toJson(QJsonDocument::Compact));
group.sync();
}
void restoreDisplacedShortcuts()
{
for (int number = 1; number <= 9; ++number) {
KConfigGroup group = displacedShortcutGroup(number);
if (!group.readEntry("Saved", false)) {
continue;
}
const QJsonArray shortcuts = QJsonDocument::fromJson(group.readEntry("Shortcuts", QByteArray())).array();
for (const QJsonValue &value : shortcuts) {
const QJsonObject shortcut = value.toObject();
QAction action;
action.setObjectName(shortcut.value(QStringLiteral("action")).toString());
action.setText(shortcut.value(QStringLiteral("actionName")).toString());
action.setProperty("componentName", shortcut.value(QStringLiteral("component")).toString());
action.setProperty("componentDisplayName", shortcut.value(QStringLiteral("componentName")).toString());
action.setProperty("isConfigurationAction", true);
QList<QKeySequence> keys;
for (const QJsonValue &key : shortcut.value(QStringLiteral("keys")).toArray()) {
keys.append(QKeySequence::fromString(key.toString(), QKeySequence::PortableText));
}
KGlobalAccel::self()->setShortcut(&action, keys, KGlobalAccel::NoAutoloading);
}
group.deleteGroup();
group.sync();
}
KGlobalAccel::cleanComponent(QString::fromLatin1(componentId));
}
KConfigGroup taskManagerConfig(const KSharedConfig::Ptr &config)
{
const KConfigGroup containments(config, QStringLiteral("Containments"));
KConfigGroup fallback;
for (const QString &containmentId : containments.groupList()) {
const KConfigGroup containment = containments.group(containmentId);
const KConfigGroup applets = containment.group(QStringLiteral("Applets"));
for (const QString &appletId : applets.groupList()) {
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"));
if (containment.readEntry("lastScreen", -1) == 0) {
return general;
}
if (!fallback.isValid()) {
fallback = general;
}
}
}
}
return fallback;
}
}
class ModifierState : public QWaylandClientExtensionTemplate<ModifierState>,
public QtWayland::org_kde_kwin_keystate
{
Q_OBJECT
public:
ModifierState()
: QWaylandClientExtensionTemplate(5)
{
connect(this, &QWaylandClientExtension::activeChanged, this, [this] {
if (isActive()) {
fetchStates();
}
});
initialize();
}
bool shiftPressed() const
{
return m_shiftPressed;
}
Q_SIGNALS:
void released();
private:
void org_kde_kwin_keystate_stateChanged(uint32_t key, uint32_t state) override
{
if (key == key_shift) {
m_shiftPressed = state == state_pressed;
} else if (key == key_meta) {
const bool pressed = state == state_pressed;
if (m_metaPressed && !pressed) {
Q_EMIT released();
}
m_metaPressed = pressed;
}
}
bool m_metaPressed = false;
bool m_shiftPressed = false;
};
class ShortcutManager : public QObject
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "se.ajpanton.PlasmaTaskGroupShortcuts.Manager")
public:
explicit ShortcutManager(QObject *parent = nullptr)
: QObject(parent)
, m_config(KSharedConfig::openConfig(QString::fromLatin1(configFile)))
{
m_model.setGroupInline(false);
reloadConfig();
connect(&m_activityInfo, &TaskManager::ActivityInfo::currentActivityChanged, this, [this] {
m_model.setActivity(m_activityInfo.currentActivity());
});
connect(qGuiApp, &QGuiApplication::primaryScreenChanged, this, &ShortcutManager::updateScreen);
connect(&m_modifierState, &ModifierState::released, this, [this] {
m_lastShortcut = -1;
});
updateScreen();
for (int number = 1; 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));
const QList<QKeySequence> sequences = numberRowShortcuts(number);
rememberDisplacedShortcuts(number, sequences);
for (const QKeySequence &sequence : sequences) {
KGlobalAccel::stealShortcutSystemwide(sequence);
}
KGlobalAccel::self()->setDefaultShortcut(action, sequences);
KGlobalAccel::self()->setShortcut(action, sequences, KGlobalAccel::NoAutoloading);
connect(action, &QAction::triggered, this, [this, number] {
useShortcut(number - 1, m_modifierState.shiftPressed());
});
}
}
public Q_SLOTS:
Q_SCRIPTABLE void restoreShortcutsAndQuit()
{
for (QAction *action : findChildren<QAction *>()) {
KGlobalAccel::self()->removeAllShortcuts(action);
}
restoreDisplacedShortcuts();
QCoreApplication::quit();
}
private:
void reloadConfig()
{
m_config->reparseConfiguration();
const KConfigGroup config = taskManagerConfig(m_config);
if (!config.isValid()) {
qWarning("No Plasma Task Manager configuration found");
return;
}
m_model.setLauncherList(config.readEntry("launchers", QStringList()));
m_model.setFilterByCurrentVirtualDesktop(config.readEntry("showOnlyCurrentDesktop", true));
m_model.setFilterByScreen(config.readEntry("showOnlyCurrentScreen", false));
m_model.setFilterByActivity(config.readEntry("showOnlyCurrentActivity", true));
m_model.setFilterNotMinimized(config.readEntry("showOnlyMinimized", false));
const int sortingStrategy = config.readEntry("sortingStrategy", 1);
m_model.setSortMode(static_cast<TaskManager::TasksModel::SortMode>(sortingStrategy));
m_separateLaunchers = sortingStrategy != TaskManager::TasksModel::SortManual
|| config.readEntry("separateLaunchers", true);
m_model.setSeparateLaunchers(m_separateLaunchers);
m_model.setLaunchInPlace(false);
m_model.setHideActivatedLaunchers(config.readEntry("hideLauncherOnStart", true));
m_model.setGroupMode(static_cast<TaskManager::TasksModel::GroupMode>(config.readEntry("groupingStrategy", 1)));
m_model.setGroupingWindowTasksThreshold(-1);
m_model.setGroupingAppIdBlacklist(config.readEntry("groupingAppIdBlacklist", QStringList()));
m_model.setGroupingLauncherUrlBlacklist(config.readEntry("groupingLauncherUrlBlacklist", QStringList()));
m_model.setActivity(m_activityInfo.currentActivity());
}
void updateScreen()
{
if (const QScreen *screen = QGuiApplication::primaryScreen()) {
m_model.setScreenGeometry(screen->geometry());
}
}
void useShortcut(int row, bool shifted)
{
reloadConfig();
const QVector<int> tasks = logicalTaskRows(m_model, m_separateLaunchers);
if (row >= tasks.size()) {
return;
}
const bool continuing = row == m_lastShortcut;
if (!continuing) {
m_cyclePosition = 0;
} else {
m_cyclePosition += shifted ? -1 : 1;
}
m_lastShortcut = row;
if (shifted && !continuing) {
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);
}
}
KSharedConfig::Ptr m_config;
TaskManager::ActivityInfo m_activityInfo;
TaskManager::TasksModel m_model;
ModifierState m_modifierState;
bool m_separateLaunchers = true;
int m_lastShortcut = -1;
int m_cyclePosition = 0;
};
int main(int argc, char **argv)
{
const bool restoring = std::any_of(argv + 1, argv + argc, [](const char *argument) {
return qstrcmp(argument, "--restore-shortcuts") == 0;
});
if (restoring) {
qputenv("QT_QPA_PLATFORM", "offscreen");
}
QGuiApplication application(argc, argv);
QCoreApplication::setQuitLockEnabled(false);
QCoreApplication::setApplicationName(QString::fromLatin1(componentId));
QGuiApplication::setApplicationDisplayName(QStringLiteral("Plasma Task Group Shortcuts"));
QGuiApplication::setDesktopFileName(QString::fromLatin1(componentId));
QCommandLineParser parser;
parser.addHelpOption();
QCommandLineOption restoreOption(QStringLiteral("restore-shortcuts"),
QStringLiteral("Restore the shortcuts displaced by this tool and exit."));
parser.addOption(restoreOption);
parser.process(application);
if (parser.isSet(restoreOption)) {
QDBusInterface running(QString::fromLatin1(componentId), QStringLiteral("/Manager"),
QStringLiteral("se.ajpanton.PlasmaTaskGroupShortcuts.Manager"));
if (running.isValid()) {
running.call(QDBus::Block, QStringLiteral("restoreShortcutsAndQuit"));
return 0;
}
restoreDisplacedShortcuts();
return 0;
}
if (!QDBusConnection::sessionBus().registerService(QString::fromLatin1(componentId))) {
return 0;
}
ShortcutManager manager;
QDBusConnection::sessionBus().registerObject(QStringLiteral("/Manager"), &manager,
QDBusConnection::ExportScriptableSlots);
return application.exec();
}
#include "main.moc"
@@ -0,0 +1,57 @@
Name: plasma-task-group-shortcuts
Version: 0.1.0
Release: 12%{?dist}
Summary: Application-group shortcuts for the Plasma Task Manager
License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools
Source0: %{name}-%{version}.tar.gz
Source1: LICENSE
Source2: README.md
BuildRequires: cmake
BuildRequires: extra-cmake-modules
BuildRequires: gcc-c++
BuildRequires: kf6-kconfig-devel
BuildRequires: kf6-kglobalaccel-devel
BuildRequires: kf6-kitemmodels-devel
BuildRequires: kf6-kservice-devel
BuildRequires: libxkbcommon-devel
BuildRequires: plasma-wayland-protocols-devel
BuildRequires: plasma-workspace-devel >= 6.7
BuildRequires: qt6-qtbase-devel
BuildRequires: qt6-qtwayland-devel
Requires: plasma-workspace >= 6.7
%description
Changes Plasma's Meta+number shortcuts to address application groups while
leaving Task Manager windows visually ungrouped. Launcher order and task
settings are read from the existing Plasma Task Manager configuration.
%prep
%autosetup
%build
%cmake
%cmake_build
%check
%ctest
%install
%cmake_install
install -Dpm 0644 %{SOURCE1} \
%{buildroot}%{_licensedir}/%{name}/LICENSE
install -Dpm 0644 %{SOURCE2} \
%{buildroot}%{_docdir}/%{name}/README.md
%files
%license %{_licensedir}/%{name}/LICENSE
%doc %{_docdir}/%{name}/README.md
%{_bindir}/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
* Fri Sep 04 2026 fedora-tools contributors - 0.1.0-12
- Initial package
@@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Plasma Task Group Shortcuts
Exec=/usr/bin/plasma-task-group-shortcuts
OnlyShowIn=KDE;
NoDisplay=true
X-KDE-autostart-phase=2
@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=Plasma Task Group Shortcuts
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
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: MIT
#include "taskselection.h"
#include <QAbstractItemModel>
#include <QHash>
#include <QSet>
#include <QUrl>
#include <abstracttasksmodel.h>
#include <tasktools.h>
using TaskManager::AbstractTasksModel;
QModelIndex taskToActivate(const QAbstractItemModel &model, int row, int cyclePosition)
{
const QModelIndex task = model.index(row, 0);
if (!task.isValid() || model.rowCount(task) == 0) {
return task;
}
const int childCount = model.rowCount(task);
const int childRow = ((cyclePosition % childCount) + childCount) % childCount;
return model.index(childRow, 0, task);
}
QVector<int> logicalTaskRows(const QAbstractItemModel &model, bool separateLaunchers)
{
QVector<int> rows;
rows.reserve(model.rowCount());
if (separateLaunchers) {
for (int row = 0; row < model.rowCount(); ++row) {
rows.append(row);
}
return rows;
}
QHash<int, int> launcherTargets;
QSet<int> mergedWindows;
for (int launcherRow = 0; launcherRow < model.rowCount(); ++launcherRow) {
const QModelIndex launcher = model.index(launcherRow, 0);
if (!launcher.data(AbstractTasksModel::IsLauncher).toBool()) {
continue;
}
for (int windowRow = 0; windowRow < model.rowCount(); ++windowRow) {
const QModelIndex window = model.index(windowRow, 0);
if (!window.data(AbstractTasksModel::IsWindow).toBool()) {
continue;
}
const QString launcherAppId = launcher.data(AbstractTasksModel::AppId).toString();
const QString windowAppId = window.data(AbstractTasksModel::AppId).toString();
const QUrl launcherUrl = launcher.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl();
const QUrl windowUrl = window.data(AbstractTasksModel::LauncherUrlWithoutIcon).toUrl();
if ((!launcherAppId.isEmpty() && launcherAppId == windowAppId)
|| (launcherUrl.isValid() && windowUrl.isValid()
&& TaskManager::launcherUrlsMatch(launcherUrl, windowUrl, TaskManager::IgnoreQueryItems))) {
launcherTargets.insert(launcherRow, windowRow);
mergedWindows.insert(windowRow);
break;
}
}
}
for (int row = 0; row < model.rowCount(); ++row) {
if (launcherTargets.contains(row)) {
rows.append(launcherTargets.value(row));
} else if (!mergedWindows.contains(row)) {
rows.append(row);
}
}
return rows;
}
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QModelIndex>
#include <QVector>
class QAbstractItemModel;
QModelIndex taskToActivate(const QAbstractItemModel &model, int row, int cyclePosition);
QVector<int> logicalTaskRows(const QAbstractItemModel &model, bool separateLaunchers);
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
#include "../keyboardlayout.h"
#include <QTest>
class KeyboardLayoutTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void mapsNumberRow();
void rejectsInvalidNumber();
};
void KeyboardLayoutTest::mapsNumberRow()
{
QCOMPARE(numberRowKey(u"se", u"", 1, false).value(), Qt::Key_1);
QCOMPARE(numberRowKey(u"se", u"", 1, true).value(), Qt::Key_Exclam);
QCOMPARE(numberRowKey(u"se", u"", 4, true).value(), Qt::Key_currency);
QCOMPARE(numberRowKey(u"us", u"", 2, true).value(), Qt::Key_At);
QCOMPARE(numberRowKey(u"de", u"", 7, true).value(), Qt::Key_Slash);
QCOMPARE(numberRowKey(u"fr", u"", 1, false).value(), Qt::Key_Ampersand);
QCOMPARE(numberRowKey(u"fr", u"", 1, true).value(), Qt::Key_1);
}
void KeyboardLayoutTest::rejectsInvalidNumber()
{
QVERIFY(!numberRowKey(u"se", u"", 0, false));
QVERIFY(!numberRowKey(u"se", u"", 10, true));
}
QTEST_GUILESS_MAIN(KeyboardLayoutTest)
#include "test-keyboardlayout.moc"
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: MIT
#include "../taskselection.h"
#include <QStandardItemModel>
#include <QTest>
#include <abstracttasksmodel.h>
using TaskManager::AbstractTasksModel;
class TaskSelectionTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void returnsTopLevelTask();
void selectsChildByCyclePosition();
void startsWithFirstChild();
void keepsSeparateLaunchersAsSlots();
void mergesLauncherWithRunningApplication();
};
void TaskSelectionTest::returnsTopLevelTask()
{
QStandardItemModel model;
model.appendRow(new QStandardItem(QStringLiteral("Dolphin")));
QCOMPARE(taskToActivate(model, 0, 0), model.index(0, 0));
QVERIFY(!taskToActivate(model, 1, 0).isValid());
}
void TaskSelectionTest::selectsChildByCyclePosition()
{
QStandardItemModel model;
auto *group = new QStandardItem(QStringLiteral("Dolphin"));
group->appendRow(new QStandardItem(QStringLiteral("one")));
group->appendRow(new QStandardItem(QStringLiteral("two")));
group->appendRow(new QStandardItem(QStringLiteral("three")));
model.appendRow(group);
const QModelIndex parent = model.index(0, 0);
QCOMPARE(taskToActivate(model, 0, 0), model.index(0, 0, parent));
QCOMPARE(taskToActivate(model, 0, 1), model.index(1, 0, parent));
QCOMPARE(taskToActivate(model, 0, 2), model.index(2, 0, parent));
QCOMPARE(taskToActivate(model, 0, 3), model.index(0, 0, parent));
QCOMPARE(taskToActivate(model, 0, -1), model.index(2, 0, parent));
QCOMPARE(taskToActivate(model, 0, -2), model.index(1, 0, parent));
}
void TaskSelectionTest::startsWithFirstChild()
{
QStandardItemModel model;
auto *group = new QStandardItem(QStringLiteral("Firefox"));
group->appendRow(new QStandardItem(QStringLiteral("first")));
group->appendRow(new QStandardItem(QStringLiteral("second")));
model.appendRow(group);
QCOMPARE(taskToActivate(model, 0, 0), model.index(0, 0, model.index(0, 0)));
}
void TaskSelectionTest::keepsSeparateLaunchersAsSlots()
{
QStandardItemModel model;
model.appendRow(new QStandardItem(QStringLiteral("launcher")));
model.appendRow(new QStandardItem(QStringLiteral("window")));
QCOMPARE(logicalTaskRows(model, true), QVector<int>({0, 1}));
}
void TaskSelectionTest::mergesLauncherWithRunningApplication()
{
QStandardItemModel model;
model.appendRow(new QStandardItem(QStringLiteral("Dolphin launcher")));
model.appendRow(new QStandardItem(QStringLiteral("Firefox window")));
model.appendRow(new QStandardItem(QStringLiteral("Dolphin windows")));
model.setData(model.index(0, 0), true, AbstractTasksModel::IsLauncher);
model.setData(model.index(0, 0), QStringLiteral("org.kde.dolphin"), AbstractTasksModel::AppId);
model.setData(model.index(1, 0), true, AbstractTasksModel::IsWindow);
model.setData(model.index(1, 0), QStringLiteral("firefox"), AbstractTasksModel::AppId);
model.setData(model.index(2, 0), true, AbstractTasksModel::IsWindow);
model.setData(model.index(2, 0), QStringLiteral("org.kde.dolphin"), AbstractTasksModel::AppId);
QCOMPARE(logicalTaskRows(model, false), QVector<int>({2, 1}));
}
QTEST_GUILESS_MAIN(TaskSelectionTest)
#include "test-taskselection.moc"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -euo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
tool_dir="$repo_root/plasma-task-group-shortcuts"
topdir="$repo_root/rpmbuild"
source_dir="$topdir/SOURCES/plasma-task-group-shortcuts-0.1.0"
mkdir -p "$topdir"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
mkdir -p "$source_dir/tests"
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/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" \
"$source_dir/tests/test-taskselection.cpp"
install -m 0644 "$tool_dir/tests/test-keyboardlayout.cpp" \
"$source_dir/tests/test-keyboardlayout.cpp"
install -m 0644 "$tool_dir/se.ajpanton.plasma-task-group-shortcuts.desktop" \
"$source_dir/se.ajpanton.plasma-task-group-shortcuts.desktop"
install -m 0644 "$tool_dir/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop" \
"$source_dir/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop"
tar -C "$topdir/SOURCES" -czf \
"$topdir/SOURCES/plasma-task-group-shortcuts-0.1.0.tar.gz" \
plasma-task-group-shortcuts-0.1.0
install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
install -m 0644 "$repo_root/README.md" "$topdir/SOURCES/README.md"
rpmbuild \
--define "_topdir $topdir" \
-bb "$tool_dir/plasma-task-group-shortcuts.spec"