diff --git a/components/CMakeLists.txt b/components/CMakeLists.txt index c88fcc1c..8930097f 100644 --- a/components/CMakeLists.txt +++ b/components/CMakeLists.txt @@ -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) diff --git a/components/mobileshell/qml/popups/PopupProviderLoader.qml b/components/mobileshell/qml/popups/PopupProviderLoader.qml index a343ab3a..07d83ba9 100644 --- a/components/mobileshell/qml/popups/PopupProviderLoader.qml +++ b/components/mobileshell/qml/popups/PopupProviderLoader.qml @@ -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 {} + } + } } diff --git a/components/mobileshell/qml/popups/actionbuttons/ActionButton.qml b/components/mobileshell/qml/popups/actionbuttons/ActionButton.qml new file mode 100644 index 00000000..6b33331c --- /dev/null +++ b/components/mobileshell/qml/popups/actionbuttons/ActionButton.qml @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: 2025 Micah Stanley + * + * 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(); + } + } + } +} diff --git a/components/mobileshell/qml/popups/actionbuttons/ActionButtonsProvider.qml b/components/mobileshell/qml/popups/actionbuttons/ActionButtonsProvider.qml new file mode 100644 index 00000000..72e79fbb --- /dev/null +++ b/components/mobileshell/qml/popups/actionbuttons/ActionButtonsProvider.qml @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2025 Micah Stanley + * + * 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 {} +} + diff --git a/components/mobileshell/qml/popups/actionbuttons/RotationButton.qml b/components/mobileshell/qml/popups/actionbuttons/RotationButton.qml new file mode 100644 index 00000000..c0c7b231 --- /dev/null +++ b/components/mobileshell/qml/popups/actionbuttons/RotationButton.qml @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2025 Micah Stanley + * + * 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; + } +} diff --git a/components/rotationplugin/CMakeLists.txt b/components/rotationplugin/CMakeLists.txt new file mode 100644 index 00000000..843494c8 --- /dev/null +++ b/components/rotationplugin/CMakeLists.txt @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2025 Micah Stanley +# 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) \ No newline at end of file diff --git a/components/rotationplugin/rotationutil.cpp b/components/rotationplugin/rotationutil.cpp new file mode 100644 index 00000000..51d9ccb6 --- /dev/null +++ b/components/rotationplugin/rotationutil.cpp @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2025 Micah Stanley + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "rotationutil.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +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(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(); +} \ No newline at end of file diff --git a/components/rotationplugin/rotationutil.h b/components/rotationplugin/rotationutil.h new file mode 100644 index 00000000..8a9af36b --- /dev/null +++ b/components/rotationplugin/rotationutil.h @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2025 Micah Stanley + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +#include +#include +#include + +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}; +}; diff --git a/containments/panel/package/contents/ui/main.qml b/containments/panel/package/contents/ui/main.qml index b0b2bb18..ed55ba37 100644 --- a/containments/panel/package/contents/ui/main.qml +++ b/containments/panel/package/contents/ui/main.qml @@ -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(); } diff --git a/containments/taskpanel/package/contents/ui/NavigationPanelComponent.qml b/containments/taskpanel/package/contents/ui/NavigationPanelComponent.qml index edd0d89f..045ef199 100644 --- a/containments/taskpanel/package/contents/ui/NavigationPanelComponent.qml +++ b/containments/taskpanel/package/contents/ui/NavigationPanelComponent.qml @@ -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(); } } diff --git a/containments/taskpanel/taskpanel.cpp b/containments/taskpanel/taskpanel.cpp index 61a4bbb1..dc88d042 100644 --- a/containments/taskpanel/taskpanel.cpp +++ b/containments/taskpanel/taskpanel.cpp @@ -11,54 +11,13 @@ #include #include -#include -#include -#include -#include - // 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(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" diff --git a/containments/taskpanel/taskpanel.h b/containments/taskpanel/taskpanel.h index 9a17509c..6bf4552a 100644 --- a/containments/taskpanel/taskpanel.h +++ b/containments/taskpanel/taskpanel.h @@ -7,33 +7,12 @@ #pragma once #include -#include - -#include 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}; };