27 Commits
v0.13 ... v0.15

Author SHA1 Message Date
Peter Fajdiga
7d4eab03b9 bump version to 0.15 2025-11-09 20:57:31 +01:00
Peter Fajdiga
7070e59044 mark cursorFollowsFocus setting as experimental 2025-10-22 21:53:09 +02:00
Peter Fajdiga
7f5745b2cf fix cursorFollowsFocus setting after c7effc8913 2025-10-22 21:01:06 +02:00
Peter Fajdiga
c7752bf20a add error notification for invalid tiled desktops regex 2025-10-21 23:01:00 +02:00
Peter Fajdiga
8149100aac DesktopManager: remove addDesktop call in constructor 2025-10-21 22:57:02 +02:00
Peter Fajdiga
99bf71f0b9 config.ui: shorten tooltip for kcfg_tiledDesktops 2025-10-21 22:48:13 +02:00
Peter Fajdiga
2b882768d9 config.ui: don't use monospace font for kcfg_tiledDesktops editbox 2025-10-21 22:45:42 +02:00
Peter Fajdiga
bb42e4d3ad clean whitespace 2025-10-21 22:16:12 +02:00
SR_team
c7effc8913 Add Desktops settings tab to control which virtual desktops Karousel operates on (#133)
* Add Desktops settings tab to control which virtual desktops Karousel operates on

* Fix cursor follow focus to only work on matched desktops

* Resolve review comments

- Implement a RegExp-based DesktopFilter.
- Add new config key tiledDesktops (String) with default ".*" (match all).
- Make DesktopManager return undefined for desktops that should not be tiled.
- Mark KWinDesktop.name as readonly.
- Replace multiline desktop editor in settings UI with a single-line QLineEdit (moved to top of Window Rules tab), shorten label to "Tiled desktops:" and add a tooltip with examples.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-10-21 21:06:25 +02:00
Peter Fajdiga
2c433867f3 bump package to 0.14 2025-09-28 09:39:17 +02:00
Peter Fajdiga
e995555074 tests: passFocus: remove assertion for null activeWindow 2025-09-13 17:31:12 +02:00
Peter Fajdiga
6a1e018df1 tests: MockWorkspace: only focus a different window if none is focused 2025-09-13 16:58:44 +02:00
Peter Fajdiga
872a67e6e1 clear Focus Passer if another window is focused by anyone 2025-09-13 15:32:19 +02:00
Peter Fajdiga
5a57ba76d8 only clear Focus Passer if another window is focused by Karousel 2025-09-13 15:31:44 +02:00
Peter Fajdiga
55c6932338 always pass Window to Focus Passer 2025-09-13 15:02:27 +02:00
Peter Fajdiga
b1d6193e48 tests: make mocks more accurately mimic kwin behaviour 2025-09-13 12:42:22 +02:00
Peter Fajdiga
456bbf30b4 fix focus passing when moving a column to another desktop 2025-09-13 12:41:21 +02:00
Peter Fajdiga
24c1fa0a38 uncomment passFocus test 2025-09-13 12:26:57 +02:00
Peter Fajdiga
ac7566d2cf fix focus passing when closing windows 2025-09-07 20:37:00 +02:00
Peter Fajdiga
195f4e6d30 don't pass focus when window is moved and followed to a different desktop (issue 116) 2025-09-07 20:37:00 +02:00
Peter Fajdiga
e8f2a50420 tests: add test for kwin shortucts for moving windows to adjacent desktops 2025-09-07 20:37:00 +02:00
Peter Fajdiga
9910bc7041 tests: MockWorkspace.removeWindow: unfocus before focusing 2025-09-07 20:37:00 +02:00
Peter Fajdiga
1b592c5b4b fix detection of full-screen windows for apps that manage their own window decorations 2025-09-07 19:45:36 +02:00
Peter Fajdiga
75384d9fb4 tests: make runOneOf functions optionally return a value 2025-09-07 19:45:36 +02:00
Peter Fajdiga
dba92d3826 upgrade node modules 2025-09-07 19:45:36 +02:00
Peter Fajdiga
dbb95e0470 enable eslint comma-dangle 2025-09-07 19:45:34 +02:00
Peter Fajdiga
056149440d readme: add mention of npm requirement 2025-05-14 21:22:05 +02:00
43 changed files with 640 additions and 232 deletions

View File

@@ -27,7 +27,7 @@ First install the _org.kde.notification_ QML module (_qml-module-org-kde-notific
Then download the [latest release](https://github.com/peterfajdiga/karousel/releases/latest) and extract it into _~/.local/share/kwin/scripts/_.
Or clone the repo and run `make install` (requires node and tsc).
Or clone the repo and run `make install` (requires npm, node, and tsc).
## Key bindings
The key bindings can be configured in KDE System Settings among KWin's own keyboard shortcuts.

View File

@@ -8,6 +8,7 @@ export default tseslint.config(
rules: {
"@typescript-eslint/no-empty-function": "off",
"semi": "error",
"comma-dangle": ["error", "always-multiline"],
"indent": ["error", 4],
},
}

117
package-lock.json generated
View File

@@ -10,9 +10,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.1.tgz",
"integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==",
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.8.0.tgz",
"integrity": "sha512-MJQFqrZgcW0UNYLGOuQpey/oTN59vyWwplvCGZztn1cKz9agZPPYpJB7h2OMmuu7VLqkvEjN8feFZJmxNF9D+Q==",
"dev": true,
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
@@ -49,9 +49,9 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
"integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dev": true,
"dependencies": {
"@eslint/object-schema": "^2.1.6",
@@ -63,18 +63,18 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz",
"integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
@@ -107,12 +107,15 @@
}
},
"node_modules/@eslint/js": {
"version": "9.24.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz",
"integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==",
"version": "9.35.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz",
"integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
@@ -125,30 +128,18 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz",
"integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==",
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true,
"dependencies": {
"@eslint/core": "^0.13.0",
"@eslint/core": "^0.15.2",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz",
"integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
"dev": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -390,9 +381,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
@@ -454,9 +445,9 @@
}
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
@@ -518,9 +509,9 @@
"dev": true
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
@@ -638,19 +629,19 @@
}
},
"node_modules/eslint": {
"version": "9.24.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
"version": "9.35.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz",
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.20.0",
"@eslint/config-helpers": "^0.2.0",
"@eslint/core": "^0.12.0",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.1",
"@eslint/core": "^0.15.2",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.24.0",
"@eslint/plugin-kit": "^0.2.7",
"@eslint/js": "9.35.0",
"@eslint/plugin-kit": "^0.3.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -661,9 +652,9 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.3.0",
"eslint-visitor-keys": "^4.2.0",
"espree": "^10.3.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@@ -698,9 +689,9 @@
}
},
"node_modules/eslint-scope": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"dependencies": {
"esrecurse": "^4.3.0",
@@ -714,9 +705,9 @@
}
},
"node_modules/eslint-visitor-keys": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -726,14 +717,14 @@
}
},
"node_modules/espree": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"dependencies": {
"acorn": "^8.14.0",
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.0"
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@@ -32,7 +32,7 @@
<item>
<widget class="QCheckBox" name="kcfg_cursorFollowsFocus">
<property name="text">
<string>Cursor follows focus</string>
<string>Cursor follows focus (experimental)</string>
</property>
<property name="toolTip">
<string>When a window gains focus, move the cursor to it</string>
@@ -458,6 +458,27 @@
<string>Window Rules</string>
</attribute>
<layout class="QVBoxLayout">
<item>
<layout class="QHBoxLayout">
<item>
<widget class="QLabel" name="label_desktops">
<property name="text">
<string>Tiled desktops:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="kcfg_tiledDesktops">
<property name="toolTip">
<string>Regex string to match desktops by desktop name"</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPlainTextEdit" name="kcfg_windowRules">
<property name="tabChangesFocus">

View File

@@ -16,6 +16,16 @@ Item {
qmlBase.karouselInstance.destroy();
}
Notification {
id: notificationInvalidTiledDesktops
componentName: "plasma_workspace"
eventId: "notification"
title: "Karousel"
text: "Your Tiled Desktops regex is malformed, please review your Karousel configuration"
flags: Notification.Persistent
urgency: Notification.HighUrgency
}
Notification {
id: notificationInvalidWindowRules
componentName: "plasma_workspace"

View File

@@ -9,7 +9,7 @@
"Name": "Peter Fajdiga"
}],
"Id": "karousel",
"Version": "0.13",
"Version": "0.15",
"License": "GPLv3",
"Website": "https://github.com/peterfajdiga/karousel",
"BugReportUrl": "https://github.com/peterfajdiga/karousel/issues"

