30 Commits
v0.12 ... v0.13

Author SHA1 Message Date
Peter Fajdiga
33be23c6dc bump version to 0.13 2025-05-03 21:54:42 +02:00
Peter Fajdiga
e31669e499 config.ui: gestureScroll: add note regarding disabling KDE workspace switching gestures 2025-04-30 20:25:36 +02:00
Peter Fajdiga
caf2b5a146 Makefile: add ability to skip the linter 2025-04-17 21:11:24 +02:00
Peter Fajdiga
b7f1876a84 Makefile: add lint 2025-04-17 21:08:29 +02:00
Peter Fajdiga
f108c4a45e require indent of 4 spaces 2025-04-17 21:02:49 +02:00
Peter Fajdiga
0aa5d8c3fa require semicolons 2025-04-17 20:55:03 +02:00
Peter Fajdiga
1674d14453 eslint: allow empty functions 2025-04-17 20:45:39 +02:00
Peter Fajdiga
ca8b78ef04 configure eslint 2025-04-17 20:45:39 +02:00
Peter Fajdiga
877767cea3 add eslint 2025-04-17 20:45:39 +02:00
Peter Fajdiga
f1a18b8276 World: simplify addExistingClients 2025-04-17 20:45:09 +02:00
Peter Fajdiga
8725bc84e0 use interface instead of type 2025-04-17 20:44:59 +02:00
Peter Fajdiga
8c23535e86 use as type assertions 2025-04-17 20:44:56 +02:00
Peter Fajdiga
c0e7234fec remove type annotations for trivially inferred types 2025-04-17 20:44:50 +02:00
Peter Fajdiga
03acbe1280 use const where possible 2025-04-17 20:44:28 +02:00
Peter Fajdiga
7ef0c60cb8 un-maximize when swithing to another window in the same column (issue 93) 2025-04-04 13:59:39 +02:00
Peter Fajdiga
7e1517bbcb tests: maximization: simplify test cases' code by passing functions with fewer parameters 2025-04-04 13:48:30 +02:00
Peter Fajdiga
a23acd056b tests: maximization: combine files 2025-04-04 13:37:45 +02:00
Peter Fajdiga
e908f7fe8a tests: combine re-maximize tests 2025-04-04 13:02:04 +02:00
Peter Fajdiga
ba63c1d4e7 tests: re-maximize: simplify and add another window to column 2025-04-04 12:48:36 +02:00
Peter Fajdiga
bf060cef17 tests: "Re-maximize enabled": simplify and add another window to column 2025-04-04 12:11:11 +02:00
Peter Fajdiga
0f21f94d02 add test for cursorFollowsFocus and add check whether cursor already within focused client 2025-04-04 11:36:04 +02:00
Himadri Bhattacharjee
6dd356dc53 add option for moving cursor to the focused window (#89) 2025-04-04 11:33:39 +02:00
Himadri Bhattacharjee
c99cad96c3 add swipe gesture scrolling support (Wayland only) (#90) 2025-04-04 09:56:07 +02:00
Peter Fajdiga
b4fe71f91b ClientWrapper: set maximizedMode to Unmaximized for non-maximizable clients (issue 79) 2025-04-03 20:25:03 +02:00
Peter Fajdiga
099b9f5d6a tests: "Start full-screen (force tiling)": add case where full-screen exit is initiated by the client 2025-04-03 17:04:04 +02:00
Peter Fajdiga
92f6942eef tests: add debug function runReorderDebug 2025-04-03 16:58:47 +02:00
Peter Fajdiga
9621c2a75b tests: "Start full-screen (force tiling)": add assert for windowed client 2025-04-03 16:58:47 +02:00
Peter Fajdiga
36bc1be8c0 tests: enable passing different column widths to Assert.grid 2025-04-03 16:58:47 +02:00
Peter Fajdiga
68b659744c MockKwinClient: when switching out of full-screen mode, remember the target windowed frame geometry throughout all the steps 2025-04-03 16:58:20 +02:00
Peter Fajdiga
c715289282 fix keepBelow and keepAbove properties for windows that start in full-screen mode (issue 79) 2025-04-03 12:23:24 +02:00
50 changed files with 2327 additions and 358 deletions

2
.gitignore vendored
View File

@@ -2,4 +2,6 @@
/package/contents/config/main.xml
/karousel*.tar.gz
run-ts-tmp.js
/node_modules
/.idea

View File

@@ -1,15 +1,26 @@
VERSION = $(shell grep '"Version":' ./package/metadata.json | grep -o '[0-9\.]*')
TESTS := true
CHECKS := true
.PHONY: *
build: tests
build: lint tests
tsc -p ./src/main --outFile ./package/contents/code/main.js
mkdir -p ./package/contents/config
./run-ts.sh ./src/generators/config > ./package/contents/config/main.xml
npm-install:
npm install
lint: npm-install
ifeq (${CHECKS}, true)
npx eslint ./src
endif
lint-fix: npm-install
npx eslint ./src --fix
tests:
ifeq (${TESTS}, true)
ifeq (${CHECKS}, true)
./run-ts.sh ./src/tests
endif

14
eslint.config.mjs Normal file
View File

@@ -0,0 +1,14 @@
// @ts-check
import tseslint from "typescript-eslint";
export default tseslint.config(
{
extends: [tseslint.configs.stylistic],
rules: {
"@typescript-eslint/no-empty-function": "off",
"semi": "error",
"indent": ["error", 4],
},
}
);

1473
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"devDependencies": {
"eslint": "^9.24.0",
"typescript-eslint": "^8.30.1"
}
}

View File

@@ -29,6 +29,16 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="kcfg_cursorFollowsFocus">
<property name="text">
<string>Cursor follows focus</string>
</property>
<property name="toolTip">
<string>When a window gains focus, move the cursor to it</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="kcfg_stackColumnsByDefault">
<property name="text">
@@ -101,6 +111,35 @@
</widget>
</item>
<item>
<widget class="QGroupBox">
<property name="title">
<string>Touchpad scrolling (Wayland only)</string>
</property>
<layout class="QVBoxLayout">
<item>
<widget class="QCheckBox" name="kcfg_gestureScroll">
<property name="text">
<string>Enable scrolling with touchpad gestures
(please don't forget to disable KDE's workspace switching gestures)</string>
</property>
<property name="toolTip">
<string>Scroll with a three-finger horizontal swipe gesture</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="kcfg_gestureScrollInvert">
<property name="text">
<string>Invert scroll direction (Natural scrolling)</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox">
<property name="title">
@@ -343,6 +382,36 @@
</item>
<item row="9" column="0">
<widget class="QLabel" name="label_gestureScrollStep">
<property name="text">
<string>Touchpad gesture scrolling speed:</string>
</property>
<property name="toolTip">
<string>The amount to scroll per edge-to-edge gesture</string>
</property>
</widget>
</item>
<item row="9" column="1">
<widget class="QSpinBox" name="kcfg_gestureScrollStep">
<property name="suffix">
<string> px</string>
</property>
<property name="maximum">
<number>10000</number>
</property>
<property name="minimum">
<number>100</number>
</property>
<property name="singleStep">
<number>100</number>
</property>
<property name="value">
<number>1920</number>
</property>
</widget>
</item>
<item row="10" column="0">
<widget class="QLabel" name="label_presetWidths">
<property name="text">
<string>Preset widths:</string>
@@ -352,7 +421,7 @@
</property>
</widget>
</item>
<item row="9" column="1">
<item row="10" column="1">
<widget class="QLineEdit" name="kcfg_presetWidths">
<property name="toolTip">
<string>Comma-separated list of widths. Supported units: "px" and "%".</string>
@@ -360,14 +429,14 @@
</widget>
</item>
<item row="10" column="0">
<item row="11" column="0">
<widget class="QLabel" name="label_offScreenOpacity">
<property name="text">
<string>Obscured window opacity:</string>
</property>
</widget>
</item>
<item row="10" column="1">
<item row="11" column="1">
<widget class="QSpinBox" name="kcfg_offScreenOpacity">
<property name="suffix">
<string> %</string>
@@ -380,6 +449,7 @@
</property>
</widget>
</item>
</layout>
</widget>

View File

@@ -35,4 +35,29 @@ Item {
flags: Notification.Persistent
urgency: Notification.HighUrgency
}
SwipeGestureHandler {
direction: SwipeGestureHandler.Direction.Left
fingerCount: 3
onActivated: qmlBase.karouselInstance.gestureScrollFinish()
onCancelled: qmlBase.karouselInstance.gestureScrollFinish()
onProgressChanged: qmlBase.karouselInstance.gestureScroll(-progress)
}
SwipeGestureHandler {
direction: SwipeGestureHandler.Direction.Right
fingerCount: 3
onActivated: qmlBase.karouselInstance.gestureScrollFinish()
onCancelled: qmlBase.karouselInstance.gestureScrollFinish()
onProgressChanged: qmlBase.karouselInstance.gestureScroll(progress)
}
DBusCall {
id: moveCursorToFocus
service: "org.kde.kglobalaccel"
path: "/component/kwin"
method: "invokeShortcut"
arguments: ["MoveMouseToFocus"]
}
}

