Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
50 changes: 50 additions & 0 deletions package/contents/ui/config.ui
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,56 @@
</widget>
</item>

<item>
<widget class="QGroupBox">
<property name="title">
<string>Pointer scrolling</string>
</property>
<layout class="QVBoxLayout">
<item>
<widget class="QCheckBox" name="kcfg_screenEdgeScrollColumns">
<property name="text">
<string>Push mouse to screen edge to scroll columns</string>
</property>
<property name="toolTip">
<string>Trigger one-column scrolling when the cursor is pushed against the left or right screen edge</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout">
<item>
<widget class="QLabel" name="label_screenEdgeScrollDelay">
<property name="text">
<string>Screen edge scroll delay:</string>
</property>
<property name="toolTip">
<string>How long the cursor must stay on the screen edge before scrolling</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="kcfg_screenEdgeScrollDelay">
<property name="suffix">
<string> ms</string>
</property>
<property name="maximum">
<number>5000</number>
</property>
<property name="singleStep">
<number>50</number>
</property>
<property name="value">
<number>200</number>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>

<item>
<spacer name="spacer_footer">
<property name="orientation">
Expand Down
36 changes: 35 additions & 1 deletion package/contents/ui/main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ Item {
id: qmlBase

property var karouselInstance

Component.onCompleted: {
qmlBase.karouselInstance = Karousel.init();
}
Expand Down Expand Up @@ -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

Expand All @@ -70,4 +103,5 @@ Item {
method: "invokeShortcut"
arguments: ["MoveMouseToFocus"]
}

}
2 changes: 2 additions & 0 deletions src/lib/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ interface Config {
gestureScroll: boolean;
gestureScrollInvert: boolean;
gestureScrollStep: number;
screenEdgeScrollColumns: boolean;
screenEdgeScrollDelay: number;
tiledKeepBelow: boolean;
floatingKeepAbove: boolean;
windowRules: string;
Expand Down
10 changes: 10 additions & 0 deletions src/lib/config/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions src/lib/keyBindings/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
56 changes: 55 additions & 1 deletion src/lib/world/World.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`
Expand Down Expand Up @@ -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) {
Expand Down
120 changes: 120 additions & 0 deletions src/tests/flows/pointerScroll.ts
Original file line number Diff line number Diff line change
@@ -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);
});