Add pin-to-dock for running apps in convergence mode

Right-click a running task icon to pin the app to the dock
favourites bar. Pinned apps show a "Remove from Dock" action.

The dock overlay renders as a LayerTop window so context menus
and the dock itself stay above application windows. Menus use
popupType Window to avoid clipping inside the narrow dock
surface.
This commit is contained in:
Marco Allegretti 2026-04-11 09:42:21 +02:00
parent eb33b533c6
commit 8c56409f1c
7 changed files with 101 additions and 14 deletions

View file

@ -30,6 +30,8 @@ Key changes so far:
- **Desktop niceties** — right-click wallpaper settings, minimize-all on - **Desktop niceties** — right-click wallpaper settings, minimize-all on
home press, clickable page indicators, action drawer toggle on click. home press, clickable page indicators, action drawer toggle on click.
- **Thumbnail previews** on dock icon hover via PipeWire screencasting. - **Thumbnail previews** on dock icon hover via PipeWire screencasting.
- **Pin to dock** — right-click a running app to pin it to favourites;
pinned apps get a "Remove from Dock" action.
- **Bug fixes** — minimized windows stay in dock instead of vanishing, - **Bug fixes** — minimized windows stay in dock instead of vanishing,
app drawer dismiss overlay no longer covers dock icons, launching an app drawer dismiss overlay no longer covers dock icons, launching an
app in convergence mode always starts a new process instead of app in convergence mode always starts a new process instead of

View file