View File

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

View File

@@ -4,3 +4,4 @@ declare const Workspace: Workspace;
declare const qmlBase: QmlObject;
declare const notificationInvalidWindowRules: Notification;
declare const notificationInvalidPresetWidths: Notification;
declare const moveCursorToFocus: DBusCall;

View File

@@ -1,7 +1,7 @@
type DocsKeyBinding = {
interface DocsKeyBinding {
description: string;
keySequence: string;
};
}
function formatDescription(item: {description: string, comment?: string}) {
const suffix = item.comment === undefined ? "" : ` (${item.comment})`;
@@ -29,7 +29,7 @@ function printCols(...columns: (string[] | string)[]) {
if (column instanceof Array) {
return Math.max(...column.map(
(cell: string) => cell.length
))
));
} else {
return column.length;
}

View File

@@ -13,8 +13,8 @@ class ContextualResizer {
return;
}
let leftVisibleColumn = grid.getLeftmostVisibleColumn(visibleRange, true);
let rightVisibleColumn = grid.getRightmostVisibleColumn(visibleRange, true);
const leftVisibleColumn = grid.getLeftmostVisibleColumn(visibleRange, true);
const rightVisibleColumn = grid.getRightmostVisibleColumn(visibleRange, true);
if (leftVisibleColumn === null || rightVisibleColumn === null) {
console.assert(false); // should at least see self
return;
@@ -31,7 +31,7 @@ class ContextualResizer {
...this.presetWidths.getWidths(minWidth, maxWidth),
],
width => width - column.getWidth(),
)
);
if (newWidth === undefined) {
return;
}
@@ -78,7 +78,7 @@ class ContextualResizer {
...this.presetWidths.getWidths(minWidth, maxWidth),
],
width => column.getWidth() - width,
)
);
if (newWidth === undefined) {
return;
}

View File

@@ -6,8 +6,8 @@ class CenterClamper {
}
const lastColumn = desktop.grid.getLastColumn()!;
let minScroll = Math.round((firstColumn.getWidth() - desktop.tilingArea.width) / 2);
let maxScroll = Math.round(desktop.grid.getWidth() - (desktop.tilingArea.width + lastColumn.getWidth()) / 2);
const minScroll = Math.round((firstColumn.getWidth() - desktop.tilingArea.width) / 2);
const maxScroll = Math.round(desktop.grid.getWidth() - (desktop.tilingArea.width + lastColumn.getWidth()) / 2);
return clamp(x, minScroll, maxScroll);
}
}

View File

