37 Commits
v0.10 ... v0.11

Author SHA1 Message Date
Peter Fajdiga
47f4bbd9b6 bump version to 0.11 2025-01-18 13:24:43 +01:00
Peter Fajdiga
2d4ad73d16 tests: always use MockQSignal<[]> for signals without parameters 2025-01-15 20:59:43 +01:00
Peter Fajdiga
bb4e4f8ebd tests: add more events and assertions to passFocus 2025-01-15 20:56:16 +01:00
Peter Fajdiga
0742975334 MockWorkspace: unset activeWindow when the active windows is removed 2025-01-15 19:13:22 +01:00
Peter Fajdiga
64457429d0 pass focus when moving a window to another desktop 2025-01-15 19:01:24 +01:00
Peter Fajdiga
02154f2f5e MockKwinClient: unset activeWindow when the active windows moves to an inactive desktop 2025-01-15 14:22:45 +01:00
Peter Fajdiga
0a2bb4f65d MockKwinClient: fire desktopsChanged signal 2025-01-15 14:08:24 +01:00
Peter Fajdiga
6f207e59c4 MockKwinClient: make underscored properties private 2025-01-15 11:56:13 +01:00
Peter Fajdiga
4c987b6c5b pass focus when moving a column to another desktop 2025-01-15 11:48:26 +01:00
Peter Fajdiga
bca0158df9 tests: give mock clients numbered captions 2025-01-15 11:47:26 +01:00
Peter Fajdiga
9feeb0f23e prevent unnecessary scrolling on focus change (fixes #67) 2025-01-15 10:40:46 +01:00
Peter Fajdiga
0241846ea5 MockKwinClient: only fire maximizedAboutToChange if there's an actual change 2025-01-14 19:34:14 +01:00
Peter Fajdiga
3bf3f16f49 MockKwinClient: initialize windowed to true 2025-01-14 19:31:27 +01:00
Peter Fajdiga
782a6db56d readme: prepend "Then" 2025-01-09 17:51:25 +01:00
Peter Fajdiga
93b6850ffd readme: mention org.kde.notification in Installation 2025-01-09 17:46:25 +01:00
Peter Fajdiga
5f0c637d1a Actions.gridScroll: simplify 2024-12-23 20:03:57 +01:00
Himadri Bhattacharjee
8829d0b291 add ability to cycle preset widths in reverse (#75) (resolves #74)
* feat: add ability to cycle preset widths in reverse

* tests: add tests for cycling widths in reverse
2024-12-22 16:57:54 +01:00
Peter Fajdiga
d37b4bc5d1 tests: add test case for no layering 2024-11-08 16:05:18 +01:00
Peter Fajdiga
ead29e5e69 rename file 3-feature_request.md 2024-11-08 10:15:26 +01:00
Peter Fajdiga
ff75d931f6 Update issue templates 2024-11-08 10:13:51 +01:00
Peter Fajdiga
d00d514d30 stop setting keepAbove in destroy methods of Floating and Pinned 2024-11-05 21:48:35 +01:00
Peter Fajdiga
3b919909dc prevent unpinned windows from retaining keepAbove 2024-11-05 21:46:26 +01:00
Peter Fajdiga
0004b6f921 fillSpace: rename to fenceposts 2024-11-05 19:37:46 +01:00
Peter Fajdiga
24265c56f9 tests: lazyScroller: add steps 2024-10-27 23:37:19 +01:00
Peter Fajdiga
dcbc0a474d Range: reorder code 2024-10-27 23:32:39 +01:00
Peter Fajdiga
88f170f5c1 Range.Basic: unexport 2024-10-27 23:31:31 +01:00
Peter Fajdiga
78ab48ee09 Range.Basic: define properties in constructor 2024-10-27 23:27:14 +01:00
Peter Fajdiga
b2d81796f8 replace Range.Basic.fromRanges with Range.fromRanges 2024-10-27 23:20:17 +01:00
Peter Fajdiga
7d27331ce5 replace Range.Basic.contains with Range.contains 2024-10-27 23:18:21 +01:00
Peter Fajdiga
55e1037a7b extract Range into Range.ts 2024-10-27 23:14:13 +01:00
Peter Fajdiga
7820c7d00e replace Column.isVisible with RangeImpl.contains 2024-10-27 23:08:52 +01:00
Peter Fajdiga
3d8ca0bc14 Grid: remove function 2024-10-27 22:46:45 +01:00
Peter Fajdiga
eaf68b87f9 tests: create LazyScroller test 2024-10-27 22:29:00 +01:00
Peter Fajdiga
b2dfad6042 tests: Assert.grid: add centered parameter 2024-10-27 21:13:14 +01:00
Peter Fajdiga
054808cb38 tests: Assert.centeredGrid: add width parameter 2024-10-27 20:29:11 +01:00
Peter Fajdiga
97059fa4f7 readme: add installation instructions 2024-10-27 16:20:07 +01:00
Peter Fajdiga
5e7959c7f4 readme: update key bindings 2024-10-27 14:36:55 +01:00
30 changed files with 571 additions and 139 deletions

View File

@@ -0,0 +1,22 @@
---
name: Feature request
about: Request a feature
title: "[Feature]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
These sections are just guidelines, feel free to remove them.

View File

@@ -22,6 +22,13 @@ Karousel requires the following QML modules:
- Doesn't support windows on all desktops
- Doesn't support windows on multiple activities
## Installation
First install the _org.kde.notification_ QML module (_qml-module-org-kde-notifications_ package on Ubuntu).
Then download the [latest release](https://github.com/peterfajdiga/karousel/releases/latest) and extract it into _~/.local/share/kwin/scripts/_.
Or clone the repo and run `make install` (requires node and tsc).
## Key bindings
The key bindings can be configured in KDE System Settings among KWin's own keyboard shortcuts.
Here's the default ones:
@@ -32,12 +39,16 @@ Here's the default ones:
| Meta+D | Move focus right (Clashes with default KDE shortcuts, may require manual remapping) |
| Meta+W | Move focus up (Clashes with default KDE shortcuts, may require manual remapping) |
| Meta+S | Move focus down (Clashes with default KDE shortcuts, may require manual remapping) |
| (unassigned) | Move focus to the next window in grid |
| (unassigned) | Move focus to the previous window in grid |
| Meta+Home | Move focus to start |
| Meta+End | Move focus to end |
| Meta+Shift+A | Move window left (Moves window out of and into columns) |
| Meta+Shift+D | Move window right (Moves window out of and into columns) |
| Meta+Shift+W | Move window up |
| Meta+Shift+S | Move window down |
| (unassigned) | Move window to the next position in grid |
| (unassigned) | Move window to the previous position in grid |
| Meta+Shift+Home | Move window to start |
| Meta+Shift+End | Move window to end |
| Meta+X | Toggle stacked layout for focused column (One window in the column visible, others shaded; not supported on Wayland) |
@@ -47,7 +58,10 @@ Here's the default ones:
| Meta+Ctrl+Shift+End | Move column to end |
| Meta+Ctrl++ | Increase column width |
| Meta+Ctrl+- | Decrease column width |
| Meta+R | Cycle through preset column widths |
| Meta+Ctrl+X | Equalize widths of visible columns |
| Meta+Ctrl+A | Squeeze left column onto the screen (Clashes with default KDE shortcuts, may require manual remapping) |
| Meta+Ctrl+D | Squeeze right column onto the screen |
| Meta+Alt+Return | Center focused window (Scrolls so that the focused window is centered in the screen) |
| Meta+Alt+A | Scroll one column to the left |
| Meta+Alt+D | Scroll one column to the right |

View File

@@ -9,7 +9,7 @@
"Name": "Peter Fajdiga"
}],
"Id": "karousel",
"Version": "0.10",
"Version": "0.11",
"License": "GPLv3",
"Website": "https://github.com/peterfajdiga/karousel",
"BugReportUrl": "https://github.com/peterfajdiga/karousel/issues"

View File

@@ -11,6 +11,12 @@ class PresetWidths {
return nextIndex >= 0 ? widths[nextIndex] : widths[0];
}
public prev(currentWidth: number, minWidth: number, maxWidth: number) {
const widths = this.getWidths(minWidth, maxWidth).reverse();
const nextIndex = widths.findIndex(width => width < currentWidth);
return nextIndex >= 0 ? widths[nextIndex] : widths[0];
}
public getWidths(minWidth: number, maxWidth: number) {
const widths = this.presets.map(f => clamp(f(maxWidth), minWidth, maxWidth));
widths.sort((a, b) => a - b);

View File

@@ -9,7 +9,7 @@ class ContextualResizer {
const visibleRange = desktop.getCurrentVisibleRange();
const minWidth = column.getMinWidth();
const maxWidth = column.getMaxWidth();
if(!column.isVisible(visibleRange, true) || column.getWidth() >= maxWidth) {
if(!Range.contains(visibleRange, column) || column.getWidth() >= maxWidth) {
return;
}
@@ -46,7 +46,7 @@ class ContextualResizer {
const visibleRange = desktop.getCurrentVisibleRange();
const minWidth = column.getMinWidth();
const maxWidth = column.getMaxWidth();
if(!column.isVisible(visibleRange, true) || column.getWidth() <= minWidth) {
if(!Range.contains(visibleRange, column) || column.getWidth() <= minWidth) {
return;
}

View File

@@ -189,6 +189,11 @@ class Actions {
column.setWidth(nextWidth, true);
}
public readonly cyclePresetWidthsReverse = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const nextWidth = this.config.presetWidths.prev(column.getWidth(), column.getMinWidth(), column.getMaxWidth());
column.setWidth(nextWidth, true);
}
public readonly columnsWidthEqualize = (cm: ClientManager, dm: DesktopManager) => {
const desktop = dm.getCurrentDesktop();
const visibleRange = desktop.getCurrentVisibleRange();
@@ -202,7 +207,7 @@ class Actions {
);
visibleColumns.forEach((column, index) => column.setWidth(widths[index], true));
desktop.scrollCenterRange(Desktop.RangeImpl.fromRanges(
desktop.scrollCenterRange(Range.fromRanges(
visibleColumns[0],
visibleColumns[visibleColumns.length - 1],
));
@@ -210,7 +215,7 @@ class Actions {
public readonly columnsSqueezeLeft = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => {
const visibleRange = grid.desktop.getCurrentVisibleRange();
if (!focusedColumn.isVisible(visibleRange, true)) {
if (!Range.contains(visibleRange, focusedColumn)) {
return;
}
@@ -237,7 +242,7 @@ class Actions {
public readonly columnsSqueezeRight = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => {
const visibleRange = grid.desktop.getCurrentVisibleRange();
if (!focusedColumn.isVisible(visibleRange, true)) {
if (!Range.contains(visibleRange, focusedColumn)) {
return;
}
@@ -279,7 +284,7 @@ class Actions {
const widths = fillSpace(availableSpace - gapsWidth, columnConstraints);
columns.forEach((column, index) => column.setWidth(widths[index], true));
desktop.scrollCenterRange(Desktop.RangeImpl.fromRanges(firstColumn, lastColumn));
desktop.scrollCenterRange(Range.fromRanges(firstColumn, lastColumn));
return true;
}
@@ -292,8 +297,7 @@ class Actions {
}
private readonly gridScroll = (desktopManager: DesktopManager, amount: number) => {
const grid = desktopManager.getCurrentDesktop().grid;
grid.desktop.adjustScroll(amount, false);
desktopManager.getCurrentDesktop().adjustScroll(amount, false);
}
public readonly gridScrollStart = (cm: ClientManager, dm: DesktopManager) => {
@@ -410,7 +414,10 @@ class Actions {
namespace Actions {
export type Config = {
manualScrollStep: number;
presetWidths: { next: (currentWidth: number, minWidth: number, maxWidth: number) => number };
presetWidths: {
next: (currentWidth: number, minWidth: number, maxWidth: number) => number;
prev: (currentWidth: number, minWidth: number, maxWidth: number) => number
};
columnResizer: ColumnResizer;
};

View File

@@ -152,6 +152,12 @@ function getKeyBindings(world: World, actions: Actions): KeyBinding[] {
defaultKeySequence: "Meta+R",
action: () => world.doIfTiledFocused(actions.cyclePresetWidths),
},
{
name: "cycle-preset-widths-reverse",
description: "Cycle through preset column widths in reverse",
defaultKeySequence: "Meta+Shift+R",
action: () => world.doIfTiledFocused(actions.cyclePresetWidthsReverse),
},
{
name: "columns-width-equalize",
description: "Equalize widths of visible columns",

View File

@@ -21,7 +21,7 @@ class Column {
if (targetGrid === this.grid) {
this.grid.moveColumn(this, leftColumn);
} else {
this.grid.onColumnRemoved(this, false);
this.grid.onColumnRemoved(this, this.isFocused());
this.grid = targetGrid;
targetGrid.onColumnAdded(this, leftColumn);
for (const window of this.windows.iterator()) {
@@ -203,9 +203,17 @@ class Column {
window.focus();
}
public isFocused() {
const lastFocusedWindow = this.grid.getLastFocusedWindow();
if (lastFocusedWindow === null) {
return false;
}
return lastFocusedWindow.column === this && lastFocusedWindow.isFocused();
}
public arrange(x: number, visibleRange: Range, forceOpaque: boolean) {
if (this.grid.config.offScreenOpacity < 1.0 && !forceOpaque) {
const opacity = this.isVisible(visibleRange, true) ? 100 : this.grid.config.offScreenOpacity;
const opacity = Range.contains(visibleRange, this) ? 100 : this.grid.config.offScreenOpacity;
for (const window of this.windows.iterator()) {
window.client.kwinClient.opacity = opacity;
}
@@ -267,16 +275,6 @@ class Column {
return true;
}
public isVisible(visibleRange: Desktop.Range, fullyVisible: boolean) {
if (fullyVisible) {
return this.getLeft() >= visibleRange.getLeft() &&
this.getRight() <= visibleRange.getRight();
} else {
return this.getRight() + this.grid.config.gapsInnerHorizontal > visibleRange.getLeft() &&
this.getLeft() - this.grid.config.gapsInnerHorizontal < visibleRange.getRight();
}
}
public onWindowAdded(window: Window, bottom: boolean) {
if (bottom) {
this.windows.insertEnd(window);

View File

@@ -55,7 +55,7 @@ class Desktop {
)
}
public scrollIntoView(range: Desktop.Range) {
public scrollIntoView(range: Range) {
const left = range.getLeft();
const right = range.getRight();
const initialVisibleRange = this.getCurrentVisibleRange();
@@ -72,7 +72,7 @@ class Desktop {
this.setScroll(targetScrollX, false);
}
public scrollCenterRange(range: Desktop.Range) {
public scrollCenterRange(range: Range) {
const windowCenter = range.getLeft() + range.getWidth() / 2;
const screenCenter = this.scrollX + this.tilingArea.width / 2;
this.adjustScroll(Math.round(windowCenter - screenCenter), true);
@@ -95,13 +95,13 @@ class Desktop {
}
public scrollToColumn(column: Column) {
if (this.dirtyScroll || !column.isVisible(this.getCurrentVisibleRange(), true)) {
if (this.dirtyScroll || !Range.contains(this.getCurrentVisibleRange(), column)) {
this.config.scroller.scrollToColumn(this, column);
}
}
private getVisibleRange(scrollX: number) {
return new Desktop.RangeImpl(scrollX, this.tilingArea.width);
return Range.create(scrollX, this.tilingArea.width);
}
public getCurrentVisibleRange() {
@@ -135,6 +135,10 @@ class Desktop {
this.dirty = false;
}
public forceArrange() {
this.dirty = true;
}
public onLayoutChanged() {
this.dirty = true;
this.dirtyScroll = true;
@@ -161,40 +165,6 @@ namespace Desktop {
clamper: Desktop.Clamper;
};
export type Range = {
getLeft(): number;
getRight(): number;
getWidth(): number;
};
export class RangeImpl {
private readonly x: number;
private readonly width: number;
constructor(x: number, width: number) {
this.x = x;
this.width = width;
}
public getLeft() {
return this.x;
}
public getRight() {
return this.x + this.width;
}
public getWidth() {
return this.width;
}
public static fromRanges(leftRange: Range, rightRange: Range) {
const left = leftRange.getLeft();
const right = rightRange.getRight();
return new RangeImpl(left, right - left);
}
}
export class ColumnRange {
private left: Column;
private right: Column;
@@ -206,7 +176,7 @@ namespace Desktop {
this.width = initialColumn.getWidth();
}
public addNeighbors(visibleRange: Desktop.Range, gap: number) {
public addNeighbors(visibleRange: Range, gap: number) {
const grid = this.left.grid;
const columnRange = this;

View File

@@ -1,5 +1,3 @@
import Range = Desktop.Range;
class Grid {
public readonly desktop: Desktop;
public readonly config: LayoutConfig;
@@ -106,19 +104,19 @@ class Grid {
this.width = x - this.config.gapsInnerHorizontal;
}
public getLeftmostVisibleColumn(visibleRange: Desktop.Range, fullyVisible: boolean) {
public getLeftmostVisibleColumn(visibleRange: Range, fullyVisible: boolean) {
for (const column of this.columns.iterator()) {
if (column.isVisible(visibleRange, fullyVisible)) {
if (Range.contains(visibleRange, column)) {
return column;
}
}
return null;
}
public getRightmostVisibleColumn(visibleRange: Desktop.Range, fullyVisible: boolean) {
public getRightmostVisibleColumn(visibleRange: Range, fullyVisible: boolean) {
let last = null;
for (const column of this.columns.iterator()) {
if (column.isVisible(visibleRange, fullyVisible)) {
if (Range.contains(visibleRange, column)) {
last = column;
} else if (last !== null) {
break;
@@ -127,29 +125,14 @@ class Grid {
return last;
}
public *getVisibleColumns(visibleRange: Desktop.Range, fullyVisible: boolean) {
public *getVisibleColumns(visibleRange: Range, fullyVisible: boolean) {
for (const column of this.columns.iterator()) {
if (column.isVisible(visibleRange, fullyVisible)) {
if (Range.contains(visibleRange, column)) {
yield column;
}
}
}
public getVisibleColumnsWidth(visibleRange: Desktop.Range, fullyVisible: boolean) {
let width = 0;
let nVisible = 0;
for (const column of this.getVisibleColumns(visibleRange, fullyVisible)) {
width += column.getWidth();
nVisible++;
}
if (nVisible > 0) {
width += (nVisible-1) * this.config.gapsInnerHorizontal;
}
return width;
}
public arrange(x: number, visibleRange: Range) {
for (const column of this.columns.iterator()) {
column.arrange(x, visibleRange, this.userResize);

41
src/lib/layout/Range.ts Normal file
View File

@@ -0,0 +1,41 @@
type Range = {
getLeft(): number;
getRight(): number;
getWidth(): number;
};
namespace Range {
export function create(x: number, width: number) {
return new Basic(x, width);
}
export function fromRanges(leftRange: Range, rightRange: Range) {
const left = leftRange.getLeft();
const right = rightRange.getRight();
return new Basic(left, right - left);
}
export function contains(parent: Range, child: Range) {
return child.getLeft() >= parent.getLeft() &&
child.getRight() <= parent.getRight();
}
class Basic {
constructor(
private readonly x: number,
private readonly width: number,
) {}
public getLeft() {
return this.x;
}
public getRight() {
return this.x + this.width;
}
public getWidth() {
return this.width;
}
}
}

View File

@@ -21,7 +21,7 @@ class Window {
if (targetColumn === this.column) {
return;
}
this.column.onWindowRemoved(this, false);
this.column.onWindowRemoved(this, this.isFocused() && targetColumn.grid !== this.column.grid);
this.column = targetColumn;
targetColumn.onWindowAdded(this, bottom);
}
@@ -66,6 +66,14 @@ class Window {
}
public onFocused() {
if (this.column.grid.config.reMaximize && (
this.focusedState.maximizedMode !== MaximizedMode.Unmaximized ||
this.focusedState.fullScreen
)) {
// We need to maximize/fullscreen this window, but we can't do it here.
// We need to do it in `arrange` to ensure it happens after placement.
this.column.grid.desktop.forceArrange();
}
this.column.onWindowFocused(this);
}
@@ -75,7 +83,6 @@ class Window {
}
this.client.setFullScreen(false);
this.client.setMaximize(false, false);
this.column.grid.desktop.onLayoutChanged();
}
public onMaximizedChanged(maximizedMode: MaximizedMode) {

View File

@@ -26,38 +26,38 @@ function fillSpace(availableSpace: number, items: { min: number, max: number }[]
}
function buildRanges(items: { min: number, max: number }[]) {
const landmarks = buildLandmarks(items);
if (landmarks.length === 1) {
const fenceposts = extractFenceposts(items);
if (fenceposts.length === 1) {
return [{
start: landmarks[0].value,
end: landmarks[0].value,
start: fenceposts[0].value,
end: fenceposts[0].value,
n: items.length,
}];
}
const ranges: Range[] = [];
let n = 0;
for (let i = 1; i < landmarks.length; i++) {
const startLandmark = landmarks[i-1];
const endLandmark = landmarks[i];
n = n - startLandmark.nMax + startLandmark.nMin;
for (let i = 1; i < fenceposts.length; i++) {
const startFencepost = fenceposts[i-1];
const endFencepost = fenceposts[i];
n = n - startFencepost.nMax + startFencepost.nMin;
ranges.push({
start: startLandmark.value,
end: endLandmark.value,
start: startFencepost.value,
end: endFencepost.value,
n: n,
});
}
return ranges;
}
function buildLandmarks(items: { min: number, max: number }[]) {
const landmarks = new Map<number, Landmark>();
function extractFenceposts(items: { min: number, max: number }[]) {
const fenceposts = new Map<number, Fencepost>();
for (const item of items) {
mapGetOrInit(landmarks, item.min, { value: item.min, nMin: 0, nMax: 0 }).nMin++;
mapGetOrInit(landmarks, item.max, { value: item.max, nMin: 0, nMax: 0 }).nMax++;
mapGetOrInit(fenceposts, item.min, { value: item.min, nMin: 0, nMax: 0 }).nMin++;
mapGetOrInit(fenceposts, item.max, { value: item.max, nMin: 0, nMax: 0 }).nMax++;
}
const array = Array.from(landmarks.values());
const array = Array.from(fenceposts.values());
array.sort((a, b) => a.value - b.value);
return array;
}
@@ -90,7 +90,7 @@ function fillSpace(availableSpace: number, items: { min: number, max: number }[]
n: number,
};
type Landmark = {
type Fencepost = {
value: number,
nMin: number,
nMax: number,

View File

@@ -11,6 +11,7 @@ class World {
let presetWidths = {
next: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth,
prev: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth,
getWidths: (minWidth: number, maxWidth: number): number[] => [],
};
try {

View File

@@ -18,9 +18,6 @@ namespace ClientState {
public destroy(passFocus: boolean) {
this.signalManager.destroy();
if (this.config.floatingKeepAbove) {
this.client.kwinClient.keepAbove = false;
}
}
// TODO: move to `Tiled.restoreClientAfterTiling`

View File

@@ -19,9 +19,6 @@ namespace ClientState {
public destroy(passFocus: boolean) {
this.signalManager.destroy();
if (this.config.floatingKeepAbove) {
this.kwinClient.keepAbove = true;
}
this.pinManager.removeClient(this.kwinClient);
for (const desktop of this.desktopManager.getDesktopsForClient(this.kwinClient)) {
desktop.onPinsChanged();

View File

@@ -204,6 +204,7 @@ namespace ClientState {
if (config.tiledKeepBelow) {
client.kwinClient.keepBelow = true;
}
client.kwinClient.keepAbove = false;
client.setFullScreen(false);
if (client.kwinClient.tile !== null) {
client.setMaximize(false, true); // disable quick tile mode

View File

@@ -0,0 +1,23 @@
tests.register("Center focused", 1, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const [client0, client1, client2] = workspaceMock.createClientsWithWidths(300, 152, 300);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(client0));
Assert.assert(clientManager.hasClient(client1));
Assert.assert(clientManager.hasClient(client2));
});
Assert.assert(workspaceMock.activeWindow === client2);
Assert.columnsFillTilingArea([client0, client1, client2]);
qtMock.fireShortcut("karousel-grid-scroll-focused");
Assert.centered(config, screen, client2);
Assert.fullyVisible(client1.frameGeometry);
Assert.fullyVisible(client2.frameGeometry);
qtMock.fireShortcut("karousel-focus-left");
Assert.centered(config, screen, client2, { message: "No scrolling should have occured" });
Assert.fullyVisible(client1.frameGeometry);
Assert.fullyVisible(client2.frameGeometry);
});

191
src/tests/flows/layering.ts Normal file
View File

@@ -0,0 +1,191 @@
tests.register("tiledKeepBelow", 10, () => {
const config = getDefaultConfig();
config.tiledKeepBelow = true;
config.floatingKeepAbove = false;
const { qtMock, workspaceMock, world } = init(config);
const pinGeometry = new MockQmlRect(0, 0, 200, screen.height);
const [client] = workspaceMock.createClients(1);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) !== null);
});
Assert.assert(client.keepBelow);
Assert.assert(!client.keepAbove);
qtMock.fireShortcut("karousel-window-toggle-floating");
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null);
});
Assert.assert(!client.keepBelow);
Assert.assert(!client.keepAbove);
client.pin(pinGeometry);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null);
});
Assert.assert(!client.keepBelow);
Assert.assert(!client.keepAbove);
client.unpin();
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null);
});
Assert.assert(!client.keepBelow);
Assert.assert(!client.keepAbove);
qtMock.fireShortcut("karousel-window-toggle-floating");
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) !== null);
});
Assert.assert(client.keepBelow);
Assert.assert(!client.keepAbove);
client.pin(pinGeometry);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null);
});
Assert.assert(!client.keepBelow);
Assert.assert(!client.keepAbove);
qtMock.fireShortcut("karousel-window-toggle-floating");
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) !== null);
});
Assert.assert(client.keepBelow);
Assert.assert(!client.keepAbove);
});
tests.register("floatingKeepAbove", 10, () => {
const config = getDefaultConfig();
config.tiledKeepBelow = false;
config.floatingKeepAbove = true;
const { qtMock, workspaceMock, world } = init(config);
const pinGeometry = new MockQmlRect(0, 0, 200, screen.height);
const [client] = workspaceMock.createClients(1);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) !== null);
});
Assert.assert(!client.keepBelow);
Assert.assert(!client.keepAbove);
qtMock.fireShortcut("karousel-window-toggle-floating");
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null);
});
Assert.assert(!client.keepBelow);
Assert.assert(client.keepAbove);
client.pin(pinGeometry);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null);
});
Assert.assert(!client.keepBelow);
Assert.assert(client.keepAbove);
client.unpin();
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null);
});
Assert.assert(!client.keepBelow);
Assert.assert(client.keepAbove);
qtMock.fireShortcut("karousel-window-toggle-floating");
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) !== null);
});
Assert.assert(!client.keepBelow);
Assert.assert(!client.keepAbove);
client.pin(pinGeometry);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null);
});
Assert.assert(!client.keepBelow);
Assert.assert(client.keepAbove);
qtMock.fireShortcut("karousel-window-toggle-floating");
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) !== null);
});
Assert.assert(!client.keepBelow);
Assert.assert(!client.keepAbove);
});
tests.register("No layering", 10, () => {
const config = getDefaultConfig();
config.tiledKeepBelow = false;
config.floatingKeepAbove = false;
// In this mode, Karousel shouldn't change keepBelow or keepAbove.
// Except when tiling a window, keepAbove should still be cleared.
const pinGeometry = new MockQmlRect(0, 0, 200, screen.height);
const testCases = [
{ keepBelow: false, keepAbove: false },
{ keepBelow: false, keepAbove: true },
{ keepBelow: true, keepAbove: false },
{ keepBelow: true, keepAbove: true },
];
for (const testCase of testCases) {
const assertOptions = { message: JSON.stringify(testCase) };
const { qtMock, workspaceMock, world } = init(config);
const [client] = workspaceMock.createClients(1);
client.keepBelow = testCase.keepBelow;
client.keepAbove = testCase.keepAbove;
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) !== null, assertOptions);
});
Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
qtMock.fireShortcut("karousel-window-toggle-floating");
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null, assertOptions);
});
Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
client.pin(pinGeometry);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null, assertOptions);
});
Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
client.unpin();
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null, assertOptions);
});
Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
qtMock.fireShortcut("karousel-window-toggle-floating");
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) !== null, assertOptions);
});
Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
Assert.assert(!client.keepAbove, assertOptions);
client.keepAbove = testCase.keepAbove;
client.pin(pinGeometry);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) === null, assertOptions);
});
Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
qtMock.fireShortcut("karousel-window-toggle-floating");
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.findTiledWindow(client) !== null, assertOptions);
});
Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
Assert.assert(!client.keepAbove, assertOptions);
}
});

