Add configurable Plasma panel scroll and click actions
This commit is contained in:
@@ -19,6 +19,7 @@ sudo dnf install plasma-always-show-unlock
|
|||||||
sudo dnf install plasma-task-group-shortcuts
|
sudo dnf install plasma-task-group-shortcuts
|
||||||
sudo dnf install plasma-fingerprint-workaround
|
sudo dnf install plasma-fingerprint-workaround
|
||||||
sudo dnf install fedora-tools-settings
|
sudo dnf install fedora-tools-settings
|
||||||
|
sudo dnf install plasma-panel-actions
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compatibility
|
## 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
|
built RPM and restore Fedora's original package. See the tool's
|
||||||
[`README`](plasma-fingerprint-workaround/README.md) for the security warning,
|
[`README`](plasma-fingerprint-workaround/README.md) for the security warning,
|
||||||
build instructions, version checks, and force-downgrade option.
|
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).
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"]}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+13
@@ -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"
|
||||||
Reference in New Issue
Block a user