GestureNavigation: Screen Rotation Popup Button

Implementation of a popup button to rotate the screen while using gesture navigation.

The button is set to appear when the device rotates while auto rotation is off. Then the button will be visible for a short period of time before disappearing.
This commit is contained in:
Micah Stanley 2025-04-21 22:09:14 +00:00
parent 842354bd70
commit 9bd4f0b747
12 changed files with 518 additions and 139 deletions

View file

@ -7,6 +7,7 @@ add_subdirectory(mmplugin)
add_subdirectory(mobileshell)
add_subdirectory(mobileshellstate)
add_subdirectory(quicksettingsplugin)
add_subdirectory(rotationplugin)
add_subdirectory(windowplugin)
add_subdirectory(shellsettingsplugin)
add_subdirectory(wallpaperimageplugin)

View file

@ -22,6 +22,7 @@ Item {
function load() {
volumeOSD.active = true;
notifications.active = true;
actionButtons.active = true;
}
Loader {
@ -37,4 +38,11 @@ Item {
MobileShell.NotificationPopupProvider {}
}
}
Loader {
id: actionButtons
sourceComponent: Component {
MobileShell.ActionButtonsProvider {}
}
}
}

View file

@ -0,0 +1,186 @@
/*
* SPDX-FileCopyrightText: 2025 Micah Stanley <stanleymicah@proton.me>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import QtQuick.Controls as Controls
import QtQuick.Layouts
import QtQuick.Effects
import org.kde.kirigami 2.20 as Kirigami
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.layershell 1.0 as LayerShell
Window {
id: root
readonly property int size: Kirigami.Units.gridUnit * 2
readonly property int margins: Math.round(Kirigami.Units.largeSpace * 0.5)
property int screenCorner: ActionButton.ScreenCorner.BottomRight
property int angle: 0
property string iconSource
property bool active: false
signal triggered()
enum ScreenCorner {
BottomRight,
BottomLeft,
TopLeft,
TopRight
}
// When the button is animating its disappearance, make sure it is transparent to inputs.
onActiveChanged: {
ShellUtil.setInputTransparent(root, !active)
if (active) {
root.visible = true;
root.raise();
hideButton.stop();
return;
}
hideButton.restart();
}
LayerShell.Window.scope: "overlay"
LayerShell.Window.margins.top: margins
LayerShell.Window.margins.bottom: margins
LayerShell.Window.margins.left: margins
LayerShell.Window.margins.right: margins
LayerShell.Window.layer: LayerShell.Window.LayerOverlay
LayerShell.Window.exclusionZone: -1
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone
LayerShell.Window.anchors: {
if (screenCorner === ActionButton.ScreenCorner.TopLeft) {
return LayerShell.Window.AnchorTop | LayerShell.Window.AnchorLeft
} else if (screenCorner === ActionButton.ScreenCorner.BottomRight) {
return LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorRight
} else if (screenCorner === ActionButton.ScreenCorner.BottomLeft) {
return LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorLeft
} else {
return LayerShell.Window.AnchorTop | LayerShell.Window.AnchorRight
}
}
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
// Double the set button size to leave room for button scale animation.
width: size * 2
height: size * 2
visible: active
color: "transparent"
// Hide the root window after the button disappearing animation finishes.
Timer {
id: hideButton
interval: Kirigami.Units.longDuration
repeat: false
onTriggered: if (!active) root.visible = false;
}
Component.onCompleted: {
// Because the window surface area had to be made larger to accommodate the button scale animation,
// set the input region to the size of the actual button.
ShellUtil.setInputRegion(root, Qt.rect((root.width - size) / 2, (root.height - size) / 2, size, size));
ShellUtil.setInputTransparent(root, !active);
}
Controls.Control {
id: content
anchors.centerIn: parent
width: root.size
height: root.size
opacity: root.active ? 1 : 0
property double scale: !root.active ? 0.5 : (button.pressed ? 1.5 : 1)
Behavior on scale {
NumberAnimation {
duration: Kirigami.Units.longDuration
easing.type: Easing.OutBack
}
}
Behavior on opacity {
NumberAnimation {
duration: Kirigami.Units.longDuration
easing.type: Easing.OutCirc
}
}
transform: Scale {
origin.x: root.size / 2
origin.y: root.size / 2
xScale: content.scale
yScale: content.scale
}
MultiEffect {
anchors.fill: parent
source: simpleShadow
blurMax: 16
shadowEnabled: true
shadowVerticalOffset: 1
shadowOpacity: 0.85
shadowColor: Qt.lighter(Kirigami.Theme.backgroundColor, 0.2)
}
Rectangle {
id: simpleShadow
anchors.fill: parent
anchors.leftMargin: -1
anchors.rightMargin: -1
anchors.bottomMargin: -1
color: {
let darkerBackgroundColor = Qt.darker(Kirigami.Theme.backgroundColor, 1.3);
return Qt.rgba(darkerBackgroundColor.r, darkerBackgroundColor.g, darkerBackgroundColor.b, 0.5)
}
radius: root.size
}
Rectangle {
anchors.fill: parent
color: Qt.lighter(Kirigami.Theme.backgroundColor, 1.5)
radius: root.size
opacity: 0.85
}
Controls.AbstractButton {
id: button
anchors.fill: parent
MobileShell.HapticsEffect {
id: haptics
}
contentItem: Item {
Kirigami.Icon {
anchors.centerIn: parent
width: Kirigami.Units.iconSizes.small
height: Kirigami.Units.iconSizes.small
transformOrigin: Item.Center
rotation: root.angle
source: root.iconSource
}
}
onPressed: {
haptics.buttonVibrate();
}
onReleased: {
if (active) root.triggered();
}
}
}
}

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2025 Micah Stanley <stanleymicah@proton.me>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell as MobileShell
/**
* This sets up the popup action buttons.
*/
QtObject {
id: component
property var rotationButton: RotationButton {}
}

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2025 Micah Stanley <stanleymicah@proton.me>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import org.kde.plasma.private.mobileshell.rotationplugin as RotationPlugin
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
ActionButton {
id: root
readonly property int deviceRotation: RotationPlugin.RotationUtil.deviceRotation
readonly property int currentRotation: RotationPlugin.RotationUtil.currentRotation
iconSource: "rotation-allowed-symbolic"
// Update button position and timeout when device rotation changes.
onDeviceRotationChanged: {
if (ShellSettings.Settings.navigationPanelEnabled) return;
// reset button if visible
if (root.visible) {
root.active = false;
timeout.stop();
}
if (!RotationPlugin.RotationUtil.showRotationButton) return;
// Position at the bottom left edge of actual device, regardless of current rotation.
root.screenCorner = (currentRotation + 1) % 4;
// match angle to physical device rotation.
root.angle = ((4 + currentRotation - deviceRotation) % 4) * 90;
root.active = true;
}
// Rotate to suggested rotation if button is pressed.
onTriggered: {
root.visible = false;
root.active = false;
timeout.stop();
rotate.restart();
}
// rotate on timeout to give time to hide the button before rotation happens
Timer {
id: rotate
interval: 0
repeat: false
onTriggered: RotationPlugin.RotationUtil.rotateToSuggestedRotation();
}
// When the button is active, hide it after a certain amount of time has passed.
// This is to prevent the button form bothering the user when they do not wish to rotate.
onActiveChanged: if (active) timeout.restart();
Timer {
id: timeout
interval: 10000
repeat: false
onTriggered: active = false;
}
}

