diff --git a/README.md b/README.md index 8837b55..71c97ff 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,7 @@ Here's the default ones: | Meta+Ctrl+Shift+[N] | Move column to position N (Requires manual remapping according to your keyboard layout, e.g. Meta+Ctrl+Shift+1 -> Meta+Ctrl+!) | | Meta+Ctrl+Shift+F[N] | Move column to desktop N | | Meta+Ctrl+Shift+Alt+F[N] | Move this and all following columns to desktop N | + +## Pointer inputs +- Screen-edge column scrolling can be enabled in Karousel settings to move one column when the cursor is pushed against the left or right screen edge. +- This can conflict with KDE's own screen-edge actions, so disable the corresponding KDE actions if necessary. diff --git a/package/contents/ui/config.ui b/package/contents/ui/config.ui index 19d9c60..ea9619b 100644 --- a/package/contents/ui/config.ui +++ b/package/contents/ui/config.ui @@ -171,6 +171,56 @@ + + + + Pointer scrolling + + + + + + Push mouse to screen edge to scroll columns + + + Trigger one-column scrolling when the cursor is pushed against the left or right screen edge + + + + + + + + + Screen edge scroll delay: + + + How long the cursor must stay on the screen edge before scrolling + + + + + + + ms + + + 5000 + + + 50 + + + 200 + + + + + + + + + diff --git a/package/contents/ui/main.qml b/package/contents/ui/main.qml index 4c5db2f..6768bb7 100644 --- a/package/contents/ui/main.qml +++ b/package/contents/ui/main.qml @@ -7,7 +7,6 @@ Item { id: qmlBase property var karouselInstance - Component.onCompleted: { qmlBase.karouselInstance = Karousel.init(); } @@ -62,6 +61,40 @@ Item { onProgressChanged: qmlBase.karouselInstance.gestureScroll(progress) } + ScreenEdgeHandler { + edge: ScreenEdgeHandler.LeftEdge + enabled: qmlBase.karouselInstance !== undefined && qmlBase.karouselInstance.screenEdgeScrollColumns + onActivated: screenEdgeScrollLeftDelay.restart() + } + + ScreenEdgeHandler { + edge: ScreenEdgeHandler.RightEdge + enabled: qmlBase.karouselInstance !== undefined && qmlBase.karouselInstance.screenEdgeScrollColumns + onActivated: screenEdgeScrollRightDelay.restart() + } + + Timer { + id: screenEdgeScrollLeftDelay + interval: qmlBase.karouselInstance !== undefined ? qmlBase.karouselInstance.screenEdgeScrollDelay : 200 + repeat: true + onTriggered: { + if (!qmlBase.karouselInstance.triggerScreenEdgeLeftIfCursorStillAtEdge()) { + stop() + } + } + } + + Timer { + id: screenEdgeScrollRightDelay + interval: qmlBase.karouselInstance !== undefined ? qmlBase.karouselInstance.screenEdgeScrollDelay : 200 + repeat: true + onTriggered: { + if (!qmlBase.karouselInstance.triggerScreenEdgeRightIfCursorStillAtEdge()) { + stop() + } + } + } + DBusCall { id: moveCursorToFocus @@ -70,4 +103,5 @@ Item { method: "invokeShortcut" arguments: ["MoveMouseToFocus"] } + } diff --git a/src/lib/config/config.ts b/src/lib/config/config.ts index 0f22600..60afca4 100644 --- a/src/lib/config/config.ts +++ b/src/lib/config/config.ts @@ -23,6 +23,8 @@ interface Config { gestureScroll: boolean; gestureScrollInvert: boolean; gestureScrollStep: number; + screenEdgeScrollColumns: boolean; + screenEdgeScrollDelay: number; tiledKeepBelow: boolean; floatingKeepAbove: boolean; windowRules: string; diff --git a/src/lib/config/definition.ts b/src/lib/config/definition.ts index 3a76fea..95929b9 100644 --- a/src/lib/config/definition.ts +++ b/src/lib/config/definition.ts @@ -179,6 +179,16 @@ const configDef = [ type: "UInt", default: 1920, }, + { + name: "screenEdgeScrollColumns", + type: "Bool", + default: false, + }, + { + name: "screenEdgeScrollDelay", + type: "UInt", + default: 200, + }, { name: "tiledKeepBelow", type: "Bool", diff --git a/src/lib/keyBindings/loader.ts b/src/lib/keyBindings/loader.ts index 23a5f8c..18b5801 100644 --- a/src/lib/keyBindings/loader.ts +++ b/src/lib/keyBindings/loader.ts @@ -52,8 +52,7 @@ function registerNumKeyBindings(shortcutActions: ShortcutAction[], numKeyBinding } } -function registerKeyBindings(world: World, config: Actions.Config) { - const actions = new Actions(config); +function registerKeyBindings(world: World, actions: Actions) { const shortcutActions: ShortcutAction[] = []; for (const keyBinding of getKeyBindings(world, actions)) { diff --git a/src/lib/world/World.ts b/src/lib/world/World.ts index efa8369..9348f32 100644 --- a/src/lib/world/World.ts +++ b/src/lib/world/World.ts @@ -2,15 +2,20 @@ class World { private readonly desktopManager: DesktopManager; private readonly clientManager: ClientManager; private readonly pinManager: PinManager; + private readonly actions: Actions; private readonly workspaceSignalManager: SignalManager; private readonly shortcutActions: ShortcutAction[]; private readonly screenResizedDelayer: Delayer; private readonly cursorFollowsFocus: boolean; + public readonly screenEdgeScrollColumns: boolean; + public readonly screenEdgeScrollDelay: number; constructor(config: Config) { const focusPasser = new FocusPassing.Passer(); this.workspaceSignalManager = initWorkspaceSignalHandlers(this, focusPasser); this.cursorFollowsFocus = config.cursorFollowsFocus; + this.screenEdgeScrollColumns = config.screenEdgeScrollColumns; + this.screenEdgeScrollDelay = config.screenEdgeScrollDelay; let presetWidths = { next: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth, @@ -24,12 +29,13 @@ class World { log("failed to parse presetWidths:", error); } - this.shortcutActions = registerKeyBindings(this, { + this.actions = new Actions({ manualScrollStep: config.manualScrollStep, presetWidths: presetWidths, verticalResizeStep: config.verticalResizeStep, columnResizer: config.scrollingCentered ? new RawResizer(presetWidths) : new ContextualResizer(presetWidths), }); + this.shortcutActions = registerKeyBindings(this, this.actions); this.screenResizedDelayer = new Delayer(1000, () => { // this delay ensures that docks are taken into account by `Workspace.clientArea` @@ -168,6 +174,54 @@ class World { }); } + public scrollColumnLeft() { + this.do(this.actions.gridScrollLeftColumn); + } + + public scrollColumnRight() { + this.do(this.actions.gridScrollRightColumn); + } + + public triggerScreenEdgeLeft() { + if (!this.screenEdgeScrollColumns) { + return; + } + this.scrollColumnLeft(); + } + + public triggerScreenEdgeRight() { + if (!this.screenEdgeScrollColumns) { + return; + } + this.scrollColumnRight(); + } + + public triggerScreenEdgeLeftIfCursorStillAtEdge() { + if (!this.isCursorAtLeftScreenEdge()) { + return false; + } + this.triggerScreenEdgeLeft(); + return true; + } + + public triggerScreenEdgeRightIfCursorStillAtEdge() { + if (!this.isCursorAtRightScreenEdge()) { + return false; + } + this.triggerScreenEdgeRight(); + return true; + } + + private isCursorAtLeftScreenEdge() { + const screen = Workspace.clientArea(ClientAreaOption.FullArea, Workspace.activeScreen, Workspace.currentDesktop); + return Workspace.cursorPos.x <= screen.x; + } + + private isCursorAtRightScreenEdge() { + const screen = Workspace.clientArea(ClientAreaOption.FullArea, Workspace.activeScreen, Workspace.currentDesktop); + return Workspace.cursorPos.x >= rectRight(screen) - 1; + } + public destroy() { this.workspaceSignalManager.destroy(); for (const shortcutAction of this.shortcutActions) { diff --git a/src/tests/flows/pointerScroll.ts b/src/tests/flows/pointerScroll.ts new file mode 100644 index 0000000..188c4a8 --- /dev/null +++ b/src/tests/flows/pointerScroll.ts @@ -0,0 +1,120 @@ +tests.register("pointer scroll methods", 5, () => { + const config = getDefaultConfig(); + const { workspaceMock, world } = init(config); + + const clients = workspaceMock.createClientsWithWidths(300, 300, 300, 300); + const [client1, client2, client3, client4] = clients; + + Assert.notFullyVisible(client1.getActualFrameGeometry(), { message: "initial client1" }); + Assert.notFullyVisible(client2.getActualFrameGeometry(), { message: "initial client2" }); + Assert.fullyVisible(client3.getActualFrameGeometry(), { message: "initial client3" }); + Assert.fullyVisible(client4.getActualFrameGeometry(), { message: "initial client4" }); + + world.scrollColumnLeft(); + Assert.notFullyVisible(client1.getActualFrameGeometry(), { message: "after left client1" }); + Assert.fullyVisible(client2.getActualFrameGeometry(), { message: "after left client2" }); + Assert.fullyVisible(client3.getActualFrameGeometry(), { message: "after left client3" }); + Assert.notFullyVisible(client4.getActualFrameGeometry(), { message: "after left client4" }); + + world.scrollColumnRight(); + Assert.notFullyVisible(client1.getActualFrameGeometry(), { message: "after right client1" }); + Assert.notFullyVisible(client2.getActualFrameGeometry(), { message: "after right client2" }); + Assert.fullyVisible(client3.getActualFrameGeometry(), { message: "after right client3" }); + Assert.fullyVisible(client4.getActualFrameGeometry(), { message: "after right client4" }); +}); + +tests.register("pointer scroll boundaries", 5, () => { + const config = getDefaultConfig(); + const { workspaceMock, world } = init(config); + + const clients = workspaceMock.createClientsWithWidths(300, 300, 300); + const [client1, client2, client3] = clients; + + world.scrollColumnLeft(); + Assert.fullyVisible(client1.getActualFrameGeometry(), { message: "scroll to start client1" }); + Assert.fullyVisible(client2.getActualFrameGeometry(), { message: "scroll to start client2" }); + Assert.notFullyVisible(client3.getActualFrameGeometry(), { message: "scroll to start client3" }); + + const startFrames = clients.map(client => client.getActualFrameGeometry().clone()); + world.scrollColumnLeft(); + clients.forEach((client, index) => { + Assert.equalRects(client.getActualFrameGeometry(), startFrames[index], { message: `start boundary ${index}` }); + }); + + world.scrollColumnRight(); + Assert.notFullyVisible(client1.getActualFrameGeometry(), { message: "scroll to end client1" }); + Assert.fullyVisible(client2.getActualFrameGeometry(), { message: "scroll to end client2" }); + Assert.fullyVisible(client3.getActualFrameGeometry(), { message: "scroll to end client3" }); + + const endFrames = clients.map(client => client.getActualFrameGeometry().clone()); + world.scrollColumnRight(); + clients.forEach((client, index) => { + Assert.equalRects(client.getActualFrameGeometry(), endFrames[index], { message: `end boundary ${index}` }); + }); +}); + +tests.register("screen edge repeated scroll", 5, () => { + const config = getDefaultConfig(); + config.screenEdgeScrollColumns = true; + const { workspaceMock, world } = init(config); + + const clients = workspaceMock.createClientsWithWidths(300, 300, 300, 300); + const [client1, client2, client3, client4] = clients; + + world.triggerScreenEdgeLeft(); + Assert.notFullyVisible(client1.getActualFrameGeometry(), { message: "first trigger client1" }); + Assert.fullyVisible(client2.getActualFrameGeometry(), { message: "first trigger client2" }); + Assert.fullyVisible(client3.getActualFrameGeometry(), { message: "first trigger client3" }); + Assert.notFullyVisible(client4.getActualFrameGeometry(), { message: "first trigger client4" }); + + world.triggerScreenEdgeLeft(); + Assert.fullyVisible(client1.getActualFrameGeometry(), { message: "second trigger client1" }); + Assert.fullyVisible(client2.getActualFrameGeometry(), { message: "second trigger client2" }); + Assert.notFullyVisible(client3.getActualFrameGeometry(), { message: "second trigger client3" }); + Assert.notFullyVisible(client4.getActualFrameGeometry(), { message: "second trigger client4" }); +}); + +tests.register("screen edge scroll requires cursor at edge", 5, () => { + const config = getDefaultConfig(); + config.screenEdgeScrollColumns = true; + const { workspaceMock, world } = init(config); + + const clients = workspaceMock.createClientsWithWidths(300, 300, 300, 300); + const [client1, client2, client3, client4] = clients; + const framesBeforeEdge = clients.map(client => client.getActualFrameGeometry().clone()); + + workspaceMock.cursorPos.x = screen.x + 1; + world.triggerScreenEdgeLeftIfCursorStillAtEdge(); + clients.forEach((client, index) => { + Assert.equalRects(client.getActualFrameGeometry(), framesBeforeEdge[index], { message: `not at edge ${index}` }); + }); + + workspaceMock.cursorPos.x = screen.x; + world.triggerScreenEdgeLeftIfCursorStillAtEdge(); + Assert.notFullyVisible(client1.getActualFrameGeometry(), { message: "at edge client1" }); + Assert.fullyVisible(client2.getActualFrameGeometry(), { message: "at edge client2" }); + Assert.fullyVisible(client3.getActualFrameGeometry(), { message: "at edge client3" }); + Assert.notFullyVisible(client4.getActualFrameGeometry(), { message: "at edge client4" }); +}); + +tests.register("screen edge scroll disabled", 5, () => { + const config = getDefaultConfig(); + config.screenEdgeScrollColumns = false; + const { workspaceMock, world } = init(config); + + const clients = workspaceMock.createClientsWithWidths(300, 300, 300, 300); + const frames = clients.map(client => client.getActualFrameGeometry().clone()); + + world.triggerScreenEdgeLeft(); + world.triggerScreenEdgeRight(); + + clients.forEach((client, index) => { + Assert.equalRects(client.getActualFrameGeometry(), frames[index], { message: `disabled ${index}` }); + }); +}); + +tests.register("pointer scroll config defaults", 1, () => { + const config = getDefaultConfig(); + Assert.equal(config.screenEdgeScrollColumns, false); + Assert.equal(config.screenEdgeScrollDelay, 200); +});