@@ -1,7 +1,7 @@
class EdgeClamper {
public clampScrollX(desktop: Desktop, x: number) {
let minScroll = 0;
let maxScroll = desktop.grid.getWidth() - desktop.tilingArea.width;
const minScroll = 0;
const maxScroll = desktop.grid.getWidth() - desktop.tilingArea.width;
if (maxScroll < 0) {
return Math.round(maxScroll / 2);
}

View File

@@ -1,4 +1,4 @@
type Config = {
interface Config {
gapsOuterTop: number;
gapsOuterBottom: number;
gapsOuterLeft: number;
@@ -11,6 +11,7 @@ type Config = {
presetWidths: string;
offScreenOpacity: number;
untileOnDrag: boolean;
cursorFollowsFocus: boolean;
stackColumnsByDefault: boolean;
resizeNeighborColumn: boolean;
reMaximize: boolean;
@@ -18,7 +19,10 @@ type Config = {
scrollingLazy: boolean;
scrollingCentered: boolean;
scrollingGrouped: boolean;
gestureScroll: boolean;
gestureScrollInvert: boolean;
gestureScrollStep: number;
tiledKeepBelow: boolean;
floatingKeepAbove: boolean;
windowRules: string;
};
}

View File

@@ -114,6 +114,11 @@ const configDef = [
type: "Bool",
default: true,
},
{
name: "cursorFollowsFocus",
type: "Bool",
default: false,
},
{
name: "stackColumnsByDefault",
type: "Bool",
@@ -149,6 +154,21 @@ const configDef = [
type: "Bool",
default: false,
},
{
name: "gestureScroll",
type: "Bool",
default: false,
},
{
name: "gestureScrollInvert",
type: "Bool",
default: false,
},
{
name: "gestureScrollStep",
type: "UInt",
default: 1920,
},
{
name: "tiledKeepBelow",
type: "Bool",

3
src/lib/extern/dbuscall.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
interface DBusCall extends QmlObject {
call(): void;
}

View File

@@ -1,10 +1,10 @@
type KWin = {
interface KWin {
__brand: "KWin";
readConfig(key: string, defaultValue: any): any;
};
}
type Workspace = {
interface Workspace {
__brand: "Workspace";
readonly activities: string[];
@@ -28,7 +28,7 @@ type Workspace = {
readonly virtualScreenSizeChanged: QSignal<[]>;
clientArea(option: ClientAreaOption, output: Output, kwinDesktop: KwinDesktop): QmlRect;
};
}
const enum ClientAreaOption {
PlacementArea,
@@ -48,10 +48,10 @@ const enum MaximizedMode {
Maximized,
}
type Tile = { __brand: "Tile" };
type Output = { __brand: "Output" };
interface Tile { __brand: "Tile" }
interface Output { __brand: "Output" }
type KwinClient = {
interface KwinClient {
__brand: "KwinClient";
readonly caption: string;
@@ -96,15 +96,15 @@ type KwinClient = {
readonly frameGeometryChanged: QSignal<[oldGeometry: QmlRect]>;
setMaximize(vertically: boolean, horizontally: boolean): void;
};
}
type KwinDesktop = {
interface KwinDesktop {
__brand: "KwinDesktop";
readonly id: string;
};
}
type ShortcutHandler = QmlObject & {
interface ShortcutHandler extends QmlObject {
readonly activated: QSignal<[]>;
destroy(): void;
};
}

View File

@@ -1,3 +1,3 @@
type Notification = QmlObject & {
interface Notification extends QmlObject {
sendEvent(): void;
};
}

30
src/lib/extern/qt.ts vendored
View File

@@ -1,27 +1,27 @@
type Console = {
interface Console {
__brand: "Console";
log(...args: any[]): void;
assert(assertion: boolean, message?: string): void;
};
}
type Qt = {
interface Qt {
__brand: "Qt";
rect(x: number, y: number, width: number, height: number): QmlRect;
createQmlObject(qml: string, parent: QmlObject): QmlObject;
};
}
type QmlObject = { __brand: "QmlObject" };
interface QmlObject { __brand: "QmlObject" }
type QmlPoint = {
interface QmlPoint {
__brand: "QmlPoint";
x: number;
y: number;
};
}
type QmlRect = {
interface QmlRect {
__brand: "QmlRect";
x: number;
@@ -32,25 +32,25 @@ type QmlRect = {
readonly bottom: number; // top + height
readonly left: number;
readonly right: number; // left + width
};
}
type QmlSize = {
interface QmlSize {
__brand: "QmlSize";
width: number;
height: number;
};
}
type QSignal<T extends unknown[]> = {
interface QSignal<T extends unknown[]> {
__brand: "QSignal";
connect(handler: (...args: [...T]) => void): void;
disconnect(handler: (...args: [...T]) => void): void;
};
}
type QmlTimer = QmlObject & {
interface QmlTimer extends QmlObject {
interval: number;
readonly triggered: QSignal<[]>;
restart(): void;
destroy(): void;
};
}

View File

@@ -9,7 +9,7 @@ class Actions {
return;
}
leftColumn.focus();
}
};
public readonly focusRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const rightColumn = grid.getRightColumn(column);
@@ -17,7 +17,7 @@ class Actions {
return;
}
rightColumn.focus();
}
};
public readonly focusUp = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const aboveWindow = column.getAboveWindow(window);
@@ -25,7 +25,7 @@ class Actions {
return;
}
aboveWindow.focus();
}
};
public readonly focusDown = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const belowWindow = column.getBelowWindow(window);
@@ -33,7 +33,7 @@ class Actions {
return;
}
belowWindow.focus();
}
};
public readonly focusNext = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const belowWindow = column.getBelowWindow(window);
@@ -46,7 +46,7 @@ class Actions {
}
rightColumn.getFirstWindow().focus();
}
}
};
public readonly focusPrevious = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const aboveWindow = column.getAboveWindow(window);
@@ -59,7 +59,7 @@ class Actions {
}
leftColumn.getLastWindow().focus();
}
}
};
public readonly focusStart = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
@@ -68,7 +68,7 @@ class Actions {
return;
}
firstColumn.focus();
}
};
public readonly focusEnd = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
@@ -77,7 +77,7 @@ class Actions {
return;
}
lastColumn.focus();
}
};
public readonly windowMoveLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
if (column.getWindowCount() === 1) {
@@ -93,9 +93,9 @@ class Actions {
const newColumn = new Column(grid, grid.getLeftColumn(column));
window.moveToColumn(newColumn, true);
}
}
};
public readonly windowMoveRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid, bottom: boolean = true) => {
public readonly windowMoveRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid, bottom = true) => {
if (column.getWindowCount() === 1) {
// move from own column into existing column
const rightColumn = grid.getRightColumn(column);
@@ -109,17 +109,17 @@ class Actions {
const newColumn = new Column(grid, column);
window.moveToColumn(newColumn, true);
}
}
};
// TODO (optimization): only arrange moved windows
public readonly windowMoveUp = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
column.moveWindowUp(window);
}
};
// TODO (optimization): only arrange moved windows
public readonly windowMoveDown = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
column.moveWindowDown(window);
}
};
public readonly windowMoveNext = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const canMoveDown = window !== column.getLastWindow();
@@ -128,7 +128,7 @@ class Actions {
} else {
this.windowMoveRight(cm, dm, window, column, grid, false);
}
}
};
public readonly windowMovePrevious = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const canMoveUp = window !== column.getFirstWindow();
@@ -137,62 +137,62 @@ class Actions {
} else {
this.windowMoveLeft(cm, dm, window, column, grid);
}
}
};
public readonly windowMoveStart = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const newColumn = new Column(grid, null);
window.moveToColumn(newColumn, true);
}
};
public readonly windowMoveEnd = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const newColumn = new Column(grid, grid.getLastColumn());
window.moveToColumn(newColumn, true);
}
};
public readonly windowToggleFloating = (cm: ClientManager, dm: DesktopManager) => {
if (Workspace.activeWindow === null) {
return;
}
cm.toggleFloatingClient(Workspace.activeWindow);
}
};
public readonly columnMoveLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumnLeft(column);
}
};
public readonly columnMoveRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumnRight(column);
}
};
public readonly columnMoveStart = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumn(column, null);
}
};
public readonly columnMoveEnd = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumn(column, grid.getLastColumn());
}
};
public readonly columnToggleStacked = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
column.toggleStacked();
}
};
public readonly columnWidthIncrease = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
this.config.columnResizer.increaseWidth(column);
}
};
public readonly columnWidthDecrease = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
this.config.columnResizer.decreaseWidth(column);
}
};
public readonly cyclePresetWidths = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const nextWidth = this.config.presetWidths.next(column.getWidth(), column.getMinWidth(), column.getMaxWidth());
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();
@@ -211,7 +211,7 @@ class Actions {
visibleColumns[0],
visibleColumns[visibleColumns.length - 1],
));
}
};
public readonly columnsSqueezeLeft = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => {
const visibleRange = grid.desktop.getCurrentVisibleRange();
@@ -238,7 +238,7 @@ class Actions {
break; // don't scroll past the currently focused column
}
}
}
};
public readonly columnsSqueezeRight = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => {
const visibleRange = grid.desktop.getCurrentVisibleRange();
@@ -265,7 +265,7 @@ class Actions {
break; // don't scroll past the currently focused column
}
}
}
};
private readonly squeezeColumns = (columns: Column[]) => {
const firstColumn = columns[0];
@@ -286,19 +286,19 @@ class Actions {
columns.forEach((column, index) => column.setWidth(widths[index], true));
desktop.scrollCenterRange(Range.fromRanges(firstColumn, lastColumn));
return true;
}
};
public readonly gridScrollLeft = (cm: ClientManager, dm: DesktopManager) => {
this.gridScroll(dm, -this.config.manualScrollStep);
}
};
public readonly gridScrollRight = (cm: ClientManager, dm: DesktopManager) => {
this.gridScroll(dm, this.config.manualScrollStep);
}
};
private readonly gridScroll = (desktopManager: DesktopManager, amount: number) => {
desktopManager.getCurrentDesktop().adjustScroll(amount, false);
}
};
public readonly gridScrollStart = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
@@ -307,7 +307,7 @@ class Actions {
return;
}
grid.desktop.scrollToColumn(firstColumn, false);
}
};
public readonly gridScrollEnd = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
@@ -316,7 +316,7 @@ class Actions {
return;
}
grid.desktop.scrollToColumn(lastColumn, false);
}
};
public readonly gridScrollFocused = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const scrollAmount = Range.minus(column, grid.desktop.getCurrentVisibleRange());
@@ -325,7 +325,7 @@ class Actions {
} else {
grid.desktop.scrollToColumn(column, true);
}
}
};
public readonly gridScrollLeftColumn = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
@@ -340,7 +340,7 @@ class Actions {
}
grid.desktop.scrollToColumn(leftColumn, false);
}
};
public readonly gridScrollRightColumn = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
@@ -355,11 +355,11 @@ class Actions {
}
grid.desktop.scrollToColumn(rightColumn, false);
}
};
public readonly screenSwitch = (cm: ClientManager, dm: DesktopManager) => {
dm.selectScreen(Workspace.activeScreen);
}
};
public readonly focus = (columnIndex: number, cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
@@ -417,17 +417,17 @@ class Actions {
}
namespace Actions {
export type Config = {
export interface Config {
manualScrollStep: number;
presetWidths: {
next: (currentWidth: number, minWidth: number, maxWidth: number) => number;
prev: (currentWidth: number, minWidth: number, maxWidth: number) => number
};
columnResizer: ColumnResizer;
};
}
export type ColumnResizer = {
export interface ColumnResizer {
increaseWidth(column: Column): void;
decreaseWidth(column: Column): void;
};
}
}

View File

@@ -1,19 +1,19 @@
type KeyBinding = {
interface KeyBinding {
name: string;
description: string;
comment?: string;
defaultKeySequence?: string;
action: () => void;
};
}
type NumKeyBinding = {
interface NumKeyBinding {
name: string;
description: string;
comment?: string;
defaultModifiers: string;
fKeys: boolean;
action: (i: number) => void;
};
}
function catchWrap(f: () => void) {
return () => {

View File

@@ -297,13 +297,13 @@ class Column {
}
public onWindowFocused(window: Window) {
this.grid.onColumnFocused(this);
this.grid.onColumnFocused(this, window);
this.focusTaker = window;
}
public restoreToTiled() {
public restoreToTiled(focusedWindow: Window) {
const lastFocusedWindow = this.getFocusTaker();
if (lastFocusedWindow !== null) {
if (lastFocusedWindow !== null && lastFocusedWindow !== focusedWindow) {
lastFocusedWindow.restoreToTiled();
}
}

View File

@@ -1,6 +1,7 @@
class Desktop {
public readonly grid: Grid;
private scrollX: number;
private gestureScrollXInitial: number | null;
private dirty: boolean;
private dirtyScroll: boolean;
private dirtyPins: boolean;
@@ -15,6 +16,7 @@ class Desktop {
layoutConfig: LayoutConfig,
) {
this.scrollX = 0;
this.gestureScrollXInitial = null;
this.dirty = true;
this.dirtyScroll = true;
this.dirtyPins = true;
@@ -52,7 +54,7 @@ class Desktop {
top,
right - left,
bottom - top,
)
);
}
public scrollIntoView(range: Range) {
@@ -124,6 +126,24 @@ class Desktop {
this.setScroll(this.scrollX + dx, force);
}
public gestureScroll(amount: number) {
if (!this.config.gestureScroll) {
return;
}
if (this.gestureScrollXInitial === null) {
this.gestureScrollXInitial = this.scrollX;
}
if (this.config.gestureScrollInvert) {
amount = -amount;
}
this.setScroll(this.gestureScrollXInitial + this.config.gestureScrollStep * amount, false);
}
public gestureScrollFinish() {
this.gestureScrollXInitial = null;
}
public arrange() {
// TODO (optimization): only arrange visible windows
this.updateArea();
@@ -155,14 +175,17 @@ class Desktop {
}
namespace Desktop {
export type Config = {
export interface Config {
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
gestureScroll: boolean;
gestureScrollInvert: boolean;
gestureScrollStep: number;
scroller: Desktop.Scroller;
clamper: Desktop.Clamper;
};
}
export class ColumnRange {
private left: Column;
@@ -236,11 +259,11 @@ namespace Desktop {
}
}
export type Scroller = {
export interface Scroller {
scrollToColumn(desktop: Desktop, column: Column): void;
};
}
export type Clamper = {
export interface Clamper {
clampScrollX(desktop: Desktop, x: number): number;
};
}
}

View File

@@ -184,10 +184,10 @@ class Grid {
}
}
public onColumnFocused(column: Column) {
public onColumnFocused(column: Column, window: Window) {
const lastFocusedColumn = this.getLastFocusedColumn();
if (lastFocusedColumn !== null && lastFocusedColumn !== column) {
lastFocusedColumn.restoreToTiled();
if (lastFocusedColumn !== null) {
lastFocusedColumn.restoreToTiled(window);
}
this.lastFocusedColumn = column;
this.desktop.scrollToColumn(column, false);

View File

@@ -1,4 +1,4 @@
type LayoutConfig = {
interface LayoutConfig {
gapsInnerHorizontal: number;
gapsInnerVertical: number;
stackOffsetX: number;
@@ -11,4 +11,4 @@ type LayoutConfig = {
tiledKeepBelow: boolean;
maximizedKeepAbove: boolean;
untileOnDrag: boolean;
};
}

View File

@@ -1,8 +1,8 @@
type Range = {
interface Range {
getLeft(): number;
getRight(): number;
getWidth(): number;
};
}
namespace Range {
export function create(x: number, width: number) {

View File

@@ -128,8 +128,8 @@ class Window {
}
namespace Window {
export type State = {
export interface State {
fullScreen: boolean;
maximizedMode: MaximizedMode;
};
}
}

View File

@@ -1,5 +1,5 @@
type WindowRule = {
interface WindowRule {
class: string | undefined;
caption: string | undefined;
tile: boolean;
};
}

View File

@@ -17,9 +17,9 @@ class Delayer {
}
function initQmlTimer() {
return <QmlTimer>Qt.createQmlObject(
return Qt.createQmlObject(
`import QtQuick 6.0
Timer {}`,
qmlBase
);
) as QmlTimer;
}

View File

@@ -16,7 +16,7 @@ class ShortcutAction {
` :
"";
return <ShortcutHandler>Qt.createQmlObject(
return Qt.createQmlObject(
`import QtQuick 6.0
import org.kde.kwin 3.0
ShortcutHandler {
@@ -24,14 +24,14 @@ ShortcutHandler {
text: "Karousel: ${keyBinding.description}";
${sequenceLine}}`,
qmlBase,
);
) as ShortcutHandler;
}
}
namespace ShortcutAction {
export type KeyBinding = {
export interface KeyBinding {
name: string;
description: string;
defaultKeySequence?: string;
};
}
}

View File

@@ -84,13 +84,13 @@ function fillSpace(availableSpace: number, items: { min: number, max: number }[]
}
}
type Range = {
interface Range {
start: number,
end: number,
n: number,
};
}
type Fencepost = {
interface Fencepost {
value: number,
nMin: number,
nMax: number,

View File

@@ -7,4 +7,4 @@ interface Function {
Function.prototype.partial = function<H extends any[], T extends any[]>(...head: H) {
return (...tail: T) => this(...head, ...tail);
}
};

View File

@@ -18,3 +18,15 @@ function rectEquals(a: QmlRect, b: QmlRect) {
a.width === b.width &&
a.height === b.height;
}
function pointEquals(a: QmlPoint, b: QmlPoint) {
return a.x === b.x &&
a.y === b.y;
}
function rectContainsPoint(rect: QmlRect, point: QmlPoint) {
return rect.left <= point.x &&
rect.right >= point.x &&
rect.top <= point.y &&
rect.bottom >= point.y;
}

View File

@@ -3,7 +3,7 @@ function initWorkspaceSignalHandlers(world: World) {
manager.connect(Workspace.windowAdded, (kwinClient: KwinClient) => {
world.do((clientManager, desktopManager) => {
clientManager.addClient(kwinClient)
clientManager.addClient(kwinClient);
});
});

View File

@@ -214,7 +214,7 @@ class ClientManager {
}
namespace ClientManager {
export type Config = {
export interface Config {
floatingKeepAbove: boolean;
};
}
}

View File

@@ -78,6 +78,7 @@ class ClientWrapper {
public setMaximize(horizontally: boolean, vertically: boolean) {
if (!this.kwinClient.maximizable) {
this.maximizedMode = MaximizedMode.Unmaximized;
return;
}

View File

@@ -32,16 +32,16 @@ namespace Clients {
export function getKwinDesktopApprox(kwinClient: KwinClient) {
switch (kwinClient.desktops.length) {
case 0:
case 0:
return Workspace.currentDesktop;
case 1:
return kwinClient.desktops[0];
default:
if (kwinClient.desktops.includes(Workspace.currentDesktop)) {
return Workspace.currentDesktop;
case 1:
} else {
return kwinClient.desktops[0];
default:
if (kwinClient.desktops.includes(Workspace.currentDesktop)) {
return Workspace.currentDesktop;
} else {
return kwinClient.desktops[0];
}
}
}
}

View File

@@ -5,9 +5,11 @@ class World {
private readonly workspaceSignalManager: SignalManager;
private readonly shortcutActions: ShortcutAction[];
private readonly screenResizedDelayer: Delayer;
private readonly cursorFollowsFocus: boolean;
constructor(config: Config) {
this.workspaceSignalManager = initWorkspaceSignalHandlers(this);
this.cursorFollowsFocus = config.cursorFollowsFocus;
let presetWidths = {
next: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth,
@@ -61,6 +63,9 @@ class World {
marginRight: config.gapsOuterRight,
scroller: World.createScroller(config),
clamper: config.scrollingLazy ? new EdgeClamper() : new CenterClamper(),
gestureScroll: config.gestureScroll,
gestureScrollInvert: config.gestureScrollInvert,
gestureScrollStep: config.gestureScrollStep,
},
layoutConfig,
Workspace.currentActivity,
@@ -85,15 +90,24 @@ class World {
}
private addExistingClients() {
const kwinClients = Workspace.windows;
for (let i = 0; i < kwinClients.length; i++) {
const kwinClient = kwinClients[i];
for (const kwinClient of Workspace.windows) {
this.clientManager.addClient(kwinClient);
}
}
private update() {
this.desktopManager.getCurrentDesktop().arrange();
this.moveCursorToFocus();
}
private moveCursorToFocus() {
if (this.cursorFollowsFocus && Workspace.activeWindow !== null) {
const cursorAlreadyInFocus = rectContainsPoint(Workspace.activeWindow.frameGeometry, Workspace.cursorPos);
if (cursorAlreadyInFocus) {
return;
}
moveCursorToFocus.call();
}
}
public do(f: (clientManager: ClientManager, desktopManager: DesktopManager) => void) {
@@ -124,6 +138,14 @@ class World {
this.doIfTiled(Workspace.activeWindow, f);
}
public gestureScroll(amount: number) {
this.do((clientManager, desktopManager) => desktopManager.getCurrentDesktop().gestureScroll(amount));
}
public gestureScrollFinish() {
this.do((clientManager, desktopManager) => desktopManager.getCurrentDesktop().gestureScrollFinish());
}
public destroy() {
this.workspaceSignalManager.destroy();
for (const shortcutAction of this.shortcutActions) {

View File

@@ -20,7 +20,7 @@ namespace ClientState {
}
}
export type State = {
export interface State {
destroy(passFocus: boolean): void;
};
}
}

View File

@@ -54,7 +54,7 @@ namespace ClientState {
}
Tiled.moveWindowToGrid(window, desktop.grid);
});
})
});
manager.connect(kwinClient.minimizedChanged, () => {
console.assert(kwinClient.minimized);
@@ -113,7 +113,7 @@ namespace ClientState {
}
});
let externalFrameGeometryChangedRateLimiter = new RateLimiter(4, Tiled.maxExternalFrameGeometryChangedIntervalMs);
const externalFrameGeometryChangedRateLimiter = new RateLimiter(4, Tiled.maxExternalFrameGeometryChangedIntervalMs);
manager.connect(kwinClient.frameGeometryChanged, (oldGeometry: QmlRect) => {
// on Wayland, this fires after `tileChanged`
if (kwinClient.tile !== null) {
@@ -216,10 +216,18 @@ namespace ClientState {
if (config.skipSwitcher) {
client.kwinClient.skipSwitcher = true;
}
if (config.tiledKeepBelow) {
client.kwinClient.keepBelow = true;
if (client.kwinClient.fullScreen) {
if (config.maximizedKeepAbove) {
client.kwinClient.keepAbove = true;
}
} else {
if (config.tiledKeepBelow) {
client.kwinClient.keepBelow = true;
}
client.kwinClient.keepAbove = false;
}
client.kwinClient.keepAbove = false;
if (client.kwinClient.tile !== null) {
client.setMaximize(false, true); // disable quick tile mode
}
@@ -245,8 +253,8 @@ namespace ClientState {
}
namespace Tiled {
export type WindowState = {
export interface WindowState {
skipSwitcher: boolean;
};
}
}
}

View File

@@ -0,0 +1,36 @@
tests.register("Drag tiled window, untile", 10, () => {
const config = getDefaultConfig();
config.cursorFollowsFocus = true;
const { qtMock, workspaceMock, world } = init(config);
const [client1, client2] = workspaceMock.createClients(2);
const initialCursorPos = new MockQmlPoint(380, 20);
Assert.assert(rectContainsPoint(client1.frameGeometry, initialCursorPos), { message: "invalid test setup" });
workspaceMock.cursorPos = initialCursorPos.clone();
runOneOf(
() => Workspace.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
);
Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
Assert.assert(!rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
Assert.assert(pointEquals(Workspace.cursorPos, initialCursorPos), { message: "Cursor should not have been moved because it was already within the focused client" });
runOneOf(
() => Workspace.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
);
Assert.assert(!rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
Assert.assert(rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
runOneOf(
() => Workspace.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
);
Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
Assert.assert(!rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
const lastCursorPos = workspaceMock.cursorPos.clone();
Workspace.activeWindow = null;
Assert.assert(pointEquals(Workspace.cursorPos, lastCursorPos), { message: "Cursor should not have been moved" });
});

View File

@@ -1,214 +1,341 @@
tests.register("Maximization", 100, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
{
function registerTests(
suffix: string,
getConfig: () => Config,
shouldKeepBelow: (tiled: boolean) => boolean,
shouldKeepAbove: (tiled: boolean) => boolean,
) {
tests.register("Maximization " + suffix, 100, () => {
const config = getConfig();
const { qtMock, workspaceMock, world } = init(config);
const [kwinClient] = workspaceMock.createClientsWithWidths(300);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(kwinClient));
});
const [kwinClient] = workspaceMock.createClientsWithWidths(300);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(kwinClient));
});
const columnLeftX = tilingArea.left + tilingArea.width/2 - 300/2;
const columnTopY = tilingArea.top;
const columnHeight = tilingArea.height;
Assert.rect(kwinClient.frameGeometry, columnLeftX, columnTopY, 300, columnHeight);
const columnLeftX = tilingArea.left + tilingArea.width/2 - 300/2;
const columnTopY = tilingArea.top;
const columnHeight = tilingArea.height;
Assert.assert(!kwinClient.fullScreen);
Assert.equal(kwinClient.keepBelow, shouldKeepBelow(true));
Assert.equal(kwinClient.keepAbove, shouldKeepAbove(true));
Assert.rect(kwinClient.frameGeometry, columnLeftX, columnTopY, 300, columnHeight);
kwinClient.fullScreen = true;
Assert.equalRects(kwinClient.frameGeometry, screen);
kwinClient.fullScreen = true;
Assert.assert(kwinClient.fullScreen);
Assert.equal(kwinClient.keepBelow, shouldKeepBelow(false));
Assert.equal(kwinClient.keepAbove, shouldKeepAbove(false));
Assert.equalRects(kwinClient.frameGeometry, screen);
kwinClient.fullScreen = false;
Assert.rect(kwinClient.frameGeometry, columnLeftX, columnTopY, 300, columnHeight);
kwinClient.fullScreen = false;
Assert.assert(!kwinClient.fullScreen);
Assert.equal(kwinClient.keepBelow, shouldKeepBelow(true));
Assert.equal(kwinClient.keepAbove, shouldKeepAbove(true));
Assert.rect(kwinClient.frameGeometry, columnLeftX, columnTopY, 300, columnHeight);
kwinClient.setMaximize(true, true);
Assert.equalRects(kwinClient.frameGeometry, screen);
kwinClient.setMaximize(true, true);
Assert.assert(!kwinClient.fullScreen);
Assert.equal(kwinClient.keepBelow, shouldKeepBelow(false));
Assert.equal(kwinClient.keepAbove, shouldKeepAbove(false));
Assert.equalRects(kwinClient.frameGeometry, screen);
kwinClient.setMaximize(true, false);
Assert.rect(kwinClient.frameGeometry, columnLeftX, 0, 300, screen.height);
kwinClient.setMaximize(true, false);
Assert.assert(!kwinClient.fullScreen);
Assert.equal(kwinClient.keepBelow, shouldKeepBelow(false));
Assert.equal(kwinClient.keepAbove, shouldKeepAbove(false));
Assert.rect(kwinClient.frameGeometry, columnLeftX, 0, 300, screen.height);
kwinClient.setMaximize(false, false);
Assert.rect(kwinClient.frameGeometry, columnLeftX, columnTopY, 300, columnHeight);
});
kwinClient.setMaximize(false, false);
Assert.assert(!kwinClient.fullScreen);
Assert.equal(kwinClient.keepBelow, shouldKeepBelow(true));
Assert.equal(kwinClient.keepAbove, shouldKeepAbove(true));
Assert.rect(kwinClient.frameGeometry, columnLeftX, columnTopY, 300, columnHeight);
});
tests.register("Maximize with transient", 100, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
tests.register("Maximize with transient " + suffix, 100, () => {
const config = getConfig();
const { qtMock, workspaceMock, world } = init(config);
const parent = new MockKwinClient(new MockQmlRect(10, 20, 300, 200));
const child = new MockKwinClient(new MockQmlRect(14, 24, 50, 50), parent);
const parent = new MockKwinClient(new MockQmlRect(10, 20, 300, 200));
const child = new MockKwinClient(new MockQmlRect(14, 24, 50, 50), parent);
workspaceMock.createWindows(parent);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(parent));
});
workspaceMock.createWindows(parent);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(parent));
});
runOneOf(
() => parent.fullScreen = true,
() => parent.setMaximize(true, true),
);
Assert.equalRects(parent.frameGeometry, screen);
runOneOf(
() => parent.fullScreen = true,
() => parent.setMaximize(true, true),
);
Assert.equal(parent.keepBelow, shouldKeepBelow(false));
Assert.equal(parent.keepAbove, shouldKeepAbove(false));
Assert.equalRects(parent.frameGeometry, screen);
workspaceMock.createWindows(child);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(child));
});
Assert.rect(child.frameGeometry, 14, 24, 50, 50);
Assert.equalRects(parent.frameGeometry, screen);
});
workspaceMock.createWindows(child);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(child));
});
Assert.assert(!child.fullScreen);
Assert.equal(child.keepBelow, shouldKeepBelow(false));
Assert.equal(child.keepAbove, shouldKeepAbove(false));
Assert.rect(child.frameGeometry, 14, 24, 50, 50);
Assert.equal(parent.keepBelow, shouldKeepBelow(false));
Assert.equal(parent.keepAbove, shouldKeepAbove(false));
Assert.equalRects(parent.frameGeometry, screen);
});
tests.register("Re-maximize disabled", 100, () => {
const config = getDefaultConfig();
config.reMaximize = false;
const { qtMock, workspaceMock, world } = init(config);
{
function assertWindowed(config: Config, clients: KwinClient[]) {
Assert.assert(!clients[0].fullScreen);
Assert.equal(clients[0].keepBelow, shouldKeepBelow(true));
Assert.equal(clients[0].keepAbove, shouldKeepAbove(true));
Assert.assert(!clients[1].fullScreen);
Assert.equal(clients[1].keepBelow, shouldKeepBelow(true));
Assert.equal(clients[1].keepAbove, shouldKeepAbove(true));
Assert.assert(!clients[2].fullScreen);
Assert.equal(clients[2].keepBelow, shouldKeepBelow(true));
Assert.equal(clients[2].keepAbove, shouldKeepAbove(true));
Assert.grid(config, tilingArea, [300, 400], [[clients[0]], [clients[1], clients[2]]], true);
}
const [client1, client2] = workspaceMock.createClientsWithWidths(300, 400);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(client1));
Assert.assert(clientManager.hasClient(client2));
});
function assertFullScreenOrMaximized(clients: KwinClient[]) {
Assert.assert(!clients[0].fullScreen);
Assert.equal(clients[0].keepBelow, shouldKeepBelow(true));
Assert.equal(clients[0].keepAbove, shouldKeepAbove(true));
Assert.assert(!clients[1].fullScreen);
Assert.equal(clients[1].keepBelow, shouldKeepBelow(true));
Assert.equal(clients[1].keepAbove, shouldKeepAbove(true));
Assert.equal(clients[2].keepBelow, shouldKeepBelow(false));
Assert.equal(clients[2].keepAbove, shouldKeepAbove(false));
Assert.equalRects(clients[2].frameGeometry, screen);
}
const columnsWidth = 300 + 400 + config.gapsInnerHorizontal;
const column1LeftX = tilingArea.left + tilingArea.width/2 - columnsWidth/2;
const column2LeftX = column1LeftX + 300 + config.gapsInnerHorizontal;
const columnTopY = tilingArea.top;
const columnHeight = tilingArea.height;
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
tests.register("Re-maximize disabled " + suffix, 100, () => {
const config = getConfig();
config.reMaximize = false;
const { qtMock, workspaceMock, world } = init(config);
runOneOf(
() => client2.fullScreen = true,
() => client2.setMaximize(true, true),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.equalRects(client2.frameGeometry, screen);
const clients = workspaceMock.createClientsWithWidths(300, 400, 400);
qtMock.fireShortcut("karousel-window-move-left");
runOneOf(
() => workspaceMock.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
assertWindowed(config, clients);
runOneOf(
() => workspaceMock.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
() => qtMock.fireShortcut("karousel-focus-end"),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
});
runOneOf(
() => clients[2].fullScreen = true,
() => clients[2].setMaximize(true, true),
);
assertFullScreenOrMaximized(clients);
tests.register("Re-maximize enabled", 100, () => {
const config = getDefaultConfig();
config.reMaximize = true;
const { qtMock, workspaceMock, world } = init(config);
runOneOf(
() => workspaceMock.activeWindow = clients[0],
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
);
assertWindowed(config, clients);
const [client1, client2] = workspaceMock.createClientsWithWidths(300, 400);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(client1));
Assert.assert(clientManager.hasClient(client2));
});
runOneOf(
() => workspaceMock.activeWindow = clients[2],
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
() => qtMock.fireShortcut("karousel-focus-end"),
);
assertWindowed(config, clients);
const columnsWidth = 300 + 400 + config.gapsInnerHorizontal;
const column1LeftX = tilingArea.left + tilingArea.width/2 - columnsWidth/2;
const column2LeftX = column1LeftX + 300 + config.gapsInnerHorizontal;
const columnTopY = tilingArea.top;
const columnHeight = tilingArea.height;
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
runOneOf(
() => clients[2].fullScreen = true,
() => clients[2].setMaximize(true, true),
);
assertFullScreenOrMaximized(clients);
runOneOf(
() => client2.fullScreen = true,
() => client2.setMaximize(true, true),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.equalRects(client2.frameGeometry, screen);
runOneOf(
() => workspaceMock.activeWindow = clients[1],
() => qtMock.fireShortcut("karousel-focus-up"),
);
assertWindowed(config, clients);
runOneOf(
() => workspaceMock.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
runOneOf(
() => workspaceMock.activeWindow = clients[2],
() => qtMock.fireShortcut("karousel-focus-down"),
);
assertWindowed(config, clients);
});
runOneOf(
() => workspaceMock.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
() => qtMock.fireShortcut("karousel-focus-end"),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.equalRects(client2.frameGeometry, screen);
});
tests.register("Re-maximize enabled " + suffix, 100, () => {
const config = getConfig();
config.reMaximize = true;
const { qtMock, workspaceMock, world } = init(config);
tests.register("Start full-screen", 100, () => {
const config = getDefaultConfig();
config.reMaximize = true;
const { qtMock, workspaceMock, world } = init(config);
const clients = workspaceMock.createClientsWithWidths(300, 400, 400);
qtMock.fireShortcut("karousel-window-move-left");
const [windowedClient] = workspaceMock.createClientsWithWidths(300);
const fullScreenClient = new MockKwinClient(new MockQmlRect(0, 0, 400, 200));
fullScreenClient.resourceClass = "full-screen-app";
fullScreenClient.fullScreen = true;
workspaceMock.createWindows(fullScreenClient);
assertWindowed(config, clients);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(windowedClient));
Assert.assert(clientManager.hasClient(fullScreenClient));
});
runOneOf(
() => clients[2].fullScreen = true,
() => clients[2].setMaximize(true, true),
);
assertFullScreenOrMaximized(clients);
Assert.centered(config, tilingArea, windowedClient);
Assert.equalRects(fullScreenClient.frameGeometry, screen);
Assert.equal(Workspace.activeWindow, fullScreenClient);
runOneOf(
() => workspaceMock.activeWindow = clients[0],
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
);
assertWindowed(config, clients);
{
qtMock.fireShortcut("karousel-focus-left");
const opts = { message: "fullScreenClient is not in the grid, so we can't move focus directionally" };
Assert.centered(config, tilingArea, windowedClient, opts);
Assert.equalRects(fullScreenClient.frameGeometry, screen, opts);
Assert.equal(Workspace.activeWindow, fullScreenClient, opts);
runOneOf(
() => workspaceMock.activeWindow = clients[2],
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
() => qtMock.fireShortcut("karousel-focus-end"),
);
assertFullScreenOrMaximized(clients);
runOneOf(
() => workspaceMock.activeWindow = clients[1],
() => qtMock.fireShortcut("karousel-focus-up"),
);
assertWindowed(config, clients);
runOneOf(
() => workspaceMock.activeWindow = clients[2],
() => qtMock.fireShortcut("karousel-focus-down"),
);
assertFullScreenOrMaximized(clients);
});
}
tests.register("Start full-screen " + suffix, 100, () => {
const config = getConfig();
config.reMaximize = true;
const { qtMock, workspaceMock, world } = init(config);
const [windowedClient] = workspaceMock.createClientsWithWidths(300);
const fullScreenClient = new MockKwinClient(new MockQmlRect(0, 0, 400, 200));
fullScreenClient.resourceClass = "full-screen-app";
fullScreenClient.fullScreen = true;
workspaceMock.createWindows(fullScreenClient);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(windowedClient));
Assert.assert(clientManager.hasClient(fullScreenClient));
});
Assert.assert(!windowedClient.fullScreen);
Assert.equal(windowedClient.keepBelow, shouldKeepBelow(true));
Assert.equal(windowedClient.keepAbove, shouldKeepAbove(true));
Assert.centered(config, tilingArea, windowedClient);
Assert.assert(fullScreenClient.fullScreen);
Assert.equal(fullScreenClient.keepBelow, shouldKeepBelow(false));
Assert.equal(fullScreenClient.keepAbove, shouldKeepAbove(false));
Assert.equalRects(fullScreenClient.frameGeometry, screen);
Assert.equal(Workspace.activeWindow, fullScreenClient);
{
qtMock.fireShortcut("karousel-focus-left");
const opts = { message: "fullScreenClient is not in the grid, so we can't move focus directionally" };
Assert.assert(!windowedClient.fullScreen);
Assert.equal(windowedClient.keepBelow, shouldKeepBelow(true));
Assert.equal(windowedClient.keepAbove, shouldKeepAbove(true));
Assert.centered(config, tilingArea, windowedClient);
Assert.assert(fullScreenClient.fullScreen);
Assert.equal(fullScreenClient.keepBelow, shouldKeepBelow(false));
Assert.equal(fullScreenClient.keepAbove, shouldKeepAbove(false));
Assert.equalRects(fullScreenClient.frameGeometry, screen);
Assert.equal(Workspace.activeWindow, fullScreenClient, opts);
}
{
qtMock.fireShortcut("karousel-focus-1");
const opts = { message: "fullScreenClient is not in grid, so it should stay full-screen" };
Assert.assert(!windowedClient.fullScreen);
Assert.equal(windowedClient.keepBelow, shouldKeepBelow(true));
Assert.equal(windowedClient.keepAbove, shouldKeepAbove(true));
Assert.centered(config, tilingArea, windowedClient);
Assert.assert(fullScreenClient.fullScreen);
Assert.equal(fullScreenClient.keepBelow, shouldKeepBelow(false));
Assert.equal(fullScreenClient.keepAbove, shouldKeepAbove(false));
Assert.equalRects(fullScreenClient.frameGeometry, screen);
Assert.equal(Workspace.activeWindow, windowedClient);
}
});
tests.register("Start full-screen (force tiling) " + suffix, 100, () => {
const config = getConfig();
config.reMaximize = true;
config.windowRules = '[{ "class": "full-screen-app", "tile": true }]';
const { qtMock, workspaceMock, world } = init(config);
const column1Width = 300;
const [windowedClient] = workspaceMock.createClientsWithWidths(column1Width);
const fullScreenClient = new MockKwinClient(new MockQmlRect(0, 0, 400, 200));
fullScreenClient.resourceClass = "full-screen-app";
fullScreenClient.fullScreen = true;
workspaceMock.createWindows(fullScreenClient);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(windowedClient));
Assert.assert(clientManager.hasClient(fullScreenClient));
});
Assert.assert(!windowedClient.fullScreen);
Assert.equal(windowedClient.keepBelow, shouldKeepBelow(true));
Assert.equal(windowedClient.keepAbove, shouldKeepAbove(true));
Assert.grid(config, tilingArea, [column1Width], [[windowedClient]], false);
Assert.assert(fullScreenClient.fullScreen);
Assert.equal(fullScreenClient.keepBelow, shouldKeepBelow(false));
Assert.equal(fullScreenClient.keepAbove, shouldKeepAbove(false));
Assert.equalRects(fullScreenClient.frameGeometry, screen);
Assert.equal(Workspace.activeWindow, fullScreenClient);
let expectedColumn2Width = 0;
let expectedActiveWindow;
runOneOf(
() => {
fullScreenClient.fullScreen = false;
expectedColumn2Width = 400;
expectedActiveWindow = fullScreenClient;
},
() => {
qtMock.fireShortcut("karousel-focus-left");
expectedColumn2Width = tilingArea.width;
expectedActiveWindow = windowedClient;
},
);
const opts = { message: "fullScreenClient should be restored from full-screen mode to tiled mode" };
Assert.assert(!windowedClient.fullScreen);
Assert.equal(windowedClient.keepBelow, shouldKeepBelow(true));
Assert.equal(windowedClient.keepAbove, shouldKeepAbove(true));
Assert.assert(!fullScreenClient.fullScreen);
Assert.equal(fullScreenClient.keepBelow, shouldKeepBelow(true));
Assert.equal(fullScreenClient.keepAbove, shouldKeepAbove(true));
Assert.grid(config, tilingArea, [column1Width, expectedColumn2Width], [[windowedClient], [fullScreenClient]], false, [], opts);
Assert.equal(Workspace.activeWindow, expectedActiveWindow);
});
}
{
qtMock.fireShortcut("karousel-focus-1");
const opts = { message: "fullScreenClient is not in grid, so it should stay full-screen" };
Assert.centered(config, tilingArea, windowedClient, opts);
Assert.equalRects(fullScreenClient.frameGeometry, screen, opts);
Assert.equal(Workspace.activeWindow, windowedClient);
function getConfig(floatingKeepAbove: boolean) {
const config = getDefaultConfig();
config.tiledKeepBelow = !floatingKeepAbove;
config.floatingKeepAbove = floatingKeepAbove;
return config;
}
});
tests.register("Start full-screen (force tiling)", 100, () => {
const config = getDefaultConfig();
config.reMaximize = true;
config.windowRules = '[{ "class": "full-screen-app", "tile": true }]';
const { qtMock, workspaceMock, world } = init(config);
registerTests(
"(tiled below)",
getConfig.partial(false),
tiled => tiled,
tiled => false,
);
const column1Width = 300;
const [windowedClient] = workspaceMock.createClientsWithWidths(column1Width);
const fullScreenClient = new MockKwinClient(new MockQmlRect(0, 0, 400, 200));
fullScreenClient.resourceClass = "full-screen-app";
fullScreenClient.fullScreen = true;
workspaceMock.createWindows(fullScreenClient);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(windowedClient));
Assert.assert(clientManager.hasClient(fullScreenClient));
});
Assert.equalRects(fullScreenClient.frameGeometry, screen);
Assert.equal(Workspace.activeWindow, fullScreenClient);
const column2Width = tilingArea.width;
const column1LeftX = tilingArea.left;
const column2LeftX = column1LeftX + column1Width + gapH;
const columnTopY = tilingArea.top;
const columnHeight = tilingArea.height;
qtMock.fireShortcut("karousel-focus-left");
const opts = { message: "fullScreenClient should be restored from full-screen mode to tiled mode" };
Assert.rect(windowedClient.frameGeometry, column1LeftX, columnTopY, column1Width, columnHeight, opts);
Assert.rect(fullScreenClient.frameGeometry, column2LeftX, columnTopY, column2Width, columnHeight, opts);
Assert.equal(Workspace.activeWindow, windowedClient);
});
registerTests(
"(floating above)",
getConfig.partial(true),
tiled => false,
tiled => !tiled,
);
}

View File

@@ -0,0 +1,53 @@
tests.register("math", 1, () => {
const rect = new MockQmlRect(100, 200, 10, 20);
const testCases: {
rect: QmlRect,
point: QmlPoint,
contained: boolean,
}[] = [
{
rect: rect,
point: new MockQmlPoint(100, 200),
contained: true,
},
{
rect: rect,
point: new MockQmlPoint(110, 220),
contained: true,
},
{
rect: rect,
point: new MockQmlPoint(105, 205),
contained: true,
},
{
rect: rect,
point: new MockQmlPoint(110.01, 205),
contained: false,
},
{
rect: rect,
point: new MockQmlPoint(105, 220.01),
contained: false,
},
{
rect: rect,
point: new MockQmlPoint(16, 205),
contained: false,
},
{
rect: rect,
point: new MockQmlPoint(105, 16),
contained: false,
},
];
for (const testCase of testCases) {
const result = rectContainsPoint(testCase.rect, testCase.point);
Assert.equal(
result,
testCase.contained,
{ message: JSON.stringify(testCase) },
);
}
});

View File

@@ -1,5 +1,5 @@
namespace Assert {
type Options = {
interface Options {
message?: string,
skip?: number,
}
@@ -126,23 +126,61 @@ namespace Assert {
export function grid(
config: Config,
tilingArea: QmlRect,
columnWidth: number,
columnWidths: number[] | number,
grid: KwinClient[][],
centered: boolean,
stackedColumns: number[] = [],
{ message, skip=0 }: Options = {},
) {
const nColumns = grid.length;
const columnsWidth = nColumns * columnWidth + (nColumns-1) * config.gapsInnerHorizontal;
function getGridWidth() {
function getColumnsWidth() {
if (columnWidths instanceof Array) {
let columnsWidth = 0;
for (const columnWidth of columnWidths) {
columnsWidth += columnWidth;
}
return columnsWidth;
} else {
return nColumns * columnWidths;
}
}
const gapsWidth = (nColumns-1) * config.gapsInnerHorizontal;
return getColumnsWidth() + gapsWidth;
}
function getColumnWidth(column: number) {
if (columnWidths instanceof Array) {
return columnWidths[column];
} else {
return columnWidths;
}
}
const gridWidth = getGridWidth();
const startX = centered ?
tilingArea.x + (tilingArea.width - columnsWidth) / 2 :
tilingArea.x + (tilingArea.width - gridWidth) / 2 :
grid[0][0].frameGeometry.x;
function getColumnX(column: number) {
if (columnWidths instanceof Array) {
let x = startX;
for (let i = 0; i < column; i++) {
x += columnWidths[i] + config.gapsInnerHorizontal;
}
return x;
} else {
return startX + column * (columnWidths + config.gapsInnerHorizontal);
}
}
// assumes uniformly sized windows within columns of uniform width
function getRectInGrid(column: number, window: number, nColumns: number, nWindows: number) {
const columnWidth = getColumnWidth(column);
const windowHeight = (tilingArea.height - config.gapsInnerVertical * (nWindows-1)) / nWindows;
return new MockQmlRect(
startX + column * (columnWidth + config.gapsInnerHorizontal),
getColumnX(column),
tilingArea.y + (windowHeight + config.gapsInnerVertical) * window,
columnWidth,
(tilingArea.height - config.gapsInnerVertical * (nWindows-1)) / nWindows,
@@ -150,9 +188,9 @@ namespace Assert {
}
function getRectInGridStacked(column: number, window: number, nColumns: number, nWindows: number) {
const columnX = startX + column * (columnWidth + config.gapsInnerHorizontal);
const columnWidth = getColumnWidth(column);
return new MockQmlRect(
columnX + window * config.stackOffsetX,
getColumnX(column) + window * config.stackOffsetX,
tilingArea.y + window * config.stackOffsetY,
columnWidth - (nWindows-1) * config.stackOffsetX,
tilingArea.height - (nWindows-1) * config.stackOffsetY,

View File

@@ -16,7 +16,7 @@ class TestRunner {
}
namespace TestRunner {
export type Test = {
export interface Test {
name: string,
count: number,
f: () => void,

View File

@@ -4,6 +4,7 @@ let Workspace: Workspace;
let qmlBase: QmlObject;
let notificationInvalidWindowRules: Notification;
let notificationInvalidPresetWidths: Notification;
let moveCursorToFocus: DBusCall;
let screen: MockQmlRect;
let tilingArea: MockQmlRect;
@@ -28,6 +29,15 @@ function init(config: Config) {
Qt = qtMock;
Workspace = workspaceMock;
moveCursorToFocus = {
__brand: "QmlObject",
call: () => {
Assert.assert(Workspace.activeWindow !== null, { message: "moveCursorToFocus should never be called if there's no focused window" });
const frame = Workspace.activeWindow!.frameGeometry;
workspaceMock.cursorPos.x = Math.floor(frame.x + frame.width/2);
workspaceMock.cursorPos.y = Math.floor(frame.y + frame.height/2);
},
};
const world = new World(config);
return { qtMock, workspaceMock, world };

View File

@@ -6,8 +6,8 @@ class MockKwinClient {
public caption = "App";
public minSize: Readonly<QmlSize> = new MockQmlSize(0, 0);
public readonly transient: boolean;
public move: boolean = false;
public resize: boolean = false;
public move = false;
public resize = false;
public readonly fullScreenable: boolean = true;
public readonly maximizable: boolean = true;
public readonly output: Output = { __brand: "Output" };
@@ -18,17 +18,17 @@ class MockKwinClient {
public readonly popupWindow: boolean = false;
public readonly pid = 1;
private _maximizedVertically: boolean = false;
private _maximizedHorizontally: boolean = false;
private _fullScreen: boolean = false;
private _maximizedVertically = false;
private _maximizedHorizontally = false;
private _fullScreen = false;
public activities: string[] = [];
public skipSwitcher: boolean = false;
public keepAbove: boolean = false;
public keepBelow: boolean = false;
private _minimized: boolean = false;
public skipSwitcher = false;
public keepAbove = false;
public keepBelow = false;
private _minimized = false;
private _desktops: KwinDesktop[] = [];
private _tile: Tile|null = null;
public opacity: number = 1.0;
public opacity = 1.0;
public readonly fullScreenChanged = new MockQSignal<[]>();
public readonly desktopsChanged = new MockQSignal<[]>();
@@ -42,8 +42,8 @@ class MockKwinClient {
public readonly frameGeometryChanged = new MockQSignal<[oldGeometry: QmlRect]>();
private windowedFrameGeometry: MockQmlRect;
private windowed: boolean = true;
private hasBorder: boolean = true;
private windowed = true;
private hasBorder = true;
constructor(
private _frameGeometry: MockQmlRect = new MockQmlRect(10, 10, 100, 200),
@@ -107,6 +107,7 @@ class MockKwinClient {
public set fullScreen(fullScreen: boolean) {
const oldFullScreen = this._fullScreen;
this.hasBorder = !fullScreen;
const targetFrameGeometry = fullScreen ? screen : this.windowedFrameGeometry;
runReorder(
() => {
@@ -118,7 +119,7 @@ class MockKwinClient {
() => {
if (oldFullScreen && !fullScreen) {
// when switching from full-screen to windowed, Kwin sometimes first adds the frame before changing the frameGeometry to the final value
if (rectEquals(this.frameGeometry, this.windowedFrameGeometry)) {
if (!rectEquals(this.frameGeometry, screen)) {
// already has windowed frame geometry, don't undo that
return;
}
@@ -141,11 +142,7 @@ class MockKwinClient {
},
() => {
this.windowed = !fullScreen;
if (fullScreen) {
this.frameGeometry = screen;
} else {
this.frameGeometry = this.windowedFrameGeometry;
}
this.frameGeometry = targetFrameGeometry;
},
);
}

View File

@@ -1,7 +1,7 @@
class MockQSignal<T extends unknown[]> {
public readonly __brand = "QSignal";
private readonly handlers: Set<(...args: [...T]) => void> = new Set();
private readonly handlers = new Set<(...args: [...T]) => void>();
public connect(handler: (...args: [...T]) => void) {
this.handlers.add(handler);

View File

@@ -5,4 +5,11 @@ class MockQmlPoint {
public x: number,
public y: number,
) {}
public clone() {
return new MockQmlPoint(
this.x,
this.y,
);
}
}

View File

@@ -22,6 +22,12 @@ function runReorder(...fs: (() => void)[]) {
}
}
function runReorderDebug(order: number[], ...fs: (() => void)[]) {
for (const index of order) {
fs[index]();
}
}
function randomInt(n: number) {
return Math.floor(Math.random() * n);
}