plasmoides KDE Store/GitHub (gnome-pager, kMenu, kvitals, windowtitle, claude, launchpad)
This commit is contained in:
+117
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -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))
|
||||
}
|
||||
+80
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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()
|
||||
}
|
||||
+209
@@ -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()
|
||||
}
|
||||
+199
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+281
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+251
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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()
|
||||
}
|
||||
]
|
||||
}
|
||||
+61
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -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);
|
||||
}
|
||||
+556
@@ -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 = {
|
||||
">": ">",
|
||||
"<": "<",
|
||||
"&": "&",
|
||||
"'": "'",
|
||||
"\"": """,
|
||||
"\u00a0": " "
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
+300
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user