plasmoides KDE Store/GitHub (gnome-pager, kMenu, kvitals, windowtitle, claude, launchpad)

This commit is contained in:
Josevi
2026-07-08 20:47:59 +02:00
parent ca606377ee
commit dd3b0f6ae8
136 changed files with 20239 additions and 0 deletions
@@ -0,0 +1,23 @@
/*
* Plasma Gnome Pager — config.qml
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The settings dialog's category list. ConfigCategory.source resolves relative to contents/ui/, so the
* pages live in contents/ui/config/ while this file + main.xml live in contents/config/ (plasmoid.md).
*/
import org.kde.plasma.configuration
ConfigModel {
ConfigCategory {
name: i18n("Behavior")
icon: "preferences-system-windows-actions"
source: "config/ConfigGeneral.qml"
}
ConfigCategory {
name: i18n("Appearance")
icon: "preferences-desktop-color"
source: "config/ConfigAppearance.qml"
}
}
@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Plasma Gnome Pager — configuration schema (KConfigXT)
SPDX-FileCopyrightText: 2026 Kenan Salar
SPDX-License-Identifier: GPL-3.0-or-later
Each <entry name="X"> becomes plasmoid.configuration.X, read live in main.qml (with a `?? <default>`
guard) and passed down as a plain value. Three keys use a `0 = auto` sentinel — their natural default is
theme/HiDPI-derived, which a fixed KConfigXT literal can't hold, so it's resolved in the widget: dotSize,
pillSize (→ match the dots), animationDuration (→ Kirigami.Units.longDuration). See CLAUDE.md "Config flow".
-->
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name=""/>
<group name="General">
<!-- Scroll over the strip to switch desktops. -->
<entry name="enableScroll" type="Bool">
<default>true</default>
</entry>
<!-- When scrolling past the first/last desktop, wrap around (else clamp). -->
<entry name="scrollWrap" type="Bool">
<default>false</default>
</entry>
<!-- Invert the scroll direction (wheel up → next desktop instead of previous). -->
<entry name="invertScroll" type="Bool">
<default>false</default>
</entry>
<!-- What clicking the ALREADY-CURRENT desktop does. Index mirrors Logic.PILL_CLICK_ACTION + the combo
order: 0 = None, 1 = Show Desktop, 2 = Overview, 3 = Grid. Clicking an inactive dot still switches. -->
<entry name="pillClickAction" type="Int">
<default>0</default>
</entry>
<!-- Show the desktop name in a tooltip on hover. -->
<entry name="showTooltips" type="Bool">
<default>true</default>
</entry>
<!-- Also list the windows open on a desktop in its tooltip (needs showTooltips). -->
<entry name="showWindowList" type="Bool">
<default>true</default>
</entry>
<!-- Offer Add/Remove desktop entries in the right-click menu. -->
<entry name="enableAddRemove" type="Bool">
<default>true</default>
</entry>
<!-- Offer a "Rename Current Desktop…" entry in the right-click menu. -->
<entry name="enableRename" type="Bool">
<default>true</default>
</entry>
<!-- GNOME-style dynamic workspaces: auto-keep exactly one empty trailing desktop. -->
<entry name="dynamicWorkspaces" type="Bool">
<default>false</default>
</entry>
<!-- Base name for auto-created desktops ("<prefix> N"); empty = the localized default "Desktop". -->
<entry name="dynamicNamePrefix" type="String">
<default></default>
</entry>
<!-- Morph animation length in ms; 0 = follow the theme (Kirigami.Units.longDuration). -->
<entry name="animationDuration" type="Int">
<default>0</default>
</entry>
</group>
<group name="Appearance">
<!-- Overall pager look. Index mirrors Logic.DOT_STYLE + the combo order:
0 = Sliding pill (GNOME reflow), 1 = Filled & ring (no pill; current = filled dot, others = hollow rings). -->
<entry name="dotStyle" type="Int">
<default>0</default>
</entry>
<!-- Ignore KWin's virtual-desktop grid rows and lay every desktop out in ONE line. Off = honour the grid
(mirror "Rows" from System Settings). Orthogonal to matchDesktopGrid (which sets the direction): single
line alone is a vertical strip on a vertical panel; single line + matchDesktopGrid is one horizontal row. -->
<entry name="singleLine" type="Bool">
<default>false</default>
</entry>
<!-- Vertical panels: run the layout ACROSS the panel instead of down it — for a multi-row grid this mirrors
KWin's orientation (rows top-to-bottom); with singleLine it makes the one line horizontal. Off = the
GNOME-reflow look (run down the panel). No effect on a horizontal panel (already runs across). -->
<entry name="matchDesktopGrid" type="Bool">
<default>false</default>
</entry>
<!-- Inactive-dot diameter in px; 0 = auto (Kirigami.Units.iconSizes.small / 2, HiDPI-aware). -->
<entry name="dotSize" type="Int">
<default>0</default>
</entry>
<!-- Active-pill thickness in px; 0 = auto (match the dot size). Sized independently of the dots. -->
<entry name="pillSize" type="Int">
<default>0</default>
</entry>
<!-- Uniform gap between elements, as a multiple of the dot size (GNOME-tight at 0.5). -->
<entry name="spacingFactor" type="Double">
<default>0.5</default>
</entry>
<!-- Active-capsule length, as a multiple of the pill thickness (its aspect ratio). -->
<entry name="pillWidthFactor" type="Double">
<default>3.5</default>
</entry>
<!-- Opacity of an inactive (dim) dot. -->
<entry name="inactiveOpacity" type="Double">
<default>0.45</default>
</entry>
<!-- Opacity an inactive dot brightens to on hover. -->
<entry name="hoverOpacity" type="Double">
<default>0.8</default>
</entry>
<!-- Brighten the dots of desktops that hold windows (occupied), so they stand out from empty ones. -->
<entry name="showOccupancy" type="Bool">
<default>false</default>
</entry>
<!-- Opacity of the occupied marker (every occupancyStyle uses it; only when showOccupancy is on). -->
<entry name="occupiedOpacity" type="Double">
<default>0.7</default>
</entry>
<!-- How an occupied dot is marked (showOccupancy on). Index mirrors Logic.OCCUPANCY + the combo order:
0 = Filled (whole dot), 1 = Inner dot, 2 = Hollow ring. All use occupiedOpacity + the occupied colour. -->
<entry name="occupancyStyle" type="Int">
<default>0</default>
</entry>
<!-- Follow the color scheme; when false, use activeColor / inactiveColor / occupiedColor below. -->
<entry name="followThemeColors" type="Bool">
<default>true</default>
</entry>
<!-- Custom active-capsule colour (used only when followThemeColors is false). -->
<entry name="activeColor" type="Color">
<default>#3daee9</default>
</entry>
<!-- Custom inactive-dot colour (used only when followThemeColors is false). -->
<entry name="inactiveColor" type="Color">
<default>#eff0f1</default>
</entry>
<!-- Custom occupied-marker colour (used only when followThemeColors is false; else the theme accent). -->
<entry name="occupiedColor" type="Color">
<default>#3daee9</default>
</entry>
</group>
</kcfg>
@@ -0,0 +1,117 @@
/*
* Plasma Gnome Pager — DynamicWorkspacesController.qml
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* GNOME-style dynamic-workspaces controller — a non-visual Item (QtQuick + pure .js only, headless-tested).
* When enabled, keep one empty trailing desktop via ONE KWin add/remove per cycle. The desktop SET is
* global, so coordinator.js does setting SYNC + single-WRITER election (else panels double-add → a flash).
*/
pragma ComponentBehavior: Bound
import QtQuick
import "logic.js" as Logic
import "coordinator.js" as Coordinator // single-writer election + prefix sync across instances
Item {
id: controller
// Inputs (injected by main.qml). dynamicEnabled (not `enabled` — QQuickItem already defines that).
property bool dynamicEnabled: false
property string namePrefix: "" // base name for auto-created desktops ("" = defaultPrefix)
property string defaultPrefix: "Desktop" // i18n default, passed IN so this stays i18n-free; never empty (KWin drops empty-name createDesktop)
property var virtualDesktopInfo: null // the read source (null-safe throughout — transiently absent)
// Per-desktop occupancy bool[], index-aligned with desktopIds. The length guard in dynamicWorkspacePlan
// makes a transient frame (occupancy still lagging a just-changed desktop set) a no-op until it catches up.
property var desktopOccupancy: []
// Outputs (the two things only main.qml's e2e boundary can do).
signal dispatchRequested(var spec) // a built KWin add/remove spec → root.dispatch(spec)
signal syncConfigRequested(bool nextEnabled, string nextPrefix) // mirror the global setting into persisted config
// Internal state.
property bool dynBusy: false
property int dynToken: 0
// Liveness fallback only — the real lock-clear is vdi.desktopIdsChanged below.
readonly property int busyFallbackMs: 750
Timer {
id: dynBusyTimer
interval: controller.busyFallbackMs
onTriggered: controller.dynBusy = false
}
// Join the coordinator (syncConfigRequested is the push channel). Adopt the global if a sibling seeded
// it, else seed it from our value, then evaluate once. Leave on teardown (stop counting in the election).
Component.onCompleted: {
controller.dynToken = Coordinator.join((en, pf) => controller.syncConfigRequested(en, pf));
if (Coordinator.haveGlobal())
controller.syncConfigRequested(Coordinator.globalEnabled(), Coordinator.globalPrefix());
else
Coordinator.publish(controller.dynamicEnabled, controller.namePrefix);
controller.scheduleDynamic();
}
Component.onDestruction: Coordinator.leave(controller.dynToken)
// Our setting changed: if it differs from the global WE changed it → publish to every panel; else it's
// a sync echo → just re-evaluate. Guard the pre-join window (dynToken 0): config onChanged fires before
// Component.onCompleted, and publishing then registers a phantom value that stalls the real writer.
function publishDynamicConfig() {
if (controller.dynToken === 0)
return;
if (!Coordinator.haveGlobal()
|| controller.dynamicEnabled !== Coordinator.globalEnabled()
|| controller.namePrefix !== Coordinator.globalPrefix())
Coordinator.publish(controller.dynamicEnabled, controller.namePrefix);
controller.scheduleDynamic();
}
// Coalesce a burst of occupancy / desktop-set changes into ONE evaluation next tick.
function scheduleDynamic() {
if (!controller.dynamicEnabled)
return;
Qt.callLater(controller.evaluateDynamic);
}
// Compute and dispatch the single action for the freshest state, or nothing. Only the elected writer acts (panels never double-add).
function evaluateDynamic() {
if (!controller.dynamicEnabled || controller.dynBusy)
return;
if (!Coordinator.isWriter(controller.dynToken))
return; // another instance is the single global writer
const ids = controller.virtualDesktopInfo?.desktopIds ?? [];
const plan = Logic.dynamicWorkspacePlan(controller.desktopOccupancy, ids);
if (!plan)
return;
let spec = null;
if (plan.kind === "add") {
// Name "<prefix> N" (prefix synced across panels); position == current count (append), so the number is pos + 1.
const pos = controller.virtualDesktopInfo?.numberOfDesktops ?? ids.length;
spec = Logic.addSpec(pos, Logic.formatDynamicDesktopName(controller.namePrefix, pos + 1, controller.defaultPrefix));
} else if (plan.kind === "remove") {
const count = controller.virtualDesktopInfo?.numberOfDesktops ?? ids.length;
spec = Logic.removeSpec(plan.uuid, count);
}
if (!spec)
return;
controller.dynBusy = true;
controller.dispatchRequested(spec);
dynBusyTimer.restart();
}
// Triggers: occupancy flips, and our own setting changing (routed through publishDynamicConfig so the global syncs before we act).
onDesktopOccupancyChanged: controller.scheduleDynamic()
onDynamicEnabledChanged: controller.publishDynamicConfig()
onNamePrefixChanged: controller.publishDynamicConfig()
// The desktop SET changing is also the signal that OUR add/remove landed → clear the lock and re-evaluate (multi-step trim converges over cycles).
Connections {
target: controller.virtualDesktopInfo
function onDesktopIdsChanged() {
controller.dynBusy = false;
controller.scheduleDynamic();
}
}
}
@@ -0,0 +1,57 @@
/*
* Plasma Gnome Pager — IndicatorMetrics.qml
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Non-visual dot-strip sizing engine (unit-tested by tst_indicatormetrics). EFFECTIVE sizes shrink to fit
* a crowded panel (floored at minDotSize); NATURAL/floor extents (the Layout hints) depend only on
* requests/grid, never on geometry — keep that split or you get a binding loop.
*/
pragma ComponentBehavior: Bound
import QtQuick
import org.kde.kirigami as Kirigami
import "logic.js" as Logic
QtObject {
id: metrics
// Inputs (bound by WorkspaceIndicator).
property int dotSizeRequest: Logic.DEFAULTS.dotSize // px; 0 = auto (HiDPI themed)
property int pillSizeRequest: Logic.DEFAULTS.pillSize // px pill thickness; 0 = auto (match dots)
property real spacingFactor: Logic.DEFAULTS.spacingFactor
property real pillWidthFactor: Logic.DEFAULTS.pillWidthFactor // pill length / pill thickness
property real availableMajor: 0 // live panel allocation along the line axis (0 before layout)
property real availableCross: 0 // live panel allocation across the stacked lines
property int perLine: 0 // desktops per line (KWin columns)
property int lineCount: 0 // stacked lines (KWin rows)
// Natural / floor — geometry-INDEPENDENT, drive the Layout hints (no loop).
readonly property real naturalDotSize: dotSizeRequest > 0 ? dotSizeRequest : Kirigami.Units.iconSizes.small / 2
readonly property real naturalPillSize: pillSizeRequest > 0 ? pillSizeRequest : naturalDotSize
readonly property real pillThicknessRatio: naturalPillSize / naturalDotSize // pill thickness in dot units; carries the dot⇄pill decoupling
readonly property real minDotSize: Math.min(naturalDotSize, Kirigami.Units.iconSizes.small / 4) // legibility floor, clamped ≤ natural
readonly property real naturalStripLength: Logic.lineExtent(perLine, naturalDotSize, naturalDotSize * spacingFactor, naturalPillSize * pillWidthFactor)
readonly property real floorStripLength: Logic.lineExtent(perLine, minDotSize, minDotSize * spacingFactor, minDotSize * pillThicknessRatio * pillWidthFactor)
readonly property real naturalCrossThickness: Logic.lineExtent(lineCount, naturalDotSize, naturalDotSize * spacingFactor, Math.max(naturalDotSize, naturalPillSize))
readonly property real floorCrossThickness: Logic.lineExtent(lineCount, minDotSize, minDotSize * spacingFactor, minDotSize * Math.max(1, pillThicknessRatio))
// Effective — geometry-DEPENDENT rendered sizes (read by each WorkspaceDot). fitDotSize is the
// inverse of lineExtent; +Infinity on an unconstrained axis, so min() picks the binding axis.
readonly property real majorFitDotSize: Logic.fitDotSize(availableMajor, perLine, pillThicknessRatio * pillWidthFactor, spacingFactor)
readonly property real crossFitDotSize: Logic.fitDotSize(availableCross, lineCount, Math.max(1, pillThicknessRatio), spacingFactor)
readonly property real fitDotSize: Math.min(majorFitDotSize, crossFitDotSize)
readonly property real dotSize: Math.max(minDotSize, Math.min(naturalDotSize, fitDotSize)) // shrink-to-fit, capped natural, floored minDotSize
readonly property real pillSize: dotSize * pillThicknessRatio // scales in lockstep with the dot
readonly property real pillWidth: pillSize * pillWidthFactor // active capsule LENGTH (major axis)
readonly property real dotSpacing: dotSize * spacingFactor // uniform gap between every element
// Conserved (capsule-bearing) line extents — the length/thickness of a line that HOLDS the capsule, which
// is the MAX over all lines and is independent of the morph progress. The indicator pins the strip to these
// so a cross-row morph can't resize+recenter it (else the dots "breathe"/drift). Effective (post-scale-to-fit).
// Assumes pillWidth >= dotSize, the same assumption naturalStripLength already makes.
readonly property real stripLength: Logic.lineExtent(perLine, dotSize, dotSpacing, pillWidth)
readonly property real crossThickness: Logic.lineExtent(lineCount, dotSize, dotSpacing, Math.max(dotSize, pillSize))
}
@@ -0,0 +1,80 @@
/*
* Plasma Gnome Pager — RenameDialog.qml
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Rename prompt — a panel-native PlasmaCore.Dialog (top-level Window), declared directly. NOT
* Kirigami.PromptDialog, whose base parents to applicationWindow().overlay (undefined in a plasmoid → it
* would clip to the thin panel). View only: the parent owns visualParent/location and the DBus write.
*/
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.components as PlasmaComponents3
import org.kde.kirigami as Kirigami
import "logic.js" as Logic
PlasmaCore.Dialog {
id: renameDialog
property string targetUuid: ""
signal accepted(string uuid, string name)
visible: false
hideOnWindowDeactivate: true // click-away cancels
// Prefill with the current name, select-all and focus for immediate typing.
function openFor(uuid, currentName) {
renameDialog.targetUuid = uuid;
renameField.text = currentName;
renameDialog.visible = true;
renameField.selectAll();
renameField.forceActiveFocus();
}
// Sanitize then emit. An empty/whitespace name (sanitize → "") keeps the prompt open; the parent re-sanitizes, so the write stays guarded.
function commit() {
const clean = Logic.sanitizeDesktopName(renameField.text);
if (clean === "") {
return;
}
renameDialog.accepted(renameDialog.targetUuid, clean);
renameDialog.visible = false;
}
mainItem: ColumnLayout {
spacing: Kirigami.Units.smallSpacing
PlasmaComponents3.Label {
text: i18n("Rename desktop:")
}
PlasmaComponents3.TextField {
id: renameField
Layout.fillWidth: true
Layout.minimumWidth: Kirigami.Units.gridUnit * 12
onAccepted: renameDialog.commit()
Keys.onEscapePressed: renameDialog.visible = false
}
RowLayout {
Layout.alignment: Qt.AlignRight
spacing: Kirigami.Units.smallSpacing
PlasmaComponents3.Button {
text: i18n("Cancel")
icon.name: "dialog-cancel"
onClicked: renameDialog.visible = false
}
PlasmaComponents3.Button {
text: i18n("Rename")
icon.name: "edit-rename"
enabled: renameField.text.trim().length > 0
onClicked: renameDialog.commit()
}
}
}
}
@@ -0,0 +1,60 @@
/*
* Plasma Gnome Pager — ScreenCurrentDesktop.qml
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Resolves the current desktop FOR ONE SCREEN (Plasma 6.7 per-output; unit-tested by tst_screencurrentdesktop).
* currentDesktopByScreenName is a METHOD + SIGNAL, not a notifying property, so currentDesktop is recomputed
* imperatively (a plain binding evaluates once); the prefer-per-screen decision is Logic.resolveCurrentDesktop.
*/
pragma ComponentBehavior: Bound
import QtQuick
import "logic.js" as Logic
Item {
id: resolver
// Inputs (injected by WorkspaceIndicator).
property var virtualDesktopInfo: null
property string screenName: ""
// Output: the current-desktop UUID for screenName (the global current when there is no per-screen info).
property string currentDesktop: ""
function updateCurrentDesktop() {
const vdi = resolver.virtualDesktopInfo;
const globalCurrent = vdi?.currentDesktop ?? "";
let perScreen; // stays undefined unless we have a screen AND the 6.7 API
if (vdi && resolver.screenName && typeof vdi.currentDesktopByScreenName === "function")
perScreen = vdi.currentDesktopByScreenName(resolver.screenName);
resolver.currentDesktop = Logic.resolveCurrentDesktop(perScreen, globalCurrent);
}
// Recompute on every external change ("bind, don't cache"). The always-present signals keep their own
// block (no ignoreUnknownSignals) so a typo here still warns.
Connections {
target: resolver.virtualDesktopInfo
function onCurrentDesktopChanged() {
resolver.updateCurrentDesktop();
}
function onDesktopIdsChanged() {
resolver.updateCurrentDesktop();
}
}
// The per-screen current signal is Plasma 6.7+ only; on 6.5/6.6 it is absent, so ignoreUnknownSignals
// keeps the Connections quiet — the resolver already degrades to the global current (typeof guard above).
Connections {
target: resolver.virtualDesktopInfo
ignoreUnknownSignals: true
function onCurrentDesktopForScreenChanged(screenName) {
if (screenName === resolver.screenName)
resolver.updateCurrentDesktop();
}
}
onScreenNameChanged: resolver.updateCurrentDesktop()
onVirtualDesktopInfoChanged: resolver.updateCurrentDesktop()
Component.onCompleted: resolver.updateCurrentDesktop()
}
@@ -0,0 +1,209 @@
/*
* Plasma Gnome Pager — WindowAggregator.qml
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The window aggregator — a non-visual Item. ONE unfiltered public TasksModel feeds the tooltip window
* list, GLOBAL dynamic-workspace occupancy, and the PER-SCREEN occupied-dot indicator from a single
* snapshot via pure JS. The desktop set is INJECTED as `virtualDesktopInfo` and this pager's output rect
* as `screenRect`; i18n + HTML formatting stays here (logic.js is i18n-free), so e2e-only.
*/
pragma ComponentBehavior: Bound
import QtQuick
import QtQml // Instantiator (materialise TasksModel rows)
import org.kde.taskmanager as TaskManager // TasksModel/ActivityInfo (read)
import "logic.js" as Logic
Item {
id: aggregator
// The read source (a VirtualDesktopInfo), injected by main.qml. Null-safe throughout (transiently absent).
property var virtualDesktopInfo: null
// The per-dot window-list tooltip wanted? Injected as (showTooltips && showWindowList). When false the
// aggregator is live only for occupancy, skipping tooltip work (drops title/minimise roles; empty tooltips).
property bool windowListActive: true
// Per-screen occupied-dot indicator wanted? Injected as showOccupancy. When false the per-screen occupancy
// reduction (and the ScreenGeometry role that feeds it) is skipped entirely (screenOccupancy stays []).
property bool occupancyActive: false
// Dynamic workspaces wanted? Injected as dynamicWorkspaces. When false the GLOBAL occupancy reduction (whose
// sole consumer is the controller) is skipped entirely (desktopOccupancy stays []).
property bool dynamicActive: false
// This pager's output rect (global compositor space), injected by main.qml from the placed representation's
// Screen attached property. A zero rect → per-screen occupancy degrades to global (see computeDesktopOccupancyForScreen).
property rect screenRect: Qt.rect(0, 0, 0, 0)
property var desktopTooltips: []
property var desktopOccupancy: [] // GLOBAL (all monitors) — consumed by the dynamic-workspaces controller
property var screenOccupancy: [] // PER-SCREEN (this monitor only) — consumed by the occupied-dot indicator
// One materialised TasksModel row, a NAMED inline component so objectAt(i) can be `as`-cast for typed role access (capitalised roles read off `model`).
component WindowRow: QtObject {
required property var model
required property string display // window title (Qt::DisplayRole)
readonly property var windowDesktops: model.VirtualDesktops
readonly property bool onAllDesktops: model.IsOnAllVirtualDesktops
readonly property bool minimized: model.IsMinimized
readonly property bool isWindow: model.IsWindow // false for launchers / startup tasks
readonly property bool skipPager: model.SkipPager // hidden from pagers — never occupies a desktop
readonly property rect windowScreen: model.ScreenGeometry // rect of the OUTPUT this window is on (per-screen occupancy)
}
TaskManager.ActivityInfo {
id: activityInfo
}
TaskManager.TasksModel {
id: tasksModel
groupMode: TaskManager.TasksModel.GroupDisabled
filterByVirtualDesktop: false
filterByActivity: true
activity: activityInfo.currentActivity
}
// The role ints rebuild() reads (PUBLIC enum); other roles are skipped (notably the IsActive focus churn, so
// window-focus changes never wake a rebuild). Built per ACTIVE feature, so an off feature's role can't trigger
// work nothing consumes: VirtualDesktops/IsOnAllVirtualDesktops/IsWindow gate desktop membership (every
// consumer); title + IsMinimized are tooltip-only; SkipPager is occupancy-only (excluded windows); and
// ScreenGeometry is PER-SCREEN-occupancy-only (so monitor-moves don't rebuild when that indicator is off).
readonly property var relevantRoles: {
var roles = [
TaskManager.AbstractTasksModel.VirtualDesktops,
TaskManager.AbstractTasksModel.IsOnAllVirtualDesktops,
TaskManager.AbstractTasksModel.IsWindow
];
if (aggregator.windowListActive)
roles.push(Qt.DisplayRole, TaskManager.AbstractTasksModel.IsMinimized);
if (aggregator.occupancyActive || aggregator.dynamicActive)
roles.push(TaskManager.AbstractTasksModel.SkipPager); // occupancy excludes skip-pager windows
if (aggregator.occupancyActive)
roles.push(TaskManager.AbstractTasksModel.ScreenGeometry); // per-screen occupancy needs the window's output
return roles;
}
// Materialise the rows so objectAt(i) can read role values by name (a C++ model has no model.get(i)).
// Row add/remove triggers a rebuild; role-value changes arrive via the model's dataChanged below.
Instantiator {
id: winInstantiator
model: tasksModel
delegate: WindowRow {}
onObjectAdded: aggregator.scheduleRebuild()
onObjectRemoved: aggregator.scheduleRebuild()
}
// Rebuild on a relevant role change, a full reset, or a desktop-SET change. All funnel through the debounced scheduleRebuild.
Connections {
target: tasksModel
function onDataChanged(topLeft, bottomRight, roles) {
if (Logic.dataChangeAffectsRoles(roles, aggregator.relevantRoles))
aggregator.scheduleRebuild();
}
function onModelReset() {
aggregator.scheduleRebuild();
}
}
Connections {
target: aggregator.virtualDesktopInfo
function onDesktopIdsChanged() {
aggregator.scheduleRebuild();
}
}
// Coalesce a burst of change signals into ONE rebuild per frame. No loop: rows read the model, rebuild() writes, the dots only read.
function scheduleRebuild() {
Qt.callLater(aggregator.rebuild);
}
// Snapshot the materialised rows into a plain JS array, group per desktop (pure logic.js), then format each
// summary. `as WindowRow` gives typed access; normalise VirtualDesktops to plain strings (variant wrappers).
function rebuild() {
let windows = [];
for (let i = 0; i < winInstantiator.count; ++i) {
const o = winInstantiator.objectAt(i) as WindowRow;
if (!o)
continue;
windows.push({
title: o.display || "",
minimized: o.minimized,
onAll: o.onAllDesktops,
isWindow: o.isWindow,
skipPager: o.skipPager,
desktops: (o.windowDesktops || []).map(x => String(x)),
// Plain {x,y,width,height} (a QRect role isn't a plain JS value); null when absent → counts on every screen.
screen: o.windowScreen ? { x: o.windowScreen.x, y: o.windowScreen.y, width: o.windowScreen.width, height: o.windowScreen.height } : null
});
}
const ids = aggregator.virtualDesktopInfo?.desktopIds ?? [];
// Three reductions of the SAME snapshot. Compare-before-assign on EACH (arraysShallowEqual) avoids waking downstream on an unchanged array.
const tooltips = aggregator.windowListActive
? Logic.groupWindowsByDesktop(windows, ids).map(aggregator.formatSubText)
: [];
if (!Logic.arraysShallowEqual(tooltips, aggregator.desktopTooltips))
aggregator.desktopTooltips = tooltips;
// GLOBAL occupancy (all monitors) for the dynamic-workspaces controller — the desktop SET is global.
// Skipped (→ []) unless dynamic workspaces is on (its sole consumer); compare-before-assign as below.
const occupancy = aggregator.dynamicActive ? Logic.computeDesktopOccupancy(windows, ids) : [];
if (!Logic.arraysShallowEqual(occupancy, aggregator.desktopOccupancy))
aggregator.desktopOccupancy = occupancy;
// PER-SCREEN occupancy (this monitor only) for the occupied-dot indicator. Skipped (→ []) unless that
// indicator is on. A zero screenRect degrades to global, so on a single monitor it equals the global array
// — reuse that array directly when dynamic already computed it, instead of recomputing the same thing.
let screenOcc = [];
if (aggregator.occupancyActive) {
screenOcc = (aggregator.dynamicActive && !Logic.isValidScreenRect(aggregator.screenRect))
? occupancy
: Logic.computeDesktopOccupancyForScreen(windows, ids, aggregator.screenRect);
}
if (!Logic.arraysShallowEqual(screenOcc, aggregator.screenOccupancy))
aggregator.screenOccupancy = screenOcc;
}
// One window title as escaped rich text, falling back to a localized "Untitled Window" (shared fallback).
function titleHtml(title) {
return Logic.sanitizeHtml(title.length ? title : i18nc("@item:intext window with no title", "Untitled Window"));
}
// One desktop's window list as a rich-text <ul> capped at Logic.windowListMaximum + an "…and N other windows" overflow (stock pager's generateWindowList).
function formatList(titles) {
const total = titles.length;
const max = Logic.windowListMaximum(total);
let t = "<ul><li>" + titles.slice(0, max).map(x => aggregator.titleHtml(x)).join("</li><li>") + "</li></ul>";
if (total > max)
t += i18ncp("@info:tooltip overflow label", "…and %1 other window", "…and %1 other windows", total - max);
return t;
}
// Assemble one desktop's subText from its { visible, minimized } summary (the stock pager's
// updateSubTextIfNeeded). The leading <style> kills the <ul>'s default margin.
function formatSubText(s) {
let t = "";
if (s.visible.length === 1)
t += aggregator.titleHtml(s.visible[0]);
else if (s.visible.length > 1)
t += i18ncp("@info:tooltip start of list", "%1 Window:", "%1 Windows:", s.visible.length) + aggregator.formatList(s.visible);
if (s.visible.length && s.minimized.length)
t += s.visible.length === 1 ? "<br><br>" : "<br>";
if (s.minimized.length > 0)
t += i18ncp("@info:tooltip", "%1 Minimized Window:", "%1 Minimized Windows:", s.minimized.length) + aggregator.formatList(s.minimized);
return t.length ? "<style>ul { margin: 0; }</style>" + t : "";
}
// Toggling a feature at runtime changes both rebuild()'s output and its triggers, so force one rebuild on
// each flip (ON repopulates that feature's array, OFF clears it to []).
onWindowListActiveChanged: aggregator.scheduleRebuild()
onOccupancyActiveChanged: aggregator.scheduleRebuild()
onDynamicActiveChanged: aggregator.scheduleRebuild()
// The panel was dragged to another monitor (new output rect) → recompute per-screen occupancy for the new
// screen. Only the occupied-dot indicator consumes per-screen occupancy, so it's a no-op when that's off.
onScreenRectChanged: if (aggregator.occupancyActive) aggregator.scheduleRebuild()
Component.onCompleted: aggregator.rebuild()
}
@@ -0,0 +1,199 @@
/*
* Plasma Gnome Pager — WorkspaceDot.qml
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* One workspace element (GNOME-style REFLOW model): the element IS the workspace, no overlay. Inactive =
* dim circle; active = a wider highlighted capsule (the pill). Colour, sizing, and the morph arrive as
* properties from the indicator (Kirigami-derived defaults so a dot renders standalone and headless).
*/
pragma ComponentBehavior: Bound
import QtQuick
import org.kde.kirigami as Kirigami
import org.kde.plasma.core as PlasmaCore // ToolTipArea
import "logic.js" as Logic
Item {
id: dot
// Inputs supplied by WorkspaceIndicator's Repeater delegate (with sane defaults).
property bool active: false
property int dotStyle: Logic.DEFAULTS.dotStyle // overall look (Logic.DOT_STYLE); Ring → non-current dots are hollow rings
property real dotSize: Kirigami.Units.iconSizes.small / 2
property real pillSize: Kirigami.Units.iconSizes.small / 2 // active-pill thickness, sized independently of the dot
property real pillWidthFactor: Logic.DEFAULTS.pillWidthFactor // capsule length / pill thickness
readonly property real pillWidth: dot.pillSize * dot.pillWidthFactor
property real inactiveOpacity: Logic.DEFAULTS.inactiveOpacity
property real hoverOpacity: Logic.DEFAULTS.hoverOpacity
property bool occupied: false // this desktop holds windows (showOccupancy feature; always false when off)
property real occupiedOpacity: Logic.DEFAULTS.occupiedOpacity // opacity of the occupied marker (all styles)
property int occupancyStyle: Logic.DEFAULTS.occupancyStyle // how an occupied dot is marked (Logic.OCCUPANCY)
property string desktopName: "" // tooltip mainText
property bool showTooltips: Logic.DEFAULTS.showTooltips
property string tooltipText: "" // tooltip subText: HTML window list, pre-formatted by main.qml (empty = name-only)
// Colours follow the scheme when followThemeColors (default), else the custom activeColor/inactiveColor/occupiedColor.
property bool followThemeColors: Logic.DEFAULTS.followThemeColors
property color activeColor: Kirigami.Theme.highlightColor
property color inactiveColor: Kirigami.Theme.textColor
property color occupiedColor: Kirigami.Theme.highlightColor // occupied marker (custom; theme accent when following the scheme)
property int animationDuration: Logic.DEFAULTS.animationDuration // ms; 0 = follow theme (resolved by effectiveDuration)
property bool vertical: false // major axis: false = horizontal panel (widen), true = vertical (grow tall)
property bool animate: false // morph gate, latched by the indicator after first valid placement (no grow-in on reload)
// Extent along the MAJOR (strip) axis and the CROSS axis: capsule when active, dot otherwise.
readonly property real longExtent: dot.active ? dot.pillWidth : dot.dotSize
readonly property real crossExtent: dot.active ? dot.pillSize : dot.dotSize
// Resolved colours: the theme accent/text when following the scheme, else the custom config colours.
readonly property color resolvedActive: dot.followThemeColors ? Kirigami.Theme.highlightColor : dot.activeColor
readonly property color resolvedInactive: dot.followThemeColors ? Kirigami.Theme.textColor : dot.inactiveColor
readonly property color resolvedOccupied: dot.followThemeColors ? Kirigami.Theme.highlightColor : dot.occupiedColor
// Occupied-indicator overlay flags (showOccupancy on) — both drawn ON TOP of the normal dim dot.
readonly property bool showInnerDot: Logic.innerDotVisible(dot.active, dot.occupied, dot.occupancyStyle) // InnerDot style: occupied → centre dot
readonly property bool showRing: Logic.ringOverlayVisible(dot.active, dot.occupied, dot.occupancyStyle, dot.dotStyle) // Ring occupancy overlay (suppressed in the Filled & ring look)
// "Filled & ring" style. The ring OUTLINE (hasRing) is decoupled from the INTERIOR (bodyIsHollow): a
// non-current dot always draws the ring border, and its interior is transparent UNLESS the Filled
// occupancy marker fills it (ringFilled → ring + filled background). Both false in the Pill style.
readonly property bool ringStyle: Logic.isRingStyle(dot.dotStyle)
readonly property bool hasRing: Logic.dotHasRing(dot.dotStyle, dot.active)
readonly property bool bodyIsHollow: Logic.dotBodyIsHollow(dot.dotStyle, dot.active, dot.occupied, dot.occupancyStyle)
readonly property bool ringFilled: Logic.dotBodyFilled(dot.dotStyle, dot.active, dot.occupied, dot.occupancyStyle) // occupied + Filled: ring outline + filled interior
// Filled-occupied ring interior: the occupied colour with its own alpha baked in (so the ring outline stays opaque). Only meaningful when ringFilled.
readonly property color filledBodyColor: Qt.rgba(dot.resolvedOccupied.r, dot.resolvedOccupied.g, dot.resolvedOccupied.b, dot.occupiedOpacity)
// Morph duration: configured value, else themed default, 0 when "reduce animations" is on. One source for the Behaviors below.
readonly property int effectiveDuration: Logic.effectiveDuration(dot.animationDuration, Kirigami.Units.longDuration)
readonly property bool morphEnabled: dot.animate && dot.effectiveDuration > 0
readonly property alias hovered: mouseArea.containsMouse
signal activated // emitted on click; the indicator turns it into a switch request
// Accessibility: announced to Orca etc. as a button named after the desktop, checkable/checked
// mirroring `active` so an AT can tell WHICH dot is current; press routes through activated() like a click.
Accessible.role: Accessible.Button
Accessible.name: dot.desktopName
Accessible.checkable: true
Accessible.checked: dot.active
Accessible.onPressAction: dot.activated()
// Footprint tracks the (possibly animating) capsule on both axes so the strip reflows smoothly.
implicitWidth: capsule.width
implicitHeight: capsule.height
// Per-dot tooltip (wrapping the content is canonical). Gated by showTooltips + a non-empty name (no empty tooltips while names lag ids).
PlasmaCore.ToolTipArea {
id: tooltip
anchors.fill: parent
active: dot.showTooltips && dot.desktopName !== ""
mainText: dot.desktopName
subText: dot.tooltipText
textFormat: Text.RichText // window list is an HTML <ul>, like the stock pager
// The dot/capsule. radius is min(width,height)/2 — orientation-agnostic stadium ends. Size
// bindings are independent ternaries (no own/parent geometry), so no loop with implicitWidth/Height.
Rectangle {
id: capsule
width: dot.vertical ? dot.crossExtent : dot.longExtent
height: dot.vertical ? dot.longExtent : dot.crossExtent
radius: Math.min(capsule.width, capsule.height) / 2
anchors.centerIn: parent
// The dot body. Pill style: the active capsule, a Filled-occupied dot, or the dim dot (dotColor).
// "Filled & ring" style: a hollow interior (transparent), OR a Filled-occupied interior (occupied
// colour at occupiedOpacity, baked into the fill so the ring border below stays crisp), else dotColor.
color: dot.bodyIsHollow ? "transparent"
: dot.ringFilled ? dot.filledBodyColor
: Logic.dotColor(dot.active, dot.occupied, dot.occupancyStyle, dot.resolvedActive, dot.resolvedInactive, dot.resolvedOccupied)
// Ring outline: drawn for every non-current dot in the Filled & ring style (in the dim/inactive colour), independent of the fill.
border.width: dot.hasRing ? Logic.ringThickness(dot.dotSize) : 0
border.color: dot.resolvedInactive
// In the Filled & ring style the body is full opacity (crisp rings; the occupied fill carries its own alpha above); the Pill style uses the dim/hover/occupied logic.
opacity: dot.ringStyle ? 1.0 : Logic.dotOpacity(dot.active, mouseArea.containsMouse, dot.occupied, dot.occupancyStyle, dot.inactiveOpacity, dot.hoverOpacity, dot.occupiedOpacity)
// Morph, gated by morphEnabled. The major axis always morphs; the cross axis too when pillSize != dotSize (so width and/or height fire).
Behavior on width {
enabled: dot.morphEnabled
NumberAnimation {
duration: dot.effectiveDuration
easing.type: Easing.OutCubic
}
}
Behavior on height {
enabled: dot.morphEnabled
NumberAnimation {
duration: dot.effectiveDuration
easing.type: Easing.OutCubic
}
}
Behavior on color {
enabled: dot.morphEnabled
ColorAnimation {
duration: dot.effectiveDuration
}
}
// Ring⇄fill: the border grows/shrinks as a dot switches between hollow-ring and filled in the "Filled & ring" style.
Behavior on border.width {
enabled: dot.morphEnabled
NumberAnimation {
duration: dot.effectiveDuration
easing.type: Easing.OutCubic
}
}
Behavior on opacity {
enabled: dot.morphEnabled
NumberAnimation {
duration: dot.effectiveDuration
}
}
}
// Occupied-dot overlay (InnerDot / Ring styles), drawn ON TOP of the dim dot. Behind a Loader so when
// occupancy is off (the default) NEITHER Rectangle exists — the dot then carries no extra scene-graph
// nodes. Only the style in use is built (showInnerDot/showRing are mutually exclusive, both false when
// active or Filled). A sibling of the capsule (its own opacity); centred on the CAPSULE for exact placement.
Loader {
id: occupancyOverlay
anchors.centerIn: capsule
active: dot.showInnerDot || dot.showRing
sourceComponent: dot.showRing ? ringComponent : innerDotComponent
}
// InnerDot style: a small occupied-colour dot in the dot's centre. Uses the occupied-opacity slider, like the other styles.
Component {
id: innerDotComponent
Rectangle {
width: Logic.innerDotDiameter(dot.dotSize)
height: width
radius: width / 2
color: dot.resolvedOccupied
opacity: dot.occupiedOpacity
}
}
// Ring style: a hollow occupied-colour ring at the dot's rim (the dim dot stays visible behind it). Uses the occupied-opacity slider, like the other styles.
Component {
id: ringComponent
Rectangle {
width: dot.dotSize
height: width
radius: width / 2
color: "transparent"
border.width: Logic.ringThickness(dot.dotSize)
border.color: dot.resolvedOccupied
opacity: dot.occupiedOpacity
}
}
// Click/hover target. acceptedButtons stays LeftButton (default) so a right-click falls
// through to the applet's context menu.
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: dot.activated()
}
}
}
@@ -0,0 +1,281 @@
/*
* Plasma Gnome Pager — WorkspaceIndicator.qml
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The dot strip (GNOME REFLOW model). Layout + scroll + wiring only: delegates size math to
* IndicatorMetrics and the per-screen current to ScreenCurrentDesktop, binds live to VirtualDesktopInfo,
* reports clicks/scroll via switchRequested. Imports no org.kde.plasma.*, so it stays headless-testable.
*/
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import "logic.js" as Logic
Item {
id: indicator
// Reactive read-only desktop state, supplied by main.qml. NOT `required` — Plasma's representation loader fails creation SILENTLY on that; null + guard instead.
property var virtualDesktopInfo: null
// Null-safe live views. The desktop SET is global; only "which is current" is per-screen (below).
readonly property var desktopIds: virtualDesktopInfo?.desktopIds ?? []
readonly property var desktopNames: virtualDesktopInfo?.desktopNames ?? []
// Per-desktop tooltip subText (window list), index-aligned with desktopIds. Default [] → name-only tooltip.
property var desktopTooltips: []
// This panel's output (KWin connector name, e.g. "DP-1") from the Screen attached property so it reflects THIS monitor. Tests override; "" → global.
property string screenName: Screen.name
// This panel's output RECT in the virtual-desktop space (Screen has no `geometry`), read by main.qml to
// drive per-screen occupancy. Tests override; a zero rect → occupancy degrades to global (single-monitor look).
property rect screenRect: Qt.rect(Screen.virtualX, Screen.virtualY, Screen.width, Screen.height)
// The current desktop FOR THIS SCREEN (Plasma 6.7 per-output), resolved by ScreenCurrentDesktop.
readonly property string currentDesktop: screenCurrent.currentDesktop
ScreenCurrentDesktop {
id: screenCurrent
virtualDesktopInfo: indicator.virtualDesktopInfo
screenName: indicator.screenName
}
// Behaviour flags, supplied by main.qml (defaults match the schema).
property bool enableScroll: Logic.DEFAULTS.enableScroll
property bool scrollWrap: Logic.DEFAULTS.scrollWrap
property bool invertScroll: Logic.DEFAULTS.invertScroll // flip the wheel-sign → direction mapping
property bool showTooltips: Logic.DEFAULTS.showTooltips
// Panel orientation. false = horizontal row (also the Planar/floating default); true = vertical column.
property bool vertical: false
// When true, ignore KWin's grid rows and lay every desktop out in ONE line (forces desktopRows = 1 below).
// ORTHOGONAL to matchDesktopGrid: this sets the line COUNT, that sets the DIRECTION — so singleLine +
// matchDesktopGrid on a vertical panel gives a single HORIZONTAL row, while singleLine alone is a vertical strip.
property bool singleLine: Logic.DEFAULTS.singleLine
// When true on a vertical panel, run the layout ACROSS the panel instead of down it: for a multi-row grid that
// mirrors KWin's orientation (rows top-to-bottom, the GNOME-reflow default is to transpose it down); with
// singleLine it makes the one line horizontal. No effect on a horizontal panel (it already runs across).
property bool matchDesktopGrid: Logic.DEFAULTS.matchDesktopGrid
// Effective major-axis orientation: true = down the panel (vertical), false = across it (horizontal). ALL grid
// geometry below (the two Grid flows, the metrics axis, the Layout hints, the dot's capsule axis) keys off THIS.
// singleLine changes the line COUNT (above), matchDesktopGrid the DIRECTION — independent.
readonly property bool gridVertical: vertical && !matchDesktopGrid
// Running total of hi-res/touchpad wheel deltas; whole notches become steps (the remainder carries).
property real wheelAccumulator: 0
readonly property int wheelNotchDelta: Logic.DEFAULTS.wheelNotchDelta
readonly property int desktopCount: desktopIds.length
// KWin's grid row count, read live (null-guarded, >= 1). We MIRROR KWin so "Rows" re-lays out reactively —
// unless singleLine forces it to 1 (collapse the grid into one line of all desktops).
readonly property int desktopRows: singleLine ? 1 : (virtualDesktopInfo?.desktopLayoutRows > 0 ? virtualDesktopInfo.desktopLayoutRows : 1)
// Desktops per line (columns = ceil(count / rows)) and the row-major split into lines.
readonly property int perLine: Logic.gridColumns(desktopCount, desktopRows)
readonly property var lines: Logic.chunk(desktopIds, perLine)
readonly property int lineCount: lines.length
// Active element index, or -1 for any transient state (empty ids, empty/absent current) → no capsule.
readonly property int activeIndex: desktopIds.indexOf(currentDesktop)
// Overall pager look (Logic.DOT_STYLE). The "Filled & ring" style has NO pill, so we neutralize the
// pill params below: feeding pillWidthFactor=1 + pillSizeRequest=0 makes the metrics AND each dot size
// every element uniformly (the active extent collapses to dotSize) without touching IndicatorMetrics.
property int dotStyle: Logic.DEFAULTS.dotStyle
readonly property bool ringStyle: Logic.isRingStyle(dotStyle)
readonly property real effPillWidthFactor: ringStyle ? 1.0 : pillWidthFactor
readonly property int effPillSizeRequest: ringStyle ? 0 : pillSizeRequest
// Config requests fed to the sizing engine; dotSize/pillSize `0 = auto` resolved in IndicatorMetrics.
property int dotSizeRequest: Logic.DEFAULTS.dotSize // px override; 0 = auto
property int pillSizeRequest: Logic.DEFAULTS.pillSize // px pill thickness; 0 = auto (match dots)
property real spacingFactor: Logic.DEFAULTS.spacingFactor // uniform gap as a multiple of a dot
property real pillWidthFactor: Logic.DEFAULTS.pillWidthFactor // active capsule length, × the pill thickness
property real inactiveOpacity: Logic.DEFAULTS.inactiveOpacity
property real hoverOpacity: Logic.DEFAULTS.hoverOpacity // inactive-dot hover brighten target
// Occupied-dot indicator: when showOccupancy, an occupied dot (desktopOccupancy[globalIndex]) is marked per occupancyStyle.
property bool showOccupancy: Logic.DEFAULTS.showOccupancy
property var desktopOccupancy: [] // per-desktop bool[], index-aligned with desktopIds (per-screen, from main.qml)
property real occupiedOpacity: Logic.DEFAULTS.occupiedOpacity // marker opacity (all styles)
property int occupancyStyle: Logic.DEFAULTS.occupancyStyle // Filled/InnerDot/Ring (Logic.OCCUPANCY)
// The sizing engine: requests + grid shape + live geometry → effective sizes + extents (forwarded below).
IndicatorMetrics {
id: metrics
dotSizeRequest: indicator.dotSizeRequest
pillSizeRequest: indicator.effPillSizeRequest // ring style: 0 → pill thickness == dot (no pill)
spacingFactor: indicator.spacingFactor
pillWidthFactor: indicator.effPillWidthFactor // ring style: 1 → active extent == dot (no pill)
availableMajor: indicator.gridVertical ? indicator.height : indicator.width
availableCross: indicator.gridVertical ? indicator.width : indicator.height
perLine: indicator.perLine
lineCount: indicator.lineCount
}
// Effective (rendered) sizes — scale-to-fit applied; == natural when there is room.
readonly property real dotSize: metrics.dotSize
readonly property real pillSize: metrics.pillSize // effective pill thickness (tracks the dot)
readonly property real pillWidth: metrics.pillWidth // active capsule LENGTH (major axis)
readonly property real dotSpacing: metrics.dotSpacing // uniform gap between every element
// Conserved (capsule-bearing) extents — the strip is pinned to these so a cross-row morph can't drift it.
readonly property real stripLength: metrics.stripLength
readonly property real crossThickness: metrics.crossThickness
// Natural/floor extents (geometry-independent — feed the Layout hints; no loop).
readonly property real naturalDotSize: metrics.naturalDotSize
readonly property real naturalPillSize: metrics.naturalPillSize
readonly property real pillThicknessRatio: metrics.pillThicknessRatio
readonly property real minDotSize: metrics.minDotSize
readonly property real naturalStripLength: metrics.naturalStripLength
readonly property real floorStripLength: metrics.floorStripLength
readonly property real naturalCrossThickness: metrics.naturalCrossThickness
readonly property real floorCrossThickness: metrics.floorCrossThickness
// Colour + animation config, passed straight through to each dot (the indicator draws nothing itself).
property bool followThemeColors: Logic.DEFAULTS.followThemeColors
property color activeColor: Kirigami.Theme.highlightColor
property color inactiveColor: Kirigami.Theme.textColor
property color occupiedColor: Kirigami.Theme.highlightColor // occupied-marker colour (custom; theme accent when following the scheme)
property int animationDuration: Logic.DEFAULTS.animationDuration // ms; 0 = follow the theme
// Raised on a click or scroll; main.qml turns the UUID into a KWin switch.
signal switchRequested(string uuid)
// Raised when the ALREADY-CURRENT desktop's dot (the pill) is clicked/pressed; main.qml maps it to the
// configured pill-click action. Scroll never raises this (handleWheel only ever emits switchRequested).
signal activeClicked()
// Wheel → switch. Thin wrapper; the branching (notch accumulation, clamp/wrap, -1 ignore) is in logic.js and unit-tested.
function handleWheel(angleDeltaY: real) {
if (!indicator.enableScroll)
return;
const acc = Logic.accumulateWheel(indicator.wheelAccumulator, angleDeltaY, indicator.wheelNotchDelta);
indicator.wheelAccumulator = acc.remainder;
if (acc.steps === 0)
return; // sub-notch motion accumulated; nothing to do yet
// Default: wheel up (+) → previous desktop; down (−) → next, so negate. invertScroll keeps the sign.
const dir = indicator.invertScroll ? acc.steps : -acc.steps;
const next = Logic.stepIndex(indicator.activeIndex, indicator.desktopIds.length, dir, indicator.scrollWrap);
if (next < 0 || next === indicator.activeIndex)
return; // empty/unknown source, or a clamped no-op at an end
const uuid = indicator.desktopIds[next];
if (!uuid)
return; // transient empty id (robustness.md: guard before use)
indicator.switchRequested(uuid);
}
// Size hints. Major axis: preferred==max==naturalStripLength, min==floorStripLength (panel can compress
// → dots scale to fit). Cross axis: preferred==natural, max==-1 (fill thickness), min==floor. Swaps with `gridVertical`.
implicitWidth: gridVertical ? naturalCrossThickness : naturalStripLength
implicitHeight: gridVertical ? naturalStripLength : naturalCrossThickness
Layout.minimumWidth: gridVertical ? floorCrossThickness : floorStripLength
Layout.preferredWidth: implicitWidth
Layout.maximumWidth: gridVertical ? -1 : naturalStripLength
Layout.minimumHeight: gridVertical ? floorStripLength : floorCrossThickness
Layout.preferredHeight: implicitHeight
Layout.maximumHeight: gridVertical ? naturalStripLength : -1
// Gate the morph so the FIRST valid placement is instant (no grow-in on reload), later switches animate.
// ScreenCurrentDesktop (a child) resolves currentDesktop in its onCompleted first, so activeIndex is valid here.
property bool animate: false
onActiveIndexChanged: {
if (activeIndex >= 0 && !animate) {
Qt.callLater(() => indicator.animate = true);
}
}
Component.onCompleted: {
if (activeIndex >= 0) {
animate = true;
}
}
// Scroll-to-switch. This MouseArea sits BEHIND the dots, accepts no buttons/hover, so clicks/hover pass through while a wheel propagates here.
MouseArea {
id: wheelArea
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: wheel => indicator.handleWheel(wheel.angleDelta.y)
}
// Two nested positioners mirror KWin's grid: OUTER stacks lines on the cross axis, INNER the dots on the major axis (one 2-D Grid would fatten a column).
Grid {
id: strip
anchors.centerIn: parent
// Pin to the conserved extent so a cross-row morph can't resize+recenter the strip (else the dots drift).
// Content-sized would dip mid-morph as the capsule moves between lines; this keeps the footprint constant.
width: indicator.gridVertical ? indicator.crossThickness : indicator.stripLength
height: indicator.gridVertical ? indicator.stripLength : indicator.crossThickness
spacing: indicator.dotSpacing
rows: indicator.gridVertical ? 1 : -1
columns: indicator.gridVertical ? -1 : 1
Repeater {
// `lines` is [] while the source is transiently null/empty, so the outer Repeater is empty.
model: indicator.lines
delegate: Grid {
id: lineStrip
required property var modelData // this line's UUIDs (a row-major chunk)
required property int index // line index (KWin row)
spacing: indicator.dotSpacing
rows: indicator.gridVertical ? -1 : 1
columns: indicator.gridVertical ? 1 : -1
// Centre every element on the CROSS axis: the line is as thick as its tallest element (the capsule when pillSize > dotSize).
verticalItemAlignment: indicator.gridVertical ? Grid.AlignTop : Grid.AlignVCenter
horizontalItemAlignment: indicator.gridVertical ? Grid.AlignHCenter : Grid.AlignLeft
Repeater {
model: lineStrip.modelData
delegate: WorkspaceDot {
id: workspaceDot
required property string modelData
required property int index
// Position in the flat desktopIds/desktopNames: earlier lines are full at perLine.
readonly property int globalIndex: lineStrip.index * indicator.perLine + workspaceDot.index
vertical: indicator.gridVertical
dotStyle: indicator.dotStyle
dotSize: indicator.dotSize
pillSize: indicator.pillSize // == dotSize in ring mode (no pill)
pillWidthFactor: indicator.effPillWidthFactor // == 1 in ring mode (active extent == dot)
inactiveOpacity: indicator.inactiveOpacity
hoverOpacity: indicator.hoverOpacity
// Gating on showOccupancy here keeps a stale/short desktopOccupancy array harmless when the feature is off.
occupied: indicator.showOccupancy && (indicator.desktopOccupancy[workspaceDot.globalIndex] ?? false)
occupiedOpacity: indicator.occupiedOpacity
occupancyStyle: indicator.occupancyStyle
followThemeColors: indicator.followThemeColors
activeColor: indicator.activeColor
inactiveColor: indicator.inactiveColor
occupiedColor: indicator.occupiedColor
animationDuration: indicator.animationDuration
active: indicator.currentDesktop === workspaceDot.modelData
animate: indicator.animate
// Each dot's tooltip name + window-list subText (|| "" guards the transient frame where names/tooltips lag ids).
desktopName: indicator.desktopNames[workspaceDot.globalIndex] || ""
tooltipText: indicator.desktopTooltips[workspaceDot.globalIndex] || ""
showTooltips: indicator.showTooltips
// Clicking the current desktop's pill runs the configured action; any other dot switches.
onActivated: workspaceDot.active ? indicator.activeClicked() : indicator.switchRequested(workspaceDot.modelData)
}
}
}
}
}
}
@@ -0,0 +1,251 @@
/*
* Plasma Gnome Pager — ConfigAppearance.qml (Appearance settings page)
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The Appearance page, built on ConfigPageBase. Ratios use ConfigSlider; dotSize/pillSize are integer
* sliders where 0 reads "Default"/"Match dots" (the 0 = auto sentinel). Colours use ColorButton, lazy-loaded
* with the dialog so the import never affects the always-on widget. Each `cfg_<key>` MUST match main.xml.
*/
pragma ComponentBehavior: Bound // the occupancyStyle delegate references outer ids (occupancyStyle/root)
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kquickcontrols as KQuickControls
ConfigPageBase {
id: root
property alias cfg_dotStyle: dotStyle.currentIndex
property alias cfg_singleLine: singleLine.checked
property alias cfg_matchDesktopGrid: matchDesktopGrid.checked
property alias cfg_dotSize: dotSize.value
property alias cfg_pillSize: pillSize.value
property alias cfg_spacingFactor: spacingFactor.value
property alias cfg_pillWidthFactor: pillWidthFactor.value
property alias cfg_inactiveOpacity: inactiveOpacity.value
property alias cfg_hoverOpacity: hoverOpacity.value
property alias cfg_showOccupancy: showOccupancy.checked
property alias cfg_occupancyStyle: occupancyStyle.currentIndex
property alias cfg_occupiedOpacity: occupiedOpacity.value
property alias cfg_followThemeColors: followThemeColors.checked
property alias cfg_activeColor: activeColor.color
property alias cfg_inactiveColor: inactiveColor.color
property alias cfg_occupiedColor: occupiedColor.color
// Injected by the config dialog from the main.xml defaults; read by the Defaults handler below.
property int cfg_dotStyleDefault
property bool cfg_singleLineDefault
property bool cfg_matchDesktopGridDefault
property int cfg_dotSizeDefault
property int cfg_pillSizeDefault
property real cfg_spacingFactorDefault
property real cfg_pillWidthFactorDefault
property real cfg_inactiveOpacityDefault
property real cfg_hoverOpacityDefault
property bool cfg_showOccupancyDefault
property int cfg_occupancyStyleDefault
property real cfg_occupiedOpacityDefault
property bool cfg_followThemeColorsDefault
property color cfg_activeColorDefault
property color cfg_inactiveColorDefault
property color cfg_occupiedColorDefault
// This page's keys + compare kind; ConfigPageBase binds isModified + the Defaults reset off it
// (reals within epsilon, colours via Qt.colorEqual).
configKeys: [
{ n: "dotStyle", t: "int" },
{ n: "singleLine", t: "bool" },
{ n: "matchDesktopGrid", t: "bool" },
{ n: "dotSize", t: "int" },
{ n: "pillSize", t: "int" },
{ n: "spacingFactor", t: "real" },
{ n: "pillWidthFactor", t: "real" },
{ n: "inactiveOpacity", t: "real" },
{ n: "hoverOpacity", t: "real" },
{ n: "showOccupancy", t: "bool" },
{ n: "occupancyStyle", t: "int" },
{ n: "occupiedOpacity", t: "real" },
{ n: "followThemeColors", t: "bool" },
{ n: "activeColor", t: "color" },
{ n: "inactiveColor", t: "color" },
{ n: "occupiedColor", t: "color" }
]
// Which pager style is selected, by the dotStyle combo index (order matches Logic.DOT_STYLE / main.xml:
// 0 = Sliding pill, 1 = Filled & ring). The pill knobs only apply to Sliding pill; Filled & ring disables
// the redundant Hollow ring occupancy marker. Named once here so the index checks aren't repeated below.
readonly property bool pillStyle: dotStyle.currentIndex === 0
readonly property bool ringStyle: dotStyle.currentIndex === 1
Kirigami.FormLayout {
QQC2.ComboBox {
id: dotStyle
Kirigami.FormData.label: i18n("Pager style:")
// Order MUST match Logic.DOT_STYLE / main.xml dotStyle (currentIndex is stored as the index).
model: [i18n("Sliding pill"), i18n("Filled & ring")]
Layout.preferredWidth: root.fieldWidth // match the other field widths (ConfigPageBase.fieldWidth)
// Filled & ring disables the Hollow ring occupancy marker (index 2) — the dot is already a ring —
// so migrate a previously-chosen Hollow ring to Filled (0) when the user switches to it.
onActivated: {
if (root.ringStyle && occupancyStyle.currentIndex === 2)
occupancyStyle.currentIndex = 0;
}
}
QQC2.CheckBox {
id: singleLine
Kirigami.FormData.label: i18n("Multiple rows:")
text: i18n("Show all desktops in a single line")
}
QQC2.Label {
// Hint: ignore the KWin grid entirely and lay everything out as one strip following the panel.
text: i18n("Ignore the grid rows from System Settings and lay every desktop out in one strip along the panel (a single vertical strip on a vertical panel).")
wrapMode: Text.WordWrap
opacity: 0.7
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
Layout.preferredWidth: root.fieldWidth // wrap within the field column
}
QQC2.CheckBox {
id: matchDesktopGrid
Kirigami.FormData.label: i18n("Vertical panels:")
text: i18n("Match the virtual-desktop grid layout")
// Orthogonal to "single line": this sets the direction (across vs. down), so it composes — single
// line + match grid gives a single HORIZONTAL row. Hence no longer greyed while single line is on.
}
QQC2.Label {
// Hint: this only matters on a vertical panel (a horizontal panel already mirrors the grid).
text: i18n("Arrange the dots like the desktop grid in System Settings (rows top to bottom) instead of running them down the panel. No effect on horizontal panels.")
wrapMode: Text.WordWrap
opacity: 0.7
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
Layout.preferredWidth: root.fieldWidth // wrap within the field column
}
ConfigSlider {
id: dotSize
label: i18n("Dot size:")
from: 0
to: 64
stepSize: 1
// 0 = auto: the widget falls back to the HiDPI-aware themed size.
format: v => v === 0 ? i18n("Default") : i18np("%1 px", "%1 px", Math.round(v))
}
ConfigSlider {
id: pillSize
// "Thickness" (not "size") disambiguates from "Pill length:" below — the pill's two axes (also avoids an msgmerge fuzzy collision).
label: i18n("Pill thickness:")
enabled: root.pillStyle // the pill only exists in the Sliding pill style
from: 0
to: 64
stepSize: 1
// 0 = auto: the pill thickness matches the (effective) dot size, so the pill tracks the dots.
format: v => v === 0 ? i18n("Match dots") : i18np("%1 px", "%1 px", Math.round(v))
}
ConfigSlider {
id: spacingFactor
label: i18n("Spacing:")
from: 0.0
to: 2.0
stepSize: 0.05
format: v => i18n("%1× dot", v.toFixed(2))
}
ConfigSlider {
id: pillWidthFactor
label: i18n("Pill length:")
enabled: root.pillStyle // the pill only exists in the Sliding pill style
from: 1.0
to: 10.0
stepSize: 0.1
// Length as a multiple of the PILL thickness (its aspect ratio), not the dot size.
format: v => i18n("%1× pill", v.toFixed(1))
}
ConfigSlider {
id: inactiveOpacity
label: i18n("Inactive opacity:")
from: 0.0
to: 1.0
stepSize: 0.01 // 1% increments for fine control (drag or arrow keys)
format: v => Math.round(v * 100) + "%"
}
ConfigSlider {
id: hoverOpacity
label: i18n("Hover opacity:")
from: 0.0
to: 1.0
stepSize: 0.01 // 1% increments for fine control (drag or arrow keys)
format: v => Math.round(v * 100) + "%"
}
QQC2.CheckBox {
id: showOccupancy
Kirigami.FormData.label: i18n("Occupied desktops:")
text: i18n("Highlight desktops with open windows")
}
QQC2.ComboBox {
id: occupancyStyle
Kirigami.FormData.label: i18n("Indicator style:")
enabled: showOccupancy.checked
// Order MUST match Logic.OCCUPANCY / main.xml occupancyStyle (currentIndex is stored as the index).
model: [i18n("Filled"), i18n("Inner dot"), i18n("Hollow ring")]
// "Hollow ring" (index 2) is redundant in the Filled & ring pager style — the dot is ALREADY a
// hollow ring — so disable that item there (selection blocked; a stored value is suppressed at runtime).
delegate: QQC2.ItemDelegate {
id: occStyleItem
required property int index
required property string modelData
width: occupancyStyle.width
text: occStyleItem.modelData
enabled: !(root.ringStyle && occStyleItem.index === 2)
highlighted: occupancyStyle.highlightedIndex === occStyleItem.index
}
}
ConfigSlider {
id: occupiedOpacity
label: i18n("Occupied opacity:")
enabled: showOccupancy.checked // every indicator style uses the occupied-marker opacity
from: 0.0
to: 1.0
stepSize: 0.01 // 1% increments for fine control (drag or arrow keys)
format: v => Math.round(v * 100) + "%"
}
Item {
Kirigami.FormData.isSection: true // a little vertical breathing room before the colours
}
QQC2.CheckBox {
id: followThemeColors
Kirigami.FormData.label: i18n("Colors:")
text: i18n("Follow the color scheme")
}
KQuickControls.ColorButton {
id: activeColor
Kirigami.FormData.label: i18n("Active desktop:")
enabled: !followThemeColors.checked // custom colours apply only when not following the theme
showAlphaChannel: false
}
KQuickControls.ColorButton {
id: inactiveColor
Kirigami.FormData.label: i18n("Inactive desktop:")
enabled: !followThemeColors.checked
showAlphaChannel: false
}
KQuickControls.ColorButton {
id: occupiedColor
Kirigami.FormData.label: i18n("Occupied desktop:")
enabled: !followThemeColors.checked // the occupied marker; theme accent is used while following the scheme
showAlphaChannel: false
}
}
}
@@ -0,0 +1,145 @@
/*
* Plasma Gnome Pager — ConfigGeneral.qml (Behavior settings page)
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* The Behavior page, built on ConfigPageBase. Each `cfg_<key>` alias MUST match a main.xml entry exactly
* (the dialog wires load/save); `cfg_<key>Default` is injected from the schema.
*/
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
ConfigPageBase {
id: root
property alias cfg_enableScroll: enableScroll.checked
property alias cfg_scrollWrap: scrollWrap.checked
property alias cfg_invertScroll: invertScroll.checked
property alias cfg_pillClickAction: pillClickAction.currentIndex
property alias cfg_showTooltips: showTooltips.checked
property alias cfg_showWindowList: showWindowList.checked
property alias cfg_enableAddRemove: enableAddRemove.checked
property alias cfg_enableRename: enableRename.checked
property alias cfg_dynamicWorkspaces: dynamicWorkspaces.checked
property alias cfg_dynamicNamePrefix: dynamicNamePrefix.text
property alias cfg_animationDuration: animationDuration.value
// Injected by the config dialog from the main.xml defaults; read by the Defaults handler below.
property bool cfg_enableScrollDefault
property bool cfg_scrollWrapDefault
property bool cfg_invertScrollDefault
property int cfg_pillClickActionDefault
property bool cfg_showTooltipsDefault
property bool cfg_showWindowListDefault
property bool cfg_enableAddRemoveDefault
property bool cfg_enableRenameDefault
property bool cfg_dynamicWorkspacesDefault
property string cfg_dynamicNamePrefixDefault
property int cfg_animationDurationDefault
// This page's keys + compare kind; ConfigPageBase binds isModified AND the Defaults reset off it.
configKeys: [
{ n: "enableScroll", t: "bool" },
{ n: "scrollWrap", t: "bool" },
{ n: "invertScroll", t: "bool" },
{ n: "pillClickAction", t: "int" },
{ n: "showTooltips", t: "bool" },
{ n: "showWindowList", t: "bool" },
{ n: "enableAddRemove", t: "bool" },
{ n: "enableRename", t: "bool" },
{ n: "dynamicWorkspaces", t: "bool" },
{ n: "dynamicNamePrefix", t: "string" },
{ n: "animationDuration", t: "int" }
]
Kirigami.FormLayout {
QQC2.CheckBox {
id: enableScroll
Kirigami.FormData.label: i18n("Mouse:")
text: i18n("Scroll over the pager to switch desktops")
}
QQC2.CheckBox {
id: scrollWrap
text: i18n("Wrap around at the first and last desktop")
enabled: enableScroll.checked // wrap only matters when scrolling is on
}
QQC2.CheckBox {
id: invertScroll
text: i18n("Invert the scroll direction")
enabled: enableScroll.checked // inversion only matters when scrolling is on
}
QQC2.ComboBox {
id: pillClickAction
Kirigami.FormData.label: i18n("Click current desktop:")
// Order MUST match Logic.PILL_CLICK_ACTION / main.xml pillClickAction (currentIndex is stored as the index).
model: [i18n("Nothing"), i18n("Show Desktop"), i18n("Overview"), i18n("Grid")]
Layout.preferredWidth: root.fieldWidth // match the other field widths (ConfigPageBase.fieldWidth)
}
QQC2.Label {
// Clarify the action only fires on the highlighted (current) desktop; other dots just switch.
text: i18n("Action when clicking the highlighted current desktop. Clicking any other desktop switches to it.")
wrapMode: Text.WordWrap
opacity: 0.7
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
Layout.preferredWidth: root.fieldWidth // wrap within the field column
}
QQC2.CheckBox {
id: showTooltips
Kirigami.FormData.label: i18n("Tooltips:")
text: i18n("Show the desktop name on hover")
}
QQC2.CheckBox {
id: showWindowList
text: i18n("List the open windows in the tooltip")
enabled: showTooltips.checked // the window list only shows when tooltips are on
}
QQC2.CheckBox {
id: enableAddRemove
Kirigami.FormData.label: i18n("Menu:")
text: i18n("Add and remove desktops from the right-click menu")
// Greyed while dynamic workspaces is on (mutually exclusive); value preserved, returns when off.
enabled: !dynamicWorkspaces.checked
}
QQC2.CheckBox {
id: enableRename
text: i18n("Rename the current desktop from the right-click menu")
}
QQC2.CheckBox {
id: dynamicWorkspaces
Kirigami.FormData.label: i18n("Dynamic desktops:")
text: i18n("Automatically add and remove desktops (GNOME-style)")
}
QQC2.Label {
// Hint explaining the exclusivity above, so a new user sees why Add/Remove greys out.
text: i18n("While on, desktops are managed automatically — the menu Add/Remove options are disabled.")
visible: dynamicWorkspaces.checked
wrapMode: Text.WordWrap
opacity: 0.7
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
Layout.preferredWidth: root.fieldWidth // wrap within the field column
}
QQC2.TextField {
id: dynamicNamePrefix
Kirigami.FormData.label: i18n("New desktop name:")
// Base name for auto-created desktops (number appended); empty → the placeholder default.
enabled: dynamicWorkspaces.checked
placeholderText: i18nc("@info default base name for auto-created virtual desktops", "Desktop")
Layout.preferredWidth: root.fieldWidth // match the slider track width (ConfigPageBase.fieldWidth)
}
ConfigSlider {
id: animationDuration
label: i18n("Animation duration:")
from: 0
to: 2000
stepSize: 25 // clean 25 ms increments (SnapAlways is the ConfigSlider default)
// 0 = follow the theme's default (and the global "reduce animations" setting).
format: v => v === 0 ? i18n("Default") : i18np("%1 ms", "%1 ms", Math.round(v))
}
}
}
@@ -0,0 +1,58 @@
/*
* Plasma Gnome Pager — ConfigPageBase.qml (shared settings-page base)
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Shared skeleton for every settings page: a Kirigami.ScrollablePage for the KDE header + scrolling, plus
* the "Defaults" button the Plasma footer lacks (defined ONCE here). A derived page only declares its
* `configKeys` { n, t } list; the modified-check and the Defaults reset are bound off it here, once.
*/
import QtQuick
import org.kde.kirigami as Kirigami
Kirigami.ScrollablePage {
id: root
// The derived page's keys + compare kind ({ n, t }); drives both isModified and the Defaults reset.
property var configKeys: []
// True when any key differs from its default (gates the Defaults action). Bound off configKeys here
// so a derived page need only declare the list; empty configKeys ⇒ false (unmodified).
property bool isModified: configKeys.some(k => root.fieldChanged(root, k.n, k.t))
// Field-column width for non-slider fields; kept equal to ConfigSlider.trackWidth so every row lines up.
readonly property int fieldWidth: Kirigami.Units.gridUnit * 18
// Tolerance for real-valued "differs from default": SnapAlways can land a value a ULP off the default.
readonly property real epsilon: 1e-9
// Does cfg_<name> differ from cfg_<name>Default? Type-aware: reals within epsilon, colours via Qt.colorEqual, else exact.
function fieldChanged(page, name, kind) {
var a = page["cfg_" + name];
var b = page["cfg_" + name + "Default"];
if (kind === "real")
return Math.abs(a - b) > root.epsilon;
if (kind === "color")
return !Qt.colorEqual(a, b);
return a !== b;
}
// Reset cfg_<name> to its injected schema default.
function resetField(page, name) {
page["cfg_" + name] = page["cfg_" + name + "Default"];
}
// Raised by the Defaults action; resets every configKeys entry to its injected schema default.
signal defaultsRequested()
onDefaultsRequested: configKeys.forEach(k => root.resetField(root, k.n))
actions: [
Kirigami.Action {
text: i18n("Defaults")
icon.name: "edit-undo-symbolic"
enabled: root.isModified
onTriggered: root.defaultsRequested()
}
]
}
@@ -0,0 +1,61 @@
/*
* Plasma Gnome Pager — ConfigSlider.qml (reusable config-page control)
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* A FormLayout row of a Slider + a right-hand value read-out. One `format` closure (value → string) drives
* BOTH the read-out AND its reserved width (widest of from/to) so the label can't reflow mid-drag. The
* track is a FIXED width (NOT fillWidth) so sliders match across both pages; the value label absorbs slack.
*/
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
RowLayout {
id: root
property alias value: slider.value
property alias from: slider.from
property alias to: slider.to
property alias stepSize: slider.stepSize
property alias snapMode: slider.snapMode
property string label: "" // the row's Kirigami.FormData label
property var format: (v) => String(v) // value → read-out string (drives text AND reserved width)
// Fixed track length, matched to ConfigPageBase.fieldWidth so every row's field column lines up.
readonly property int trackWidth: Kirigami.Units.gridUnit * 18
Kirigami.FormData.label: root.label
QQC2.Slider {
id: slider
// Fixed track length (NOT fillWidth): a fillWidth track would stretch wider on the Behavior page's long labels. min == preferred so it can't shrink.
Layout.preferredWidth: root.trackWidth
Layout.minimumWidth: root.trackWidth
snapMode: QQC2.Slider.SnapAlways // clean increments even when dragged (override via alias)
}
QQC2.Label {
id: valueLabel
text: root.format(slider.value)
horizontalAlignment: Text.AlignRight
// fillWidth so extra column width is absorbed here — the right-aligned read-out pins to the column edge and the slider keeps its length.
Layout.fillWidth: true
// Reserve the widest the read-out can get (+ buffer) so the cell never resizes mid-drag.
Layout.minimumWidth: Math.max(valueMetricsFrom.advanceWidth, valueMetricsTo.advanceWidth) + Kirigami.Units.smallSpacing
Layout.preferredWidth: Layout.minimumWidth
TextMetrics {
id: valueMetricsFrom
font: valueLabel.font
text: root.format(slider.from)
}
TextMetrics {
id: valueMetricsTo
font: valueLabel.font
text: root.format(slider.to)
}
}
}
@@ -0,0 +1,62 @@
/*
* Plasma Gnome Pager — coordinator.js
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Cross-instance coordination for dynamic workspaces. plasmashell runs every panel in ONE QML engine and a
* `.pragma library` is one instance per engine, so the module state below is SHARED across all pagers (the
* only pure-QML way). Provides SETTING SYNC + SINGLE-WRITER election (lowest token writes).
*/
.pragma library
.import "logic.js" as Logic
var _present = {}; // token -> true: instances currently joined (the election candidates)
var _subs = {}; // token -> onSync(enabled, prefix): how to push the global value to each instance
var _enabled = false; // GLOBAL dynamic-workspaces enabled, synced across all panels
var _prefix = ""; // GLOBAL auto-created-desktop name prefix, synced across all panels
var _haveGlobal = false; // has any instance established the global yet?
var _seq = 0; // hands out unique, monotonically increasing tokens
// Register this instance. `onSync(enabled, prefix)` is how publish() pushes the global value here. Returns
// the unique token (store it; pass it to leave()/isWriter()). Never 0 (the caller's "not joined" sentinel).
function join(onSync) {
_seq += 1;
_present[_seq] = true;
_subs[_seq] = onSync;
return _seq;
}
// Deregister on destruction so a removed panel stops counting toward the election and is not notified.
function leave(token) {
delete _present[token];
delete _subs[token];
}
function haveGlobal() { return _haveGlobal; }
function globalEnabled() { return _enabled; }
function globalPrefix() { return _prefix; }
// Set the single global setting and push it to EVERY instance. Called by the panel the user toggled, and
// once at startup by the first instance to seed the global.
function publish(enabled, prefix) {
_enabled = !!enabled;
_prefix = prefix;
_haveGlobal = true;
for (var t in _subs) {
try {
_subs[t](_enabled, _prefix);
} catch (_e) {
/* an instance torn down mid-iteration: ignore */
}
}
}
// Is THIS instance the single writer — the lowest-token present instance, and only when globally enabled?
// Feeding {token: _enabled} to the pure election yields the lowest present token when on, or -1 when off.
function isWriter(token) {
var reg = {};
for (var t in _present)
reg[t] = _enabled;
return Logic.electDynamicWriter(reg) === Number(token);
}
@@ -0,0 +1,556 @@
/*
* Plasma Gnome Pager — logic.js
*
* SPDX-FileCopyrightText: 2026 Kenan Salar
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Pure, dependency-free branching logic (no Plasma/Qt deps), headless-tested by tst_logic.qml.
* `.pragma library`: one stateless instance, no QML ids/context.
*/
.pragma library
// QML-side config fallback defaults (the `?? Logic.DEFAULTS.<key>` guard), mirroring main.xml.
// dotSize/pillSize/animationDuration 0 = "auto" sentinel; wheelNotchDelta has no schema entry.
var DEFAULTS = Object.freeze({
// Behaviour
enableScroll: true,
scrollWrap: false,
invertScroll: false, // wheel up → next desktop instead of previous
showTooltips: true,
showWindowList: true,
enableAddRemove: true,
enableRename: true,
dynamicWorkspaces: false, // GNOME-style: auto-keep one empty trailing desktop
dynamicNamePrefix: "", // base name for auto-created desktops ("" = i18n default "Desktop")
pillClickAction: 0, // what clicking the CURRENT desktop's pill does; see PILL_CLICK_ACTION (0 = None)
animationDuration: 0, // ms; 0 = follow the theme
// Appearance
dotStyle: 0, // overall look; see DOT_STYLE (0 = Sliding pill, mirrors main.xml)
singleLine: false, // ignore KWin's grid rows: lay every desktop out in ONE strip along the panel
matchDesktopGrid: false, // vertical panel: render the grid in KWin orientation instead of transposing
dotSize: 0, // px; 0 = auto (HiDPI themed)
pillSize: 0, // px pill thickness; 0 = auto (match dots)
spacingFactor: 0.5,
pillWidthFactor: 3.5, // pill length / pill thickness (aspect ratio)
inactiveOpacity: 0.45,
hoverOpacity: 0.8,
showOccupancy: false, // mark the dots of desktops that hold windows
occupiedOpacity: 0.7, // opacity of the occupied marker (all styles); empty < occupied < hover < active
occupancyStyle: 0, // HOW an occupied dot is marked; see OCCUPANCY (0 = Filled, mirrors main.xml)
followThemeColors: true,
activeColor: "#3daee9", // used only when followThemeColors is false
inactiveColor: "#eff0f1", // used only when followThemeColors is false
occupiedColor: "#3daee9", // occupied-marker colour; used only when followThemeColors is false (else theme accent)
wheelNotchDelta: 120 // angleDelta units per mouse notch (no schema entry)
});
// Occupied-dot indicator styles (showOccupancy on). The int values MIRROR the main.xml occupancyStyle
// choices and the ConfigAppearance combo order, so a stored index always maps to the same style. Every
// style marks the OCCUPIED dot using the occupied colour + occupiedOpacity; they differ only in shape:
// Filled — the whole occupied dot is filled with the occupied colour.
// InnerDot — a small occupied-colour dot drawn on top of an otherwise-dim dot.
// Ring — a hollow occupied-colour ring drawn on top of an otherwise-dim dot.
// InnerDot and Ring keep the normal dim dot as their background and add an overlay marker; only Filled
// recolours/brightens the dot body itself.
var OCCUPANCY = Object.freeze({ Filled: 0, InnerDot: 1, Ring: 2 });
// Overall pager look (the top-level style selector — a DIFFERENT axis from OCCUPANCY above). The int
// values MIRROR the main.xml dotStyle choices and the ConfigAppearance combo order, so a stored index
// always maps to the same style.
// Pill — GNOME REFLOW: dim filled dots, the current desktop morphs into a wider highlighted pill.
// Ring — "Filled & ring": no pill, every dot the same size; the current desktop is a solid filled
// circle and non-current desktops are hollow rings (transparent body + border). Occupancy still
// composes via Filled/InnerDot (Ring occupancy is suppressed — see ringOverlayVisible).
var DOT_STYLE = Object.freeze({ Pill: 0, Ring: 1 });
// Is the "Filled & ring" pager look active? The one predicate the dot-style branching keys off — used by
// the ring helpers below and by the QML tier — so the DOT_STYLE.Ring comparison lives in exactly one place.
function isRingStyle(dotStyle) {
return dotStyle === DOT_STYLE.Ring;
}
// Action taken when the ALREADY-CURRENT desktop's pill is clicked (default None). Int values MIRROR the
// main.xml pillClickAction choices and the ConfigGeneral combo order, so a stored index always maps to the
// same action. Each non-None action TOGGLES a KWin global shortcut (see pillClickSpec); clicking an
// inactive dot still just switches desktops.
var PILL_CLICK_ACTION = Object.freeze({ None: 0, ShowDesktop: 1, Overview: 2, Grid: 3 });
// Coerce to string, mapping null/undefined to "". Shared by the sanitize* functions.
function toStringOrEmpty(value) {
return (value === undefined || value === null) ? "" : String(value);
}
// Step the active index by delta → new index in [0, count-1], or -1 to ignore (empty/transient). wrap clamps/wraps.
function stepIndex(currentIndex, count, delta, wrap) {
if (count <= 0)
return -1;
if (currentIndex < 0 || currentIndex >= count)
return -1;
var i = currentIndex + delta;
if (wrap)
return ((i % count) + count) % count; // true modulo (handles negatives)
if (i < 0)
return 0;
if (i > count - 1)
return count - 1;
return i;
}
// Never remove the last desktop — there must always be at least one.
function canRemoveDesktop(count) {
return count > 1;
}
// UUID of the last desktop, or "" when the list is null/empty (guards transient state).
function lastDesktopId(ids) {
if (!ids || ids.length === 0)
return "";
return ids[ids.length - 1];
}
// Current desktop for one screen (Plasma 6.7 per-output): prefer the per-screen value, else global —
// degrades when the screen is unknown, the feature is off, or Plasma is older.
function resolveCurrentDesktop(perScreen, global) {
if (perScreen !== undefined && perScreen !== null && perScreen !== "")
return String(perScreen);
return global ? String(global) : "";
}
// Accumulate hi-res/touchpad wheel deltas and emit whole notches as integer steps. Returns { steps,
// remainder } — feed `remainder` back as `accumulated` next event so sub-notch motion is not lost.
function accumulateWheel(accumulated, deltaY, threshold) {
var t = (threshold > 0) ? threshold : DEFAULTS.wheelNotchDelta;
var total = accumulated + deltaY;
var steps = (total / t) | 0; // truncate toward zero
return { steps: steps, remainder: total - steps * t };
}
// Opacity of the DOT body (brightest first): active capsule full (1.0); hover brightens to hoverOpacity;
// a Filled-style occupied dot (whose body IS the marker) brightens to occupiedOpacity; else inactiveOpacity.
// InnerDot and Ring keep a dim body — their markers are OVERLAYS that carry occupiedOpacity themselves.
// `occupied` is always false when showOccupancy is off, so the empty look is unchanged.
function dotOpacity(active, hovered, occupied, style, inactiveOpacity, hoverOpacity, occupiedOpacity) {
if (active)
return 1.0;
if (hovered)
return hoverOpacity;
if (occupied && style === OCCUPANCY.Filled)
return occupiedOpacity;
return inactiveOpacity;
}
// Which colour fills the dot BODY, from three pre-resolved colours (the caller resolves theme-vs-custom):
// the active capsule → activeColor; a Filled-style occupied dot → occupiedColor; otherwise inactiveColor
// (an empty dot, or the dim body under the InnerDot/Ring styles, whose markers are drawn as overlays on top).
function dotColor(active, occupied, style, activeColor, inactiveColor, occupiedColor) {
if (active)
return activeColor;
if (occupied && style === OCCUPANCY.Filled)
return occupiedColor;
return inactiveColor;
}
// Ring outline/border thickness for a given dot diameter (px, min 1). Shared by the "Filled & ring" body
// outline and the Ring-occupancy overlay rim — one geometry rule, the `0.18` factor in a single place.
function ringThickness(dotSize) {
return Math.max(1, Math.round(dotSize * 0.18));
}
// Inner-dot occupancy marker diameter for a given dot diameter (the InnerDot style centre dot) — a
// fixed fraction of the dot, kept here so the geometry constant lives in one place (cf. ringThickness).
function innerDotDiameter(dotSize) {
return dotSize * 0.45;
}
// "Filled & ring" dot-style (DOT_STYLE.Ring): does THIS dot draw the ring OUTLINE (border)? Every
// non-current dot does, regardless of occupancy — so a Filled-occupied dot is a filled disc WITH the
// ring still around it ("ring and dot background"). Always false in the Pill style and for the current
// (filled-circle) dot.
function dotHasRing(dotStyle, active) {
return isRingStyle(dotStyle) && !active;
}
// "Filled & ring" dot-style: is the dot's INTERIOR hollow (transparent fill)? True for a non-current ring
// dot UNLESS the Filled occupancy marker is filling its interior (occupied + Filled). Decoupled from
// dotHasRing so an occupied+Filled dot keeps its ring outline but gets a filled background. Always false
// in the Pill style, so the default look is unchanged.
function dotBodyIsHollow(dotStyle, active, occupied, occupancyStyle) {
if (!isRingStyle(dotStyle))
return false;
if (active)
return false; // current desktop: filled circle
if (occupied && occupancyStyle === OCCUPANCY.Filled)
return false; // Filled occupancy fills the ring interior
return true;
}
// "Filled & ring" dot-style: is the dot's INTERIOR filled rather than hollow? The third body state — a ring
// OUTLINE plus a filled background (occupied + Filled occupancy). Composed from dotHasRing/dotBodyIsHollow
// so all three ring body predicates live (and are tested) in one place. Always false in the Pill style.
function dotBodyFilled(dotStyle, active, occupied, occupancyStyle) {
return dotHasRing(dotStyle, active) && !dotBodyIsHollow(dotStyle, active, occupied, occupancyStyle);
}
// Ring style: an OCCUPIED inactive dot shows a hollow occupied-colour ring OVERLAY on top of the dim dot
// (empty/active do not). Suppressed in the DOT_STYLE.Ring look, where the dot body is ALREADY a ring
// (a ring-on-a-ring would be redundant), so Ring occupancy has no visible effect in that style.
function ringOverlayVisible(active, occupied, style, dotStyle) {
return style === OCCUPANCY.Ring && !active && occupied && !isRingStyle(dotStyle);
}
// InnerDot style: an OCCUPIED inactive dot shows a small occupied-colour dot OVERLAY in its centre (empty/active do not).
function innerDotVisible(active, occupied, style) {
return style === OCCUPANCY.InnerDot && !active && occupied;
}
// Morph duration: reduce-animations (themeDuration <= 0) wins → 0; else the override, else the themed default.
function effectiveDuration(requested, themeDuration) {
if (themeDuration <= 0)
return 0;
return requested > 0 ? requested : themeDuration;
}
// Desktops per line, mirroring KWin's grid: columns = ceil(count / rows). 0 for empty; missing/<1 rows → 1.
function gridColumns(count, rows) {
if (count <= 0)
return 0;
var r = (rows && rows > 0) ? rows : 1;
return Math.ceil(count / r);
}
// Split `arr` into row-major chunks of at most `size` (the grid lines; last may be shorter). [] for null/empty/size<1.
function chunk(arr, size) {
if (!arr || arr.length === 0 || !size || size < 1)
return [];
var out = [];
for (var i = 0; i < arr.length; i += size)
out.push(arr.slice(i, i + size));
return out;
}
// Shallow element-wise equality for arrays of primitives — the aggregator's compare-before-assign
// guard (a QML var property notifies on every reassignment, even to an equal fresh array).
function arraysShallowEqual(a, b) {
if (a === b)
return true;
if (!a || !b || a.length !== b.length)
return false;
for (var i = 0; i < a.length; i++)
if (a[i] !== b[i])
return false;
return true;
}
// Total extent of one reflow line: `count` slots with uniform `gap`, exactly ONE active capsule
// (`activeExtent`), the rest dots. `dotSize` for count <= 0. Cross axis passes activeExtent == dotSize.
function lineExtent(count, dotSize, gap, activeExtent) {
if (count <= 0)
return dotSize;
return activeExtent + (count - 1) * (dotSize + gap);
}
// Dot size that makes ONE full line exactly fill `available` — the inverse of lineExtent. +Infinity
// when there's nothing to fit (so the caller's min(natural, fit) keeps natural). Caller clamps.
function fitDotSize(available, perLine, pillWidthFactor, spacingFactor) {
if (available <= 0 || perLine <= 0)
return Number.POSITIVE_INFINITY;
var denom = pillWidthFactor + (perLine - 1) * (1 + spacingFactor);
if (denom <= 0)
return Number.POSITIVE_INFINITY;
return available / denom;
}
// Title count before "…and N other windows": 4, but all 5 when exactly 5 (stock KDE pager rule).
function windowListMaximum(count) {
return count === 5 ? 5 : 4;
}
// HTML-escape a window title for the rich-text tooltip: markup chars + no-break space, NOT the ordinary space (must wrap).
function sanitizeHtml(input) {
var table = {
">": "&gt;",
"<": "&lt;",
"&": "&amp;",
"'": "&apos;",
"\"": "&quot;",
"\u00a0": "&nbsp;"
};
return toStringOrEmpty(input).replace(/[<>&'"\u00a0]/g, function (c) {
return table[c];
});
}
// Cap (chars) on a user-entered desktop name, so an absurd name stays sane in the tooltip/markup.
var MAX_DESKTOP_NAME_LENGTH = 100;
// Normalise a user-entered name before the setDesktopName write: trim, empty/whitespace → "" (no-op sentinel), cap length.
function sanitizeDesktopName(input) {
var s = toStringOrEmpty(input).trim();
if (s.length === 0)
return "";
return s.length > MAX_DESKTOP_NAME_LENGTH ? s.slice(0, MAX_DESKTOP_NAME_LENGTH) : s;
}
// Does `window`'s own `desktops` list name `uuid`? The membership primitive shared by the tooltip and
// occupancy predicates below (each adds its own on-all/skipPager handling). Missing list → false.
function windowListsDesktop(window, uuid) {
return !!(window.desktops && window.desktops.indexOf(uuid) !== -1);
}
// Tooltip membership: a real window that is on-all or whose `desktops` lists uuid. Null/missing → false.
function windowIsOnDesktop(window, uuid) {
if (!window || !window.isWindow)
return false;
return !!(window.onAll || windowListsDesktop(window, uuid));
}
// Group a flat window snapshot into per-desktop { visible:[title…], minimized:[title…] }, index-aligned
// with `desktopIds`. Titles stay RAW (i18n + HTML happen in main.qml). Null windows → empty; null ids → [].
function groupWindowsByDesktop(windows, desktopIds) {
if (!desktopIds || desktopIds.length === 0)
return [];
var wins = windows || [];
var out = [];
for (var d = 0; d < desktopIds.length; d++) {
var uuid = desktopIds[d];
var visible = [];
var minimized = [];
for (var i = 0; i < wins.length; i++) {
var w = wins[i];
if (!windowIsOnDesktop(w, uuid))
continue;
if (w.minimized)
minimized.push(w.title);
else
visible.push(w.title);
}
out.push({ visible: visible, minimized: minimized });
}
return out;
}
// Dynamic workspaces (GNOME-style, default OFF): the PURE decision layer keeping one empty trailing
// desktop — main.qml dispatches the single add/remove these return.
// Does `window` make a desktop NON-EMPTY for dynamic workspaces? Real window only; UNLIKE
// windowIsOnDesktop, on-all/skipPager do NOT count (would pin every desktop); minimized DO count.
function windowOccupiesDesktop(window, uuid) {
if (!window || !window.isWindow)
return false;
if (window.onAll || window.skipPager)
return false;
return windowListsDesktop(window, uuid);
}
// Reduce a window snapshot to a per-desktop occupancy boolean[], index-aligned with `desktopIds`:
// each entry is true when ANY window satisfies the `occupies(window, uuid)` predicate. Null windows →
// all-false; null/empty ids → []. The shared scaffold for the global and per-screen reducers below.
function foldDesktopOccupancy(windows, desktopIds, occupies) {
if (!desktopIds || desktopIds.length === 0)
return [];
var wins = windows || [];
var out = [];
for (var d = 0; d < desktopIds.length; d++) {
var uuid = desktopIds[d];
var occupied = false;
for (var i = 0; i < wins.length; i++) {
if (occupies(wins[i], uuid)) {
occupied = true;
break;
}
}
out.push(occupied);
}
return out;
}
// Global (screen-agnostic) per-desktop occupancy boolean[], index-aligned with `desktopIds`.
function computeDesktopOccupancy(windows, desktopIds) {
return foldDesktopOccupancy(windows, desktopIds, windowOccupiesDesktop);
}
// A usable screen rect: present with a positive size. A null/zero rect means "don't know" → callers
// fall back to GLOBAL (screen-agnostic) occupancy rather than hiding windows (robustness.md).
function isValidScreenRect(r) {
return !!r && r.width > 0 && r.height > 0;
}
// Per-screen occupancy (Plasma 6.7 "switch desktops independently per screen"): a window only marks a
// desktop occupied on the pager whose monitor it is physically on. Extends windowOccupiesDesktop with a
// screen-ORIGIN match (each output has a unique top-left; width/height can differ between the window's
// reported screen rect and the pager's under per-output scaling, so compare (x,y) only — integers, exact).
// NEVER drops a window: an unknown target rect (pager not placed) OR an unknown own screen (e.g. a window
// with no geometry) counts everywhere, degrading to the global behaviour.
function windowOccupiesDesktopOnScreen(window, uuid, screenRect) {
if (!windowOccupiesDesktop(window, uuid))
return false;
if (!isValidScreenRect(screenRect))
return true;
var ws = window.screen;
if (!isValidScreenRect(ws))
return true;
return ws.x === screenRect.x && ws.y === screenRect.y;
}
// Per-desktop occupancy boolean[] for ONE pager's screen, index-aligned with `desktopIds`. An unknown
// `screenRect` delegates to computeDesktopOccupancy → the byte-identical GLOBAL array, so single-monitor
// setups and pre-placement frames behave exactly as before (no per-screen difference).
function computeDesktopOccupancyForScreen(windows, desktopIds, screenRect) {
if (!isValidScreenRect(screenRect))
return computeDesktopOccupancy(windows, desktopIds);
return foldDesktopOccupancy(windows, desktopIds, function (w, uuid) {
return windowOccupiesDesktopOnScreen(w, uuid, screenRect);
});
}
// The SINGLE dynamic-workspace action, or null (one per call → re-triggering converges to one trailing
// empty): 0 trailing empties → add; >=2 → remove the LAST; else null. Only the trailing run is managed.
// Transient frames no-op (null/empty arrays, or occupancy.length !== desktopIds.length).
function dynamicWorkspacePlan(occupancy, desktopIds) {
if (!occupancy || !desktopIds)
return null;
var n = desktopIds.length;
if (n === 0 || occupancy.length !== n)
return null;
var trailing = 0;
for (var i = n - 1; i >= 0 && !occupancy[i]; i--)
trailing++;
if (trailing === 0)
return { kind: "add" };
if (trailing >= 2 && canRemoveDesktop(n))
return { kind: "remove", uuid: desktopIds[n - 1] };
return null;
}
// Name for an auto-created desktop: "<base> <number>". NEVER empty — KWin silently drops createDesktop on an empty name.
function formatDynamicDesktopName(prefix, number, fallback) {
var base = sanitizeDesktopName(prefix);
if (base === "")
base = sanitizeDesktopName(fallback);
if (base === "")
base = "Desktop";
return base + " " + number;
}
// Elect the single dynamic-workspace "writer" among the pager instances: the ENABLED instance with the
// smallest coordinator token (-1 when none enabled). Without it two pagers double-create on a fill → flash.
function electDynamicWriter(registry) {
if (!registry)
return -1;
var winner = -1;
for (var token in registry) {
if (!registry[token])
continue;
var t = Number(token);
if (winner === -1 || t < winner)
winner = t;
}
return winner;
}
// Should a TasksModel dataChanged(…, roles) trigger a rebuild? Only when a relevant role changed —
// skips the high-frequency IsActive focus churn. Empty/absent `changedRoles` is Qt's "all changed" → yes.
function dataChangeAffectsRoles(changedRoles, relevantRoles) {
if (!changedRoles || changedRoles.length === 0)
return true;
for (var i = 0; i < changedRoles.length; i++)
if (relevantRoles.indexOf(changedRoles[i]) !== -1)
return true;
return false;
}
/*
* KWin DBus call SHAPES. Each builder returns { service, path, iface, member, args } (or null on a
* robustness guard); main.qml maps each arg { t, v } to a DBus.* constructor. The exact strings/types
* matter — a wrong one fails SILENTLY (KWin drops the call), so these are unit-tested.
*/
var KWIN_SERVICE = "org.kde.KWin";
var KWIN_VDM_PATH = "/VirtualDesktopManager";
var KWIN_VDM_IFACE = "org.kde.KWin.VirtualDesktopManager";
var DBUS_PROPERTIES_IFACE = "org.freedesktop.DBus.Properties";
// kglobalaccel's public Component interface: invokeShortcut(uniqueName) TOGGLES a KWin global shortcut.
// Used by the pill-click action — public/stable and avoids the version-suffixed effect DBus paths (e.g.
// /org/kde/KWin/Effect/Overview/<ver>) that break across KWin upgrades. Same session bus as KWin.
var KGLOBALACCEL_SERVICE = "org.kde.kglobalaccel";
var KGLOBALACCEL_KWIN_PATH = "/component/kwin";
var KGLOBALACCEL_COMPONENT_IFACE = "org.kde.kglobalaccel.Component";
// Shared envelope for the createDesktop/removeDesktop/setDesktopName writes (all on KWIN_VDM_IFACE;
// switchSpec differs). Key order is load-bearing — tst_logic compares specs via JSON.stringify.
function vdmCall(member, args) {
return {
service: KWIN_SERVICE,
path: KWIN_VDM_PATH,
iface: KWIN_VDM_IFACE,
member: member,
args: args
};
}
// Switch the (global) current desktop to `uuid` via the VirtualDesktopManager "current" property (null
// for a falsy uuid). The variant arg wraps a PLAIN string — a wrapped DBus.string is silently rejected.
function switchSpec(uuid) {
if (!uuid)
return null;
return {
service: KWIN_SERVICE,
path: KWIN_VDM_PATH,
iface: DBUS_PROPERTIES_IFACE,
member: "Set",
args: [{ t: "s", v: KWIN_VDM_IFACE }, { t: "s", v: "current" }, { t: "v", v: uuid }]
};
}
// Append a new desktop at `position` (createDesktop(uint32, string)). `position|0` coerces a transient undefined/NaN to 0.
function addSpec(position, name) {
return vdmCall("createDesktop", [{ t: "u", v: position | 0 }, { t: "s", v: String(name) }]);
}
// Remove the desktop `uuid` (removeDesktop(string)). null for a falsy uuid OR count <= 1 (never-remove-last).
function removeSpec(uuid, count) {
if (!uuid || !canRemoveDesktop(count))
return null;
return vdmCall("removeDesktop", [{ t: "s", v: uuid }]);
}
// Rename `uuid` to `name` (setDesktopName(string, string)) via sanitizeDesktopName; null for falsy uuid / empty name.
function renameSpec(uuid, name) {
var clean = sanitizeDesktopName(name);
if (!uuid || !clean)
return null;
return vdmCall("setDesktopName", [{ t: "s", v: uuid }, { t: "s", v: clean }]);
}
// Invoke (toggle) a KWin global shortcut by its unique name (invokeShortcut(string)); null for a falsy
// name. Key order is load-bearing — tst_logic compares specs via JSON.stringify.
function invokeShortcutSpec(name) {
if (!name)
return null;
return {
service: KGLOBALACCEL_SERVICE,
path: KGLOBALACCEL_KWIN_PATH,
iface: KGLOBALACCEL_COMPONENT_IFACE,
member: "invokeShortcut",
args: [{ t: "s", v: name }]
};
}
// Map a pill-click action (PILL_CLICK_ACTION) to its KWin shortcut spec, or null for None / any unknown
// value (a safe no-op). The shortcut UNIQUE NAMES are DBus identifiers (verified live) — NEVER i18n-wrapped,
// which is why they live here in the i18n-free logic tier. KWin's name for the "Grid" option is "Grid View".
function pillClickSpec(action) {
switch (action) {
case PILL_CLICK_ACTION.ShowDesktop:
return invokeShortcutSpec("Show Desktop");
case PILL_CLICK_ACTION.Overview:
return invokeShortcutSpec("Overview");
case PILL_CLICK_ACTION.Grid:
return invokeShortcutSpec("Grid View");
default:
return null;
}
}
@@ -0,0 +1,300 @@
/*
* 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)
}
}