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>
This commit is contained in:
SR_team
2025-10-22 00:06:25 +05:00
committed by GitHub
parent 2c433867f3
commit c7effc8913
14 changed files with 309 additions and 23 deletions

View File

@@ -458,6 +458,30 @@
<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="font">
<font><family>Monospace</family></font>
</property>
<property name="toolTip">
<string>Regex string to match desktops. Default value is ".*". Example: "Desktop [1-3]"</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPlainTextEdit" name="kcfg_windowRules">
<property name="tabChangesFocus">

View File

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

View File

@@ -189,4 +189,9 @@ const configDef = [
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

@@ -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;
}

View File

@@ -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
}
}
}

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);

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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,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

@@ -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) {

View File

@@ -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" };