@ -4,6 +4,7 @@
#include "favouritesmodel.h" #include "favouritesmodel.h"
#include "homescreenstate.h" #include "homescreenstate.h"
#include <KService>
#include <QByteArray> #include <QByteArray>
#include <QDebug> #include <QDebug>
#include <QJsonArray> #include <QJsonArray>
@ -76,6 +77,35 @@ void FavouritesModel::removeEntry(int row)
save(); save();
} }
bool FavouritesModel::addApplication(const QString &storageId)
{
if (containsApplication(storageId)) {
return false;
}
KService::Ptr service = KService::serviceByStorageId(storageId);
if (!service) {
return false;
}
auto app = std::make_shared<FolioApplication>(service);
auto delegate = std::make_shared<FolioDelegate>(app);
return addEntry(m_delegates.size(), delegate);
}
bool FavouritesModel::containsApplication(const QString &storageId) const
{
for (const auto &entry : m_delegates) {
if (entry.delegate && entry.delegate->type() == FolioDelegate::Application) {
auto app = entry.delegate->application();
if (app && app->storageId() == storageId) {
return true;
}
}
}
return false;
}
void FavouritesModel::moveEntry(int fromRow, int toRow) void FavouritesModel::moveEntry(int fromRow, int toRow)
{ {
if (fromRow < 0 || toRow < 0 || fromRow >= m_delegates.size() || toRow >= m_delegates.size() || fromRow == toRow) { if (fromRow < 0 || toRow < 0 || fromRow >= m_delegates.size() || toRow >= m_delegates.size() || fromRow == toRow) {

View file

@ -42,6 +42,8 @@ public:
QHash<int, QByteArray> roleNames() const override; QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE void removeEntry(int row); Q_INVOKABLE void removeEntry(int row);
Q_INVOKABLE bool addApplication(const QString &storageId);
Q_INVOKABLE bool containsApplication(const QString &storageId) const;
void moveEntry(int fromRow, int toRow); void moveEntry(int fromRow, int toRow);
bool canAddEntry(int row, std::shared_ptr<FolioDelegate> delegate); bool canAddEntry(int row, std::shared_ptr<FolioDelegate> delegate);
bool addEntry(int row, std::shared_ptr<FolioDelegate> delegate); bool addEntry(int row, std::shared_ptr<FolioDelegate> delegate);

View file

@ -13,6 +13,7 @@ import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import QtQuick.Controls as Controls import QtQuick.Controls as Controls
import QtQuick.Templates as T
import "./private" import "./private"
import "./delegate" import "./delegate"
@ -345,6 +346,7 @@ MouseArea {
ContextMenuLoader { ContextMenuLoader {
id: contextMenu id: contextMenu
menuPopupType: root.convergenceMode ? T.Popup.Window : T.Popup.Item
// close menu when drag starts // close menu when drag starts
Connections { Connections {
@ -425,6 +427,7 @@ MouseArea {
ContextMenuLoader { ContextMenuLoader {
id: contextMenu id: contextMenu
menuPopupType: root.convergenceMode ? T.Popup.Window : T.Popup.Item
// close menu when drag starts // close menu when drag starts
Connections { Connections {
@ -661,7 +664,7 @@ MouseArea {
if (mouse.button === Qt.RightButton) { if (mouse.button === Qt.RightButton) {
thumbnailPopup.close() thumbnailPopup.close()
thumbnailShowTimer.stop() thumbnailShowTimer.stop()
taskContextMenu.popup(); taskContextMenu.open();
} else { } else {
thumbnailPopup.close() thumbnailPopup.close()
tasksModel.requestActivate(tasksModel.makeModelIndex(taskDelegate.index)); tasksModel.requestActivate(tasksModel.makeModelIndex(taskDelegate.index));
@ -688,23 +691,38 @@ MouseArea {
} }
} }
Controls.Menu { PC3.Menu {
id: taskContextMenu id: taskContextMenu
Controls.MenuItem { popupType: T.Popup.Window
text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize")
property string taskStorageId: {
var id = taskDelegate.model.AppId || ""
if (id && !id.endsWith(".desktop"))
id += ".desktop"
return id
}
PC3.MenuItem {
icon.name: "window-pin"
text: i18n("Pin to Dock")
visible: taskContextMenu.taskStorageId !== "" && !folio.FavouritesModel.containsApplication(taskContextMenu.taskStorageId)
enabled: !folio.FolioSettings.lockLayout
onClicked: folio.FavouritesModel.addApplication(taskContextMenu.taskStorageId)
}
PC3.MenuItem {
icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize" icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize"
onTriggered: tasksModel.requestToggleMinimized(tasksModel.makeModelIndex(taskDelegate.index)) text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize")
onClicked: tasksModel.requestToggleMinimized(tasksModel.makeModelIndex(taskDelegate.index))
} }
Controls.MenuItem { PC3.MenuItem {
text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize")
icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize" icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize"
onTriggered: tasksModel.requestToggleMaximized(tasksModel.makeModelIndex(taskDelegate.index)) text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize")
onClicked: tasksModel.requestToggleMaximized(tasksModel.makeModelIndex(taskDelegate.index))
} }
Controls.MenuSeparator {} PC3.MenuItem {
Controls.MenuItem {
text: i18n("Close")
icon.name: "window-close" icon.name: "window-close"
onTriggered: tasksModel.requestClose(tasksModel.makeModelIndex(taskDelegate.index)) text: i18n("Close")
onClicked: tasksModel.requestClose(tasksModel.makeModelIndex(taskDelegate.index))
} }
} }
} }

View file

@ -348,7 +348,8 @@ Item {
// don't show in settings mode // don't show in settings mode
opacity: 1 - folio.HomeScreenState.settingsOpenProgress opacity: 1 - folio.HomeScreenState.settingsOpenProgress
visible: opacity > 0 // Hidden in convergence mode the dock overlay window renders it instead
visible: opacity > 0 && !ShellSettings.Settings.convergenceModeEnabled
// one is ignored as anchors are set // one is ignored as anchors are set
height: ShellSettings.Settings.convergenceModeEnabled ? Kirigami.Units.gridUnit * 3 : Kirigami.Units.gridUnit * 6 height: ShellSettings.Settings.convergenceModeEnabled ? Kirigami.Units.gridUnit * 3 : Kirigami.Units.gridUnit * 6

View file

@ -17,6 +17,8 @@ import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.layershell 1.0 as LayerShell
import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio
import "./private" import "./private"
@ -156,6 +158,33 @@ ContainmentItem {
opacity: folio.HomeScreenState.settingsOpenProgress opacity: folio.HomeScreenState.settingsOpenProgress
} }
// Dock overlay window renders the favourites bar above application
// windows in convergence mode. LayerTop sits above normal windows but
// below LayerOverlay (notifications, volume OSD). The exclusive zone
// that reserves screen space is handled by the dockSpaceReserver in the
// task panel containment; this window only provides the visible dock.
Window {
id: dockOverlay
visible: ShellSettings.Settings.convergenceModeEnabled
color: "transparent"
width: Screen.width
height: Kirigami.Units.gridUnit * 3
LayerShell.Window.scope: "dock-overlay"
LayerShell.Window.layer: LayerShell.Window.LayerTop
LayerShell.Window.anchors: LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight
LayerShell.Window.exclusionZone: -1
LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityOnDemand
FavouritesBar {
id: dockOverlayBar
anchors.fill: parent
folio: root.folio
maskManager: root.maskManager
homeScreen: folioHomeScreen
}
}
MobileShell.HomeScreen { MobileShell.HomeScreen {
id: homeScreen id: homeScreen
anchors.fill: parent anchors.fill: parent

View file

@ -4,6 +4,7 @@
import QtQuick import QtQuick
import QtQuick.Window import QtQuick.Window
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Templates as T
import org.kde.plasma.components 3.0 as PC3 import org.kde.plasma.components 3.0 as PC3
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
@ -13,6 +14,7 @@ Loader {
active: false active: false
property list<Kirigami.Action> actions property list<Kirigami.Action> actions
property int menuPopupType: T.Popup.Item
function open() { function open() {
root.active = true; root.active = true;
@ -28,7 +30,10 @@ Loader {
sourceComponent: PC3.Menu { sourceComponent: PC3.Menu {
id: menu id: menu
title: "Context Menu" title: "Context Menu"
closePolicy: PC3.Menu.CloseOnReleaseOutside | PC3.Menu.CloseOnEscape popupType: root.menuPopupType
closePolicy: root.menuPopupType === T.Popup.Window
? (PC3.Menu.CloseOnPressOutside | PC3.Menu.CloseOnEscape)
: (PC3.Menu.CloseOnReleaseOutside | PC3.Menu.CloseOnEscape)
Repeater { Repeater {
model: root.actions model: root.actions