View File

@@ -2,6 +2,7 @@ declare const Qt: Qt;
declare const KWin: KWin;
declare const Workspace: Workspace;
declare const qmlBase: QmlObject;
declare const notificationInvalidTiledDesktops: Notification;
declare const notificationInvalidWindowRules: Notification;
declare const notificationInvalidPresetWidths: Notification;
declare const moveCursorToFocus: DBusCall;

View File

@@ -15,9 +15,9 @@ function printCols(...columns: (string[] | string)[]) {
}
let nRows = Math.min(...columns.filter(
(column: string[] | string) => column instanceof Array
(column: string[] | string) => column instanceof Array,
).map(
(column: string[] | string) => column.length
(column: string[] | string) => column.length,
));
if (nRows === Infinity) {
// we only have single string columns
@@ -28,12 +28,12 @@ function printCols(...columns: (string[] | string)[]) {
(column: string[] | string) => {
if (column instanceof Array) {
return Math.max(...column.map(
(cell: string) => cell.length
(cell: string) => cell.length,
));
} else {
return column.length;
}
}
},
);
function getCell(col: number, row: number) {

View File

@@ -25,4 +25,5 @@ interface Config {
tiledKeepBelow: boolean;
floatingKeepAbove: boolean;
windowRules: string;
tiledDesktops: string;
}

View File

@@ -188,5 +188,10 @@ const configDef = [
name: "windowRules",
type: "String",
default: defaultWindowRules,
}
},
{
name: "tiledDesktops",
type: "String",
default: ".*",
},
];

View File

