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