diff --git a/package/contents/ui/config.ui b/package/contents/ui/config.ui
index d1c0add..488b862 100644
--- a/package/contents/ui/config.ui
+++ b/package/contents/ui/config.ui
@@ -458,6 +458,30 @@
Window Rules
+ -
+
+
-
+
+
+ Tiled desktops:
+
+
+ true
+
+
+
+ -
+
+
+ Monospace
+
+
+ Regex string to match desktops. Default value is ".*". Example: "Desktop [1-3]"
+
+
+
+
+
-
diff --git a/src/lib/config/config.ts b/src/lib/config/config.ts
index 59047e8..907d833 100644
--- a/src/lib/config/config.ts
+++ b/src/lib/config/config.ts
@@ -25,4 +25,5 @@ interface Config {
tiledKeepBelow: boolean;
floatingKeepAbove: boolean;
windowRules: string;
+ tiledDesktops: string;
}
diff --git a/src/lib/config/definition.ts b/src/lib/config/definition.ts
index 1984bee..9a8858a 100644
--- a/src/lib/config/definition.ts
+++ b/src/lib/config/definition.ts
@@ -189,4 +189,9 @@ const configDef = [
type: "String",
default: defaultWindowRules,
},
+ {
+ name: "tiledDesktops",
+ type: "String",
+ default: ".*",
+ },
];
diff --git a/src/lib/extern/kwin.ts b/src/lib/extern/kwin.ts
index 13b4f09..1fc7c1f 100644
--- a/src/lib/extern/kwin.ts
+++ b/src/lib/extern/kwin.ts
@@ -102,6 +102,7 @@ interface KwinDesktop {
__brand: "KwinDesktop";
readonly id: string;
+ readonly name: string;
}
interface ShortcutHandler extends QmlObject {
diff --git a/src/lib/keyBindings/Actions.ts b/src/lib/keyBindings/Actions.ts
index ba22e27..4b9912c 100644
--- a/src/lib/keyBindings/Actions.ts
+++ b/src/lib/keyBindings/Actions.ts
@@ -62,7 +62,11 @@ 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;
@@ -71,7 +75,11 @@ class Actions {
};
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;
@@ -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,7 +392,11 @@ 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;
@@ -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;
}
diff --git a/src/lib/rules/DesktopFilter.ts b/src/lib/rules/DesktopFilter.ts
new file mode 100644
index 0000000..dd045f5
--- /dev/null
+++ b/src/lib/rules/DesktopFilter.ts
@@ -0,0 +1,29 @@
+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) {
+ log(`Invalid regex pattern in tiledDesktops config: ${trimmed}. Working on all desktops.`);
+ return null; // Invalid regex means work on all desktops as fallback
+ }
+ }
+}
diff --git a/src/lib/world/ClientManager.ts b/src/lib/world/ClientManager.ts
index a8b4e07..d91eb5a 100644
--- a/src/lib/world/ClientManager.ts
+++ b/src/lib/world/ClientManager.ts
@@ -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);
diff --git a/src/lib/world/DesktopManager.ts b/src/lib/world/DesktopManager.ts
index 30f83cb..b79737d 100644
--- a/src/lib/world/DesktopManager.ts
+++ b/src/lib/world/DesktopManager.ts
@@ -9,6 +9,7 @@ class DesktopManager {
private readonly config: Desktop.Config,
private readonly layoutConfig: LayoutConfig,
private readonly focusPasser: FocusPassing.Passer,
+ private readonly desktopFilter: DesktopFilter,
currentActivity: string,
currentDesktop: KwinDesktop,
) {
@@ -19,10 +20,15 @@ class DesktopManager {
this.selectedScreen = Workspace.activeScreen;
this.kwinActivities = new Set(Workspace.activities);
this.kwinDesktops = new Set(Workspace.desktops);
- this.addDesktop(currentActivity, currentDesktop);
+ if (this.desktopFilter.shouldWorkOnDesktop(currentDesktop)) {
+ 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) {
diff --git a/src/lib/world/World.ts b/src/lib/world/World.ts
index 0bd9ddb..be6149c 100644
--- a/src/lib/world/World.ts
+++ b/src/lib/world/World.ts
@@ -5,12 +5,10 @@ class World {
private readonly workspaceSignalManager: SignalManager;
private readonly shortcutActions: ShortcutAction[];
private readonly screenResizedDelayer: Delayer;
- private readonly cursorFollowsFocus: boolean;
constructor(config: Config) {
const focusPasser = new FocusPassing.Passer();
this.workspaceSignalManager = initWorkspaceSignalHandlers(this, focusPasser);
- this.cursorFollowsFocus = config.cursorFollowsFocus;
let presetWidths = {
next: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth,
@@ -70,6 +68,7 @@ class World {
},
layoutConfig,
focusPasser,
+ new DesktopFilter(config.tiledDesktops),
Workspace.currentActivity,
Workspace.currentDesktop,
);
@@ -98,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) {
+ if (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;
@@ -141,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() {
diff --git a/src/tests/flows/cursorFollowsFocus.ts b/src/tests/flows/cursorFollowsFocus.ts
index ed614c5..029f8ca 100644
--- a/src/tests/flows/cursorFollowsFocus.ts
+++ b/src/tests/flows/cursorFollowsFocus.ts
@@ -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" });
+});
diff --git a/src/tests/flows/desktopFiltering.ts b/src/tests/flows/desktopFiltering.ts
new file mode 100644
index 0000000..54c2a88
--- /dev/null
+++ b/src/tests/flows/desktopFiltering.ts
@@ -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" });
+ });
+});
diff --git a/src/tests/units/rules/DesktopFilter.ts b/src/tests/units/rules/DesktopFilter.ts
new file mode 100644
index 0000000..21f072f
--- /dev/null
+++ b/src/tests/units/rules/DesktopFilter.ts
@@ -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)" });
+});
diff --git a/src/tests/utils/mocks/MockKwinClient.ts b/src/tests/utils/mocks/MockKwinClient.ts
index 47f6781..6c692fe 100644
--- a/src/tests/utils/mocks/MockKwinClient.ts
+++ b/src/tests/utils/mocks/MockKwinClient.ts
@@ -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) {
diff --git a/src/tests/utils/mocks/MockWorkspace.ts b/src/tests/utils/mocks/MockWorkspace.ts
index d53eb99..67fbfbb 100644
--- a/src/tests/utils/mocks/MockWorkspace.ts
+++ b/src/tests/utils/mocks/MockWorkspace.ts
@@ -3,8 +3,8 @@ 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 currentActivity = this.activities[0];
public activeScreen: Output = { __brand: "Output" };