@@ -102,6 +102,7 @@ interface KwinDesktop {
__brand: "KwinDesktop";
readonly id: string;
readonly name: string;
}
interface ShortcutHandler extends QmlObject {

View File

@@ -8,7 +8,7 @@ class Actions {
if (leftColumn === null) {
return;
}
leftColumn.focus();
leftColumn.getWindowToFocus().focus();
};
public readonly focusRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
@@ -16,7 +16,7 @@ class Actions {
if (rightColumn === null) {
return;
}
rightColumn.focus();
rightColumn.getWindowToFocus().focus();
};
public readonly focusUp = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
@@ -62,21 +62,29 @@ class Actions {
};
public readonly focusStart = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const firstColumn = grid.getFirstColumn();
if (firstColumn === null) {
return;
}
firstColumn.focus();
firstColumn.getWindowToFocus().focus();
};
public readonly focusEnd = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const lastColumn = grid.getLastColumn();
if (lastColumn === null) {
return;
}
lastColumn.focus();
lastColumn.getWindowToFocus().focus();
};
public readonly windowMoveLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
@@ -86,12 +94,12 @@ class Actions {
if (leftColumn === null) {
return;
}
window.moveToColumn(leftColumn, true);
window.moveToColumn(leftColumn, true, FocusPassing.Type.None);
grid.desktop.autoAdjustScroll();
} else {
// move from shared column into own column
const newColumn = new Column(grid, grid.getLeftColumn(column));
window.moveToColumn(newColumn, true);
window.moveToColumn(newColumn, true, FocusPassing.Type.None);
}
};
@@ -102,12 +110,12 @@ class Actions {
if (rightColumn === null) {
return;
}
window.moveToColumn(rightColumn, bottom);
window.moveToColumn(rightColumn, bottom, FocusPassing.Type.None);
grid.desktop.autoAdjustScroll();
} else {
// move from shared column into own column
const newColumn = new Column(grid, column);
window.moveToColumn(newColumn, true);
window.moveToColumn(newColumn, true, FocusPassing.Type.None);
}
};
@@ -141,12 +149,12 @@ class Actions {
public readonly windowMoveStart = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const newColumn = new Column(grid, null);
window.moveToColumn(newColumn, true);
window.moveToColumn(newColumn, true, FocusPassing.Type.None);
};
public readonly windowMoveEnd = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const newColumn = new Column(grid, grid.getLastColumn());
window.moveToColumn(newColumn, true);
window.moveToColumn(newColumn, true, FocusPassing.Type.None);
};
public readonly windowToggleFloating = (cm: ClientManager, dm: DesktopManager) => {
@@ -196,6 +204,9 @@ class Actions {
public readonly columnsWidthEqualize = (cm: ClientManager, dm: DesktopManager) => {
const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const visibleRange = desktop.getCurrentVisibleRange();
const visibleColumns = Array.from(desktop.grid.getVisibleColumns(visibleRange, true));
@@ -297,11 +308,18 @@ class Actions {
};
private readonly gridScroll = (desktopManager: DesktopManager, amount: number) => {
desktopManager.getCurrentDesktop().adjustScroll(amount, false);
const desktop = desktopManager.getCurrentDesktop();
if (desktop !== undefined) {
desktop.adjustScroll(amount, false);
}
};
public readonly gridScrollStart = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const firstColumn = grid.getFirstColumn();
if (firstColumn === null) {
return;
@@ -310,7 +328,11 @@ class Actions {
};
public readonly gridScrollEnd = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const lastColumn = grid.getLastColumn();
if (lastColumn === null) {
return;
@@ -328,7 +350,11 @@ class Actions {
};
public readonly gridScrollLeftColumn = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const column = grid.getLeftmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true);
if (column === null) {
return;
@@ -343,7 +369,11 @@ class Actions {
};
public readonly gridScrollRightColumn = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const column = grid.getRightmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true);
if (column === null) {
return;
@@ -362,12 +392,16 @@ class Actions {
};
public readonly focus = (columnIndex: number, cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const targetColumn = grid.getColumnAtIndex(columnIndex);
if (targetColumn === null) {
return;
}
targetColumn.focus();
targetColumn.getWindowToFocus().focus();
};
public readonly windowMoveToColumn = (columnIndex: number, cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
@@ -375,7 +409,7 @@ class Actions {
if (targetColumn === null) {
return;
}
window.moveToColumn(targetColumn, true);
window.moveToColumn(targetColumn, true, FocusPassing.Type.None);
grid.desktop.autoAdjustScroll();
};
@@ -396,7 +430,11 @@ class Actions {
if (kwinDesktop === undefined) {
return;
}
const newGrid = dm.getDesktopInCurrentActivity(kwinDesktop).grid;
const newDesktop = dm.getDesktopInCurrentActivity(kwinDesktop);
if (newDesktop === undefined) {
return;
}
const newGrid = newDesktop.grid;
if (newGrid === null || newGrid === oldGrid) {
return;
}
@@ -408,7 +446,11 @@ class Actions {
if (kwinDesktop === undefined) {
return;
}
const newGrid = dm.getDesktopInCurrentActivity(kwinDesktop).grid;
const newDesktop = dm.getDesktopInCurrentActivity(kwinDesktop);
if (newDesktop === undefined) {
return;
}
const newGrid = newDesktop.grid;
if (newGrid === null || newGrid === oldGrid) {
return;
}

View File

@@ -21,7 +21,7 @@ class Column {
if (targetGrid === this.grid) {
this.grid.moveColumn(this, leftColumn);
} else {
this.grid.onColumnRemoved(this, this.isFocused());
this.grid.onColumnRemoved(this, this.isFocused() ? FocusPassing.Type.Immediate : FocusPassing.Type.None);
this.grid = targetGrid;
targetGrid.onColumnAdded(this, leftColumn);
for (const window of this.windows.iterator()) {
@@ -195,12 +195,8 @@ class Column {
return this.focusTaker;
}
public focus() {
const window = this.getFocusTaker() ?? this.windows.getFirst();
if (window === null) {
return;
}
window.focus();
public getWindowToFocus() {
return this.getFocusTaker() ?? this.windows.getFirst()!;
}
public isFocused() {
@@ -273,7 +269,7 @@ class Column {
this.grid.desktop.onLayoutChanged();
}
public onWindowRemoved(window: Window, passFocus: boolean) {
public onWindowRemoved(window: Window, passFocus: FocusPassing.Type) {
const lastWindow = this.windows.length() === 1;
const windowToFocus = this.getAboveWindow(window) ?? this.getBelowWindow(window);
@@ -288,8 +284,15 @@ class Column {
this.destroy(passFocus);
} else {
this.resizeWindows();
if (passFocus && windowToFocus !== null) {
windowToFocus.focus();
if (windowToFocus !== null) {
switch (passFocus) {
case FocusPassing.Type.Immediate:
windowToFocus.focus();
break;
case FocusPassing.Type.OnUnfocus:
this.grid.focusPasser.request(windowToFocus.client.kwinClient);
break;
}
}
}
@@ -308,7 +311,7 @@ class Column {
}
}
private destroy(passFocus: boolean) {
private destroy(passFocus: FocusPassing.Type) {
this.grid.onColumnRemoved(this, passFocus);
}
}

View File

@@ -14,13 +14,14 @@ class Desktop {
private readonly config: Desktop.Config,
private readonly getScreen: () => Output,
layoutConfig: LayoutConfig,
focusPasser: FocusPassing.Passer,
) {
this.scrollX = 0;
this.gestureScrollXInitial = null;
this.dirty = true;
this.dirtyScroll = true;
this.dirtyPins = true;
this.grid = new Grid(this, layoutConfig);
this.grid = new Grid(this, layoutConfig, focusPasser);
this.clientArea = Desktop.getClientArea(this.getScreen(), kwinDesktop);
this.tilingArea = Desktop.getTilingArea(this.clientArea, kwinDesktop, pinManager, config);
}

View File

@@ -1,15 +1,17 @@
class Grid {
public readonly desktop: Desktop;
public readonly config: LayoutConfig;
public readonly focusPasser: FocusPassing.Passer;
private readonly columns: LinkedList<Column>;
private lastFocusedColumn: Column|null;
private width: number;
private userResize: boolean; // is any part of the grid being resized by the user
private readonly userResizeFinishedDelayer: Delayer;
constructor(desktop: Desktop, config: LayoutConfig) {
constructor(desktop: Desktop, config: LayoutConfig, focusPasser: FocusPassing.Passer) {
this.desktop = desktop;
this.config = config;
this.focusPasser = focusPasser;
this.columns = new LinkedList();
this.lastFocusedColumn = null;
this.width = 0;
@@ -156,7 +158,7 @@ class Grid {
this.desktop.autoAdjustScroll();
}
public onColumnRemoved(column: Column, passFocus: boolean) {
public onColumnRemoved(column: Column, passFocus: FocusPassing.Type) {
const isLastColumn = this.columns.length() === 1;
const rightColumn = this.getRightColumn(column);
const columnToFocus = isLastColumn ? null : this.getLeftColumn(column) ?? rightColumn;
@@ -168,11 +170,17 @@ class Grid {
this.columnsSetX(rightColumn);
this.desktop.onLayoutChanged();
if (passFocus && columnToFocus !== null) {
columnToFocus.focus();
} else {
this.desktop.autoAdjustScroll();
if (columnToFocus !== null) {
switch (passFocus) {
case FocusPassing.Type.Immediate:
columnToFocus.getWindowToFocus().focus();
return;
case FocusPassing.Type.OnUnfocus:
this.focusPasser.request(columnToFocus.getWindowToFocus().client.kwinClient);
return;
}
}
this.desktop.autoAdjustScroll();
}
public onColumnWidthChanged(column: Column) {

View File

@@ -23,11 +23,11 @@ class Window {
column.onWindowAdded(this, true);
}
public moveToColumn(targetColumn: Column, bottom: boolean) {
public moveToColumn(targetColumn: Column, bottom: boolean, passFocus: FocusPassing.Type) {
if (targetColumn === this.column) {
return;
}
this.column.onWindowRemoved(this, this.isFocused() && targetColumn.grid !== this.column.grid);
this.column.onWindowRemoved(this, passFocus);
this.column = targetColumn;
targetColumn.onWindowAdded(this, bottom);
}
@@ -61,6 +61,11 @@ class Window {
public focus() {
this.client.focus();
const kwinClient = this.client.kwinClient;
if (!this.isFocused()) {
// in some situations focus assignment just doesn't work, let's do it later
this.column.grid.focusPasser.request(kwinClient);
}
}
public isFocused() {
@@ -122,7 +127,7 @@ class Window {
this.column.grid.desktop.onLayoutChanged();
}
public destroy(passFocus: boolean) {
public destroy(passFocus: FocusPassing.Type) {
this.column.onWindowRemoved(this, passFocus);
}
}

View File

@@ -0,0 +1,30 @@
class DesktopFilter {
private readonly desktopRegex: RegExp | null; // null means all desktops
constructor(desktopsConfig: string) {
this.desktopRegex = DesktopFilter.parseDesktopConfig(desktopsConfig);
}
public shouldWorkOnDesktop(kwinDesktop: KwinDesktop): boolean {
if (this.desktopRegex === null) {
return true; // Work on all desktops
}
return this.desktopRegex.test(kwinDesktop.name);
}
private static parseDesktopConfig(config: string): RegExp | null {
const trimmed = config.trim();
if (trimmed.length === 0) {
return null; // Empty config means work on all desktops
}
try {
return new RegExp(`^${trimmed}$`);
} catch (e) {
notificationInvalidTiledDesktops.sendEvent();
log(`Invalid regex pattern in tiledDesktops config: ${trimmed}. Working on all desktops.`);
return null; // Invalid regex means work on all desktops as fallback
}
}
}

View File

@@ -52,7 +52,7 @@ class WindowRuleEnforcer {
const ruleCaption = WindowRuleEnforcer.parseRegex(windowRule.caption);
const ruleString = ClientMatcher.getRuleString(
WindowRuleEnforcer.wrapParens(ruleClass),
WindowRuleEnforcer.wrapParens(ruleCaption)
WindowRuleEnforcer.wrapParens(ruleCaption),
);
(windowRule.tile ? tileRegexes : floatRegexes).push(ruleString);

View File

@@ -20,6 +20,6 @@ function initQmlTimer() {
return Qt.createQmlObject(
`import QtQuick 6.0
Timer {}`,
qmlBase
qmlBase,
) as QmlTimer;
}

View File

@@ -1,4 +1,4 @@
function initWorkspaceSignalHandlers(world: World) {
function initWorkspaceSignalHandlers(world: World, focusPasser: FocusPassing.Passer) {
const manager = new SignalManager();
manager.connect(Workspace.windowAdded, (kwinClient: KwinClient) => {
@@ -9,17 +9,19 @@ function initWorkspaceSignalHandlers(world: World) {
manager.connect(Workspace.windowRemoved, (kwinClient: KwinClient) => {
world.do((clientManager, desktopManager) => {
clientManager.removeClient(kwinClient, true);
clientManager.removeClient(kwinClient, FocusPassing.Type.Immediate);
});
});
manager.connect(Workspace.windowActivated, (kwinClient: KwinClient|null) => {
if (kwinClient === null) {
return;
focusPasser.activate();
} else {
focusPasser.clearIfDifferent(kwinClient);
world.do((clientManager, desktopManager) => {
clientManager.onClientFocused(kwinClient);
});
}
world.do((clientManager, desktopManager) => {
clientManager.onClientFocused(kwinClient);
});
});
manager.connect(Workspace.currentDesktopChanged, () => {

View File

@@ -31,16 +31,16 @@ class ClientManager {
console.assert(!this.hasClient(kwinClient));
let constructState: (client: ClientWrapper) => ClientState.State;
let desktop: Desktop | undefined;
if (kwinClient.dock) {
constructState = () => new ClientState.Docked(this.world, kwinClient);
} else if (
Clients.canTileEver(kwinClient) &&
this.windowRuleEnforcer.shouldTile(kwinClient)
this.windowRuleEnforcer.shouldTile(kwinClient) &&
(desktop = this.desktopManager.getDesktopForClient(kwinClient)) !== undefined
) {
Clients.makeTileable(kwinClient);
console.assert(Clients.canTileNow(kwinClient));
const desktop = this.desktopManager.getDesktopForClient(kwinClient);
console.assert(desktop !== undefined);
constructState = (client: ClientWrapper) => new ClientState.Tiled(this.world, client, desktop!.grid);
} else {
constructState = (client: ClientWrapper) => new ClientState.Floating(this.world, client, this.config, false);
@@ -55,13 +55,16 @@ class ClientManager {
this.clientMap.set(kwinClient, client);
}
public removeClient(kwinClient: KwinClient, passFocus: boolean) {
public removeClient(kwinClient: KwinClient, passFocus: FocusPassing.Type) {
console.assert(this.hasClient(kwinClient));
const client = this.clientMap.get(kwinClient);
if (client === undefined) {
return;
}
client.destroy(passFocus && kwinClient === this.lastFocusedClient);
if (kwinClient !== this.lastFocusedClient) {
passFocus = FocusPassing.Type.None;
}
client.destroy(passFocus);
this.clientMap.delete(kwinClient);
}
@@ -84,9 +87,10 @@ class ClientManager {
return;
}
if (client.stateManager.getState() instanceof ClientState.Tiled) {
const passFocus = kwinClient === this.lastFocusedClient ? FocusPassing.Type.Immediate : FocusPassing.Type.None;
client.stateManager.setState(
() => new ClientState.TiledMinimized(this.world, client),
kwinClient === this.lastFocusedClient,
passFocus,
);
}
}
@@ -95,14 +99,14 @@ class ClientManager {
if (client.stateManager.getState() instanceof ClientState.Tiled) {
return;
}
client.stateManager.setState(() => new ClientState.Tiled(this.world, client, grid), false);
client.stateManager.setState(() => new ClientState.Tiled(this.world, client, grid), FocusPassing.Type.None);
}
public floatClient(client: ClientWrapper) {
if (client.stateManager.getState() instanceof ClientState.Floating) {
return;
}
client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), false);
client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), FocusPassing.Type.None);
}
public tileKwinClient(kwinClient: KwinClient, grid: Grid) {
@@ -131,7 +135,7 @@ class ClientManager {
kwinClient.tile = null;
return;
}
client.stateManager.setState(() => new ClientState.Pinned(this.world, this.pinManager, this.desktopManager, kwinClient, this.config), false);
client.stateManager.setState(() => new ClientState.Pinned(this.world, this.pinManager, this.desktopManager, kwinClient, this.config), FocusPassing.Type.None);
this.pinManager.addClient(kwinClient);
for (const desktop of this.desktopManager.getDesktopsForClient(kwinClient)) {
desktop.onPinsChanged();
@@ -144,7 +148,7 @@ class ClientManager {
return;
}
console.assert(client.stateManager.getState() instanceof ClientState.Pinned);
client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, false), false);
client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, false), FocusPassing.Type.None);
this.pinManager.removeClient(kwinClient);
for (const desktop of this.desktopManager.getDesktopsForClient(kwinClient)) {
desktop.onPinsChanged();
@@ -164,9 +168,9 @@ class ClientManager {
if (desktop === undefined) {
return;
}
client.stateManager.setState(() => new ClientState.Tiled(this.world, client, desktop.grid), false);
client.stateManager.setState(() => new ClientState.Tiled(this.world, client, desktop.grid), FocusPassing.Type.None);
} else if (clientState instanceof ClientState.Tiled) {
client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), false);
client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), FocusPassing.Type.None);
}
}
@@ -204,7 +208,7 @@ class ClientManager {
private removeAllClients() {
for (const kwinClient of Array.from(this.clientMap.keys())) {
this.removeClient(kwinClient, false);
this.removeClient(kwinClient, FocusPassing.Type.None);
}
}

View File

@@ -150,7 +150,7 @@ class ClientWrapper {
}
}
public destroy(passFocus: boolean) {
public destroy(passFocus: FocusPassing.Type) {
this.stateManager.destroy(passFocus);
this.signalManager.destroy();
if (this.rulesSignalManager !== null) {

View File

@@ -47,8 +47,8 @@ namespace Clients {
export function isFullScreenGeometry(kwinClient: KwinClient) {
const fullScreenArea = Workspace.clientArea(ClientAreaOption.FullScreenArea, kwinClient.output, getKwinDesktopApprox(kwinClient));
return kwinClient.clientGeometry.width === fullScreenArea.width &&
kwinClient.clientGeometry.height === fullScreenArea.height;
return kwinClient.clientGeometry.width >= fullScreenArea.width &&
kwinClient.clientGeometry.height >= fullScreenArea.height;
}
export function isOnVirtualDesktop(kwinClient: KwinClient, kwinDesktop: KwinDesktop) {

View File

@@ -7,9 +7,9 @@ class DesktopManager {
constructor(
private readonly pinManager: PinManager,
private readonly config: Desktop.Config,
public readonly layoutConfig: LayoutConfig,
currentActivity: string,
currentDesktop: KwinDesktop,
private readonly layoutConfig: LayoutConfig,
private readonly focusPasser: FocusPassing.Passer,
private readonly desktopFilter: DesktopFilter,
) {
this.pinManager = pinManager;
this.config = config;
@@ -18,10 +18,12 @@ class DesktopManager {
this.selectedScreen = Workspace.activeScreen;
this.kwinActivities = new Set(Workspace.activities);
this.kwinDesktops = new Set(Workspace.desktops);
this.addDesktop(currentActivity, currentDesktop);
}
public getDesktop(activity: string, kwinDesktop: KwinDesktop) {
if (!this.desktopFilter.shouldWorkOnDesktop(kwinDesktop)) {
return undefined;
}
const desktopKey = DesktopManager.getDesktopKey(activity, kwinDesktop);
const desktop = this.desktops.get(desktopKey);
if (desktop !== undefined) {
@@ -54,6 +56,7 @@ class DesktopManager {
this.config,
() => this.selectedScreen,
this.layoutConfig,
this.focusPasser,
);
this.desktops.set(desktopKey, desktop);
return desktop;

View File

@@ -0,0 +1,51 @@
namespace FocusPassing {
export const enum Type {
None,
Immediate,
OnUnfocus,
}
export class Passer {
private currentRequest: Request | null = null;
public request(target: KwinClient) {
this.currentRequest = new Request(target, Date.now());
}
public clear() {
this.currentRequest = null;
}
public clearIfDifferent(kwinClient: KwinClient) {
if (this.currentRequest !== null && this.currentRequest.target !== kwinClient) {
this.clear();
}
}
public activate() {
if (this.currentRequest === null) {
return;
}
if (this.currentRequest.isExpired()) {
this.clear();
return;
}
Workspace.activeWindow = this.currentRequest.target;
}
}
class Request {
private static readonly validMs = 200;
constructor(
public readonly target: KwinClient,
private readonly time: number,
) {}
public isExpired() {
return Date.now() - this.time > Request.validMs;
}
}
}

View File

@@ -8,7 +8,8 @@ class World {
private readonly cursorFollowsFocus: boolean;
constructor(config: Config) {
this.workspaceSignalManager = initWorkspaceSignalHandlers(this);
const focusPasser = new FocusPassing.Passer();
this.workspaceSignalManager = initWorkspaceSignalHandlers(this, focusPasser);
this.cursorFollowsFocus = config.cursorFollowsFocus;
let presetWidths = {
@@ -68,8 +69,8 @@ class World {
gestureScrollStep: config.gestureScrollStep,
},
layoutConfig,
Workspace.currentActivity,
Workspace.currentDesktop,
focusPasser,
new DesktopFilter(config.tiledDesktops),
);
this.clientManager = new ClientManager(config, this, this.desktopManager, this.pinManager);
this.addExistingClients();
@@ -96,12 +97,20 @@ class World {
}
private update() {
this.desktopManager.getCurrentDesktop().arrange();
this.moveCursorToFocus();
const currentDesktop = this.desktopManager.getCurrentDesktop();
if (currentDesktop !== undefined) {
currentDesktop.arrange();
this.moveCursorToFocus();
}
}
private moveCursorToFocus() {
if (this.cursorFollowsFocus && Workspace.activeWindow !== null) {
// Only move cursor for tiled windows
const tiledWindow = this.clientManager.findTiledWindow(Workspace.activeWindow);
if (tiledWindow === null) {
return;
}
const cursorAlreadyInFocus = rectContainsPoint(Workspace.activeWindow.frameGeometry, Workspace.cursorPos);
if (cursorAlreadyInFocus) {
return;
@@ -139,11 +148,21 @@ class World {
}
public gestureScroll(amount: number) {
this.do((clientManager, desktopManager) => desktopManager.getCurrentDesktop().gestureScroll(amount));
this.do((clientManager, desktopManager) => {
const currentDesktop = desktopManager.getCurrentDesktop();
if (currentDesktop !== undefined) {
currentDesktop.gestureScroll(amount);
}
});
}
public gestureScrollFinish() {
this.do((clientManager, desktopManager) => desktopManager.getCurrentDesktop().gestureScrollFinish());
this.do((clientManager, desktopManager) => {
const currentDesktop = desktopManager.getCurrentDesktop();
if (currentDesktop !== undefined) {
currentDesktop.gestureScrollFinish();
}
});
}
public destroy() {

View File

@@ -9,7 +9,7 @@ namespace ClientState {
world.onScreenResized();
}
public destroy(passFocus: boolean) {
public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy();
this.world.onScreenResized();
}

View File

@@ -16,7 +16,7 @@ namespace ClientState {
this.signalManager = Floating.initSignalManager(world, client.kwinClient);
}
public destroy(passFocus: boolean) {
public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy();
}

View File

@@ -6,7 +6,7 @@ namespace ClientState {
this.state = initialState;
}
public setState(constructNewState: () => State, passFocus: boolean) {
public setState(constructNewState: () => State, passFocus: FocusPassing.Type) {
this.state.destroy(passFocus);
this.state = constructNewState();
}
@@ -15,12 +15,12 @@ namespace ClientState {
return this.state;
}
public destroy(passFocus: boolean) {
public destroy(passFocus: FocusPassing.Type) {
this.state.destroy(passFocus);
}
}
export interface State {
destroy(passFocus: boolean): void;
destroy(passFocus: FocusPassing.Type): void;
}
}

View File

@@ -17,7 +17,7 @@ namespace ClientState {
this.signalManager = Pinned.initSignalManager(world, pinManager, kwinClient);
}
public destroy(passFocus: boolean) {
public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy();
this.pinManager.removeClient(this.kwinClient);
for (const desktop of this.desktopManager.getDesktopsForClient(this.kwinClient)) {

View File

@@ -16,7 +16,7 @@ namespace ClientState {
this.signalManager = Tiled.initSignalManager(world, window, grid.config);
}
public destroy(passFocus: boolean) {
public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy();
const window = this.window;
@@ -209,7 +209,8 @@ namespace ClientState {
}
const newColumn = new Column(grid, grid.getLastFocusedColumn() ?? grid.getLastColumn());
window.moveToColumn(newColumn, true);
const passFocus = window.isFocused() ? FocusPassing.Type.OnUnfocus : FocusPassing.Type.None;
window.moveToColumn(newColumn, true, passFocus);
}
private static prepareClientForTiling(client: ClientWrapper, config: LayoutConfig) {

View File

@@ -6,7 +6,7 @@ namespace ClientState {
this.signalManager = TiledMinimized.initSignalManager(world, client);
}
public destroy(passFocus: boolean) {
public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy();
}

View File

@@ -9,23 +9,23 @@ tests.register("Drag tiled window, untile", 10, () => {
workspaceMock.cursorPos = initialCursorPos.clone();
runOneOf(
() => Workspace.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
() => { Workspace.activeWindow = client1; },
() => { qtMock.fireShortcut("karousel-focus-1"); },
);
Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
Assert.assert(!rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
Assert.assert(pointEquals(Workspace.cursorPos, initialCursorPos), { message: "Cursor should not have been moved because it was already within the focused client" });
runOneOf(
() => Workspace.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
() => { Workspace.activeWindow = client2; },
() => { qtMock.fireShortcut("karousel-focus-2"); },
);
Assert.assert(!rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
Assert.assert(rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
runOneOf(
() => Workspace.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
() => { Workspace.activeWindow = client1; },
() => { qtMock.fireShortcut("karousel-focus-1"); },
);
Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
Assert.assert(!rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
@@ -34,3 +34,49 @@ tests.register("Drag tiled window, untile", 10, () => {
Workspace.activeWindow = null;
Assert.assert(pointEquals(Workspace.cursorPos, lastCursorPos), { message: "Cursor should not have been moved" });
});
tests.register("Cursor follows focus only on matched desktops", 1, () => {
// Test that cursor follow focus only works for windows on matched desktops (tiled windows)
const config = getDefaultConfig();
config.cursorFollowsFocus = true;
config.tiledDesktops = "^Desktop 1$"; // Only work on Desktop 1
const { workspaceMock, world } = init(config);
// Create a client on Desktop 1 (matched desktop) - should be tiled
const client1 = new MockKwinClient();
client1.desktops = [workspaceMock.desktops[0]]; // Desktop 1
workspaceMock.createWindows(client1);
// Create a client on Desktop 2 (non-matched desktop) - should be floating
const client2 = new MockKwinClient();
client2.desktops = [workspaceMock.desktops[1]]; // Desktop 2
workspaceMock.createWindows(client2);
// Set initial cursor position outside both windows
const initialCursorPos = new MockQmlPoint(10, 10);
workspaceMock.cursorPos = initialCursorPos.clone();
// Test 1: Focus client1 on matched desktop (Desktop 1) - cursor should move
workspaceMock.currentDesktop = workspaceMock.desktops[0]; // Switch to Desktop 1
Workspace.activeWindow = client1;
world.do(() => {});
Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos),
{ message: "Cursor should have moved to tiled window on matched desktop" });
// Test 2: Switch to non-matched desktop (Desktop 2) and focus client2 - cursor should NOT move
workspaceMock.cursorPos = initialCursorPos.clone();
workspaceMock.currentDesktop = workspaceMock.desktops[1]; // Switch to Desktop 2
Workspace.activeWindow = client2;
world.do(() => {});
Assert.assert(pointEquals(Workspace.cursorPos, initialCursorPos),
{ message: "Cursor should NOT move on non-matched desktop" });
// Test 3: Even if we focus client1 (tiled) while on Desktop 2, cursor should NOT move
// because the current desktop is not matched
workspaceMock.cursorPos = initialCursorPos.clone();
workspaceMock.currentDesktop = workspaceMock.desktops[1]; // Stay on Desktop 2
Workspace.activeWindow = client1;
world.do(() => {});
Assert.assert(pointEquals(Workspace.cursorPos, initialCursorPos),
{ message: "Cursor should NOT move even for tiled window when current desktop is not matched" });
});

View File

@@ -0,0 +1,73 @@
tests.register("Desktop filtering", 1, () => {
// Test 1: Default config should work on all desktops
const config1 = getDefaultConfig();
const { workspaceMock: wm1, world: world1 } = init(config1);
const client1 = new MockKwinClient();
client1.desktops = [wm1.desktops[0]];
wm1.createWindows(client1);
world1.do((clientManager) => {
Assert.tiledClient(clientManager, client1, { message: "Client should be tiled on desktop1 with default config (*)" });
});
});
tests.register("Desktop filtering - specific desktop", 1, () => {
// Test 2: Specific desktop name - should work only on matching desktop
const config2 = getDefaultConfig();
config2.tiledDesktops = "^Desktop 1$";
const { workspaceMock: wm2, world: world2 } = init(config2);
const client1 = new MockKwinClient();
client1.desktops = [wm2.desktops[0]]; // Desktop 1
wm2.createWindows(client1);
world2.do((clientManager) => {
Assert.tiledClient(clientManager, client1, { message: "Client should be tiled on Desktop 1" });
});
wm2.removeWindow(client1);
const client2 = new MockKwinClient();
client2.desktops = [wm2.desktops[1]]; // Desktop 2
wm2.createWindows(client2);
world2.do((clientManager) => {
Assert.notTiledClient(clientManager, client2, { message: "Client should NOT be tiled on Desktop 2" });
});
});
tests.register("Desktop filtering - multiple desktops", 1, () => {
// Test 3: Multiple desktop names using regex alternation
const config3 = getDefaultConfig();
config3.tiledDesktops = "^Desktop [12]$";
const { workspaceMock: wm3, world: world3 } = init(config3);
const client1 = new MockKwinClient();
client1.desktops = [wm3.desktops[0]]; // Desktop 1
wm3.createWindows(client1);
world3.do((clientManager) => {
Assert.tiledClient(clientManager, client1, { message: "Client should be tiled on Desktop 1" });
});
wm3.removeWindow(client1);
const client2 = new MockKwinClient();
client2.desktops = [wm3.desktops[1]]; // Desktop 2
wm3.createWindows(client2);
world3.do((clientManager) => {
Assert.tiledClient(clientManager, client2, { message: "Client should be tiled on Desktop 2" });
});
});
tests.register("Desktop filtering - windows on multiple desktops", 1, () => {
// Test 4: Windows on multiple desktops should not be tiled (fallback to floating)
const config4 = getDefaultConfig();
config4.tiledDesktops = ".*";
const { workspaceMock: wm4, world: world4 } = init(config4);
const client1 = new MockKwinClient();
client1.desktops = [wm4.desktops[0], wm4.desktops[1]]; // Multiple desktops
wm4.createWindows(client1);
world4.do((clientManager) => {
Assert.notTiledClient(clientManager, client1, { message: "Client on multiple desktops should not be tiled" });
});
});

View File

@@ -0,0 +1,10 @@
tests.register("Move and follow window to desktop", 20, () => {
// This tests the Kwin shortcuts for moving windows to adjacent desktops.
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const [client0, client1] = workspaceMock.createClients(2);
client1.moveAndFollowToDesktop(workspaceMock.desktops[1], workspaceMock);
Assert.equal(workspaceMock.activeWindow, client1);
});

View File

@@ -16,18 +16,18 @@ tests.register("LazyScroller", 20, () => {
Assert.equal(client3.frameGeometry.right, tilingArea.right);
runOneOf(
() => workspaceMock.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => { workspaceMock.activeWindow = client2; },
() => { qtMock.fireShortcut("karousel-focus-2"); },
() => { qtMock.fireShortcut("karousel-focus-left"); },
);
Assert.grid(config, tilingArea, 300, [[client1], [client2], [client3]], false);
Assert.equal(client3.frameGeometry.right, tilingArea.right);
runOneOf(
() => workspaceMock.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
() => { workspaceMock.activeWindow = client1; },
() => { qtMock.fireShortcut("karousel-focus-1"); },
() => { qtMock.fireShortcut("karousel-focus-left"); },
() => { qtMock.fireShortcut("karousel-focus-start"); },
);
workspaceMock.activeWindow = client1;
Assert.grid(config, tilingArea, 300, [[client1], [client2], [client3]], false);
@@ -38,9 +38,9 @@ tests.register("LazyScroller", 20, () => {
Assert.grid(config, tilingArea, 300, [[client1]], true);
runOneOf(
() => workspaceMock.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
() => { workspaceMock.activeWindow = client2; },
() => { qtMock.fireShortcut("karousel-focus-2"); },
() => { qtMock.fireShortcut("karousel-focus-right"); },
);
Assert.grid(config, tilingArea, 300, [[client1], [client2], [client3]], false);
Assert.equal(client1.frameGeometry.left, tilingArea.left);

View File

@@ -66,8 +66,8 @@
});
runOneOf(
() => parent.fullScreen = true,
() => parent.setMaximize(true, true),
() => { parent.fullScreen = true; },
() => { parent.setMaximize(true, true); },
);
Assert.equal(parent.keepBelow, shouldKeepBelow(false));
Assert.equal(parent.keepAbove, shouldKeepAbove(false));
@@ -123,42 +123,42 @@
assertWindowed(config, clients);
runOneOf(
() => clients[2].fullScreen = true,
() => clients[2].setMaximize(true, true),
() => { clients[2].fullScreen = true; },
() => { clients[2].setMaximize(true, true); },
);
assertFullScreenOrMaximized(clients);
runOneOf(
() => workspaceMock.activeWindow = clients[0],
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
() => { workspaceMock.activeWindow = clients[0]; },
() => { qtMock.fireShortcut("karousel-focus-1"); },
() => { qtMock.fireShortcut("karousel-focus-left"); },
() => { qtMock.fireShortcut("karousel-focus-start"); },
);
assertWindowed(config, clients);
runOneOf(
() => workspaceMock.activeWindow = clients[2],
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
() => qtMock.fireShortcut("karousel-focus-end"),
() => { workspaceMock.activeWindow = clients[2]; },
() => { qtMock.fireShortcut("karousel-focus-2"); },
() => { qtMock.fireShortcut("karousel-focus-right"); },
() => { qtMock.fireShortcut("karousel-focus-end"); },
);
assertWindowed(config, clients);
runOneOf(
() => clients[2].fullScreen = true,
() => clients[2].setMaximize(true, true),
() => { clients[2].fullScreen = true; },
() => { clients[2].setMaximize(true, true); },
);
assertFullScreenOrMaximized(clients);
runOneOf(
() => workspaceMock.activeWindow = clients[1],
() => qtMock.fireShortcut("karousel-focus-up"),
() => { workspaceMock.activeWindow = clients[1]; },
() => { qtMock.fireShortcut("karousel-focus-up"); },
);
assertWindowed(config, clients);
runOneOf(
() => workspaceMock.activeWindow = clients[2],
() => qtMock.fireShortcut("karousel-focus-down"),
() => { workspaceMock.activeWindow = clients[2]; },
() => { qtMock.fireShortcut("karousel-focus-down"); },
);
assertWindowed(config, clients);
});
@@ -174,36 +174,36 @@
assertWindowed(config, clients);
runOneOf(
() => clients[2].fullScreen = true,
() => clients[2].setMaximize(true, true),
() => { clients[2].fullScreen = true; },
() => { clients[2].setMaximize(true, true); },
);
assertFullScreenOrMaximized(clients);
runOneOf(
() => workspaceMock.activeWindow = clients[0],
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
() => { workspaceMock.activeWindow = clients[0]; },
() => { qtMock.fireShortcut("karousel-focus-1"); },
() => { qtMock.fireShortcut("karousel-focus-left"); },
() => { qtMock.fireShortcut("karousel-focus-start"); },
);
assertWindowed(config, clients);
runOneOf(
() => workspaceMock.activeWindow = clients[2],
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
() => qtMock.fireShortcut("karousel-focus-end"),
() => { workspaceMock.activeWindow = clients[2]; },
() => { qtMock.fireShortcut("karousel-focus-2"); },
() => { qtMock.fireShortcut("karousel-focus-right"); },
() => { qtMock.fireShortcut("karousel-focus-end"); },
);
assertFullScreenOrMaximized(clients);
runOneOf(
() => workspaceMock.activeWindow = clients[1],
() => qtMock.fireShortcut("karousel-focus-up"),
() => { workspaceMock.activeWindow = clients[1]; },
() => { qtMock.fireShortcut("karousel-focus-up"); },
);
assertWindowed(config, clients);
runOneOf(
() => workspaceMock.activeWindow = clients[2],
() => qtMock.fireShortcut("karousel-focus-down"),
() => { workspaceMock.activeWindow = clients[2]; },
() => { qtMock.fireShortcut("karousel-focus-down"); },
);
assertFullScreenOrMaximized(clients);
});

View File

@@ -1,4 +1,4 @@
tests.register("Pass focus", 20, () => {
tests.register("Pass focus", 100, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
@@ -34,7 +34,4 @@ tests.register("Pass focus", 20, () => {
removeWindow(client0);
Assert.equal(workspaceMock.activeWindow, client6);
removeWindow(client6);
Assert.equal(workspaceMock.activeWindow, null);
});

View File

@@ -0,0 +1,41 @@
tests.register("DesktopFilter", 1, () => {
const desktop1 = { __brand: "KwinDesktop" as const, id: "1", name: "Desktop 1" };
const desktop2 = { __brand: "KwinDesktop" as const, id: "2", name: "Work" };
const desktop3 = { __brand: "KwinDesktop" as const, id: "3", name: "Desktop 2" };
// Test 1: Empty config means all desktops
let filter = new DesktopFilter("");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Empty config should work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Empty config should work on desktop2" });
// Test 2: Whitespace only means all desktops
filter = new DesktopFilter(" \n \n ");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Whitespace only should work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Whitespace only should work on desktop2" });
// Test 3: Match all regex pattern
filter = new DesktopFilter(".*");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Regex '.*' should work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Regex '.*' should work on desktop2" });
// Test 4: Partial match without anchors
filter = new DesktopFilter("Work");
Assert.assert(!filter.shouldWorkOnDesktop(desktop1), { message: "Should not work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Should work on desktop2 containing 'Work'" });
// Test 5: Regex alternation for multiple desktops
filter = new DesktopFilter("Desktop 1|Work");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Should work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Should work on desktop2" });
Assert.assert(!filter.shouldWorkOnDesktop(desktop3), { message: "Should not work on desktop3" });
// Test 6: Regex pattern with character class
filter = new DesktopFilter("Desktop [12]");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Should work on desktop1" });
Assert.assert(!filter.shouldWorkOnDesktop(desktop2), { message: "Should not work on desktop2" });
Assert.assert(filter.shouldWorkOnDesktop(desktop3), { message: "Should work on desktop3" });
// Test 7: Case-sensitive matching
filter = new DesktopFilter("work");
Assert.assert(!filter.shouldWorkOnDesktop(desktop2), { message: "Should not work on desktop2 (case mismatch)" });
});

View File

@@ -2,6 +2,7 @@ let Qt: Qt;
let KWin: KWin;
let Workspace: Workspace;
let qmlBase: QmlObject;
let notificationInvalidTiledDesktops: Notification;
let notificationInvalidWindowRules: Notification;
let notificationInvalidPresetWidths: Notification;
let moveCursorToFocus: DBusCall;
@@ -61,3 +62,10 @@ function getClientManager(world: World): ClientManager {
world.do((cm, dm) => clientManager = cm);
return clientManager!;
}
function activateRandomWindowOnDesktop(desktop: KwinDesktop) {
const windows = Workspace.windows.filter(w => w.desktops.includes(desktop));
if (windows.length > 0) {
Workspace.activeWindow = randomItem(windows);
}
}

View File

@@ -52,6 +52,7 @@ class MockKwinClient {
this.windowedFrameGeometry = _frameGeometry.clone();
this.transient = transientFor !== null;
this._desktops = [Workspace.currentDesktop];
this.activities = [Workspace.currentActivity];
}
setMaximize(vertically: boolean, horizontally: boolean) {
@@ -68,7 +69,7 @@ class MockKwinClient {
horizontally ? MaximizedMode.Maximized : MaximizedMode.Vertically
) : (
horizontally ? MaximizedMode.Horizontally : MaximizedMode.Unmaximized
)
),
);
this.frameGeometry = new MockQmlRect(
@@ -88,7 +89,15 @@ class MockKwinClient {
this.frameGeometry.height - 2 * MockKwinClient.borderThickness,
);
} else {
return this.frameGeometry;
return runOneOf(
() => this.frameGeometry,
() => new MockQmlRect(
this.frameGeometry.x - 20,
this.frameGeometry.y - 20,
this.frameGeometry.width + 40,
this.frameGeometry.height + 40,
), // some full-screen windows that manage their own window decorations can temporarily have a client geometry bigger than the screen
);
}
}
@@ -124,18 +133,22 @@ class MockKwinClient {
return;
}
runOneOf(
() => this.frameGeometry = new MockQmlRect(
0,
0,
screen.width + 2 * MockKwinClient.borderThickness,
screen.height + 2 * MockKwinClient.borderThickness,
),
() => this.frameGeometry = new MockQmlRect(
-MockKwinClient.borderThickness,
-MockKwinClient.borderThickness,
screen.width + 2 * MockKwinClient.borderThickness,
screen.height + 2 * MockKwinClient.borderThickness,
),
() => {
this.frameGeometry = new MockQmlRect(
0,
0,
screen.width + 2 * MockKwinClient.borderThickness,
screen.height + 2 * MockKwinClient.borderThickness,
);
},
() => {
this.frameGeometry = new MockQmlRect(
-MockKwinClient.borderThickness,
-MockKwinClient.borderThickness,
screen.width + 2 * MockKwinClient.borderThickness,
screen.height + 2 * MockKwinClient.borderThickness,
);
},
() => {},
);
}
@@ -186,9 +199,20 @@ class MockKwinClient {
this.desktopsChanged.fire();
if (Workspace.activeWindow === this && !desktops.includes(Workspace.currentDesktop)) {
Workspace.activeWindow = null;
runMaybe(() => Workspace.activeWindow = null); // fired again for some reason
if (Workspace.activeWindow === null) {
activateRandomWindowOnDesktop(Workspace.currentDesktop);
}
};
}
public moveAndFollowToDesktop(desktop: KwinDesktop, workspaceMock: MockWorkspace) {
Assert.assert(workspaceMock.activeWindow === this);
this._desktops = [desktop];
this.desktopsChanged.fire();
workspaceMock.currentDesktop = desktop;
}
public get tile() {
return this._tile;
}

View File

@@ -3,15 +3,15 @@ class MockWorkspace {
public activities = ["test-activity"];
public desktops: KwinDesktop[] = [
{ __brand: "KwinDesktop", id: "desktop1" },
{ __brand: "KwinDesktop", id: "desktop2" }
{ __brand: "KwinDesktop", id: "desktop1", name: "Desktop 1" },
{ __brand: "KwinDesktop", id: "desktop2", name: "Desktop 2" },
];
public currentDesktop = this.desktops[0];
public currentActivity = this.activities[0];
public activeScreen: Output = { __brand: "Output" };
public readonly windows: MockKwinClient[] = [];
public cursorPos = new MockQmlPoint(0, 0);
private _currentDesktop = this.desktops[0];
private _activeWindow: KwinClient|null = null;
public readonly currentDesktopChanged = new MockQSignal<[]>();
@@ -52,13 +52,13 @@ class MockWorkspace {
}
public removeWindow(window: MockKwinClient) {
this.activeWindow = null;
runReorder(
() => this.windows.splice(this.windows.indexOf(window), 1),
() => this.windowRemoved.fire(window),
);
if (window === this.activeWindow) {
const windows = this.windows.filter(w => w.desktops.includes(this.currentDesktop));
Workspace.activeWindow = windows.length > 0 ? randomItem(windows) : null;
if (this.activeWindow === null) {
activateRandomWindowOnDesktop(this.currentDesktop);
};
}
@@ -123,6 +123,15 @@ class MockWorkspace {
window.interactiveMoveResizeFinished.fire();
}
public get currentDesktop() {
return this._currentDesktop;
}
public set currentDesktop(currentDesktop: KwinDesktop) {
this._currentDesktop = currentDesktop;
this.currentDesktopChanged.fire();
}
public get activeWindow() {
return this._activeWindow;
}

View File

@@ -4,10 +4,10 @@ function runMaybe(f: () => void) {
}
}
function runOneOf(...fs: (() => void)[]) {
function runOneOf<T>(...fs: (() => T)[]) {
const index = randomInt(fs.length);
runLog.push(`${getStackFrame(1)} - Chose ${index}`);
fs[index]();
return fs[index]();
}
function runReorder(...fs: (() => void)[]) {