View file

@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: 2025 Micah Stanley <stanleymicah@proton.me>
# SPDX-License-Identifier: GPL-2.0-or-later
ecm_add_qml_module(rotationplugin URI org.kde.plasma.private.mobileshell.rotationplugin GENERATE_PLUGIN_SOURCE)
target_sources(rotationplugin PRIVATE rotationutil.cpp)
target_link_libraries(rotationplugin PRIVATE
Qt::Gui
Qt::DBus
Qt::Qml
Qt::Quick
Qt::Sensors
Plasma::Plasma
KF6::Screen
Qt::Qml
)
ecm_finalize_qml_module(rotationplugin)

View file

@ -0,0 +1,158 @@
/*
* SPDX-FileCopyrightText: 2025 Micah Stanley <stanleymicah@proton.me>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "rotationutil.h"
#include <QDBusConnection>
#include <QDBusPendingReply>
#include <QDebug>
#include <QGuiApplication>
#include <kscreen/configmonitor.h>
#include <kscreen/getconfigoperation.h>
#include <kscreen/output.h>
#include <kscreen/setconfigoperation.h>
#include <qtmetamacros.h>
KScreen::Output::Rotation mapReadingOrientation(QOrientationReading::Orientation orientation)
{
switch (orientation) {
case QOrientationReading::Orientation::TopUp:
return KScreen::Output::Rotation::None;
case QOrientationReading::Orientation::TopDown:
return KScreen::Output::Rotation::Inverted;
case QOrientationReading::Orientation::LeftUp:
return KScreen::Output::Rotation::Left;
case QOrientationReading::Orientation::RightUp:
return KScreen::Output::Rotation::Right;
case QOrientationReading::Orientation::FaceUp:
case QOrientationReading::Orientation::FaceDown:
case QOrientationReading::Orientation::Undefined:
return KScreen::Output::Rotation::None;
}
return KScreen::Output::Rotation::None;
}
RotationUtil::Rotation mapRotation(KScreen::Output::Rotation rotation)
{
switch (rotation) {
case KScreen::Output::Rotation::Left:
return RotationUtil::Rotation::LandscapeLeft;
case KScreen::Output::Rotation::Inverted:
return RotationUtil::Rotation::UpsideDown;
case KScreen::Output::Rotation::Right:
return RotationUtil::Rotation::LandscapeRight;
default:
return RotationUtil::Rotation::Portrait;
}
}
RotationUtil::RotationUtil(QObject *parent)
: QObject{parent}
, m_sensor{new QOrientationSensor(this)}
{
connect(new KScreen::GetConfigOperation(), &KScreen::GetConfigOperation::finished, this, [this](auto *op) {
m_config = qobject_cast<KScreen::GetConfigOperation *>(op)->config();
KScreen::ConfigMonitor::instance()->addConfig(m_config);
// update all screens with event connect
for (KScreen::OutputPtr output : m_config->outputs()) {
connect(output.data(), &KScreen::Output::autoRotatePolicyChanged, this, &RotationUtil::updateShowRotationButton);
}
// listen to all new screens and connect
connect(m_config.data(), &KScreen::Config::outputAdded, this, [this](const auto &output) {
connect(output.data(), &KScreen::Output::autoRotatePolicyChanged, this, &RotationUtil::updateShowRotationButton);
});
});
connect(m_sensor, &QOrientationSensor::readingChanged, this, &RotationUtil::updateShowRotationButton);
m_sensor->start();
}
void RotationUtil::rotateToSuggestedRotation()
{
if (!m_config || !m_showRotationButton) {
return;
}
const auto outputs = m_config->outputs();
if (outputs.empty()) {
return;
}
// HACK: Assume the output we care about is the first device
for (KScreen::OutputPtr output : outputs) {
// apparently it's possible to get nullptr outputs?
if (!output) {
continue;
}
output->setRotation(m_rotateTo);
}
auto setop = new KScreen::SetConfigOperation(m_config, this);
setop->exec();
updateShowRotationButton();
}
bool RotationUtil::showRotationButton() const
{
return m_showRotationButton;
}
RotationUtil::Rotation RotationUtil::deviceRotation() const
{
return m_deviceRotation;
}
RotationUtil::Rotation RotationUtil::currentRotation() const
{
return m_currentRotation;
}
void RotationUtil::updateShowRotationButton()
{
if (!m_config) {
return;
}
QOrientationReading *reading = m_sensor->reading();
if (!reading) {
return;
}
m_rotateTo = mapReadingOrientation(reading->orientation());
m_deviceRotation = mapRotation(m_rotateTo);
const auto outputs = m_config->outputs();
if (outputs.empty()) {
m_showRotationButton = false;
Q_EMIT rotationChanged();
return;
}
// HACK: Assume the output we care about is the first device
for (KScreen::OutputPtr output : outputs) {
if (!output) {
// apparently it's possible to get nullptr outputs?
continue;
}
if (output->autoRotatePolicy() != KScreen::Output::AutoRotatePolicy::Never) {
// only check displays that have autorotate on
continue;
}
m_currentRotation = mapRotation(output->rotation());
m_showRotationButton = output->rotation() != m_rotateTo;
Q_EMIT rotationChanged();
return;
}
m_showRotationButton = false;
Q_EMIT rotationChanged();
}

View file

@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: 2025 Micah Stanley <stanleymicah@proton.me>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
#pragma once
#include <QObject>
#include <QOrientationSensor>
#include <kscreen/config.h>
#include <qqmlregistration.h>
#include <qtmetamacros.h>
class RotationUtil : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool showRotationButton READ showRotationButton NOTIFY rotationChanged)
Q_PROPERTY(Rotation deviceRotation READ deviceRotation NOTIFY rotationChanged)
Q_PROPERTY(Rotation currentRotation READ currentRotation NOTIFY rotationChanged)
public:
RotationUtil(QObject *parent = nullptr);
enum Rotation {
Portrait = 0,
LandscapeLeft,
UpsideDown,
LandscapeRight
};
Q_ENUM(Rotation)
bool showRotationButton() const;
Rotation deviceRotation() const;
Rotation currentRotation() const;
Q_INVOKABLE void rotateToSuggestedRotation();
Q_SIGNALS:
void rotationChanged();
private Q_SLOTS:
void updateShowRotationButton();
private:
bool m_showRotationButton{false};
KScreen::Output::Rotation m_rotateTo;
Rotation m_deviceRotation;
Rotation m_currentRotation;
KScreen::ConfigPtr m_config{nullptr};
QOrientationSensor *m_sensor{nullptr};
};

View file

@ -141,8 +141,9 @@ ContainmentItem {
MobileShellState.ShellDBusObject.registerObject();
// HACK: we need to initialize the DBus server somewhere, it might as well be here...
// initialize the volume osd, and volume keys
// initialize notification popups
// Initialize the volume osd, and volume keys.
// Initialize notification popups.
// Initialize action popup buttons.
MobileShell.PopupProviderLoader.load();
}

View file

@ -14,6 +14,7 @@ import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.taskmanager as TaskManager
import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.rotationplugin as RotationPlugin
import org.kde.kirigami as Kirigami
@ -105,13 +106,13 @@ MobileShell.NavigationPanel {
leftCornerAction: MobileShell.NavigationPanelAction {
id: rotationAction
visible: Plasmoid.showRotationButton
visible: RotationPlugin.RotationUtil.showRotationButton
enabled: true
iconSource: "rotation-allowed-symbolic"
iconSizeFactor: 0.75
onTriggered: {
Plasmoid.rotateToSuggestedRotation();
RotationPlugin.RotationUtil.rotateToSuggestedRotation();
}
}

View file

@ -11,54 +11,13 @@
#include <QDebug>
#include <QGuiApplication>
#include <kscreen/configmonitor.h>
#include <kscreen/getconfigoperation.h>
#include <kscreen/output.h>
#include <kscreen/setconfigoperation.h>
// register type for Keyboards.KWinVirtualKeyboard.forceActivate();
Q_DECLARE_METATYPE(QDBusPendingReply<>)
KScreen::Output::Rotation mapReadingOrientation(QOrientationReading::Orientation orientation)
{
switch (orientation) {
case QOrientationReading::Orientation::TopUp:
return KScreen::Output::Rotation::None;
case QOrientationReading::Orientation::TopDown:
return KScreen::Output::Rotation::Inverted;
case QOrientationReading::Orientation::LeftUp:
return KScreen::Output::Rotation::Left;
case QOrientationReading::Orientation::RightUp:
return KScreen::Output::Rotation::Right;
case QOrientationReading::Orientation::FaceUp:
case QOrientationReading::Orientation::FaceDown:
case QOrientationReading::Orientation::Undefined:
return KScreen::Output::Rotation::None;
}
return KScreen::Output::Rotation::None;
}
TaskPanel::TaskPanel(QObject *parent, const KPluginMetaData &data, const QVariantList &args)
: Plasma::Containment(parent, data, args)
, m_sensor{new QOrientationSensor(this)}
{
connect(new KScreen::GetConfigOperation(), &KScreen::GetConfigOperation::finished, this, [this](auto *op) {
m_config = qobject_cast<KScreen::GetConfigOperation *>(op)->config();
KScreen::ConfigMonitor::instance()->addConfig(m_config);
// update all screens with event connect
for (KScreen::OutputPtr output : m_config->outputs()) {
connect(output.data(), &KScreen::Output::autoRotatePolicyChanged, this, &TaskPanel::updateShowRotationButton);
}
// listen to all new screens and connect
connect(m_config.data(), &KScreen::Config::outputAdded, this, [this](const auto &output) {
connect(output.data(), &KScreen::Output::autoRotatePolicyChanged, this, &TaskPanel::updateShowRotationButton);
});
});
connect(m_sensor, &QOrientationSensor::readingChanged, this, &TaskPanel::updateShowRotationButton);
m_sensor->start();
}
void TaskPanel::triggerTaskSwitcher() const
@ -70,79 +29,6 @@ void TaskPanel::triggerTaskSwitcher() const
QDBusConnection::sessionBus().send(message);
}
void TaskPanel::rotateToSuggestedRotation()
{
if (!m_config || !m_showRotationButton) {
return;
}
const auto outputs = m_config->outputs();
if (outputs.empty()) {
return;
}
// HACK: Assume the output we care about is the first device
for (KScreen::OutputPtr output : outputs) {
// apparently it's possible to get nullptr outputs?
if (!output) {
continue;
}
output->setRotation(m_rotateTo);
}
auto setop = new KScreen::SetConfigOperation(m_config, this);
setop->exec();
updateShowRotationButton();
}
bool TaskPanel::showRotationButton() const
{
return m_showRotationButton;
}
void TaskPanel::updateShowRotationButton()
{
if (!m_config) {
return;
}
QOrientationReading *reading = m_sensor->reading();
if (!reading) {
return;
}
m_rotateTo = mapReadingOrientation(reading->orientation());
const auto outputs = m_config->outputs();
if (outputs.empty()) {
m_showRotationButton = false;
Q_EMIT showRotationButtonChanged();
return;
}
// HACK: Assume the output we care about is the first device
for (KScreen::OutputPtr output : outputs) {
if (!output) {
// apparently it's possible to get nullptr outputs?
continue;
}
if (output->autoRotatePolicy() != KScreen::Output::AutoRotatePolicy::Never) {
// only check displays that have autorotate on
continue;
}
m_showRotationButton = output->rotation() != m_rotateTo;
Q_EMIT showRotationButtonChanged();
return;
}
m_showRotationButton = false;
Q_EMIT showRotationButtonChanged();
}
K_PLUGIN_CLASS(TaskPanel)
#include "taskpanel.moc"

View file

@ -7,33 +7,12 @@
#pragma once
#include <Plasma/Containment>
#include <QOrientationSensor>
#include <kscreen/config.h>
class TaskPanel : public Plasma::Containment
{
Q_OBJECT
Q_PROPERTY(bool showRotationButton READ showRotationButton NOTIFY showRotationButtonChanged)
public:
TaskPanel(QObject *parent, const KPluginMetaData &data, const QVariantList &args);
Q_INVOKABLE void triggerTaskSwitcher() const;
bool showRotationButton() const;
Q_INVOKABLE void rotateToSuggestedRotation();
Q_SIGNALS:
void showRotationButtonChanged();
private Q_SLOTS:
void updateShowRotationButton();
private:
bool m_showRotationButton{false};
KScreen::Output::Rotation m_rotateTo;
KScreen::ConfigPtr m_config{nullptr};
QOrientationSensor *m_sensor{nullptr};
};