Files
T

301 lines
17 KiB
QML

/*
* Plasma Gnome Pager — main.qml
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Root PlasmoidItem: owns the virtual-desktop data source (read) and the KWin DBus helpers (write),
* and renders the dot strip inline in the panel. This is the e2e boundary (not headless-testable).
*/
pragma ComponentBehavior: Bound
import QtQuick
import org.kde.plasma.plasmoid
// Public, stable imports only — never org.kde.plasma.private.* (robustness.md).
import org.kde.plasma.core as PlasmaCore // PlasmaCore.Action + PlasmaCore.Types
import org.kde.taskmanager as TaskManager // VirtualDesktopInfo + TasksModel/ActivityInfo (read)
import org.kde.plasma.workspace.dbus as DBus // KWin DBus (switch/add/remove/rename)
import org.kde.kcmutils as KCM // KCMLauncher — open the system Virtual Desktops settings module
import "logic.js" as Logic
PlasmoidItem {
id: root
Plasmoid.icon: "virtual-desktops" // match metadata.json's Icon (theme-safe Breeze name)
// Passive always-on widget, no background of its own (dots float on the panel — the GNOME look).
Plasmoid.status: PlasmaCore.Types.PassiveStatus
Plasmoid.backgroundHints: PlasmaCore.Types.NoBackground
// Panel orientation, passed DOWN as a plain bool so the sub-components stay Plasmoid-free. Planar/Floating → horizontal.
readonly property bool isVertical: Plasmoid.formFactor === PlasmaCore.Types.Vertical
// Behaviour settings, read live from main.xml. Each `?? Logic.DEFAULTS` guards the transient-undefined frame (undefined → false).
readonly property bool enableScroll: Plasmoid.configuration.enableScroll ?? Logic.DEFAULTS.enableScroll
readonly property bool scrollWrap: Plasmoid.configuration.scrollWrap ?? Logic.DEFAULTS.scrollWrap
readonly property bool invertScroll: Plasmoid.configuration.invertScroll ?? Logic.DEFAULTS.invertScroll
// Action when the current desktop's pill is clicked (default None); see Logic.PILL_CLICK_ACTION.
readonly property int pillClickAction: Plasmoid.configuration.pillClickAction ?? Logic.DEFAULTS.pillClickAction
readonly property bool showTooltips: Plasmoid.configuration.showTooltips ?? Logic.DEFAULTS.showTooltips
readonly property bool showWindowList: Plasmoid.configuration.showWindowList ?? Logic.DEFAULTS.showWindowList
readonly property bool enableAddRemove: Plasmoid.configuration.enableAddRemove ?? Logic.DEFAULTS.enableAddRemove
readonly property bool enableRename: Plasmoid.configuration.enableRename ?? Logic.DEFAULTS.enableRename
// Dynamic workspaces (default OFF): auto-keep one empty trailing desktop; dynamicNamePrefix is the created-desktop base name ("" = "Desktop").
readonly property bool dynamicWorkspaces: Plasmoid.configuration.dynamicWorkspaces ?? Logic.DEFAULTS.dynamicWorkspaces
readonly property string dynamicNamePrefix: Plasmoid.configuration.dynamicNamePrefix ?? Logic.DEFAULTS.dynamicNamePrefix
// Manual Add/Remove only when enabled AND dynamic workspaces off — the two conflict. Reused by the contextualActions below.
readonly property bool canAddRemove: enableAddRemove && !dynamicWorkspaces
// Appearance/animation settings, read the same way. dotSize/pillSize/animationDuration use a 0 = auto sentinel resolved in the indicator/dot.
readonly property int animationDuration: Plasmoid.configuration.animationDuration ?? Logic.DEFAULTS.animationDuration
// Overall pager look (Sliding pill / Filled & ring — see Logic.DOT_STYLE); the indicator neutralizes the pill in ring mode.
readonly property int dotStyle: Plasmoid.configuration.dotStyle ?? Logic.DEFAULTS.dotStyle
// Ignore KWin's grid rows: lay every desktop out in one strip along the panel (a vertical strip on a vertical panel).
readonly property bool singleLine: Plasmoid.configuration.singleLine ?? Logic.DEFAULTS.singleLine
// Vertical panels: lay the grid out in KWin orientation (rows top-to-bottom) instead of transposing it down the panel.
readonly property bool matchDesktopGrid: Plasmoid.configuration.matchDesktopGrid ?? Logic.DEFAULTS.matchDesktopGrid
readonly property int dotSize: Plasmoid.configuration.dotSize ?? Logic.DEFAULTS.dotSize
readonly property int pillSize: Plasmoid.configuration.pillSize ?? Logic.DEFAULTS.pillSize
readonly property real spacingFactor: Plasmoid.configuration.spacingFactor ?? Logic.DEFAULTS.spacingFactor
readonly property real pillWidthFactor: Plasmoid.configuration.pillWidthFactor ?? Logic.DEFAULTS.pillWidthFactor
readonly property real inactiveOpacity: Plasmoid.configuration.inactiveOpacity ?? Logic.DEFAULTS.inactiveOpacity
readonly property real hoverOpacity: Plasmoid.configuration.hoverOpacity ?? Logic.DEFAULTS.hoverOpacity
// Occupied-dot indicator (default OFF): mark desktops that hold windows (reuses the occupancy snapshot below).
// occupancyStyle picks HOW (Filled/InnerDot/Ring — see Logic.OCCUPANCY); occupiedOpacity applies to every style.
readonly property bool showOccupancy: Plasmoid.configuration.showOccupancy ?? Logic.DEFAULTS.showOccupancy
readonly property real occupiedOpacity: Plasmoid.configuration.occupiedOpacity ?? Logic.DEFAULTS.occupiedOpacity
readonly property int occupancyStyle: Plasmoid.configuration.occupancyStyle ?? Logic.DEFAULTS.occupancyStyle
readonly property bool followThemeColors: Plasmoid.configuration.followThemeColors ?? Logic.DEFAULTS.followThemeColors
readonly property color activeColor: Plasmoid.configuration.activeColor ?? Logic.DEFAULTS.activeColor
readonly property color inactiveColor: Plasmoid.configuration.inactiveColor ?? Logic.DEFAULTS.inactiveColor
readonly property color occupiedColor: Plasmoid.configuration.occupiedColor ?? Logic.DEFAULTS.occupiedColor
// A pager renders inline. Plasma instantiates NO representation unless a fullRepresentation exists, so
// the dot strip IS the full representation, forced inline via preferredRepresentation.
preferredRepresentation: fullRepresentation
fullRepresentation: WorkspaceIndicator {
vertical: root.isVertical
virtualDesktopInfo: vdi
enableScroll: root.enableScroll
scrollWrap: root.scrollWrap
invertScroll: root.invertScroll
showTooltips: root.showTooltips
desktopTooltips: root.desktopTooltips
dotStyle: root.dotStyle
singleLine: root.singleLine
matchDesktopGrid: root.matchDesktopGrid
// dotSize/pillSize passed as raw 0=auto requests; resolved in the indicator (pillSize 0 = match dots).
dotSizeRequest: root.dotSize
pillSizeRequest: root.pillSize
spacingFactor: root.spacingFactor
pillWidthFactor: root.pillWidthFactor
inactiveOpacity: root.inactiveOpacity
hoverOpacity: root.hoverOpacity
showOccupancy: root.showOccupancy
desktopOccupancy: root.screenOccupancy // PER-SCREEN: only mark dots for windows on THIS pager's monitor
occupiedOpacity: root.occupiedOpacity
occupancyStyle: root.occupancyStyle
followThemeColors: root.followThemeColors
activeColor: root.activeColor
inactiveColor: root.inactiveColor
occupiedColor: root.occupiedColor
animationDuration: root.animationDuration
onSwitchRequested: uuid => root.switchTo(uuid)
// Clicking the current desktop's pill: dispatch the configured action (pillClickSpec is null for None → no-op).
onActiveClicked: root.dispatch(Logic.pillClickSpec(root.pillClickAction))
}
// Reactive read-only desktop state — bind, never cache; updates on ANY change. Writes go through KWin DBus below.
TaskManager.VirtualDesktopInfo {
id: vdi
}
// Per-desktop tooltip subText (rich-text window list), gated by showTooltips && showWindowList INDEPENDENTLY of the Loader.
readonly property var desktopTooltips: (root.showTooltips && root.showWindowList && tooltipLoader.item)
? (tooltipLoader.item as WindowAggregator).desktopTooltips : []
// GLOBAL per-desktop occupancy boolean[] from the snapshot, consumed by the dynamic-workspaces controller (the desktop SET is global). Empty [] when the Loader is inactive.
readonly property var desktopOccupancy: tooltipLoader.item ? (tooltipLoader.item as WindowAggregator).desktopOccupancy : []
// PER-SCREEN per-desktop occupancy (this monitor only) from the SAME snapshot, consumed by the occupied-dot indicator. Empty [] when the Loader is inactive.
readonly property var screenOccupancy: tooltipLoader.item ? (tooltipLoader.item as WindowAggregator).screenOccupancy : []
// This pager's output rect, read from the placed representation (Screen.* is only valid on the on-screen item).
// Empty until placed → per-screen occupancy degrades to global. Cast mirrors `tooltipLoader.item as WindowAggregator`.
readonly property rect screenRect: root.fullRepresentationItem
? (root.fullRepresentationItem as WorkspaceIndicator).screenRect : Qt.rect(0, 0, 0, 0)
// The window-list machinery lives behind a Loader (zero cost when unused). Needed by the tooltip list, dynamic workspaces, OR the occupied-dot indicator — gate is the OR.
Loader {
id: tooltipLoader
active: (root.showTooltips && root.showWindowList) || root.dynamicWorkspaces || root.showOccupancy
sourceComponent: aggregatorComponent
}
Component {
id: aggregatorComponent
WindowAggregator {
virtualDesktopInfo: vdi // inject the read source (the aggregator is data-source-agnostic)
screenRect: root.screenRect // inject this pager's output rect (drives per-screen occupancy)
// Each feature gate is injected as a plain bool so the aggregator computes ONLY the reductions an
// active consumer reads (an off feature's array stays []). windowListActive also trims tooltip-only roles.
windowListActive: root.showTooltips && root.showWindowList
occupancyActive: root.showOccupancy // per-screen occupancy → occupied-dot indicator
dynamicActive: root.dynamicWorkspaces // global occupancy → dynamic-workspaces controller
}
}
// Every desktop write goes through KWin's VirtualDesktopManager. The CALL SHAPES live in logic.js's
// *Spec builders; here we only DISPATCH a built spec (async fire-and-forget). A null spec is a no-op.
function dispatch(spec) {
if (!spec) {
return;
}
// Async fire-and-forget — VirtualDesktopInfo reflects the resulting state, not a return value.
// We capture the reply ONLY to warn on a rejected call (the silent-DBus-drop class this widget avoids).
const reply = DBus.SessionBus.asyncCall({
"service": spec.service,
"path": spec.path,
"iface": spec.iface,
"member": spec.member,
"arguments": spec.args.map(a => root.toDBusArg(a))
});
if (reply) {
reply.finished.connect(() => {
if (reply.isError) {
console.warn("plasma-gnome-pager: DBus call failed:", spec.member, "-", reply.error.name, reply.error.message);
}
});
}
}
// Map ONE spec arg { t, v } to its DBus.* constructor. The "v" case wraps a PLAIN value — a wrapped DBus.string is silently rejected by KWin.
function toDBusArg(a) {
switch (a.t) {
case "s":
return new DBus.string(a.v);
case "u":
return new DBus.uint32(a.v);
case "i":
return new DBus.int32(a.v);
case "v":
return new DBus.variant(a.v);
default:
// Unknown type letter: warn LOUDLY rather than fail silently (the silent-DBus-drop class this widget avoids).
console.warn("plasma-gnome-pager: toDBusArg got unknown DBus type letter", a.t);
return a.v;
}
}
function switchTo(uuid) {
root.dispatch(Logic.switchSpec(uuid));
}
// Append a new desktop at the end. `?? 0` keeps a transient-undefined count out of the uint32 (i18n label stays here).
function addDesktop() {
root.dispatch(Logic.addSpec(vdi.numberOfDesktops ?? 0, i18n("New Desktop")));
}
// Remove a desktop by UUID. removeSpec enforces never-remove-last (returns null).
function removeDesktop(uuid) {
root.dispatch(Logic.removeSpec(uuid, vdi.numberOfDesktops));
}
// "Remove" targets the last desktop (the one addDesktop appended).
function removeLastDesktop() {
root.removeDesktop(Logic.lastDesktopId(vdi.desktopIds));
}
// Dynamic workspaces (GNOME-style): one GLOBAL behaviour keeping an empty trailing desktop. The non-visual controller emits the two signals below.
DynamicWorkspacesController {
id: dynamicController
dynamicEnabled: root.dynamicWorkspaces
namePrefix: root.dynamicNamePrefix
// i18n default base name, passed IN so the controller stays i18n-free (never empty — KWin drops createDesktop on an empty name).
defaultPrefix: i18nc("@info default base name for auto-created virtual desktops", "Desktop")
virtualDesktopInfo: vdi
desktopOccupancy: root.desktopOccupancy
onDispatchRequested: spec => root.dispatch(spec)
onSyncConfigRequested: (nextEnabled, nextPrefix) => {
// Mirror the global setting into this instance's persisted config (value-guarded so sync→onChanged→publish can't loop).
if (Plasmoid.configuration.dynamicWorkspaces !== nextEnabled)
Plasmoid.configuration.dynamicWorkspaces = nextEnabled;
if (Plasmoid.configuration.dynamicNamePrefix !== nextPrefix)
Plasmoid.configuration.dynamicNamePrefix = nextPrefix;
}
}
// Rename a desktop via KWin setDesktopName. renameSpec sanitizes and rejects empty (null → no-op); `vdi` reports the new name.
function renameDesktop(uuid, name) {
root.dispatch(Logic.renameSpec(uuid, name));
}
// Open the rename prompt prefilled with the desktop's current name (resolved from the live vdi arrays, guarded for the transient frame).
function openRenameDialog(uuid) {
if (!uuid) {
return;
}
const ids = vdi.desktopIds ?? [];
const names = vdi.desktopNames ?? [];
renameDialog.openFor(uuid, names[ids.indexOf(uuid)] ?? "");
}
// Open the system "Virtual Desktops" settings module (like the stock pager). NOT a DBus write — an imperative
// GUI launch via the public KCMLauncher, so it lives here, not in logic.js. Module name is platform-branched.
function openVirtualDesktopsKcm() {
KCM.KCMLauncher.openSystemSettings(Qt.platform.pluginName.includes("wayland")
? "kcm_kwin_virtualdesktops"
: "kcm_kwin_virtualdesktops_x11");
}
// Right-click menu. Add/Remove gated by canAddRemove (they conflict with dynamic workspaces); Remove
// also disables at the last desktop. "Configure Virtual Desktops…" is always shown and opens the SYSTEM
// KCM (distinct from "Configure Workspaces…", which Plasma auto-adds for THIS widget's own settings).
Plasmoid.contextualActions: [
PlasmaCore.Action {
text: i18n("Add Desktop")
icon.name: "list-add"
priority: Plasmoid.LowPriorityAction
visible: root.canAddRemove
enabled: root.canAddRemove
onTriggered: root.addDesktop()
},
PlasmaCore.Action {
text: i18n("Remove Last Desktop")
icon.name: "list-remove"
priority: Plasmoid.LowPriorityAction
visible: root.canAddRemove
enabled: root.canAddRemove && Logic.canRemoveDesktop(vdi.numberOfDesktops)
onTriggered: root.removeLastDesktop()
},
PlasmaCore.Action {
text: i18n("Rename Current Desktop…")
icon.name: "edit-rename"
priority: Plasmoid.LowPriorityAction
visible: root.enableRename
enabled: root.enableRename
onTriggered: root.openRenameDialog(vdi.currentDesktop)
},
PlasmaCore.Action {
text: i18n("Configure Virtual Desktops…")
icon.name: "preferences-desktop-virtual" // the icon the Virtual Desktops KCM itself uses
priority: Plasmoid.LowPriorityAction
onTriggered: root.openVirtualDesktopsKcm()
}
]
// Rename prompt — the view lives in RenameDialog.qml; here we only place it and turn accepted() into the KWin write.
RenameDialog {
id: renameDialog
visualParent: root.fullRepresentationItem
location: Plasmoid.location
onAccepted: (uuid, name) => root.renameDesktop(uuid, name)
}
}