diff --git a/README.md b/README.md index 6162f72..42ca102 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ sudo dnf config-manager addrepo \ ```bash sudo dnf install touchpad-hold-tap sudo dnf install plasma-always-show-unlock +sudo dnf install plasma-task-group-shortcuts ``` ## 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 instead of being modified speculatively. Removing the package restores Plasma's 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 +``` diff --git a/plasma-task-group-shortcuts/CMakeLists.txt b/plasma-task-group-shortcuts/CMakeLists.txt new file mode 100644 index 0000000..736976b --- /dev/null +++ b/plasma-task-group-shortcuts/CMakeLists.txt @@ -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() diff --git a/plasma-task-group-shortcuts/keyboardlayout.cpp b/plasma-task-group-shortcuts/keyboardlayout.cpp new file mode 100644 index 0000000..9fa1067 --- /dev/null +++ b/plasma-task-group-shortcuts/keyboardlayout.cpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT + +#include "keyboardlayout.h" + +#include +#include +#include +#include +#include + +#include + +#include + +namespace KeyboardLayoutDbus +{ +struct Layout +{ + QString name; + QString variant; + QString displayName; +}; + +using Layouts = QList; + +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(); + qDBusRegisterMetaType(); + + QDBusInterface layouts(QStringLiteral("org.kde.KWin"), QStringLiteral("/Layouts"), + QStringLiteral("org.kde.KeyboardLayouts")); + const QDBusReply reply = layouts.call(QStringLiteral("getLayoutsList")); + if (!reply.isValid()) { + qWarning() << "Could not read KWin keyboard layouts:" << reply.error().message(); + return {}; + } + return reply.value(); +} +} + +std::optional numberRowKey(QStringView layout, QStringView variant, int number, bool shifted) +{ + if (number < 1 || number > 9) { + return std::nullopt; + } + + using Context = std::unique_ptr; + using Keymap = std::unique_ptr; + using State = std::unique_ptr; + + 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(codePoint); +} + +QList numberRowShortcuts(int number) +{ + QList 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; +} diff --git a/plasma-task-group-shortcuts/keyboardlayout.h b/plasma-task-group-shortcuts/keyboardlayout.h new file mode 100644 index 0000000..883adb9 --- /dev/null +++ b/plasma-task-group-shortcuts/keyboardlayout.h @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +#include + +std::optional numberRowKey(QStringView layout, QStringView variant, int number, bool shifted); +QList numberRowShortcuts(int number); diff --git a/plasma-task-group-shortcuts/main.cpp b/plasma-task-group-shortcuts/main.cpp new file mode 100644 index 0000000..e6fe5a9 --- /dev/null +++ b/plasma-task-group-shortcuts/main.cpp @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: MIT + +#include "keyboardlayout.h" +#include "taskselection.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "qwayland-keystate.h" + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +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 &sequences) +{ + KConfigGroup group = displacedShortcutGroup(number); + QJsonArray shortcuts = QJsonDocument::fromJson(group.readEntry("Shortcuts", QByteArray())).array(); + QSet 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 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, + 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 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()) { + 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(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(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 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" diff --git a/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec b/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec new file mode 100644 index 0000000..066c515 --- /dev/null +++ b/plasma-task-group-shortcuts/plasma-task-group-shortcuts.spec @@ -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 diff --git a/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop b/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop new file mode 100644 index 0000000..7985c04 --- /dev/null +++ b/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts-autostart.desktop @@ -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 diff --git a/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts.desktop b/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts.desktop new file mode 100644 index 0000000..eeadb66 --- /dev/null +++ b/plasma-task-group-shortcuts/se.ajpanton.plasma-task-group-shortcuts.desktop @@ -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 diff --git a/plasma-task-group-shortcuts/taskselection.cpp b/plasma-task-group-shortcuts/taskselection.cpp new file mode 100644 index 0000000..36fc644 --- /dev/null +++ b/plasma-task-group-shortcuts/taskselection.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT + +#include "taskselection.h" + +#include +#include +#include +#include + +#include +#include + +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 logicalTaskRows(const QAbstractItemModel &model, bool separateLaunchers) +{ + QVector rows; + rows.reserve(model.rowCount()); + if (separateLaunchers) { + for (int row = 0; row < model.rowCount(); ++row) { + rows.append(row); + } + return rows; + } + + QHash launcherTargets; + QSet 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; +} diff --git a/plasma-task-group-shortcuts/taskselection.h b/plasma-task-group-shortcuts/taskselection.h new file mode 100644 index 0000000..97b0490 --- /dev/null +++ b/plasma-task-group-shortcuts/taskselection.h @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +class QAbstractItemModel; + +QModelIndex taskToActivate(const QAbstractItemModel &model, int row, int cyclePosition); +QVector logicalTaskRows(const QAbstractItemModel &model, bool separateLaunchers); diff --git a/plasma-task-group-shortcuts/tests/test-keyboardlayout.cpp b/plasma-task-group-shortcuts/tests/test-keyboardlayout.cpp new file mode 100644 index 0000000..3db966d --- /dev/null +++ b/plasma-task-group-shortcuts/tests/test-keyboardlayout.cpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT + +#include "../keyboardlayout.h" + +#include + +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" diff --git a/plasma-task-group-shortcuts/tests/test-taskselection.cpp b/plasma-task-group-shortcuts/tests/test-taskselection.cpp new file mode 100644 index 0000000..45617c9 --- /dev/null +++ b/plasma-task-group-shortcuts/tests/test-taskselection.cpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT + +#include "../taskselection.h" + +#include +#include + +#include + +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({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({2, 1})); +} + +QTEST_GUILESS_MAIN(TaskSelectionTest) + +#include "test-taskselection.moc" diff --git a/scripts/build-plasma-task-group-shortcuts-rpm b/scripts/build-plasma-task-group-shortcuts-rpm new file mode 100755 index 0000000..ddf8bf4 --- /dev/null +++ b/scripts/build-plasma-task-group-shortcuts-rpm @@ -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"