diff --git a/README.md b/README.md index e6eb894..0fcf4bf 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/plasma-panel-actions/CMakeLists.txt b/plasma-panel-actions/CMakeLists.txt new file mode 100644 index 0000000..79c6f1f --- /dev/null +++ b/plasma-panel-actions/CMakeLists.txt @@ -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() diff --git a/plasma-panel-actions/README.md b/plasma-panel-actions/README.md new file mode 100644 index 0000000..760e5f1 --- /dev/null +++ b/plasma-panel-actions/README.md @@ -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. diff --git a/plasma-panel-actions/controller/contents/ui/areas.js b/plasma-panel-actions/controller/contents/ui/areas.js new file mode 100644 index 0000000..ebd9cf8 --- /dev/null +++ b/plasma-panel-actions/controller/contents/ui/areas.js @@ -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"; +} diff --git a/plasma-panel-actions/controller/contents/ui/main.qml b/plasma-panel-actions/controller/contents/ui/main.qml new file mode 100644 index 0000000..4288b53 --- /dev/null +++ b/plasma-panel-actions/controller/contents/ui/main.qml @@ -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); + } + } +} diff --git a/plasma-panel-actions/controller/metadata.json b/plasma-panel-actions/controller/metadata.json new file mode 100644 index 0000000..0c5d9b6 --- /dev/null +++ b/plasma-panel-actions/controller/metadata.json @@ -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"] +} diff --git a/plasma-panel-actions/data/plasma-panel-actions.json b/plasma-panel-actions/data/plasma-panel-actions.json new file mode 100644 index 0000000..a8416b7 --- /dev/null +++ b/plasma-panel-actions/data/plasma-panel-actions.json @@ -0,0 +1 @@ +{"command": ["/usr/bin/plasma-panel-actions", "--settings"]} diff --git a/plasma-panel-actions/data/qmldir b/plasma-panel-actions/data/qmldir new file mode 100644 index 0000000..cea23ed --- /dev/null +++ b/plasma-panel-actions/data/qmldir @@ -0,0 +1,2 @@ +module se.ajpanton.panelactions +plugin panelactionsplugin diff --git a/plasma-panel-actions/data/se.ajpanton.plasma-panel-actions-autostart.desktop b/plasma-panel-actions/data/se.ajpanton.plasma-panel-actions-autostart.desktop new file mode 100644 index 0000000..f05ca78 --- /dev/null +++ b/plasma-panel-actions/data/se.ajpanton.plasma-panel-actions-autostart.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name=Panel Actions +Exec=plasma-panel-actions +OnlyShowIn=KDE; +X-KDE-autostart-phase=2 +NoDisplay=true diff --git a/plasma-panel-actions/data/se.ajpanton.plasma-panel-actions.desktop b/plasma-panel-actions/data/se.ajpanton.plasma-panel-actions.desktop new file mode 100644 index 0000000..7b51c78 --- /dev/null +++ b/plasma-panel-actions/data/se.ajpanton.plasma-panel-actions.desktop @@ -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; diff --git a/plasma-panel-actions/package/contents/ui/main.qml b/plasma-panel-actions/package/contents/ui/main.qml new file mode 100644 index 0000000..00a9acf --- /dev/null +++ b/plasma-panel-actions/package/contents/ui/main.qml @@ -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) +} diff --git a/plasma-panel-actions/package/metadata.json b/plasma-panel-actions/package/metadata.json new file mode 100644 index 0000000..273511a --- /dev/null +++ b/plasma-panel-actions/package/metadata.json @@ -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"] +} diff --git a/plasma-panel-actions/plasma-panel-actions.spec b/plasma-panel-actions/plasma-panel-actions.spec new file mode 100644 index 0000000..4bb9080 --- /dev/null +++ b/plasma-panel-actions/plasma-panel-actions.spec @@ -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 diff --git a/plasma-panel-actions/src/ConfigGeneral.qml b/plasma-panel-actions/src/ConfigGeneral.qml new file mode 100644 index 0000000..6165ea9 --- /dev/null +++ b/plasma-panel-actions/src/ConfigGeneral.qml @@ -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 } +} diff --git a/plasma-panel-actions/src/SettingsWindow.qml b/plasma-panel-actions/src/SettingsWindow.qml new file mode 100644 index 0000000..cb4d160 --- /dev/null +++ b/plasma-panel-actions/src/SettingsWindow.qml @@ -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() + } + } + } + } +} diff --git a/plasma-panel-actions/src/main.cpp b/plasma-panel-actions/src/main.cpp new file mode 100644 index 0000000..e3e61fd --- /dev/null +++ b/plasma-panel-actions/src/main.cpp @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +#include "settings.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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(); +} diff --git a/plasma-panel-actions/src/panelcontroller.cpp b/plasma-panel-actions/src/panelcontroller.cpp new file mode 100644 index 0000000..1b0552e --- /dev/null +++ b/plasma-panel-actions/src/panelcontroller.cpp @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MIT +#include "panelcontroller.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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(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(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(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(); + }); +} diff --git a/plasma-panel-actions/src/panelcontroller.h b/plasma-panel-actions/src/panelcontroller.h new file mode 100644 index 0000000..9c86239 --- /dev/null +++ b/plasma-panel-actions/src/panelcontroller.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include +#include +#include +#include + +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 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; +}; diff --git a/plasma-panel-actions/src/plugin.cpp b/plasma-panel-actions/src/plugin.cpp new file mode 100644 index 0000000..2e324df --- /dev/null +++ b/plasma-panel-actions/src/plugin.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +#include "panelcontroller.h" +#include "settings.h" +#include + +class PanelActionsPlugin : public QQmlExtensionPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID QQmlExtensionInterface_iid) +public: + void registerTypes(const char *uri) override + { + qmlRegisterType(uri, 1, 0, "PanelController"); + qmlRegisterType(uri, 1, 0, "PanelSettings"); + } +}; +#include "plugin.moc" diff --git a/plasma-panel-actions/src/settings.cpp b/plasma-panel-actions/src/settings.cpp new file mode 100644 index 0000000..3a48b91 --- /dev/null +++ b/plasma-panel-actions/src/settings.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +#include "settings.h" +#include +#include +#include +#include +#include +#include +#include + +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(); + } +} diff --git a/plasma-panel-actions/src/settings.h b/plasma-panel-actions/src/settings.h new file mode 100644 index 0000000..7a5ea83 --- /dev/null +++ b/plasma-panel-actions/src/settings.h @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include +#include + +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; +}; diff --git a/plasma-panel-actions/tests/quicktest.cpp b/plasma-panel-actions/tests/quicktest.cpp new file mode 100644 index 0000000..da381c0 --- /dev/null +++ b/plasma-panel-actions/tests/quicktest.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +#include +#include +#include +#include + +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" diff --git a/plasma-panel-actions/tests/test-panelcontroller.cpp b/plasma-panel-actions/tests/test-panelcontroller.cpp new file mode 100644 index 0000000..f74cc52 --- /dev/null +++ b/plasma-panel-actions/tests/test-panelcontroller.cpp @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT +#include "panelcontroller.h" +#include +#include +#include +#include +#include +#include +#include + +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("button"); + QTest::addColumn("singleSlot"); + QTest::addColumn("doubleSlot"); + QTest::addColumn("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" diff --git a/plasma-panel-actions/tests/test-settings-upgrade b/plasma-panel-actions/tests/test-settings-upgrade new file mode 100644 index 0000000..29deb5f --- /dev/null +++ b/plasma-panel-actions/tests/test-settings-upgrade @@ -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' diff --git a/plasma-panel-actions/tests/test-settings.cpp b/plasma-panel-actions/tests/test-settings.cpp new file mode 100644 index 0000000..8a957ea --- /dev/null +++ b/plasma-panel-actions/tests/test-settings.cpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +#include "settings.h" +#include +#include + +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" diff --git a/plasma-panel-actions/tests/tst_areas.qml b/plasma-panel-actions/tests/tst_areas.qml new file mode 100644 index 0000000..aa35390 --- /dev/null +++ b/plasma-panel-actions/tests/tst_areas.qml @@ -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), ""); + } + } +} diff --git a/plasma-panel-actions/tests/tst_configuration.qml b/plasma-panel-actions/tests/tst_configuration.qml new file mode 100644 index 0000000..eb658d7 --- /dev/null +++ b/plasma-panel-actions/tests/tst_configuration.qml @@ -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(); + } +} diff --git a/plasma-panel-actions/tests/tst_plugin.qml b/plasma-panel-actions/tests/tst_plugin.qml new file mode 100644 index 0000000..4cf694c --- /dev/null +++ b/plasma-panel-actions/tests/tst_plugin.qml @@ -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(); + } +} diff --git a/scripts/build-plasma-panel-actions-rpm b/scripts/build-plasma-panel-actions-rpm new file mode 100755 index 0000000..894f52a --- /dev/null +++ b/scripts/build-plasma-panel-actions-rpm @@ -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"