View File

@@ -12,7 +12,7 @@ tests.register("Focus and move windows", 1, () => {
function testLayout(shortcutName: string, grid: KwinClient[][]) {
qtMock.fireShortcut(shortcutName);
Assert.grid(config, screen, grid, { skip: 1 });
Assert.grid(config, screen, 100, grid, true, { skip: 1 });
}
function testFocus(shortcutName: string, expectedFocus: KwinClient) {

View File

@@ -0,0 +1,47 @@
tests.register("LazyScroller", 20, () => {
const config = getDefaultConfig();
config.scrollingLazy = true;
config.scrollingCentered = false;
config.scrollingGrouped = false;
const { qtMock, workspaceMock, world } = init(config);
const [client1] = workspaceMock.createClientsWithWidths(300);
Assert.grid(config, screen, 300, [[client1]], true);
const [client2] = workspaceMock.createClientsWithWidths(300);
Assert.grid(config, screen, 300, [[client1], [client2]], true);
const [client3] = workspaceMock.createClientsWithWidths(300);
Assert.grid(config, screen, 300, [[client1], [client2], [client3]], false);
Assert.equal(client3.frameGeometry.right, tilingArea.right);
runOneOf(
() => workspaceMock.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-left"),
);
Assert.grid(config, screen, 300, [[client1], [client2], [client3]], false);
Assert.equal(client3.frameGeometry.right, tilingArea.right);
runOneOf(
() => workspaceMock.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
);
workspaceMock.activeWindow = client1;
Assert.grid(config, screen, 300, [[client1], [client2], [client3]], false);
Assert.equal(client1.frameGeometry.left, tilingArea.left);
qtMock.fireShortcut("karousel-grid-scroll-focused");
Assert.grid(config, screen, 300, [[client1], [client2], [client3]], false);
Assert.grid(config, screen, 300, [[client1]], true);
runOneOf(
() => workspaceMock.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
);
Assert.grid(config, screen, 300, [[client1], [client2], [client3]], false);
Assert.equal(client1.frameGeometry.left, tilingArea.left);
});

View File

@@ -0,0 +1,40 @@
tests.register("Pass focus", 20, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const [client0, client1a, client1b, client1c, client4, client5, client6] = workspaceMock.createClients(7);
workspaceMock.activeWindow = client1b;
qtMock.fireShortcut("karousel-window-move-left");
workspaceMock.activeWindow = client1c;
qtMock.fireShortcut("karousel-window-move-left");
workspaceMock.activeWindow = client1b;
workspaceMock.activeWindow = client5;
function removeWindow(client: MockKwinClient) {
runOneOf(
() => workspaceMock.removeWindow(client),
() => client.desktops = [workspaceMock.desktops[1]],
);
}
removeWindow(client5);
Assert.equal(workspaceMock.activeWindow, client4);
qtMock.fireShortcut("karousel-column-move-to-desktop-2");
Assert.equal(workspaceMock.activeWindow, client1b);
removeWindow(client1b);
Assert.equal(workspaceMock.activeWindow, client1a);
removeWindow(client1a);
Assert.equal(workspaceMock.activeWindow, client1c);
removeWindow(client1c);
Assert.equal(workspaceMock.activeWindow, client0);
removeWindow(client0);
Assert.equal(workspaceMock.activeWindow, client6);
removeWindow(client6);
Assert.equal(workspaceMock.activeWindow, null);
});

View File

@@ -6,34 +6,34 @@ tests.register("Pin", 20, () => {
const screenHalfRight = new MockQmlRect(screen.width/2, 0, screen.width/2, screen.height);
const [pinned, tiled1, tiled2] = workspaceMock.createClients(3);
Assert.grid(config, screen, [ [pinned], [tiled1], [tiled2] ]);
Assert.grid(config, screen, 100, [ [pinned], [tiled1], [tiled2] ], true);
pinned.pin(screenHalfLeft);
Assert.equalRects(pinned.frameGeometry, screenHalfLeft);
Assert.grid(config, screenHalfRight, [ [tiled1], [tiled2] ]);
Assert.grid(config, screenHalfRight, 100, [ [tiled1], [tiled2] ], true);
pinned.pin(screenHalfRight);
Assert.equalRects(pinned.frameGeometry, screenHalfRight);
Assert.grid(config, screenHalfLeft, [ [tiled1], [tiled2] ]);
Assert.grid(config, screenHalfLeft, 100, [ [tiled1], [tiled2] ], true);
pinned.unpin();
Assert.equalRects(pinned.frameGeometry, screenHalfRight);
Assert.grid(config, screen, [ [tiled1], [tiled2] ]);
Assert.grid(config, screen, 100, [ [tiled1], [tiled2] ], true);
pinned.pin(screenHalfRight);
Assert.equalRects(pinned.frameGeometry, screenHalfRight);
Assert.grid(config, screenHalfLeft, [ [tiled1], [tiled2] ]);
Assert.grid(config, screenHalfLeft, 100, [ [tiled1], [tiled2] ], true);
pinned.minimized = true;
Assert.grid(config, screen, [ [tiled1], [tiled2] ]);
Assert.grid(config, screen, 100, [ [tiled1], [tiled2] ], true);
pinned.minimized = false;
Assert.equalRects(pinned.frameGeometry, screenHalfRight);
Assert.grid(config, screenHalfLeft, [ [tiled1], [tiled2] ]);
Assert.grid(config, screenHalfLeft, 100, [ [tiled1], [tiled2] ], true);
workspaceMock.activeWindow = pinned;
qtMock.fireShortcut("karousel-window-toggle-floating");
Assert.assert(pinned.tile === null);
pinned.frameGeometry = new MockQmlRect(10, 20, 100, 200); // This is needed because the window's preferredWidth can change when pinning, because frameGeometryChanged can fire before tileChanged. TODO: Ensure pinned window keeps its preferredWidth.
Assert.grid(config, screen, [ [tiled1], [tiled2], [pinned] ]);
Assert.grid(config, screen, 100, [ [tiled1], [tiled2], [pinned] ], true);
});

View File

@@ -25,6 +25,12 @@ tests.register("Preset Widths default", 1, () => {
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(maxWidth));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
});
tests.register("Preset Widths custom", 1, () => {
@@ -61,6 +67,15 @@ tests.register("Preset Widths custom", 1, () => {
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(250));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(100));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(500));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
});
tests.register("Preset Widths fill screen uniform", 1, () => {

View File

@@ -126,23 +126,29 @@ namespace Assert {
export function grid(
config: Config,
screen: QmlRect,
columnWidth: number,
grid: KwinClient[][],
centered: boolean,
{ message, skip=0 }: Options = {},
) {
// assumes uniformly sized windows within columns of width 100
const nColumns = grid.length;
const columnHeight = screen.height - config.gapsOuterTop - config.gapsOuterBottom;
const columnsWidth = nColumns * columnWidth + (nColumns-1) * config.gapsInnerHorizontal;
const startX = centered ?
screen.x + (screen.width - columnsWidth) / 2 :
grid[0][0].frameGeometry.x;
// assumes uniformly sized windows within columns of uniform width
function getRectInGrid(column: number, window: number, nColumns: number, nWindows: number) {
const columnHeight = screen.height - config.gapsOuterTop - config.gapsOuterBottom;
const columnsWidth = nColumns * 100 + (nColumns-1) * config.gapsInnerHorizontal;
const windowHeight = (columnHeight - config.gapsInnerVertical * (nWindows-1)) / nWindows;
return new MockQmlRect(
screen.x + column * (100 + config.gapsInnerHorizontal) + (screen.width-columnsWidth) / 2,
startX + column * (columnWidth + config.gapsInnerHorizontal),
screen.y + config.gapsOuterTop + (windowHeight + config.gapsInnerVertical) * window,
100,
columnWidth,
(columnHeight - config.gapsInnerVertical * (nWindows-1)) / nWindows,
);
}
const nColumns = grid.length;
for (let iColumn = 0; iColumn < nColumns; iColumn++) {
const column = grid[iColumn];
const nWindows = column.length;
@@ -157,6 +163,22 @@ namespace Assert {
}
}
export function centered(
config: Config,
screen: QmlRect,
client:KwinClient,
{ message, skip=0 }: Options = {},
) {
grid(
config,
screen,
client.frameGeometry.width,
[[client]],
true,
{ message: appendMessage("Window not centered", message), skip: skip+1 },
);
}
export function fullyVisible(
rect: QmlRect,
{ message, skip=0 }: Options = {},

View File

@@ -4,7 +4,7 @@ class MockKwinClient {
private static readonly borderThickness = 10;
public readonly shadeable: boolean = false;
public readonly caption = "App";
public caption = "App";
public minSize: Readonly<QmlSize> = new MockQmlSize(0, 0);
public readonly transient: boolean;
public readonly move: boolean = false;
@@ -21,30 +21,32 @@ class MockKwinClient {
public readonly popupWindow: boolean = false;
public readonly pid = 1;
private _maximizedVertically: boolean = false;
private _maximizedHorizontally: boolean = false;
private _fullScreen: boolean = false;
public activities: string[] = [];
public skipSwitcher: boolean = false;
public keepAbove: boolean = false;
public keepBelow: boolean = false;
public shade: boolean = false;
public _minimized: boolean = false;
public desktops: KwinDesktop[] = [];
public _tile: Tile|null = null;
private _minimized: boolean = false;
private _desktops: KwinDesktop[] = [];
private _tile: Tile|null = null;
public opacity: number = 1.0;
public readonly fullScreenChanged = new MockQSignal();
public readonly desktopsChanged = new MockQSignal();
public readonly activitiesChanged = new MockQSignal();
public readonly minimizedChanged = new MockQSignal();
public readonly fullScreenChanged = new MockQSignal<[]>();
public readonly desktopsChanged = new MockQSignal<[]>();
public readonly activitiesChanged = new MockQSignal<[]>();
public readonly minimizedChanged = new MockQSignal<[]>();
public readonly maximizedAboutToChange = new MockQSignal<[MaximizedMode]>();
public readonly captionChanged = new MockQSignal();
public readonly tileChanged = new MockQSignal();
public readonly interactiveMoveResizeStarted = new MockQSignal();
public readonly interactiveMoveResizeFinished = new MockQSignal();
public readonly captionChanged = new MockQSignal<[]>();
public readonly tileChanged = new MockQSignal<[]>();
public readonly interactiveMoveResizeStarted = new MockQSignal<[]>();
public readonly interactiveMoveResizeFinished = new MockQSignal<[]>();
public readonly frameGeometryChanged = new MockQSignal<[oldGeometry: QmlRect]>();
private windowedFrameGeometry: MockQmlRect;
private windowed: boolean = false;
private windowed: boolean = true;
private hasBorder: boolean = true;
constructor(
@@ -53,11 +55,18 @@ class MockKwinClient {
) {
this.windowedFrameGeometry = _frameGeometry.clone();
this.transient = transientFor !== null;
this._desktops = [Workspace.currentDesktop];
}
setMaximize(vertically: boolean, horizontally: boolean) {
this.windowed = !(vertically || horizontally);
if (vertically === this._maximizedVertically && horizontally === this._maximizedHorizontally) {
return;
}
this._maximizedVertically = vertically;
this._maximizedHorizontally = horizontally;
this.maximizedAboutToChange.fire(
vertically ? (
horizontally ? MaximizedMode.Maximized : MaximizedMode.Vertically
@@ -167,6 +176,18 @@ class MockKwinClient {
this.minimizedChanged.fire();
}
public get desktops() {
return this._desktops;
}
public set desktops(desktops: KwinDesktop[]) {
this._desktops = desktops;
this.desktopsChanged.fire();
if (Workspace.activeWindow === this && !desktops.includes(Workspace.currentDesktop)) {
Workspace.activeWindow = null;
};
}
public get tile() {
return this._tile;
}
@@ -189,4 +210,8 @@ class MockKwinClient {
public getFrameGeometryCopy() {
return this._frameGeometry.clone();
}
public toString() {
return `MockKwinClient("${this.caption}")`;
}
}

View File

@@ -2,7 +2,7 @@ class MockQmlTimer {
public readonly __brand = "QmlObject";
public interval = 0;
public readonly triggered = new MockQSignal();
public readonly triggered = new MockQSignal<[]>();
public restart() {
// no need to wait in tests, just fire immediately

View File

@@ -1,7 +1,7 @@
class MockShortcutHandler {
public readonly __brand = "QmlObject";
public readonly activated: MockQSignal<[]> = new MockQSignal();
public readonly activated: MockQSignal<[]> = new MockQSignal<[]>();
public destroy() {}
}

View File

@@ -9,7 +9,7 @@ class MockWorkspace {
public currentDesktop = this.desktops[0];
public currentActivity = this.activities[0];
public activeScreen: Output = { __brand: "Output" };
public windows = [];
public readonly windows: MockKwinClient[] = [];
public cursorPos = new MockQmlPoint(0, 0);
private _activeWindow: KwinClient|null = null;
@@ -30,6 +30,7 @@ class MockWorkspace {
public createWindows(...kwinClients: MockKwinClient[]) {
for (const kwinClient of kwinClients) {
this.windows.push(kwinClient);
this.windowAdded.fire(kwinClient);
this.activeWindow = kwinClient;
}
@@ -41,6 +42,7 @@ class MockWorkspace {
public createClientsWithFrames(...frames: MockQmlRect[]) {
const clients = frames.map(rect => new MockKwinClient(rect));
clients.forEach((client, index) => client.caption = `Client ${index}`);
this.createWindows(...clients);
return clients;
}
@@ -49,6 +51,17 @@ class MockWorkspace {
return this.createClientsWithFrames(...widths.map(width => new MockQmlRect(randomInt(100), randomInt(100), width, 100+randomInt(400))));
}
public removeWindow(window: MockKwinClient) {
runReorder(
() => this.windows.splice(this.windows.indexOf(window), 1),
() => this.windowRemoved.fire(window),
);
if (window === this.activeWindow) {
const windows = this.windows.filter(w => w.desktops.includes(this.currentDesktop));
Workspace.activeWindow = windows.length > 0 ? randomItem(windows) : null;
};
}
public resizeWindow(window: MockKwinClient, edgeResize: boolean, leftEdge: boolean, topEdge: boolean, ...deltas: QmlSize[]) {
const frame = window.getFrameGeometryCopy();
if (edgeResize) {

View File

@@ -26,6 +26,12 @@ function randomInt(n: number) {
return Math.floor(Math.random() * n);
}
function randomItem(items: any[]) {
Assert.assert(items.length > 0);
const index = randomInt(items.length);
return items[index];
}
function shuffle(items: any[]) {
for (let n = items.length; n > 1; n--) {
const i = n-1;