Files
T

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 = {
">": "&gt;",
"<": "&lt;",
"&": "&amp;",
"'": "&apos;",
"\"": "&quot;",
"\u00a0": "&nbsp;"
};
return toStringOrEmpty(input).replace(/[<>&'"\u00a0]/g, function (c) {
return table[c];
});
}
// Cap (chars) on a user-entered desktop name, so an absurd name stays sane in the tooltip/markup.
var MAX_DESKTOP_NAME_LENGTH = 100;
// Normalise a user-entered name before the setDesktopName write: trim, empty/whitespace → "" (no-op sentinel), cap length.
function sanitizeDesktopName(input) {
var s = toStringOrEmpty(input).trim();
if (s.length === 0)
return "";
return s.length > MAX_DESKTOP_NAME_LENGTH ? s.slice(0, MAX_DESKTOP_NAME_LENGTH) : s;
}
// Does `window`'s own `desktops` list name `uuid`? The membership primitive shared by the tooltip and
// occupancy predicates below (each adds its own on-all/skipPager handling). Missing list → false.
function windowListsDesktop(window, uuid) {
return !!(window.desktops && window.desktops.indexOf(uuid) !== -1);
}
// Tooltip membership: a real window that is on-all or whose `desktops` lists uuid. Null/missing → false.
function windowIsOnDesktop(window, uuid) {
if (!window || !window.isWindow)
return false;
return !!(window.onAll || windowListsDesktop(window, uuid));
}
// Group a flat window snapshot into per-desktop { visible:[title…], minimized:[title…] }, index-aligned
// with `desktopIds`. Titles stay RAW (i18n + HTML happen in main.qml). Null windows → empty; null ids → [].
function groupWindowsByDesktop(windows, desktopIds) {
if (!desktopIds || desktopIds.length === 0)
return [];
var wins = windows || [];
var out = [];
for (var d = 0; d < desktopIds.length; d++) {
var uuid = desktopIds[d];
var visible = [];
var minimized = [];
for (var i = 0; i < wins.length; i++) {
var w = wins[i];
if (!windowIsOnDesktop(w, uuid))
continue;
if (w.minimized)
minimized.push(w.title);
else
visible.push(w.title);
}
out.push({ visible: visible, minimized: minimized });
}
return out;
}
// Dynamic workspaces (GNOME-style, default OFF): the PURE decision layer keeping one empty trailing
// desktop — main.qml dispatches the single add/remove these return.
// Does `window` make a desktop NON-EMPTY for dynamic workspaces? Real window only; UNLIKE
// windowIsOnDesktop, on-all/skipPager do NOT count (would pin every desktop); minimized DO count.
function windowOccupiesDesktop(window, uuid) {
if (!window || !window.isWindow)
return false;
if (window.onAll || window.skipPager)
return false;
return windowListsDesktop(window, uuid);
}
// Reduce a window snapshot to a per-desktop occupancy boolean[], index-aligned with `desktopIds`:
// each entry is true when ANY window satisfies the `occupies(window, uuid)` predicate. Null windows →
// all-false; null/empty ids → []. The shared scaffold for the global and per-screen reducers below.
function foldDesktopOccupancy(windows, desktopIds, occupies) {
if (!desktopIds || desktopIds.length === 0)
return [];
var wins = windows || [];
var out = [];
for (var d = 0; d < desktopIds.length; d++) {
var uuid = desktopIds[d];
var occupied = false;
for (var i = 0; i < wins.length; i++) {
if (occupies(wins[i], uuid)) {
occupied = true;
break;
}
}
out.push(occupied);
}
return out;
}
// Global (screen-agnostic) per-desktop occupancy boolean[], index-aligned with `desktopIds`.
function computeDesktopOccupancy(windows, desktopIds) {
return foldDesktopOccupancy(windows, desktopIds, windowOccupiesDesktop);
}
// A usable screen rect: present with a positive size. A null/zero rect means "don't know" → callers
// fall back to GLOBAL (screen-agnostic) occupancy rather than hiding windows (robustness.md).
function isValidScreenRect(r) {
return !!r && r.width > 0 && r.height > 0;
}
// Per-screen occupancy (Plasma 6.7 "switch desktops independently per screen"): a window only marks a
// desktop occupied on the pager whose monitor it is physically on. Extends windowOccupiesDesktop with a
// screen-ORIGIN match (each output has a unique top-left; width/height can differ between the window's
// reported screen rect and the pager's under per-output scaling, so compare (x,y) only — integers, exact).
// NEVER drops a window: an unknown target rect (pager not placed) OR an unknown own screen (e.g. a window
// with no geometry) counts everywhere, degrading to the global behaviour.
function windowOccupiesDesktopOnScreen(window, uuid, screenRect) {
if (!windowOccupiesDesktop(window, uuid))
return false;
if (!isValidScreenRect(screenRect))
return true;
var ws = window.screen;
if (!isValidScreenRect(ws))
return true;
return ws.x === screenRect.x && ws.y === screenRect.y;
}
// Per-desktop occupancy boolean[] for ONE pager's screen, index-aligned with `desktopIds`. An unknown
// `screenRect` delegates to computeDesktopOccupancy → the byte-identical GLOBAL array, so single-monitor
// setups and pre-placement frames behave exactly as before (no per-screen difference).
function computeDesktopOccupancyForScreen(windows, desktopIds, screenRect) {
if (!isValidScreenRect(screenRect))
return computeDesktopOccupancy(windows, desktopIds);
return foldDesktopOccupancy(windows, desktopIds, function (w, uuid) {
return windowOccupiesDesktopOnScreen(w, uuid, screenRect);
});
}
// The SINGLE dynamic-workspace action, or null (one per call → re-triggering converges to one trailing
// empty): 0 trailing empties → add; >=2 → remove the LAST; else null. Only the trailing run is managed.
// Transient frames no-op (null/empty arrays, or occupancy.length !== desktopIds.length).
function dynamicWorkspacePlan(occupancy, desktopIds) {
if (!occupancy || !desktopIds)
return null;
var n = desktopIds.length;
if (n === 0 || occupancy.length !== n)
return null;
var trailing = 0;
for (var i = n - 1; i >= 0 && !occupancy[i]; i--)
trailing++;
if (trailing === 0)
return { kind: "add" };
if (trailing >= 2 && canRemoveDesktop(n))
return { kind: "remove", uuid: desktopIds[n - 1] };
return null;
}
// Name for an auto-created desktop: "<base> <number>". NEVER empty — KWin silently drops createDesktop on an empty name.
function formatDynamicDesktopName(prefix, number, fallback) {
var base = sanitizeDesktopName(prefix);
if (base === "")
base = sanitizeDesktopName(fallback);
if (base === "")
base = "Desktop";
return base + " " + number;
}
// Elect the single dynamic-workspace "writer" among the pager instances: the ENABLED instance with the
// smallest coordinator token (-1 when none enabled). Without it two pagers double-create on a fill → flash.
function electDynamicWriter(registry) {
if (!registry)
return -1;
var winner = -1;
for (var token in registry) {
if (!registry[token])
continue;
var t = Number(token);
if (winner === -1 || t < winner)
winner = t;
}
return winner;
}
// Should a TasksModel dataChanged(…, roles) trigger a rebuild? Only when a relevant role changed —
// skips the high-frequency IsActive focus churn. Empty/absent `changedRoles` is Qt's "all changed" → yes.
function dataChangeAffectsRoles(changedRoles, relevantRoles) {
if (!changedRoles || changedRoles.length === 0)
return true;
for (var i = 0; i < changedRoles.length; i++)
if (relevantRoles.indexOf(changedRoles[i]) !== -1)
return true;
return false;
}
/*
* KWin DBus call SHAPES. Each builder returns { service, path, iface, member, args } (or null on a
* robustness guard); main.qml maps each arg { t, v } to a DBus.* constructor. The exact strings/types
* matter — a wrong one fails SILENTLY (KWin drops the call), so these are unit-tested.
*/
var KWIN_SERVICE = "org.kde.KWin";
var KWIN_VDM_PATH = "/VirtualDesktopManager";
var KWIN_VDM_IFACE = "org.kde.KWin.VirtualDesktopManager";
var DBUS_PROPERTIES_IFACE = "org.freedesktop.DBus.Properties";
// kglobalaccel's public Component interface: invokeShortcut(uniqueName) TOGGLES a KWin global shortcut.
// Used by the pill-click action — public/stable and avoids the version-suffixed effect DBus paths (e.g.
// /org/kde/KWin/Effect/Overview/<ver>) that break across KWin upgrades. Same session bus as KWin.
var KGLOBALACCEL_SERVICE = "org.kde.kglobalaccel";
var KGLOBALACCEL_KWIN_PATH = "/component/kwin";
var KGLOBALACCEL_COMPONENT_IFACE = "org.kde.kglobalaccel.Component";
// Shared envelope for the createDesktop/removeDesktop/setDesktopName writes (all on KWIN_VDM_IFACE;
// switchSpec differs). Key order is load-bearing — tst_logic compares specs via JSON.stringify.
function vdmCall(member, args) {
return {
service: KWIN_SERVICE,
path: KWIN_VDM_PATH,
iface: KWIN_VDM_IFACE,
member: member,
args: args
};
}
// Switch the (global) current desktop to `uuid` via the VirtualDesktopManager "current" property (null
// for a falsy uuid). The variant arg wraps a PLAIN string — a wrapped DBus.string is silently rejected.
function switchSpec(uuid) {
if (!uuid)
return null;
return {
service: KWIN_SERVICE,
path: KWIN_VDM_PATH,
iface: DBUS_PROPERTIES_IFACE,
member: "Set",
args: [{ t: "s", v: KWIN_VDM_IFACE }, { t: "s", v: "current" }, { t: "v", v: uuid }]
};
}
// Append a new desktop at `position` (createDesktop(uint32, string)). `position|0` coerces a transient undefined/NaN to 0.
function addSpec(position, name) {
return vdmCall("createDesktop", [{ t: "u", v: position | 0 }, { t: "s", v: String(name) }]);
}
// Remove the desktop `uuid` (removeDesktop(string)). null for a falsy uuid OR count <= 1 (never-remove-last).
function removeSpec(uuid, count) {
if (!uuid || !canRemoveDesktop(count))
return null;
return vdmCall("removeDesktop", [{ t: "s", v: uuid }]);
}
// Rename `uuid` to `name` (setDesktopName(string, string)) via sanitizeDesktopName; null for falsy uuid / empty name.
function renameSpec(uuid, name) {
var clean = sanitizeDesktopName(name);
if (!uuid || !clean)
return null;
return vdmCall("setDesktopName", [{ t: "s", v: uuid }, { t: "s", v: clean }]);
}
// Invoke (toggle) a KWin global shortcut by its unique name (invokeShortcut(string)); null for a falsy
// name. Key order is load-bearing — tst_logic compares specs via JSON.stringify.
function invokeShortcutSpec(name) {
if (!name)
return null;
return {
service: KGLOBALACCEL_SERVICE,
path: KGLOBALACCEL_KWIN_PATH,
iface: KGLOBALACCEL_COMPONENT_IFACE,
member: "invokeShortcut",
args: [{ t: "s", v: name }]
};
}
// Map a pill-click action (PILL_CLICK_ACTION) to its KWin shortcut spec, or null for None / any unknown
// value (a safe no-op). The shortcut UNIQUE NAMES are DBus identifiers (verified live) — NEVER i18n-wrapped,
// which is why they live here in the i18n-free logic tier. KWin's name for the "Grid" option is "Grid View".
function pillClickSpec(action) {
switch (action) {
case PILL_CLICK_ACTION.ShowDesktop:
return invokeShortcutSpec("Show Desktop");
case PILL_CLICK_ACTION.Overview:
return invokeShortcutSpec("Overview");
case PILL_CLICK_ACTION.Grid:
return invokeShortcutSpec("Grid View");
default:
return null;
}
}