Add configurable Plasma panel scroll and click actions

This commit is contained in:
ajp_anton
2026-09-07 19:47:47 +00:00
parent ed2bb7f92c
commit f5988605c8
29 changed files with 1707 additions and 0 deletions
+14
View File
@@ -19,6 +19,7 @@ sudo dnf install plasma-always-show-unlock
sudo dnf install plasma-task-group-shortcuts
sudo dnf install plasma-fingerprint-workaround
sudo dnf install fedora-tools-settings
sudo dnf install plasma-panel-actions
```
## Compatibility
@@ -118,3 +119,16 @@ selected by a normal system update. The controller can also install a locally
built RPM and restore Fedora's original package. See the tool's
[`README`](plasma-fingerprint-workaround/README.md) for the security warning,
build instructions, version checks, and force-downgrade option.
### Plasma panel actions
The `plasma-panel-actions` package enables panel scroll actions at login,
without requiring a visible widget. Scrolling defaults to volume
control across the panel; each area can instead control screen brightness,
keyboard backlight, virtual desktops, or retain its normal behaviour.
Optional left- and middle-click/double-click actions apply only to empty space
and can also launch applications or custom commands. Add
**Panel Actions Spacer** to reserve flexible space with a configurable minimum
width. Open **Panel Actions** from the application menu or Fedora Tools settings,
with or without a spacer. See its [README](plasma-panel-actions/README.md).
+52
View File
@@ -0,0 +1,52 @@
# SPDX-License-Identifier: MIT
cmake_minimum_required(VERSION 3.16)
project(plasma-panel-actions VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(ECM 6.0 REQUIRED NO_MODULE)
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
include(KDEInstallDirs)
include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE)
include(CTest)
find_package(Qt6 6.8 REQUIRED COMPONENTS Quick Widgets DBus Test QuickTest)
find_package(KF6 REQUIRED COMPONENTS Config I18n Service KIO)
add_library(panelactionsplugin MODULE src/plugin.cpp src/panelcontroller.cpp src/settings.cpp)
target_link_libraries(panelactionsplugin PRIVATE Qt6::Quick Qt6::DBus KF6::ConfigCore KF6::Service KF6::KIOGui)
set_target_properties(panelactionsplugin PROPERTIES PREFIX "lib"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml/se/ajpanton/panelactions")
configure_file(data/qmldir qml/se/ajpanton/panelactions/qmldir COPYONLY)
add_executable(plasma-panel-actions src/main.cpp src/settings.cpp)
target_link_libraries(plasma-panel-actions PRIVATE Qt6::Quick Qt6::Widgets Qt6::DBus KF6::ConfigCore KF6::I18n KF6::I18nQml KF6::Service KF6::KIOGui)
# RPM normalizes file timestamps. A content-specific URL prevents Qt from
# reusing an older settings page's disk cache after a same-day upgrade.
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS src/SettingsWindow.qml src/ConfigGeneral.qml)
file(SHA256 src/SettingsWindow.qml window_hash)
file(SHA256 src/ConfigGeneral.qml form_hash)
string(SHA256 ui_hash "${window_hash}${form_hash}")
set(settings_ui_dir "plasma-panel-actions/${ui_hash}")
target_compile_definitions(plasma-panel-actions PRIVATE SETTINGS_UI_DIR="${settings_ui_dir}")
install(TARGETS plasma-panel-actions DESTINATION ${KDE_INSTALL_BINDIR})
install(FILES src/SettingsWindow.qml src/ConfigGeneral.qml DESTINATION ${KDE_INSTALL_DATADIR}/${settings_ui_dir})
install(FILES data/se.ajpanton.plasma-panel-actions.desktop DESTINATION ${KDE_INSTALL_APPDIR})
install(FILES data/se.ajpanton.plasma-panel-actions-autostart.desktop DESTINATION ${KDE_INSTALL_AUTOSTARTDIR})
install(FILES data/plasma-panel-actions.json DESTINATION ${KDE_INSTALL_DATADIR}/fedora-tools/settings)
install(TARGETS panelactionsplugin DESTINATION ${KDE_INSTALL_QMLDIR}/se/ajpanton/panelactions)
install(FILES data/qmldir DESTINATION ${KDE_INSTALL_QMLDIR}/se/ajpanton/panelactions)
install(DIRECTORY package/ DESTINATION ${KDE_INSTALL_DATADIR}/plasma/plasmoids/se.ajpanton.panelactions)
install(DIRECTORY controller/ DESTINATION ${KDE_INSTALL_DATADIR}/plasma/plasmoids/se.ajpanton.panelactions.controller)
if(BUILD_TESTING)
add_executable(test-panelsettings tests/test-settings.cpp src/settings.cpp)
target_link_libraries(test-panelsettings PRIVATE Qt6::Test KF6::ConfigCore KF6::Service KF6::KIOGui)
target_include_directories(test-panelsettings PRIVATE src)
add_test(NAME panelsettings COMMAND test-panelsettings)
add_executable(test-panelcontroller tests/test-panelcontroller.cpp src/panelcontroller.cpp)
target_link_libraries(test-panelcontroller PRIVATE Qt6::Quick Qt6::DBus Qt6::Test)
target_include_directories(test-panelcontroller PRIVATE src)
add_test(NAME panelcontroller COMMAND test-panelcontroller)
set_tests_properties(panelcontroller PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
add_executable(test-panelareas tests/quicktest.cpp)
target_link_libraries(test-panelareas PRIVATE Qt6::QuickTest Qt6::Qml)
add_test(NAME panelareas COMMAND test-panelareas -input ${CMAKE_CURRENT_SOURCE_DIR}/tests)
set_tests_properties(panelareas PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML_IMPORT_PATH=${CMAKE_BINARY_DIR}/qml")
endif()
+50
View File
@@ -0,0 +1,50 @@
# Panel Actions
Configurable scroll actions across Plasma panels, with optional empty-space
click actions. Starts automatically at login, without adding a visible widget.
Open **Panel Actions** from the application menu, Fedora Tools settings, or run
`plasma-panel-actions --settings`. Settings apply to all panels and are stored
in `~/.config/plasma-panel-actionsrc`; saved changes apply immediately.
Log out and back in after installing or updating. This starts the session
helper and ensures Plasma loads the installed version of the native plugin.
Scroll areas are empty space, task buttons and pinned apps, the application
launcher, clock, system tray, and other widgets. All default to volume control.
Other choices are screen brightness, keyboard backlight, virtual desktops, and
normal widget behaviour. Actions use Plasma's existing shortcuts, including its
usual volume/brightness steps and on-screen indicators.
Left and middle clicks, and double clicks, on empty space can toggle mute, show
the desktop, open Overview, play/pause media, launch an installed application,
or run a custom shell command as the current user. All default to normal
behaviour. Commands are user-authored shell code: only enter commands you trust.
For a task-manager shortcut, set left double click to **Launch application →
System Monitor** (if installed). Other widgets'
clicks, right-click menus, and popup scrolling remain unchanged. Dragging does
not trigger a click action, and overrides pause in Panel Edit Mode.
Double clicks use the desktop's timing and movement tolerance. Assigning a
double-click action delays the corresponding single action until the interval
expires; a double click runs only the double action. With single click set to
Normal behaviour and a double action assigned, a single click does nothing.
Optionally add **Panel Actions Spacer** from the widget chooser to reserve
empty space. It expands into remaining space while giving other widgets their
preferred width, down to a configurable minimum (48 logical pixels by default).
Move it in Panel Edit Mode like any other widget. It is transparent outside
Edit Mode, with no icon or popup. Standard spacers and unused Task Manager
space also count as empty space; the optional spacer is not required for them.
The session helper attaches an invisible controller to each panel and restores
it after Plasma restarts or panels are added. It watches for changes, rather
than polling. The controller handles panel input inside Plasma, leaving popup
and application windows alone. No system files are patched.
Requires Plasma 6.7 and Qt 6.8 or newer. Area detection follows Plasma's panel
layout and Task Manager delegates; changes to those internals may require an
update. Brightness actions depend on hardware support. Firmware-controlled
keyboard-backlight Auto modes can override manual adjustments; select a fixed
level with the keyboard first. Virtual desktop actions
need more than one desktop. Horizontal scrolling is consumed without an action
in areas with an override. Smooth scrolling is accumulated into discrete steps.
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
.pragma library
// Task Manager fills unused panel space. Only delegates are task buttons.
function hasTaskAt(item, x, y) {
if (!item.visible || !item.contains(item.mapFromItem(null, x, y))) return false;
if (item.tasksRoot !== undefined && item.isWindow !== undefined && item.index !== undefined) return true;
for (const child of item.children) {
if (hasTaskAt(child, x, y)) return true;
}
return false;
}
function areaAt(panelLayout, x, y) {
if (!panelLayout) return "";
for (const child of panelLayout.children) {
if (!child.visible || !child.contains(child.mapFromItem(null, x, y))) continue;
const applet = child.applet;
const name = applet?.plasmoid?.pluginName;
if (!name) return "other";
if (name === "org.kde.plasma.taskmanager" || name === "org.kde.plasma.icontasks") {
return hasTaskAt(applet, x, y) ? "tasks" : "empty";
}
if (name === "se.ajpanton.panelactions" || name === "org.kde.plasma.panelspacer"
|| name === "org.kde.plasma.marginsseparator") return "empty";
if (name === "org.kde.plasma.systemtray") return "tray";
if (name === "org.kde.plasma.digitalclock" || name === "org.kde.plasma.analogclock") return "clock";
if (name === "org.kde.plasma.kickoff" || name === "org.kde.plasma.kicker"
|| name === "org.kde.plasma.kickerdash") return "launcher";
return "other";
}
return "empty";
}
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtQuick.Layouts
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.plasmoid
import se.ajpanton.panelactions 1.0
import "areas.js" as Areas
PlasmoidItem {
id: root
readonly property Item panelLayout: {
let item = root.parent;
while (item) {
if (item instanceof GridLayout) return item;
item = item.parent;
}
return null;
}
Plasmoid.backgroundHints: PlasmaCore.Types.NoBackground
Layout.minimumWidth: 0
Layout.minimumHeight: 0
Layout.preferredWidth: 0
Layout.preferredHeight: 0
Layout.maximumWidth: 0
Layout.maximumHeight: 0
preferredRepresentation: fullRepresentation
PanelSettings { id: settings }
PanelController {
active: root.panelLayout !== null && !(Plasmoid.containment.corona?.editMode ?? false)
locateArea: (x, y) => Areas.areaAt(root.panelLayout, x, y)
scrollActions: ({
empty: settings.values.emptyAction,
tasks: settings.values.tasksAction,
launcher: settings.values.launcherAction,
clock: settings.values.clockAction,
tray: settings.values.trayAction,
other: settings.values.otherAction
})
clickActions: settings.values
onActionRequested: (action, direction) => invoke(action, direction)
onClickRequested: slot => {
const action = settings.values[slot];
if (action === "command" || action === "application") settings.launchClick(slot);
else invoke(action, 0);
}
}
}
@@ -0,0 +1,14 @@
{
"KPlugin": {
"Id": "se.ajpanton.panelactions.controller",
"Name": "Panel Actions Controller",
"Description": "Automatic panel scroll and click handling",
"Icon": "preferences-desktop",
"License": "MIT",
"Version": "0.1.0"
},
"NoDisplay": true,
"KPackageStructure": "Plasma/Applet",
"X-Plasma-API-Minimum-Version": "6.0",
"X-Plasma-FormFactors": ["horizontal", "vertical"]
}
@@ -0,0 +1 @@
{"command": ["/usr/bin/plasma-panel-actions", "--settings"]}
+2
View File
@@ -0,0 +1,2 @@
module se.ajpanton.panelactions
plugin panelactionsplugin
@@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Panel Actions
Exec=plasma-panel-actions
OnlyShowIn=KDE;
X-KDE-autostart-phase=2
NoDisplay=true
@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=Panel Actions
Comment=Configure panel scrolling and empty-space clicks
Icon=preferences-desktop
Exec=plasma-panel-actions --settings
Categories=Qt;KDE;Settings;DesktopSettings;
OnlyShowIn=KDE;
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtQuick.Layouts
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.plasmoid
import org.kde.kirigami as Kirigami
import se.ajpanton.panelactions 1.0
PlasmoidItem {
id: root
readonly property bool vertical: Plasmoid.formFactor === PlasmaCore.Types.Vertical
readonly property bool editing: Plasmoid.containment.corona?.editMode ?? false
readonly property int minimumLength: Math.max(editing ? 48 : 1, settings.values.spacerLength)
readonly property Item panelLayout: {
let item = root.parent;
while (item) {
if (item instanceof GridLayout) return item;
item = item.parent;
}
return null;
}
// Give other widgets their preferred space first; share the remainder
// with any other flexible spacers, instead of competing with task buttons.
readonly property real availableLength: {
if (!panelLayout) return minimumLength;
let used = 0;
let spacers = 0;
let count = 0;
for (const child of panelLayout.children) {
if (!child.visible) continue;
++count;
const plugin = child.applet?.plasmoid?.pluginName;
if (plugin === "se.ajpanton.panelactions"
|| (plugin === "org.kde.plasma.panelspacer" && child.applet.plasmoid.configuration.expanding)) {
++spacers;
} else {
const minimum = vertical ? child.Layout.minimumHeight : child.Layout.minimumWidth;
const preferred = vertical ? child.Layout.preferredHeight : child.Layout.preferredWidth;
const maximum = vertical ? child.Layout.maximumHeight : child.Layout.maximumWidth;
used += Math.min(maximum, Math.max(minimum, preferred));
}
}
used += Math.max(0, count - 1) * (vertical ? panelLayout.rowSpacing : panelLayout.columnSpacing);
return Math.max(minimumLength, ((vertical ? panelLayout.height : panelLayout.width) - used) / Math.max(1, spacers));
}
Plasmoid.backgroundHints: PlasmaCore.Types.NoBackground
Layout.minimumWidth: vertical ? 1 : minimumLength
Layout.minimumHeight: vertical ? minimumLength : 1
Layout.preferredWidth: vertical ? 0 : availableLength
Layout.preferredHeight: vertical ? availableLength : 0
Layout.fillWidth: !vertical
Layout.fillHeight: vertical
preferredRepresentation: fullRepresentation
PanelSettings { id: settings }
// Direct content, like Plasma's own spacer: never a compact icon or popup.
Rectangle {
anchors.fill: parent
visible: root.editing
color: Kirigami.Theme.highlightColor
opacity: 0.4
radius: 4
}
PlasmaCore.Action {
id: configureAction
text: i18n("Configure Panel Actions…")
icon.name: "configure"
onTriggered: settings.open()
}
Component.onCompleted: Plasmoid.setInternalAction("configure", configureAction)
}
@@ -0,0 +1,16 @@
{
"KPlugin": {
"Id": "se.ajpanton.panelactions",
"Name": "Panel Actions Spacer",
"Description": "Flexible empty space for panel click actions",
"Icon": "distribute-horizontal",
"Category": "Utilities",
"License": "MIT",
"Version": "0.1.0",
"Authors": [{ "Name": "fedora-tools contributors" }],
"Website": "https://git.ajpanton.se/ajp_anton/fedora-tools"
},
"KPackageStructure": "Plasma/Applet",
"X-Plasma-API-Minimum-Version": "6.0",
"X-Plasma-FormFactors": ["horizontal", "vertical"]
}
@@ -0,0 +1,65 @@
Name: plasma-panel-actions
Version: 0.1.0
Release: 8%{?dist}
Summary: Configurable panel scrolling and empty-space click actions
License: MIT
URL: https://git.ajpanton.se/ajp_anton/fedora-tools
Source0: %{name}-%{version}.tar.gz
Source1: LICENSE
BuildRequires: cmake
BuildRequires: extra-cmake-modules
BuildRequires: gcc-c++
BuildRequires: qt6-qtdeclarative-devel
BuildRequires: kf6-kconfig-devel
BuildRequires: kf6-ki18n-devel
BuildRequires: kf6-kservice-devel
BuildRequires: kf6-kio-devel
Requires: plasma-workspace >= 6.7
Requires: plasma-pa
Requires: powerdevil
Provides: fedora-tools-tool
%description
Automatic per-area Plasma panel scroll actions with independent settings and
an optional flexible spacer. Controls volume, screen and keyboard brightness,
or virtual desktops.
Optional empty-space click actions leave other widgets' clicks unchanged.
%prep
%autosetup
%build
%cmake
%cmake_build
%install
%cmake_install
install -Dpm 0644 %{SOURCE1} %{buildroot}%{_licensedir}/%{name}/LICENSE
%check
%ctest
%files
%license %{_licensedir}/%{name}/LICENSE
%doc README.md
%{_libdir}/qt6/qml/se/ajpanton/panelactions/
%{_datadir}/plasma/plasmoids/se.ajpanton.panelactions/
%{_datadir}/plasma/plasmoids/se.ajpanton.panelactions.controller/
%{_bindir}/plasma-panel-actions
%{_datadir}/plasma-panel-actions/
%{_datadir}/applications/se.ajpanton.plasma-panel-actions.desktop
%{_sysconfdir}/xdg/autostart/se.ajpanton.plasma-panel-actions-autostart.desktop
%{_datadir}/fedora-tools/settings/plasma-panel-actions.json
%changelog
* Mon Sep 07 2026 fedora-tools contributors - 0.1.0-8
- Invalidate settings caches on UI changes with content-specific install paths
* Mon Sep 07 2026 fedora-tools contributors - 0.1.0-7
- Align scroll and click fields and size application selectors to their contents
* Mon Sep 07 2026 fedora-tools contributors - 0.1.0-6
- Place click action details beside the action, wrapping in narrow windows
* Mon Sep 07 2026 fedora-tools contributors - 0.1.0-5
- Compact settings into scroll and click sections and keep Save visible
* Mon Sep 07 2026 fedora-tools contributors - 0.1.0-4
- Preserve double-click recognition when Qt sends its additional double-click event
* Mon Sep 07 2026 fedora-tools contributors - 0.1.0-3
- Add empty-space double clicks, application launching, and custom commands
* Sun Sep 06 2026 fedora-tools contributors - 0.1.0-2
- Independent settings, automatic panel handling, and an optional flexible spacer
* Sun Sep 06 2026 fedora-tools contributors - 0.1.0-1
- Initial package
+187
View File
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtQuick.Controls as Controls
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
ColumnLayout {
id: root
property string cfg_emptyAction
property string cfg_tasksAction
property string cfg_launcherAction
property string cfg_clockAction
property string cfg_trayAction
property string cfg_otherAction
property string cfg_leftClick
property string cfg_middleClick
property string cfg_leftDoubleClick: "normal"
property string cfg_middleDoubleClick: "normal"
property string cfg_leftClickCommand
property string cfg_middleClickCommand
property string cfg_leftDoubleClickCommand
property string cfg_middleDoubleClickCommand
property string cfg_leftClickApplication
property string cfg_middleClickApplication
property string cfg_leftDoubleClickApplication
property string cfg_middleDoubleClickApplication
property int cfg_spacerLength
property var applications: []
readonly property var scrollChoices: [
{text: i18n("Volume"), value: "volume"},
{text: i18n("Screen brightness"), value: "brightness"},
{text: i18n("Keyboard backlight"), value: "keyboard"},
{text: i18n("Virtual desktops"), value: "desktops"},
{text: i18n("Normal behaviour"), value: "normal"}
]
readonly property var clickChoices: [
{text: i18n("Normal behaviour"), value: "normal"},
{text: i18n("Mute / unmute"), value: "mute"},
{text: i18n("Show / hide desktop"), value: "desktop"},
{text: i18n("Overview"), value: "overview"},
{text: i18n("Play / pause media"), value: "playpause"},
{text: i18n("Launch application"), value: "application"},
{text: i18n("Run command"), value: "command"}
]
Kirigami.Heading {
text: i18n("Scrolling")
level: 2
}
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: i18n("Overrides scrolling in each panel area, not in popups. No spacer is needed.")
}
Kirigami.FormLayout {
id: scrollForm
implicitWidth: root.width
wideMode: width >= Kirigami.Units.gridUnit * 28
twinFormLayouts: [clickForm]
Repeater {
model: [
{label: i18n("Empty space:"), key: "emptyAction"},
{label: i18n("Task buttons and pinned apps:"), key: "tasksAction"},
{label: i18n("Application launcher:"), key: "launcherAction"},
{label: i18n("Clock:"), key: "clockAction"},
{label: i18n("System tray:"), key: "trayAction"},
{label: i18n("Other widgets:"), key: "otherAction"}
]
delegate: Item {
id: scrollRow
required property var modelData
Layout.fillWidth: true
implicitWidth: scrollAction.implicitWidth
implicitHeight: scrollAction.implicitHeight
Kirigami.FormData.label: modelData.label
Controls.ComboBox {
id: scrollAction
objectName: scrollRow.modelData.key
width: Math.min(implicitWidth, scrollRow.width)
model: root.scrollChoices
textRole: "text"
valueRole: "value"
currentIndex: Math.max(0, root.scrollChoices.findIndex(choice => choice.value === root["cfg_" + scrollRow.modelData.key]))
onActivated: root["cfg_" + scrollRow.modelData.key] = currentValue
}
}
}
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
}
Kirigami.Heading {
text: i18n("Empty-space clicks")
level: 2
}
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: i18n("Other widgets keep their clicks. Assigning a double click delays the single click.")
}
Kirigami.FormLayout {
id: clickForm
implicitWidth: root.width
wideMode: scrollForm.wideMode
Repeater {
model: [
{label: i18n("Left click:"), key: "leftClick"},
{label: i18n("Left double click:"), key: "leftDoubleClick"},
{label: i18n("Middle click:"), key: "middleClick"},
{label: i18n("Middle double click:"), key: "middleDoubleClick"}
]
delegate: Flow {
id: clickRow
required property var modelData
Layout.fillWidth: true
Layout.preferredWidth: action.implicitWidth
spacing: Kirigami.Units.smallSpacing
Kirigami.FormData.label: modelData.label
Controls.ComboBox {
id: action
objectName: clickRow.modelData.key
width: Math.min(implicitWidth, clickRow.width)
implicitContentWidthPolicy: Controls.ComboBox.WidestText
model: root.clickChoices
textRole: "text"
valueRole: "value"
currentIndex: Math.max(0, root.clickChoices.findIndex(choice => choice.value === root["cfg_" + clickRow.modelData.key]))
onActivated: root["cfg_" + clickRow.modelData.key] = currentValue
}
Controls.ComboBox {
objectName: clickRow.modelData.key + "Application"
visible: root["cfg_" + clickRow.modelData.key] === "application"
width: Math.min(implicitWidth, clickRow.width)
implicitContentWidthPolicy: Controls.ComboBox.WidestText
model: root.applications
textRole: "text"
valueRole: "value"
currentIndex: root.applications.findIndex(app => app.value === root["cfg_" + clickRow.modelData.key + "Application"])
displayText: currentIndex < 0 ? i18n("Choose an application…") : currentText
onActivated: root["cfg_" + clickRow.modelData.key + "Application"] = currentValue
}
Controls.TextField {
objectName: clickRow.modelData.key + "Command"
visible: root["cfg_" + clickRow.modelData.key] === "command"
width: clickRow.width - action.width - clickRow.spacing >= Kirigami.Units.gridUnit * 12
? clickRow.width - action.width - clickRow.spacing : clickRow.width
placeholderText: i18n("Trusted shell command (runs as your user)")
text: root["cfg_" + clickRow.modelData.key + "Command"]
onTextEdited: root["cfg_" + clickRow.modelData.key + "Command"] = text
}
}
}
Controls.SpinBox {
Kirigami.FormData.label: i18n("Spacer minimum width:")
Controls.ToolTip.text: i18n("In logical pixels")
Controls.ToolTip.visible: hovered
from: 0
to: 2000
editable: true
value: root.cfg_spacerLength
onValueModified: root.cfg_spacerLength = value
}
}
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: i18n("Optionally add Panel Actions Spacer in Edit Mode to reserve flexible empty space. Settings apply to all panels.")
}
Controls.Button {
objectName: "restoreDefaults"
text: i18n("Restore defaults")
icon.name: "edit-undo"
onClicked: {
root.cfg_emptyAction = root.cfg_tasksAction = root.cfg_launcherAction = "volume";
root.cfg_clockAction = root.cfg_trayAction = root.cfg_otherAction = "volume";
for (const key of ["leftClick", "middleClick", "leftDoubleClick", "middleDoubleClick"]) {
root["cfg_" + key] = "normal";
root["cfg_" + key + "Command"] = "";
root["cfg_" + key + "Application"] = "";
}
root.cfg_spacerLength = 48;
}
}
Item { Layout.fillHeight: true }
}
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtQuick.Controls as Controls
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
Kirigami.ApplicationWindow {
id: window
title: i18n("Panel Actions")
width: 760
height: 840
minimumWidth: 600
minimumHeight: 400
visible: true
pageStack.initialPage: Kirigami.ScrollablePage {
title: window.title
ColumnLayout {
Kirigami.InlineMessage {
id: result
Layout.fillWidth: true
}
ConfigGeneral {
id: form
objectName: "settingsForm"
applications: panelSettings.applications
Layout.fillWidth: true
function load() {
for (const key in panelSettings.values) form["cfg_" + key] = panelSettings.values[key];
}
Component.onCompleted: load()
}
}
footer: Controls.ToolBar {
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
Item { Layout.fillWidth: true }
Controls.Button {
objectName: "saveSettings"
text: i18n("Save")
icon.name: "document-save"
onClicked: {
const values = {};
for (const key in panelSettings.values) values[key] = form["cfg_" + key];
const saved = panelSettings.save(values);
result.type = saved ? Kirigami.MessageType.Positive : Kirigami.MessageType.Error;
result.text = saved ? i18n("Settings saved. Changes apply immediately.") : i18n("Could not save settings. Select an application or enter a command for each launch action, and check that the configuration file is writable.");
result.visible = true;
}
}
Controls.Button {
text: i18n("Close")
onClicked: window.close()
}
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
#include "settings.h"
#include <KLocalizedQmlContext>
#include <KLocalizedString>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QDBusServiceWatcher>
#include <QFileSystemWatcher>
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QStandardPaths>
#include <QTimer>
int main(int argc, char **argv)
{
QApplication app(argc, argv);
app.setApplicationName(QStringLiteral("plasma-panel-actions"));
app.setDesktopFileName(QStringLiteral("se.ajpanton.plasma-panel-actions"));
KLocalizedString::setApplicationDomain("plasma-panel-actions");
if (app.arguments().contains(QStringLiteral("--settings"))) {
Settings settings;
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
engine.rootContext()->setContextProperty(QStringLiteral("panelSettings"), &settings);
const QString path = QStandardPaths::locate(QStandardPaths::GenericDataLocation,
QStringLiteral(SETTINGS_UI_DIR "/SettingsWindow.qml"));
engine.load(QUrl::fromLocalFile(path));
if (engine.rootObjects().isEmpty()) return 1;
return app.exec();
}
app.setQuitOnLastWindowClosed(false);
auto bus = QDBusConnection::sessionBus();
if (!bus.registerService(QStringLiteral("se.ajpanton.PanelActions"))) {
qWarning("Panel Actions is already running, or the session bus is unavailable");
return 1;
}
// The event filter must live in plasmashell. Install an invisible controller
// in each panel, including panels created later or after a shell restart.
QTimer debounce;
debounce.setSingleShot(true);
debounce.setInterval(1500);
bool pending = false;
QObject::connect(&debounce, &QTimer::timeout, &app, [&] {
if (pending) {
debounce.start();
return;
}
auto message = QDBusMessage::createMethodCall(QStringLiteral("org.kde.plasmashell"),
QStringLiteral("/PlasmaShell"), QStringLiteral("org.kde.PlasmaShell"), QStringLiteral("evaluateScript"));
message << QStringLiteral(R"JS(
for (const panel of panels()) {
if (!panel.widgets().some(w => w.type === "se.ajpanton.panelactions.controller")) {
panel.addWidget("se.ajpanton.panelactions.controller");
}
}
)JS");
pending = true;
auto *watcher = new QDBusPendingCallWatcher(bus.asyncCall(message), &app);
QObject::connect(watcher, &QDBusPendingCallWatcher::finished, &app, [&](QDBusPendingCallWatcher *call) {
const QDBusPendingReply<QString> reply = *call;
if (reply.isError()) qWarning() << "Panel Actions: cannot attach to panels:" << reply.error().message();
pending = false;
call->deleteLater();
});
});
QDBusServiceWatcher shell(QStringLiteral("org.kde.plasmashell"), bus,
QDBusServiceWatcher::WatchForRegistration, &app);
QObject::connect(&shell, &QDBusServiceWatcher::serviceRegistered, &debounce, qOverload<>(&QTimer::start));
QFileSystemWatcher config;
// Watch the directory, not the file: Plasma replaces its config atomically.
config.addPath(QStandardPaths::writableLocation(QStandardPaths::ConfigLocation));
QObject::connect(&config, &QFileSystemWatcher::directoryChanged, &debounce, qOverload<>(&QTimer::start));
debounce.start();
return app.exec();
}
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: MIT
#include "panelcontroller.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QGuiApplication>
#include <QMouseEvent>
#include <QQuickWindow>
#include <QStyleHints>
#include <QWheelEvent>
PanelController::PanelController(QQuickItem *parent) : QQuickItem(parent)
{
m_singleClickTimer.setSingleShot(true);
m_singleClickTimer.setTimerType(Qt::PreciseTimer);
connect(&m_singleClickTimer, &QTimer::timeout, this, [this] {
if (m_active && m_clickActions.value(m_pendingClick, QStringLiteral("normal")) != QLatin1String("normal")) {
Q_EMIT clickRequested(m_pendingClick);
}
m_pendingButton = Qt::NoButton;
});
connect(this, &PanelController::settingsChanged, this, [this] {
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
m_pressedButton = Qt::NoButton;
});
connect(this, &QQuickItem::windowChanged, this, [this](QQuickWindow *window) {
if (m_panel) {
m_panel->removeEventFilter(this);
}
m_panel = window;
if (m_panel) {
m_panel->installEventFilter(this);
}
m_remainder = 0;
m_pressedButton = Qt::NoButton;
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
});
}
PanelController::~PanelController()
{
// QQuickItem emits windowChanged while destroying its base class, after
// this class's members have already been destroyed.
disconnect(this, &QQuickItem::windowChanged, this, nullptr);
if (m_panel) {
m_panel->removeEventFilter(this);
}
}
QString PanelController::areaAt(const QPointF &position)
{
if (!m_locateArea.isCallable()) {
return {};
}
const QJSValue result = m_locateArea.call({position.x(), position.y()});
if (result.isError()) {
qWarning() << "Panel Actions: could not identify panel area:" << result.toString();
return {};
}
return result.toString();
}
bool PanelController::eventFilter(QObject *object, QEvent *event)
{
if (object != m_panel || !m_active) {
m_remainder = 0;
m_pressedButton = Qt::NoButton;
m_singleClickTimer.stop();
m_pendingButton = Qt::NoButton;
return false;
}
if (event->type() == QEvent::Wheel) {
auto *wheel = static_cast<QWheelEvent *>(event);
const QString area = areaAt(wheel->position());
const QString action = m_scrollActions.value(area, QStringLiteral("normal")).toString();
if (action == QLatin1String("normal") || action.isEmpty()) {
m_remainder = 0;
return false;
}
const bool pixels = !wheel->pixelDelta().isNull();
if (area != m_scrollArea || action != m_scrollAction || pixels != m_pixelScroll
|| !m_scrollTimer.isValid() || m_scrollTimer.elapsed() > 500
|| wheel->phase() == Qt::ScrollBegin) {
m_remainder = 0;
}
m_scrollArea = area;
m_scrollAction = action;
m_pixelScroll = pixels;
m_scrollTimer.restart();
const int delta = pixels ? wheel->pixelDelta().y() : wheel->angleDelta().y();
if ((delta > 0 && m_remainder < 0) || (delta < 0 && m_remainder > 0)) {
m_remainder = 0;
}
m_remainder += delta;
const int threshold = pixels ? 40 : 120;
while (qAbs(m_remainder) >= threshold) {
const int direction = m_remainder > 0 ? 1 : -1;
m_remainder -= direction * threshold;
Q_EMIT actionRequested(action, direction);
}
if (wheel->phase() == Qt::ScrollEnd) {
m_remainder = 0;
}
wheel->accept();
return true;
}
if (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonDblClick) {
auto *mouse = static_cast<QMouseEvent *>(event);
if (mouse->button() != Qt::LeftButton && mouse->button() != Qt::MiddleButton) {
return false;
}
// QWindow receives DblClick in addition to the second Press. That
// press already selected the action; do not start a third press.
if (event->type() == QEvent::MouseButtonDblClick && m_pressedButton == mouse->button()) {
return true;
}
const QString slot = mouse->button() == Qt::LeftButton ? QStringLiteral("leftClick") : QStringLiteral("middleClick");
const QString doubleSlot = mouse->button() == Qt::LeftButton ? QStringLiteral("leftDoubleClick") : QStringLiteral("middleDoubleClick");
const bool hasDouble = m_clickActions.value(doubleSlot, QStringLiteral("normal")) != QLatin1String("normal");
if ((!hasDouble && m_clickActions.value(slot, QStringLiteral("normal")) == QLatin1String("normal"))
|| areaAt(mouse->position()) != QLatin1String("empty")) {
return false;
}
m_doubleClick = hasDouble && m_singleClickTimer.isActive() && m_pendingButton == mouse->button()
&& m_clickInterval.elapsed() <= QGuiApplication::styleHints()->mouseDoubleClickInterval()
&& (mouse->position() - m_pendingPosition).manhattanLength() <= QGuiApplication::styleHints()->mouseDoubleClickDistance();
if (m_singleClickTimer.isActive()) {
m_singleClickTimer.stop();
if (!m_doubleClick && m_clickActions.value(m_pendingClick, QStringLiteral("normal")) != QLatin1String("normal")) {
Q_EMIT clickRequested(m_pendingClick);
}
m_pendingButton = Qt::NoButton;
}
m_pressedButton = mouse->button();
m_pressPosition = mouse->position();
m_dragged = false;
m_clickAction = m_doubleClick ? doubleSlot : slot;
if (!m_doubleClick) m_clickInterval.restart();
return true;
}
if (event->type() == QEvent::MouseMove && m_pressedButton != Qt::NoButton) {
auto *mouse = static_cast<QMouseEvent *>(event);
m_dragged |= (mouse->position() - m_pressPosition).manhattanLength()
> QGuiApplication::styleHints()->startDragDistance();
return true;
}
if (event->type() == QEvent::MouseButtonRelease && m_pressedButton != Qt::NoButton) {
auto *mouse = static_cast<QMouseEvent *>(event);
if (mouse->button() != m_pressedButton) {
return false;
}
m_pressedButton = Qt::NoButton;
if (!m_dragged && areaAt(mouse->position()) == QLatin1String("empty")
&& (mouse->position() - m_pressPosition).manhattanLength()
<= QGuiApplication::styleHints()->startDragDistance()) {
const QString doubleSlot = mouse->button() == Qt::LeftButton ? QStringLiteral("leftDoubleClick") : QStringLiteral("middleDoubleClick");
if (!m_doubleClick && m_clickActions.value(doubleSlot, QStringLiteral("normal")) != QLatin1String("normal")) {
m_pendingClick = m_clickAction;
m_pendingButton = mouse->button();
m_pendingPosition = m_pressPosition;
m_singleClickTimer.start(qMax(0, QGuiApplication::styleHints()->mouseDoubleClickInterval() - int(m_clickInterval.elapsed())));
} else if (m_clickActions.value(m_clickAction, QStringLiteral("normal")) != QLatin1String("normal")) {
Q_EMIT clickRequested(m_clickAction);
}
}
return true;
}
return false;
}
void PanelController::invoke(const QString &action, int direction)
{
QString component;
QString shortcut;
const bool up = direction > 0;
if (action == QLatin1String("volume")) {
component = QStringLiteral("kmix");
shortcut = up ? QStringLiteral("increase_volume") : QStringLiteral("decrease_volume");
} else if (action == QLatin1String("brightness")) {
component = QStringLiteral("org_kde_powerdevil");
shortcut = up ? QStringLiteral("Increase Screen Brightness") : QStringLiteral("Decrease Screen Brightness");
} else if (action == QLatin1String("keyboard")) {
component = QStringLiteral("org_kde_powerdevil");
shortcut = up ? QStringLiteral("Increase Keyboard Brightness") : QStringLiteral("Decrease Keyboard Brightness");
} else if (action == QLatin1String("desktops")) {
component = QStringLiteral("kwin");
shortcut = up ? QStringLiteral("Switch to Previous Desktop") : QStringLiteral("Switch to Next Desktop");
} else if (action == QLatin1String("mute")) {
component = QStringLiteral("kmix");
shortcut = QStringLiteral("mute");
} else if (action == QLatin1String("desktop") || action == QLatin1String("overview")) {
component = QStringLiteral("kwin");
shortcut = action == QLatin1String("desktop") ? QStringLiteral("Show Desktop") : QStringLiteral("Overview");
} else if (action == QLatin1String("playpause")) {
component = QStringLiteral("mediacontrol");
shortcut = QStringLiteral("playpausemedia");
} else {
qWarning() << "Panel Actions: unknown action" << action;
return;
}
auto message = QDBusMessage::createMethodCall(QStringLiteral("org.kde.kglobalaccel"),
QStringLiteral("/component/") + component,
QStringLiteral("org.kde.kglobalaccel.Component"), QStringLiteral("invokeShortcut"));
message << shortcut;
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [shortcut](QDBusPendingCallWatcher *call) {
const QDBusPendingReply<> reply = *call;
if (reply.isError()) {
qWarning() << "Panel Actions:" << shortcut << reply.error().message();
}
call->deleteLater();
});
}
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QElapsedTimer>
#include <QJSValue>
#include <QPointer>
#include <QQuickItem>
#include <QVariantMap>
#include <QTimer>
class PanelController : public QQuickItem
{
Q_OBJECT
Q_PROPERTY(bool active MEMBER m_active NOTIFY settingsChanged)
Q_PROPERTY(QJSValue locateArea READ locateArea WRITE setLocateArea NOTIFY settingsChanged)
Q_PROPERTY(QVariantMap scrollActions MEMBER m_scrollActions NOTIFY settingsChanged)
Q_PROPERTY(QVariantMap clickActions MEMBER m_clickActions NOTIFY settingsChanged)
public:
explicit PanelController(QQuickItem *parent = nullptr);
~PanelController() override;
QJSValue locateArea() const { return m_locateArea; }
void setLocateArea(const QJSValue &value) { m_locateArea = value; Q_EMIT settingsChanged(); }
Q_INVOKABLE void invoke(const QString &action, int direction);
Q_SIGNALS:
void settingsChanged();
void actionRequested(const QString &action, int direction);
void clickRequested(const QString &slot);
protected:
bool eventFilter(QObject *object, QEvent *event) override;
private:
QString areaAt(const QPointF &position);
QPointer<QQuickWindow> m_panel;
bool m_active = false;
QJSValue m_locateArea;
QVariantMap m_scrollActions;
QVariantMap m_clickActions;
QString m_scrollArea;
QString m_scrollAction;
QElapsedTimer m_scrollTimer;
int m_remainder = 0;
bool m_pixelScroll = false;
Qt::MouseButton m_pressedButton = Qt::NoButton;
QPointF m_pressPosition;
bool m_dragged = false;
QString m_clickAction;
bool m_doubleClick = false;
QTimer m_singleClickTimer;
QString m_pendingClick;
Qt::MouseButton m_pendingButton = Qt::NoButton;
QPointF m_pendingPosition;
QElapsedTimer m_clickInterval;
};
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: MIT
#include "panelcontroller.h"
#include "settings.h"
#include <QQmlExtensionPlugin>
class PanelActionsPlugin : public QQmlExtensionPlugin
{
Q_OBJECT
Q_PLUGIN_METADATA(IID QQmlExtensionInterface_iid)
public:
void registerTypes(const char *uri) override
{
qmlRegisterType<PanelController>(uri, 1, 0, "PanelController");
qmlRegisterType<Settings>(uri, 1, 0, "PanelSettings");
}
};
#include "plugin.moc"
+116
View File
@@ -0,0 +1,116 @@
// SPDX-License-Identifier: MIT
#include "settings.h"
#include <KConfigGroup>
#include <QProcess>
#include <KApplicationTrader>
#include <KService>
#include <KIO/ApplicationLauncherJob>
#include <KIO/CommandLauncherJob>
#include <algorithm>
namespace {
const QStringList areas = {QStringLiteral("empty"), QStringLiteral("tasks"), QStringLiteral("launcher"),
QStringLiteral("clock"), QStringLiteral("tray"), QStringLiteral("other")};
const QStringList scrollActions = {QStringLiteral("volume"), QStringLiteral("brightness"),
QStringLiteral("keyboard"), QStringLiteral("desktops"), QStringLiteral("normal")};
const QStringList clickActions = {QStringLiteral("normal"), QStringLiteral("mute"),
QStringLiteral("desktop"), QStringLiteral("overview"), QStringLiteral("playpause"),
QStringLiteral("command"), QStringLiteral("application")};
const QStringList clickSlots = {QStringLiteral("leftClick"), QStringLiteral("middleClick"),
QStringLiteral("leftDoubleClick"), QStringLiteral("middleDoubleClick")};
}
Settings::Settings(QObject *parent)
: QObject(parent)
, m_config(KSharedConfig::openConfig(QStringLiteral("plasma-panel-actionsrc")))
, m_watcher(KConfigWatcher::create(m_config))
{
connect(m_watcher.data(), &KConfigWatcher::configChanged, this, &Settings::changed);
}
QVariantMap Settings::values() const
{
const KConfigGroup group(m_config, QStringLiteral("Actions"));
QVariantMap result;
for (const auto &area : areas) {
const QString key = area + QStringLiteral("Action");
result.insert(key, group.readEntry(key, "volume"));
}
for (const auto &key : clickSlots) {
result.insert(key, group.readEntry(key, "normal"));
result.insert(key + QStringLiteral("Command"), group.readEntry(key + QStringLiteral("Command"), QString()));
result.insert(key + QStringLiteral("Application"), group.readEntry(key + QStringLiteral("Application"), QString()));
}
result.insert(QStringLiteral("spacerLength"), group.readEntry("spacerLength", 48));
return result;
}
bool Settings::save(const QVariantMap &values)
{
for (const auto &area : areas) {
if (!scrollActions.contains(values.value(area + QStringLiteral("Action")).toString())) return false;
}
for (const auto &key : clickSlots) {
const auto action = values.value(key).toString();
if (!clickActions.contains(action)) return false;
if (action == QLatin1String("command") && values.value(key + QStringLiteral("Command")).toString().trimmed().isEmpty()) return false;
if (action == QLatin1String("application") && values.value(key + QStringLiteral("Application")).toString().isEmpty()) return false;
}
bool valid = false;
const int length = values.value(QStringLiteral("spacerLength")).toInt(&valid);
if (!valid || length < 0 || length > 2000) return false;
KConfigGroup group(m_config, QStringLiteral("Actions"));
// Write only known settings; callers cannot introduce arbitrary entries.
for (const auto &key : this->values().keys()) {
group.writeEntry(key, values.value(key), KConfig::Notify);
}
const bool saved = group.sync();
Q_EMIT changed();
return saved;
}
void Settings::open()
{
if (!QProcess::startDetached(QStringLiteral("/usr/bin/plasma-panel-actions"), {QStringLiteral("--settings")})) {
qWarning("Panel Actions: could not open settings");
}
}
QVariantList Settings::applications() const
{
auto services = KApplicationTrader::query([](const KService::Ptr &service) { return !service->noDisplay(); });
std::sort(services.begin(), services.end(), [](const auto &a, const auto &b) {
return QString::localeAwareCompare(a->name(), b->name()) < 0;
});
QVariantList result;
for (const auto &service : services) {
result.append(QVariantMap{{QStringLiteral("text"), service->name()}, {QStringLiteral("value"), service->storageId()}});
}
return result;
}
void Settings::launchClick(const QString &slot)
{
if (!clickSlots.contains(slot)) return;
const auto config = values();
KJob *job = nullptr;
if (config.value(slot) == QLatin1String("command")) {
const auto command = config.value(slot + QStringLiteral("Command")).toString();
if (command.trimmed().isEmpty()) return;
job = new KIO::CommandLauncherJob(command, this);
} else if (config.value(slot) == QLatin1String("application")) {
const auto id = config.value(slot + QStringLiteral("Application")).toString();
const auto service = KService::serviceByStorageId(id);
if (!service) {
qWarning() << "Panel Actions: application is no longer installed:" << id;
return;
}
job = new KIO::ApplicationLauncherJob(service, this);
}
if (job) {
connect(job, &KJob::result, this, [](KJob *result) {
if (result->error()) qWarning() << "Panel Actions: launch failed:" << result->errorString();
});
job->start();
}
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <KConfigWatcher>
#include <KSharedConfig>
#include <QObject>
#include <QVariantMap>
class Settings : public QObject
{
Q_OBJECT
Q_PROPERTY(QVariantMap values READ values NOTIFY changed)
Q_PROPERTY(QVariantList applications READ applications CONSTANT)
public:
explicit Settings(QObject *parent = nullptr);
QVariantMap values() const;
QVariantList applications() const;
Q_INVOKABLE bool save(const QVariantMap &values);
Q_INVOKABLE void open();
Q_INVOKABLE void launchClick(const QString &slot);
Q_SIGNALS:
void changed();
private:
KSharedConfig::Ptr m_config;
KConfigWatcher::Ptr m_watcher;
};
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
#include <QtQuickTest/quicktest.h>
#include <QQmlContext>
#include <QQmlEngine>
#include <QTemporaryDir>
class TestSetup : public QObject
{
Q_OBJECT
public:
TestSetup() { qputenv("XDG_CONFIG_HOME", m_config.path().toUtf8()); }
Q_INVOKABLE QString i18n(const QString &text) { return text; }
public Q_SLOTS:
void qmlEngineAvailable(QQmlEngine *engine) { engine->rootContext()->setContextObject(this); }
private:
QTemporaryDir m_config;
};
QUICK_TEST_MAIN_WITH_SETUP(panelareas, TestSetup)
#include "quicktest.moc"
@@ -0,0 +1,238 @@
// SPDX-License-Identifier: MIT
#include "panelcontroller.h"
#include <QJSEngine>
#include <QMouseEvent>
#include <QQuickWindow>
#include <QSignalSpy>
#include <QTest>
#include <QWheelEvent>
#include <QStyleHints>
using namespace Qt::StringLiterals;
class PanelWindow : public QQuickWindow
{
public:
int delivered = 0;
bool event(QEvent *event) override
{
if (event->type() == QEvent::Wheel || event->type() == QEvent::MouseButtonPress
|| event->type() == QEvent::MouseButtonRelease || event->type() == QEvent::MouseMove) {
++delivered;
return true;
}
return QQuickWindow::event(event);
}
};
class PanelControllerTest : public QObject
{
Q_OBJECT
private:
static void setup(PanelController &controller, PanelWindow &window, QJSEngine &engine)
{
controller.setParentItem(window.contentItem());
controller.setProperty("active", true);
controller.setProperty("locateArea", QVariant::fromValue(engine.evaluate(
u"(function(x,y) { return x < 100 ? 'empty' : 'tasks'; })"_s)));
controller.setProperty("scrollActions", QVariantMap{{u"empty"_s, u"volume"_s}, {u"tasks"_s, u"brightness"_s}});
}
static void wheel(PanelWindow &window, int delta, int x = 20, bool pixels = false)
{
QWheelEvent event(QPointF(x, 10), QPointF(x, 10),
pixels ? QPoint(0, delta) : QPoint(), pixels ? QPoint() : QPoint(0, delta),
Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false);
QCoreApplication::sendEvent(&window, &event);
}
static void mouse(PanelWindow &window, QEvent::Type type, int x, Qt::MouseButton button = Qt::LeftButton)
{
QMouseEvent event(type, QPointF(x, 10), QPointF(x, 10),
type == QEvent::MouseMove ? Qt::NoButton : button,
type == QEvent::MouseButtonRelease ? Qt::NoButton : button, Qt::NoModifier);
QCoreApplication::sendEvent(&window, &event);
}
private Q_SLOTS:
void wheelOverridesAndNormalBehaviour()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
QSignalSpy actions(&controller, &PanelController::actionRequested);
wheel(window, 120);
wheel(window, -120, 150);
QCOMPARE(actions.count(), 2);
QCOMPARE(actions.at(0), QVariantList({u"volume"_s, 1}));
QCOMPARE(actions.at(1), QVariantList({u"brightness"_s, -1}));
QCOMPARE(window.delivered, 0);
controller.setProperty("scrollActions", QVariantMap{{u"empty"_s, u"normal"_s}});
wheel(window, 120);
QCOMPARE(window.delivered, 1);
QCOMPARE(actions.count(), 2);
}
void smoothScrollingAndAreaChanges()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
QSignalSpy actions(&controller, &PanelController::actionRequested);
wheel(window, 60);
QCOMPARE(actions.count(), 0);
wheel(window, 60);
QCOMPARE(actions.count(), 1);
wheel(window, 60);
wheel(window, 60, 150);
QCOMPARE(actions.count(), 1); // partial gestures cannot cross areas
wheel(window, 60, 150);
QCOMPARE(actions.count(), 2);
wheel(window, 20, 20, true);
QCOMPARE(actions.count(), 2);
wheel(window, 20, 20, true);
QCOMPARE(actions.count(), 3);
}
void onlyEmptySpaceClicksAreOverridden()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{u"leftClick"_s, u"mute"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 20);
QCOMPARE(actions.count(), 0);
mouse(window, QEvent::MouseButtonRelease, 20);
QCOMPARE(actions.count(), 1);
QCOMPARE(actions.at(0), QVariantList({u"leftClick"_s}));
QCOMPARE(window.delivered, 0);
mouse(window, QEvent::MouseButtonPress, 150);
mouse(window, QEvent::MouseButtonRelease, 150);
mouse(window, QEvent::MouseButtonPress, 20, Qt::RightButton);
mouse(window, QEvent::MouseButtonRelease, 20, Qt::RightButton);
QCOMPARE(window.delivered, 4);
QCOMPARE(actions.count(), 1);
}
void draggingCancelsClickEvenWhenReturning()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{u"leftClick"_s, u"mute"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseMove, 80);
mouse(window, QEvent::MouseMove, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
QCOMPARE(actions.count(), 0);
}
void disabledAndOtherWindowsAreUntouched()
{
QJSEngine engine;
PanelWindow window;
PanelWindow popup;
PanelController controller;
setup(controller, window, engine);
QSignalSpy actions(&controller, &PanelController::actionRequested);
wheel(popup, 120);
QCOMPARE(popup.delivered, 1);
controller.setProperty("active", false);
wheel(window, 120);
QCOMPARE(window.delivered, 1);
QCOMPARE(actions.count(), 0);
controller.setParentItem(popup.contentItem());
controller.setProperty("active", true);
wheel(window, 120);
QCOMPARE(window.delivered, 2);
wheel(popup, 120);
QCOMPARE(actions.count(), 1);
}
void doubleClicks_data()
{
QTest::addColumn<int>("button");
QTest::addColumn<QString>("singleSlot");
QTest::addColumn<QString>("doubleSlot");
QTest::addColumn<bool>("precedingPress");
QTest::newRow("left") << int(Qt::LeftButton) << u"leftClick"_s << u"leftDoubleClick"_s << false;
QTest::newRow("middle") << int(Qt::MiddleButton) << u"middleClick"_s << u"middleDoubleClick"_s << false;
QTest::newRow("qt-window-left") << int(Qt::LeftButton) << u"leftClick"_s << u"leftDoubleClick"_s << true;
QTest::newRow("qt-window-middle") << int(Qt::MiddleButton) << u"middleClick"_s << u"middleDoubleClick"_s << true;
}
void doubleClicks()
{
QFETCH(int, button);
QFETCH(QString, singleSlot);
QFETCH(QString, doubleSlot);
QFETCH(bool, precedingPress);
const auto mouseButton = Qt::MouseButton(button);
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{singleSlot, u"mute"_s}, {doubleSlot, u"application"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 20, mouseButton);
mouse(window, QEvent::MouseButtonRelease, 20, mouseButton);
QCOMPARE(actions.count(), 0);
// QGuiApplication sends both Press and DblClick for the second down.
if (precedingPress) mouse(window, QEvent::MouseButtonPress, 20, mouseButton);
mouse(window, QEvent::MouseButtonDblClick, 20, mouseButton);
mouse(window, QEvent::MouseButtonRelease, 20, mouseButton);
QCOMPARE(actions.count(), 1);
QCOMPARE(actions.at(0), QVariantList({doubleSlot}));
QTest::qWait(QGuiApplication::styleHints()->mouseDoubleClickInterval() + 30);
QCOMPARE(actions.count(), 1); // no single-click action after the double
mouse(window, QEvent::MouseButtonPress, 20, mouseButton);
mouse(window, QEvent::MouseButtonRelease, 20, mouseButton);
QCOMPARE(actions.count(), 1);
QTRY_COMPARE(actions.count(), 2);
QCOMPARE(actions.at(1), QVariantList({singleSlot}));
}
void distantClicksDoNotCombine()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{u"leftClick"_s, u"mute"_s}, {u"leftDoubleClick"_s, u"overview"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 10);
mouse(window, QEvent::MouseButtonRelease, 10);
mouse(window, QEvent::MouseButtonPress, 90);
mouse(window, QEvent::MouseButtonRelease, 90);
QTRY_COMPARE(actions.count(), 2);
QCOMPARE(actions.at(0), QVariantList({u"leftClick"_s}));
QCOMPARE(actions.at(1), QVariantList({u"leftClick"_s}));
}
void doubleOnlyAndDisablingPendingClicks()
{
QJSEngine engine;
PanelWindow window;
PanelController controller;
setup(controller, window, engine);
controller.setProperty("clickActions", QVariantMap{{u"leftDoubleClick"_s, u"command"_s}});
QSignalSpy actions(&controller, &PanelController::clickRequested);
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
// Some platforms deliver the second press without a DblClick event.
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
QCOMPARE(actions.count(), 1);
QCOMPARE(actions.at(0), QVariantList({u"leftDoubleClick"_s}));
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseMove, 80);
mouse(window, QEvent::MouseMove, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
QCOMPARE(actions.count(), 1); // dragging the second press cancels the double
mouse(window, QEvent::MouseButtonPress, 20);
mouse(window, QEvent::MouseButtonRelease, 20);
controller.setProperty("active", false);
QTest::qWait(QGuiApplication::styleHints()->mouseDoubleClickInterval() + 30);
QCOMPARE(actions.count(), 1);
}
};
QTEST_MAIN(PanelControllerTest)
#include "test-panelcontroller.moc"
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# Run in a Fedora build environment with the application's dependencies.
set -euo pipefail
if (( $# != 2 )); then
printf 'Usage: %s OLD_RPM NEW_RPM\n' "$0" >&2
exit 2
fi
old_rpm=$(realpath "$1")
new_rpm=$(realpath "$2")
work=$(mktemp -d)
trap 'rm -rf -- "$work"' EXIT
mkdir -p "$work/root" "$work/runtime"
export XDG_CONFIG_HOME="$work/config" XDG_CACHE_HOME="$work/cache"
export XDG_DATA_HOME="$work/data" XDG_DATA_DIRS="$work/root/usr/share:/usr/share"
export XDG_RUNTIME_DIR="$work/runtime"
export QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software
unset QML_DISABLE_DISK_CACHE QML_FORCE_DISK_CACHE QML_DISK_CACHE_PATH QML_DISK_CACHE
launch_settings() {
if timeout 3s dbus-run-session -- "$work/root/usr/bin/plasma-panel-actions" --settings > "$work/launch.log" 2>&1; then
printf 'Settings exited before the test timeout.\n' >&2
cat "$work/launch.log" >&2
exit 1
else
status=$?
if (( status != 124 )); then
cat "$work/launch.log" >&2
exit "$status"
fi
fi
}
cache_for() {
for file in "$XDG_CACHE_HOME"/plasma-panel-actions/qmlcache/*.qmlc; do
[[ -f $file ]] || continue
if strings -el "$file" | grep -Fx "file://$1" > /dev/null; then
printf '%s\n' "$file"
return
fi
done
printf 'No QML cache was generated for %s\n' "$1" >&2
return 1
}
rpm2cpio "$old_rpm" | (cd "$work/root" && cpio -idm --quiet)
launch_settings
old_ui="$work/root/usr/share/plasma-panel-actions/ConfigGeneral.qml"
old_cache=$(cache_for "$old_ui")
old_checksum=$(sha256sum "$old_cache")
# Extract over the same location and keep the old cache intact. Changing
# the staging directory would hide the cache-collision regression.
rpm2cpio "$new_rpm" | (cd "$work/root" && cpio -idmu --quiet)
new_ui="$work/root$(rpm -qpl "$new_rpm" | grep '/ConfigGeneral.qml$')"
[[ $(stat -c %Y "$old_ui") == "$(stat -c %Y "$new_ui")" ]]
launch_settings
new_cache=$(cache_for "$new_ui")
[[ $new_cache != "$old_cache" ]]
[[ $(sha256sum "$old_cache") == "$old_checksum" ]]
strings -el "$new_cache" | grep -Fx twinFormLayouts > /dev/null
printf 'PASS: updated settings loaded with identical source timestamps and the old cache intact.\n'
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
#include "settings.h"
#include <QTemporaryDir>
#include <QTest>
class SettingsTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void savesWithoutAWidget()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
qputenv("XDG_CONFIG_HOME", directory.path().toUtf8());
Settings settings;
auto values = settings.values();
QCOMPARE(values.value(QStringLiteral("spacerLength")).toInt(), 48);
QCOMPARE(values.value(QStringLiteral("tasksAction")).toString(), QStringLiteral("volume"));
values[QStringLiteral("tasksAction")] = QStringLiteral("normal");
values[QStringLiteral("spacerLength")] = 128;
QVERIFY(settings.save(values));
Settings reopened;
QCOMPARE(reopened.values(), values);
auto invalid = values;
invalid[QStringLiteral("tasksAction")] = QStringLiteral("invalid");
QVERIFY(!settings.save(invalid));
QCOMPARE(settings.values(), values);
auto launch = values;
launch[QStringLiteral("leftDoubleClick")] = QStringLiteral("command");
QVERIFY(!settings.save(launch)); // empty command cannot be enabled
launch[QStringLiteral("leftDoubleClickCommand")] = QStringLiteral("true");
QVERIFY(settings.save(launch));
QCOMPARE(settings.values(), launch);
launch[QStringLiteral("middleDoubleClick")] = QStringLiteral("application");
QVERIFY(!settings.save(launch));
launch[QStringLiteral("middleDoubleClickApplication")] = QStringLiteral("org.kde.plasma-systemmonitor.desktop");
QVERIFY(settings.save(launch));
QCOMPARE(settings.values(), launch);
invalid = values;
invalid[QStringLiteral("spacerLength")] = -1;
QVERIFY(!settings.save(invalid));
QCOMPARE(settings.values(), launch);
}
};
QTEST_GUILESS_MAIN(SettingsTest)
#include "test-settings.moc"
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtTest
import "../controller/contents/ui/areas.js" as Areas
Item {
width: 600
height: 100
Item {
id: panel
x: 30
y: 20
width: 500
height: 40
Item {
width: 200
height: 40
property alias applet: tasks
Item {
id: tasks
anchors.fill: parent
property QtObject plasmoid: QtObject { property string pluginName: "org.kde.plasma.taskmanager" }
Item {
id: button
width: 80
height: 40
property Item tasksRoot: tasks
property bool isWindow: true
property int index: 0
}
}
}
Item {
x: 200
width: 60
height: 40
property QtObject applet: QtObject {
property QtObject plasmoid: QtObject { property string pluginName: "org.kde.plasma.systemtray" }
}
}
Item {
id: arbitraryWidget
x: 260
width: 60
height: 40
property QtObject applet: QtObject {
property QtObject plasmoid: QtObject { property string pluginName: "third.party.widget" }
}
}
}
TestCase {
name: "PanelAreas"
when: windowShown
function test_taskButtonsVersusUnusedSpace() {
compare(Areas.areaAt(panel, 40, 30), "tasks");
compare(Areas.areaAt(panel, 150, 30), "empty");
button.isWindow = false; // pinned launchers still count as buttons
compare(Areas.areaAt(panel, 40, 30), "tasks");
button.visible = false;
compare(Areas.areaAt(panel, 40, 30), "empty");
button.visible = true;
}
function test_trayAndUnrecognizedWidgetsKeepClicks() {
compare(Areas.areaAt(panel, 240, 30), "tray");
compare(Areas.areaAt(panel, 300, 30), "other");
arbitraryWidget.visible = false;
compare(Areas.areaAt(panel, 300, 30), "empty");
arbitraryWidget.visible = true;
compare(Areas.areaAt(null, 40, 30), "");
}
}
}
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtTest
TestCase {
name: "PanelConfiguration"
function test_savedValuesAndDefaults() {
const component = Qt.createComponent("../src/ConfigGeneral.qml");
compare(component.status, Component.Ready, component.errorString());
const config = component.createObject(this, {
width: 600, height: 800,
cfg_emptyAction: "brightness", cfg_tasksAction: "normal",
cfg_launcherAction: "volume", cfg_clockAction: "keyboard",
cfg_trayAction: "desktops", cfg_otherAction: "normal",
cfg_leftClick: "mute", cfg_middleClick: "overview",
cfg_leftDoubleClick: "command", cfg_leftDoubleClickCommand: "true",
cfg_middleDoubleClick: "application", cfg_middleDoubleClickApplication: "test.desktop",
applications: [{text: "Test application", value: "test.desktop"}],
cfg_spacerLength: 100
});
verify(config !== null);
compare(findChild(config, "emptyAction").currentValue, "brightness");
compare(findChild(config, "tasksAction").currentValue, "normal");
compare(findChild(config, "leftDoubleClick").currentValue, "command");
compare(findChild(config, "leftDoubleClickCommand").text, "true");
compare(findChild(config, "middleDoubleClickApplication").currentValue, "test.desktop");
config.cfg_emptyAction = "keyboard";
compare(findChild(config, "emptyAction").currentValue, "keyboard");
findChild(config, "restoreDefaults").clicked();
for (const area of ["empty", "tasks", "launcher", "clock", "tray", "other"]) {
compare(config["cfg_" + area + "Action"], "volume");
compare(findChild(config, area + "Action").currentValue, "volume");
}
compare(config.cfg_leftClick, "normal");
compare(config.cfg_middleClick, "normal");
compare(config.cfg_leftDoubleClick, "normal");
compare(config.cfg_middleDoubleClick, "normal");
compare(config.cfg_leftDoubleClickCommand, "");
compare(config.cfg_middleDoubleClickApplication, "");
compare(config.cfg_spacerLength, 48);
config.destroy();
component.destroy();
}
}
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT
import QtQuick
import QtTest
import se.ajpanton.panelactions 1.0
TestCase {
name: "PanelPlugin"
PanelController { id: controller }
PanelSettings { id: panelSettings }
function test_loadsInstalledTypes() {
verify(controller !== null);
verify(panelSettings.values.emptyAction !== undefined);
}
function test_widgetComponentsCompile() {
for (const path of ["../package/contents/ui/main.qml", "../controller/contents/ui/main.qml"]) {
const component = Qt.createComponent(path);
compare(component.status, Component.Ready, component.errorString());
component.destroy();
}
}
function test_settingsWindowWithoutWidget() {
const component = Qt.createComponent("../src/SettingsWindow.qml");
compare(component.status, Component.Ready, component.errorString());
const window = component.createObject(null);
verify(window !== null);
tryCompare(window, "visible", true);
const form = findChild(window, "settingsForm");
verify(form !== null);
compare(form.cfg_tasksAction, panelSettings.values.tasksAction);
compare(findChild(form, "tasksAction").currentValue, panelSettings.values.tasksAction);
window.width = 600;
window.height = 400;
const save = findChild(window, "saveSettings");
const page = window.pageStack.currentItem;
tryVerify(() => page.flickable.contentHeight > page.flickable.height);
tryVerify(() => {
const position = save.mapToItem(window.contentItem, 0, 0);
return position.y >= 0 && position.y + save.height <= window.height;
});
const saveY = save.mapToItem(window.contentItem, 0, 0).y;
page.flickable.contentY = page.flickable.contentHeight - page.flickable.height;
compare(save.mapToItem(window.contentItem, 0, 0).y, saveY);
form.cfg_tasksAction = "brightness";
save.clicked();
compare(panelSettings.values.tasksAction, "brightness");
window.close();
window.destroy();
component.destroy();
}
function test_clickDetailsWrap() {
const component = Qt.createComponent("../src/SettingsWindow.qml");
compare(component.status, Component.Ready, component.errorString());
const window = component.createObject(null);
verify(window !== null);
const form = findChild(window, "settingsForm");
form.cfg_leftClick = "application";
form.applications = [{text: "A moderately long application name", value: "test.desktop"}];
form.cfg_leftClickApplication = "test.desktop";
form.cfg_middleClick = "command";
const action = findChild(form, "leftClick");
const application = findChild(form, "leftClickApplication");
const middleAction = findChild(form, "middleClick");
const command = findChild(form, "middleClickCommand");
const scrollAction = findChild(form, "emptyAction");
window.minimumWidth = 320;
for (const width of [760, 360, 900]) {
window.width = width;
if (width === 360) {
tryVerify(() => application.y >= action.y + action.height);
tryVerify(() => command.y >= middleAction.y + middleAction.height);
} else {
tryVerify(() => application.x >= action.x + action.width && application.y === action.y);
tryVerify(() => command.x >= middleAction.x + middleAction.width && command.y === middleAction.y);
}
verify(application.x + application.width <= application.parent.width);
verify(command.x + command.width <= command.parent.width);
tryCompare(application, "width", Math.min(application.implicitWidth, application.parent.width));
tryVerify(() => Math.abs(command.x + command.width - command.parent.width) < 1);
tryVerify(() => Math.abs(scrollAction.mapToItem(form, 0, 0).x - action.mapToItem(form, 0, 0).x) <= 1);
}
window.close();
window.destroy();
component.destroy();
}
}
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -euo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
topdir="$repo_root/rpmbuild"
mkdir -p "$topdir"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
tar -C "$repo_root" --transform='s,^plasma-panel-actions,plasma-panel-actions-0.1.0,' \
-czf "$topdir/SOURCES/plasma-panel-actions-0.1.0.tar.gz" \
plasma-panel-actions/CMakeLists.txt plasma-panel-actions/src \
plasma-panel-actions/package plasma-panel-actions/controller plasma-panel-actions/data \
plasma-panel-actions/tests plasma-panel-actions/README.md
install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
rpmbuild --define "_topdir $topdir" -bb "$repo_root/plasma-panel-actions/plasma-panel-actions.spec"