557 lines
25 KiB
JavaScript
557 lines
25 KiB
JavaScript
/*
|
|
* 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;
|
|
}
|
|
}
|