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