├── .github
└── ISSUE_TEMPLATE
│ ├── 1-compatibility-issue.md
│ ├── 2-bug-report.md
│ └── 3-feature_request.md
├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── eslint.config.mjs
├── package-lock.json
├── package.json
├── package
├── contents
│ └── ui
│ │ ├── config.ui
│ │ └── main.qml
└── metadata.json
├── run-ts.sh
└── src
├── extern
└── global.d.ts
├── generators
├── config
│ ├── main.ts
│ └── tsconfig.json
└── docs
│ ├── keyBindings.ts
│ ├── keyBindingsBbcode
│ ├── main.ts
│ └── tsconfig.json
│ ├── keyBindingsFmt
│ ├── main.ts
│ └── tsconfig.json
│ └── keyBindingsMarkdown
│ ├── main.ts
│ └── tsconfig.json
├── lib
├── behavior
│ ├── PresetWidths.ts
│ ├── columnResizer
│ │ ├── ContextualResizer.ts
│ │ └── RawResizer.ts
│ ├── scrollClamper
│ │ ├── CenterClamper.ts
│ │ └── EdgeClamper.ts
│ └── scroller
│ │ ├── CenteredScroller.ts
│ │ ├── GroupedScroller.ts
│ │ └── LazyScroller.ts
├── config
│ ├── config.ts
│ └── definition.ts
├── extern
│ ├── dbuscall.ts
│ ├── global.d.ts
│ ├── kwin.ts
│ ├── notification.ts
│ └── qt.ts
├── keyBindings
│ ├── Actions.ts
│ ├── definition.ts
│ └── loader.ts
├── layout
│ ├── Column.ts
│ ├── Desktop.ts
│ ├── Grid.ts
│ ├── LayoutConfig.ts
│ ├── Range.ts
│ └── Window.ts
├── rules
│ ├── ClientMatcher.ts
│ ├── WindowRule.ts
│ └── WindowRuleEnforcer.ts
├── utils
│ ├── Delayer.ts
│ ├── Doer.ts
│ ├── LinkedList.ts
│ ├── RateLimiter.ts
│ ├── ShortcutAction.ts
│ ├── SignalManager.ts
│ ├── collections.ts
│ ├── fillSpace.ts
│ ├── functions.ts
│ ├── log.ts
│ ├── math.ts
│ └── strings.ts
├── workspace.ts
└── world
│ ├── ClientManager.ts
│ ├── ClientWrapper.ts
│ ├── Clients.ts
│ ├── DesktopManager.ts
│ ├── PinManager.ts
│ ├── World.ts
│ └── clientState
│ ├── Docked.ts
│ ├── Floating.ts
│ ├── Manager.ts
│ ├── Pinned.ts
│ ├── Tiled.ts
│ └── TiledMinimized.ts
├── main
├── main.ts
└── tsconfig.json
├── tests
├── flows
│ ├── centerFocused.ts
│ ├── columnsSqueezeSide.ts
│ ├── cursorFollowsFocus.ts
│ ├── dragTiled.ts
│ ├── externalResize.ts
│ ├── layering.ts
│ ├── layout.ts
│ ├── lazyScroller.ts
│ ├── maximization.ts
│ ├── passFocus.ts
│ ├── pinning.ts
│ ├── presetWidths.ts
│ ├── stacked.ts
│ └── userResize.ts
├── main.ts
├── tsconfig.json
├── units
│ ├── behavior
│ │ └── PresetWidths.ts
│ ├── rules
│ │ └── WindowRuleEnforcer.ts
│ ├── utils
│ │ ├── RateLimiter.ts
│ │ ├── fillSpace.ts
│ │ └── math.ts
│ └── world
│ │ └── Clients.ts
└── utils
│ ├── Assert.ts
│ ├── TestRunner.ts
│ ├── config.ts
│ ├── extern
│ └── node.ts
│ ├── global.ts
│ ├── mocks
│ ├── MockKwinClient.ts
│ ├── MockQSignal.ts
│ ├── MockQmlPoint.ts
│ ├── MockQmlRect.ts
│ ├── MockQmlSize.ts
│ ├── MockQmlTimer.ts
│ ├── MockQt.ts
│ ├── MockShortcutHandler.ts
│ └── MockWorkspace.ts
│ ├── random.ts
│ └── timeControl.ts
└── tsconfig.json
/.github/ISSUE_TEMPLATE/1-compatibility-issue.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Compatibility issue
3 | about: Report an issue with a specific application or window
4 | title: "[Compatibility]"
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | Karousel version:
11 | Plasma version:
12 | X11 / Wayland:
13 |
14 | Window class:
15 | Window caption (title):
16 | Window type:
17 | (Get this info [here](https://github.com/peterfajdiga/karousel/wiki/Getting-window-info))
18 |
19 | Description:
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/2-bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Generic bug report
3 | about: Report a bug
4 | title: "[Bug]"
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | Karousel version:
11 | Plasma version:
12 | X11 / Wayland:
13 |
14 | Description:
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/3-feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Request a feature
4 | title: "[Feature]"
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
22 | These sections are just guidelines, feel free to remove them.
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /package/contents/code/main.js
2 | /package/contents/config/main.xml
3 | /karousel*.tar.gz
4 | run-ts-tmp.js
5 |
6 | /node_modules
7 | /.idea
8 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | VERSION = $(shell grep '"Version":' ./package/metadata.json | grep -o '[0-9\.]*')
2 | CHECKS := true
3 |
4 | .PHONY: *
5 |
6 | build: lint tests
7 | tsc -p ./src/main --outFile ./package/contents/code/main.js
8 | mkdir -p ./package/contents/config
9 | ./run-ts.sh ./src/generators/config > ./package/contents/config/main.xml
10 |
11 | npm-install:
12 | npm install
13 |
14 | lint: npm-install
15 | ifeq (${CHECKS}, true)
16 | npx eslint ./src
17 | endif
18 |
19 | lint-fix: npm-install
20 | npx eslint ./src --fix
21 |
22 | tests:
23 | ifeq (${CHECKS}, true)
24 | ./run-ts.sh ./src/tests
25 | endif
26 |
27 | install: build
28 | kpackagetool6 --type=KWin/Script --install=./package || kpackagetool6 --type=KWin/Script --upgrade=./package
29 |
30 | uninstall:
31 | kpackagetool6 --type=KWin/Script --remove=karousel
32 |
33 | package: build
34 | tar -czf ./karousel_${subst .,_,${VERSION}}.tar.gz ./package --transform s/package/karousel/
35 |
36 | docs-key-bindings-bbcode:
37 | @./run-ts.sh ./src/generators/docs/keyBindingsBbcode
38 |
39 | docs-key-bindings-markdown:
40 | @./run-ts.sh ./src/generators/docs/keyBindingsMarkdown
41 |
42 | docs-key-bindings-fmt:
43 | @./run-ts.sh ./src/generators/docs/keyBindingsFmt
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Karousel
2 | Scrollable tiling Kwin script. Works especially well with ultrawide screens.
3 | Use with [this](https://github.com/peterfajdiga/kwin4_effect_geometry_change) for animations.
4 |
5 | https://github.com/peterfajdiga/karousel/assets/22796326/2ab62d18-09c7-45f9-8fda-e5e36b8d7a02
6 |
7 | A scrollable tiling window manager tiles windows, but it does not maximize their widths. Instead, it leaves the width of windows to the user's control.
8 | Windows are automatically centered when possible. And when running out of width, windows can be scrolled through horizontally.
9 |
10 | Similar window managers include [PaperWM](https://github.com/paperwm/PaperWM),
11 | [Niri](https://github.com/YaLTeR/niri), and
12 | [Cardboard](https://gitlab.com/cardboardwm/cardboard).
13 |
14 | ## Dependencies
15 | Karousel requires the following QML modules:
16 | - QtQuick 6.0
17 | - org.kde.kwin 3.0
18 | - org.kde.notification 1.0
19 |
20 | ## Limitations
21 | - Doesn't support multiple screens
22 | - Doesn't support windows on all desktops
23 | - Doesn't support windows on multiple activities
24 |
25 | ## Installation
26 | First install the _org.kde.notification_ QML module (_qml-module-org-kde-notifications_ package on Ubuntu).
27 |
28 | Then download the [latest release](https://github.com/peterfajdiga/karousel/releases/latest) and extract it into _~/.local/share/kwin/scripts/_.
29 |
30 | Or clone the repo and run `make install` (requires npm, node, and tsc).
31 |
32 | ## Key bindings
33 | The key bindings can be configured in KDE System Settings among KWin's own keyboard shortcuts.
34 | Here's the default ones:
35 | | Shortcut | Action |
36 | | --- | --- |
37 | | Meta+Space | Toggle floating |
38 | | Meta+A | Move focus left |
39 | | Meta+D | Move focus right (Clashes with default KDE shortcuts, may require manual remapping) |
40 | | Meta+W | Move focus up (Clashes with default KDE shortcuts, may require manual remapping) |
41 | | Meta+S | Move focus down (Clashes with default KDE shortcuts, may require manual remapping) |
42 | | (unassigned) | Move focus to the next window in grid |
43 | | (unassigned) | Move focus to the previous window in grid |
44 | | Meta+Home | Move focus to start |
45 | | Meta+End | Move focus to end |
46 | | Meta+Shift+A | Move window left (Moves window out of and into columns) |
47 | | Meta+Shift+D | Move window right (Moves window out of and into columns) |
48 | | Meta+Shift+W | Move window up |
49 | | Meta+Shift+S | Move window down |
50 | | (unassigned) | Move window to the next position in grid |
51 | | (unassigned) | Move window to the previous position in grid |
52 | | Meta+Shift+Home | Move window to start |
53 | | Meta+Shift+End | Move window to end |
54 | | Meta+X | Toggle stacked layout for focused column (Only the active window visible) |
55 | | Meta+Ctrl+Shift+A | Move column left |
56 | | Meta+Ctrl+Shift+D | Move column right |
57 | | Meta+Ctrl+Shift+Home | Move column to start |
58 | | Meta+Ctrl+Shift+End | Move column to end |
59 | | Meta+Ctrl++ | Increase column width |
60 | | Meta+Ctrl+- | Decrease column width |
61 | | Meta+R | Cycle through preset column widths |
62 | | Meta+Shift+R | Cycle through preset column widths in reverse |
63 | | Meta+Ctrl+X | Equalize widths of visible columns |
64 | | Meta+Ctrl+A | Squeeze left column onto the screen (Clashes with default KDE shortcuts, may require manual remapping) |
65 | | Meta+Ctrl+D | Squeeze right column onto the screen |
66 | | Meta+Alt+Return | Center focused window (Scrolls so that the focused window is centered in the screen) |
67 | | Meta+Alt+A | Scroll one column to the left |
68 | | Meta+Alt+D | Scroll one column to the right |
69 | | Meta+Alt+PgUp | Scroll left |
70 | | Meta+Alt+PgDown | Scroll right |
71 | | Meta+Alt+Home | Scroll to start |
72 | | Meta+Alt+End | Scroll to end |
73 | | Meta+Ctrl+Return | Move Karousel grid to the current screen |
74 | | Meta+[N] | Move focus to column N (Clashes with default KDE shortcuts, may require manual remapping) |
75 | | Meta+Shift+[N] | Move window to column N (Requires manual remapping according to your keyboard layout, e.g. Meta+Shift+1 -> Meta+!) |
76 | | Meta+Ctrl+Shift+[N] | Move column to position N (Requires manual remapping according to your keyboard layout, e.g. Meta+Ctrl+Shift+1 -> Meta+Ctrl+!) |
77 | | Meta+Ctrl+Shift+F[N] | Move column to desktop N |
78 | | Meta+Ctrl+Shift+Alt+F[N] | Move this and all following columns to desktop N |
79 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import tseslint from "typescript-eslint";
4 |
5 | export default tseslint.config(
6 | {
7 | extends: [tseslint.configs.stylistic],
8 | rules: {
9 | "@typescript-eslint/no-empty-function": "off",
10 | "semi": "error",
11 | "indent": ["error", 4],
12 | },
13 | }
14 | );
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "eslint": "^9.24.0",
4 | "typescript-eslint": "^8.30.1"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/package/contents/ui/main.qml:
--------------------------------------------------------------------------------
1 | import QtQuick 6.0
2 | import org.kde.kwin 3.0
3 | import org.kde.notification 1.0
4 | import "../code/main.js" as Karousel
5 |
6 | Item {
7 | id: qmlBase
8 |
9 | property var karouselInstance
10 |
11 | Component.onCompleted: {
12 | qmlBase.karouselInstance = Karousel.init();
13 | }
14 |
15 | Component.onDestruction: {
16 | qmlBase.karouselInstance.destroy();
17 | }
18 |
19 | Notification {
20 | id: notificationInvalidWindowRules
21 | componentName: "plasma_workspace"
22 | eventId: "notification"
23 | title: "Karousel"
24 | text: "Your Window Rules JSON is malformed, please review your Karousel configuration"
25 | flags: Notification.Persistent
26 | urgency: Notification.HighUrgency
27 | }
28 |
29 | Notification {
30 | id: notificationInvalidPresetWidths
31 | componentName: "plasma_workspace"
32 | eventId: "notification"
33 | title: "Karousel"
34 | text: "Your preset widths are malformed, please review your Karousel configuration"
35 | flags: Notification.Persistent
36 | urgency: Notification.HighUrgency
37 | }
38 |
39 | SwipeGestureHandler {
40 | direction: SwipeGestureHandler.Direction.Left
41 | fingerCount: 3
42 | onActivated: qmlBase.karouselInstance.gestureScrollFinish()
43 | onCancelled: qmlBase.karouselInstance.gestureScrollFinish()
44 | onProgressChanged: qmlBase.karouselInstance.gestureScroll(-progress)
45 | }
46 |
47 | SwipeGestureHandler {
48 | direction: SwipeGestureHandler.Direction.Right
49 | fingerCount: 3
50 | onActivated: qmlBase.karouselInstance.gestureScrollFinish()
51 | onCancelled: qmlBase.karouselInstance.gestureScrollFinish()
52 | onProgressChanged: qmlBase.karouselInstance.gestureScroll(progress)
53 | }
54 |
55 | DBusCall {
56 | id: moveCursorToFocus
57 |
58 | service: "org.kde.kglobalaccel"
59 | path: "/component/kwin"
60 | method: "invokeShortcut"
61 | arguments: ["MoveMouseToFocus"]
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/package/metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "KPackageStructure": "KWin/Script",
3 | "KPlugin": {
4 | "Name": "Karousel",
5 | "Description": "Scrollable tiling extension for KWin",
6 | "Icon": "preferences-system-windows",
7 | "Authors": [{
8 | "Email": "peter.fajdiga@gmail.com",
9 | "Name": "Peter Fajdiga"
10 | }],
11 | "Id": "karousel",
12 | "Version": "0.13",
13 | "License": "GPLv3",
14 | "Website": "https://github.com/peterfajdiga/karousel",
15 | "BugReportUrl": "https://github.com/peterfajdiga/karousel/issues"
16 | },
17 | "X-Plasma-API": "declarativescript",
18 | "X-Plasma-API-Minimum-Version": "6.0",
19 | "X-Plasma-MainScript": "ui/main.qml",
20 | "X-KDE-ConfigModule": "kwin/effects/configs/kcm_kwin4_genericscripted"
21 | }
22 |
--------------------------------------------------------------------------------
/run-ts.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 | set -o pipefail
4 |
5 | JS_FILE='./run-ts-tmp.js'
6 |
7 | tsc -p "$1" --outFile "$JS_FILE"
8 | node "$JS_FILE"
9 |
--------------------------------------------------------------------------------
/src/extern/global.d.ts:
--------------------------------------------------------------------------------
1 | declare const Qt: Qt;
2 | declare const KWin: KWin;
3 | declare const Workspace: Workspace;
4 | declare const qmlBase: QmlObject;
5 | declare const notificationInvalidWindowRules: Notification;
6 | declare const notificationInvalidPresetWidths: Notification;
7 | declare const moveCursorToFocus: DBusCall;
8 |
--------------------------------------------------------------------------------
/src/generators/config/main.ts:
--------------------------------------------------------------------------------
1 | console.log(`
2 |
3 |
4 | `);
5 |
6 | for (const entry of configDef) {
7 | console.log(`
8 | ${escapeXml(entry.default)}
9 | `);
10 | }
11 |
12 | console.log(`
13 | `);
14 |
15 | function escapeXml(input: any) {
16 | if (typeof input === "string") {
17 | return input
18 | .replace(/&/g, '&')
19 | .replace(//g, '>');
21 | } else {
22 | return input;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/generators/config/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": [
4 | "../../extern/**/*",
5 | "../../lib/**/*",
6 | "./**/*"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/src/generators/docs/keyBindings.ts:
--------------------------------------------------------------------------------
1 | interface DocsKeyBinding {
2 | description: string;
3 | keySequence: string;
4 | }
5 |
6 | function formatDescription(item: {description: string, comment?: string}) {
7 | const suffix = item.comment === undefined ? "" : ` (${item.comment})`;
8 | return `${applyMacro(item.description, "N")}${suffix}`;
9 | }
10 |
11 | function printCols(...columns: (string[] | string)[]) {
12 | const nCols = columns.length;
13 | if (nCols === 0) {
14 | return;
15 | }
16 |
17 | let nRows = Math.min(...columns.filter(
18 | (column: string[] | string) => column instanceof Array
19 | ).map(
20 | (column: string[] | string) => column.length
21 | ));
22 | if (nRows === Infinity) {
23 | // we only have single string columns
24 | nRows = 1;
25 | }
26 |
27 | const colWidths = columns.map(
28 | (column: string[] | string) => {
29 | if (column instanceof Array) {
30 | return Math.max(...column.map(
31 | (cell: string) => cell.length
32 | ));
33 | } else {
34 | return column.length;
35 | }
36 | }
37 | );
38 |
39 | function getCell(col: number, row: number) {
40 | const column = columns[col];
41 | const cell = column instanceof Array ? column[row] : column;
42 | if (col < nCols-1) {
43 | return cell.padEnd(colWidths[col]);
44 | } else {
45 | return cell;
46 | }
47 | }
48 |
49 | for (let row = 0; row < nRows; row++) {
50 | let line = "";
51 | for (let col = 0; col < nCols; col++) {
52 | line += getCell(col, row);
53 | }
54 | console.log(line);
55 | }
56 | }
57 |
58 | const empty: any = {};
59 | const keyBindings: DocsKeyBinding[] = Array.prototype.concat(
60 | getKeyBindings(empty, empty).map(binding => ({
61 | description: formatDescription(binding),
62 | keySequence: binding.defaultKeySequence || "(unassigned)",
63 | })),
64 | getNumKeyBindings(empty, empty).map(binding => ({
65 | description: formatDescription(binding),
66 | keySequence: `${binding.defaultModifiers}+${binding.fKeys ? "F" : ""}[N]`,
67 | })),
68 | );
69 |
--------------------------------------------------------------------------------
/src/generators/docs/keyBindingsBbcode/main.ts:
--------------------------------------------------------------------------------
1 | console.log(`[list]`);
2 |
3 | for (const binding of keyBindings) {
4 | console.log(` [*] ${binding.keySequence} — ${binding.description}`);
5 | }
6 |
7 | console.log(`[/list]`);
8 |
--------------------------------------------------------------------------------
/src/generators/docs/keyBindingsBbcode/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "include": [
4 | "../../../extern/**/*",
5 | "../../../lib/**/*",
6 | "../keyBindings.ts",
7 | "./**/*"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/src/generators/docs/keyBindingsFmt/main.ts:
--------------------------------------------------------------------------------
1 | const colLeft = [
2 | ...keyBindings.map(binding => binding.keySequence),
3 | ];
4 |
5 | const colRight = [
6 | ...keyBindings.map(binding => binding.description),
7 | ];
8 |
9 | printCols(colLeft, " ", colRight);
10 |
--------------------------------------------------------------------------------
/src/generators/docs/keyBindingsFmt/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "include": [
4 | "../../../extern/**/*",
5 | "../../../lib/**/*",
6 | "../keyBindings.ts",
7 | "./**/*"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/src/generators/docs/keyBindingsMarkdown/main.ts:
--------------------------------------------------------------------------------
1 | const colLeft = [
2 | "Shortcut",
3 | "---",
4 | ...keyBindings.map(binding => binding.keySequence),
5 | ];
6 |
7 | const colRight = [
8 | "Action",
9 | "---",
10 | ...keyBindings.map(binding => binding.description),
11 | ];
12 |
13 | printCols("| ", colLeft, " | ", colRight, " |");
14 |
--------------------------------------------------------------------------------
/src/generators/docs/keyBindingsMarkdown/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "include": [
4 | "../../../extern/**/*",
5 | "../../../lib/**/*",
6 | "../keyBindings.ts",
7 | "./**/*"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/behavior/PresetWidths.ts:
--------------------------------------------------------------------------------
1 | class PresetWidths {
2 | private readonly presets: ((maxWidth: number) => number)[];
3 |
4 | constructor(presetWidths: string, spacing: number) {
5 | this.presets = PresetWidths.parsePresetWidths(presetWidths, spacing);
6 | }
7 |
8 | public next(currentWidth: number, minWidth: number, maxWidth: number) {
9 | const widths = this.getWidths(minWidth, maxWidth);
10 | const nextIndex = widths.findIndex(width => width > currentWidth);
11 | return nextIndex >= 0 ? widths[nextIndex] : widths[0];
12 | }
13 |
14 | public prev(currentWidth: number, minWidth: number, maxWidth: number) {
15 | const widths = this.getWidths(minWidth, maxWidth).reverse();
16 | const nextIndex = widths.findIndex(width => width < currentWidth);
17 | return nextIndex >= 0 ? widths[nextIndex] : widths[0];
18 | }
19 |
20 | public getWidths(minWidth: number, maxWidth: number) {
21 | const widths = this.presets.map(f => clamp(f(maxWidth), minWidth, maxWidth));
22 | widths.sort((a, b) => a - b);
23 | return uniq(widths);
24 | }
25 |
26 | private static parsePresetWidths(presetWidths: string, spacing: number): ((maxWidth: number) => number)[] {
27 | function getRatioFunction(ratio: number) {
28 | return (maxWidth: number) => Math.floor((maxWidth + spacing) * ratio - spacing);
29 | }
30 |
31 | return presetWidths.split(",").map((widthStr: string) => {
32 | widthStr = widthStr.trim();
33 |
34 | const widthPx = PresetWidths.parseNumberWithSuffix(widthStr, "px");
35 | if (widthPx !== undefined) {
36 | return () => widthPx;
37 | }
38 |
39 | const widthPct = PresetWidths.parseNumberWithSuffix(widthStr, "%");
40 | if (widthPct !== undefined) {
41 | return getRatioFunction(widthPct / 100.0);
42 | }
43 |
44 | return getRatioFunction(PresetWidths.parseNumberSafe(widthStr));
45 | });
46 | }
47 |
48 | private static parseNumberSafe(str: string) {
49 | const num = Number(str);
50 | if (isNaN(num) || num <= 0) {
51 | throw new Error("Invalid number: " + str);
52 | }
53 | return num;
54 | }
55 |
56 | private static parseNumberWithSuffix(str: string, suffix: string) {
57 | if (!str.endsWith(suffix)) {
58 | return undefined;
59 | }
60 | return PresetWidths.parseNumberSafe(str.substring(0, str.length-suffix.length).trim());
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/lib/behavior/columnResizer/ContextualResizer.ts:
--------------------------------------------------------------------------------
1 | class ContextualResizer {
2 | constructor(
3 | private readonly presetWidths: { getWidths: (minWidth: number, maxWidth: number) => number[] },
4 | ) {}
5 |
6 | public increaseWidth(column: Column) {
7 | const grid = column.grid;
8 | const desktop = grid.desktop;
9 | const visibleRange = desktop.getCurrentVisibleRange();
10 | const minWidth = column.getMinWidth();
11 | const maxWidth = column.getMaxWidth();
12 | if(!Range.contains(visibleRange, column) || column.getWidth() >= maxWidth) {
13 | return;
14 | }
15 |
16 | const leftVisibleColumn = grid.getLeftmostVisibleColumn(visibleRange, true);
17 | const rightVisibleColumn = grid.getRightmostVisibleColumn(visibleRange, true);
18 | if (leftVisibleColumn === null || rightVisibleColumn === null) {
19 | console.assert(false); // should at least see self
20 | return;
21 | }
22 |
23 | const leftSpace = leftVisibleColumn.getLeft() - visibleRange.getLeft();
24 | const rightSpace = visibleRange.getRight() - rightVisibleColumn.getRight();
25 |
26 | const newWidth = findMinPositive(
27 | [
28 | column.getWidth() + leftSpace + rightSpace,
29 | column.getWidth() + leftSpace + rightSpace + leftVisibleColumn.getWidth() + grid.config.gapsInnerHorizontal,
30 | column.getWidth() + leftSpace + rightSpace + rightVisibleColumn.getWidth() + grid.config.gapsInnerHorizontal,
31 | ...this.presetWidths.getWidths(minWidth, maxWidth),
32 | ],
33 | width => width - column.getWidth(),
34 | );
35 | if (newWidth === undefined) {
36 | return;
37 | }
38 |
39 | column.setWidth(newWidth, true);
40 | desktop.scrollCenterVisible(column);
41 | }
42 |
43 | public decreaseWidth(column: Column) {
44 | const grid = column.grid;
45 | const desktop = grid.desktop;
46 | const visibleRange = desktop.getCurrentVisibleRange();
47 | const minWidth = column.getMinWidth();
48 | const maxWidth = column.getMaxWidth();
49 | if(!Range.contains(visibleRange, column) || column.getWidth() <= minWidth) {
50 | return;
51 | }
52 |
53 | const leftVisibleColumn = grid.getLeftmostVisibleColumn(visibleRange, true);
54 | const rightVisibleColumn = grid.getRightmostVisibleColumn(visibleRange, true);
55 | if (leftVisibleColumn === null || rightVisibleColumn === null) {
56 | console.assert(false); // should at least see self
57 | return;
58 | }
59 |
60 | let leftOffScreenColumn = grid.getLeftColumn(leftVisibleColumn);
61 | if (leftOffScreenColumn === column) {
62 | leftOffScreenColumn = null;
63 | }
64 | let rightOffScreenColumn = grid.getRightColumn(rightVisibleColumn);
65 | if (rightOffScreenColumn === column) {
66 | rightOffScreenColumn = null;
67 | }
68 |
69 | const visibleColumnsWidth = rightVisibleColumn.getRight() - leftVisibleColumn.getLeft();
70 | const unusedWidth = visibleRange.getWidth() - visibleColumnsWidth;
71 | const leftOffScreen = leftOffScreenColumn === null ? 0 : leftOffScreenColumn.getWidth() + grid.config.gapsInnerHorizontal - unusedWidth;
72 | const rightOffScreen = rightOffScreenColumn === null ? 0 : rightOffScreenColumn.getWidth() + grid.config.gapsInnerHorizontal - unusedWidth;
73 |
74 | const newWidth = findMinPositive(
75 | [
76 | column.getWidth() - leftOffScreen,
77 | column.getWidth() - rightOffScreen,
78 | ...this.presetWidths.getWidths(minWidth, maxWidth),
79 | ],
80 | width => column.getWidth() - width,
81 | );
82 | if (newWidth === undefined) {
83 | return;
84 | }
85 |
86 | column.setWidth(newWidth, true);
87 | desktop.scrollCenterVisible(column);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/lib/behavior/columnResizer/RawResizer.ts:
--------------------------------------------------------------------------------
1 | class RawResizer {
2 | constructor(
3 | private readonly presetWidths: { getWidths: (minWidth: number, maxWidth: number) => number[] },
4 | ) {}
5 |
6 | public increaseWidth(column: Column) {
7 | const newWidth = findMinPositive(
8 | [
9 | ...this.presetWidths.getWidths(column.getMinWidth(), column.getMaxWidth()),
10 | ],
11 | width => width - column.getWidth(),
12 | );
13 | if (newWidth === undefined) {
14 | return;
15 | }
16 | column.setWidth(newWidth, true);
17 | }
18 |
19 | public decreaseWidth(column: Column) {
20 | const newWidth = findMinPositive(
21 | [
22 | ...this.presetWidths.getWidths(column.getMinWidth(), column.getMaxWidth()),
23 | ],
24 | width => column.getWidth() - width,
25 | );
26 | if (newWidth === undefined) {
27 | return;
28 | }
29 | column.setWidth(newWidth, true);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/lib/behavior/scrollClamper/CenterClamper.ts:
--------------------------------------------------------------------------------
1 | class CenterClamper {
2 | public clampScrollX(desktop: Desktop, x: number) {
3 | const firstColumn = desktop.grid.getFirstColumn();
4 | if (firstColumn === null) {
5 | return 0;
6 | }
7 | const lastColumn = desktop.grid.getLastColumn()!;
8 |
9 | const minScroll = Math.round((firstColumn.getWidth() - desktop.tilingArea.width) / 2);
10 | const maxScroll = Math.round(desktop.grid.getWidth() - (desktop.tilingArea.width + lastColumn.getWidth()) / 2);
11 | return clamp(x, minScroll, maxScroll);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/behavior/scrollClamper/EdgeClamper.ts:
--------------------------------------------------------------------------------
1 | class EdgeClamper {
2 | public clampScrollX(desktop: Desktop, x: number) {
3 | const minScroll = 0;
4 | const maxScroll = desktop.grid.getWidth() - desktop.tilingArea.width;
5 | if (maxScroll < 0) {
6 | return Math.round(maxScroll / 2);
7 | }
8 | return clamp(x, minScroll, maxScroll);
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/lib/behavior/scroller/CenteredScroller.ts:
--------------------------------------------------------------------------------
1 | class CenteredScroller {
2 | public scrollToColumn(desktop: Desktop, column: Column) {
3 | desktop.scrollCenterRange(column);
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/lib/behavior/scroller/GroupedScroller.ts:
--------------------------------------------------------------------------------
1 | class GroupedScroller {
2 | public scrollToColumn(desktop: Desktop, column: Column) {
3 | desktop.scrollCenterVisible(column);
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/lib/behavior/scroller/LazyScroller.ts:
--------------------------------------------------------------------------------
1 | class LazyScroller {
2 | public scrollToColumn(desktop: Desktop, column: Column) {
3 | desktop.scrollIntoView(column);
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/lib/config/config.ts:
--------------------------------------------------------------------------------
1 | interface Config {
2 | gapsOuterTop: number;
3 | gapsOuterBottom: number;
4 | gapsOuterLeft: number;
5 | gapsOuterRight: number;
6 | gapsInnerHorizontal: number;
7 | gapsInnerVertical: number;
8 | stackOffsetX: number;
9 | stackOffsetY: number;
10 | manualScrollStep: number;
11 | presetWidths: string;
12 | offScreenOpacity: number;
13 | untileOnDrag: boolean;
14 | cursorFollowsFocus: boolean;
15 | stackColumnsByDefault: boolean;
16 | resizeNeighborColumn: boolean;
17 | reMaximize: boolean;
18 | skipSwitcher: boolean;
19 | scrollingLazy: boolean;
20 | scrollingCentered: boolean;
21 | scrollingGrouped: boolean;
22 | gestureScroll: boolean;
23 | gestureScrollInvert: boolean;
24 | gestureScrollStep: number;
25 | tiledKeepBelow: boolean;
26 | floatingKeepAbove: boolean;
27 | windowRules: string;
28 | }
29 |
--------------------------------------------------------------------------------
/src/lib/config/definition.ts:
--------------------------------------------------------------------------------
1 | const defaultWindowRules = `[
2 | {
3 | "class": "(org\\\\.kde\\\\.)?plasmashell",
4 | "tile": false
5 | },
6 | {
7 | "class": "(org\\\\.kde\\\\.)?polkit-kde-authentication-agent-1",
8 | "tile": false
9 | },
10 | {
11 | "class": "(org\\\\.kde\\\\.)?kded6",
12 | "tile": false
13 | },
14 | {
15 | "class": "(org\\\\.kde\\\\.)?kcalc",
16 | "tile": false
17 | },
18 | {
19 | "class": "(org\\\\.kde\\\\.)?kfind",
20 | "tile": true
21 | },
22 | {
23 | "class": "(org\\\\.kde\\\\.)?kruler",
24 | "tile": false
25 | },
26 | {
27 | "class": "(org\\\\.kde\\\\.)?krunner",
28 | "tile": false
29 | },
30 | {
31 | "class": "(org\\\\.kde\\\\.)?yakuake",
32 | "tile": false
33 | },
34 | {
35 | "class": "steam",
36 | "caption": "Steam Big Picture Mode",
37 | "tile": false
38 | },
39 | {
40 | "class": "zoom",
41 | "caption": "Zoom Cloud Meetings|zoom|zoom <2>",
42 | "tile": false
43 | },
44 | {
45 | "class": "jetbrains-.*",
46 | "caption": "splash",
47 | "tile": false
48 | },
49 | {
50 | "class": "jetbrains-.*",
51 | "caption": "Unstash Changes|Paths Affected by stash@.*",
52 | "tile": true
53 | }
54 | ]`;
55 |
56 | const configDef = [
57 | {
58 | name: "gapsOuterTop",
59 | type: "UInt",
60 | default: 16,
61 | },
62 | {
63 | name: "gapsOuterBottom",
64 | type: "UInt",
65 | default: 16,
66 | },
67 | {
68 | name: "gapsOuterLeft",
69 | type: "UInt",
70 | default: 16,
71 | },
72 | {
73 | name: "gapsOuterRight",
74 | type: "UInt",
75 | default: 16,
76 | },
77 | {
78 | name: "gapsInnerHorizontal",
79 | type: "UInt",
80 | default: 8,
81 | },
82 | {
83 | name: "gapsInnerVertical",
84 | type: "UInt",
85 | default: 8,
86 | },
87 | {
88 | name: "stackOffsetX",
89 | type: "UInt",
90 | default: 8,
91 | },
92 | {
93 | name: "stackOffsetY",
94 | type: "UInt",
95 | default: 32,
96 | },
97 | {
98 | name: "manualScrollStep",
99 | type: "UInt",
100 | default: 200,
101 | },
102 | {
103 | name: "presetWidths",
104 | type: "String",
105 | default: "50%, 100%",
106 | },
107 | {
108 | name: "offScreenOpacity",
109 | type: "UInt",
110 | default: 100,
111 | },
112 | {
113 | name: "untileOnDrag",
114 | type: "Bool",
115 | default: true,
116 | },
117 | {
118 | name: "cursorFollowsFocus",
119 | type: "Bool",
120 | default: false,
121 | },
122 | {
123 | name: "stackColumnsByDefault",
124 | type: "Bool",
125 | default: false,
126 | },
127 | {
128 | name: "resizeNeighborColumn",
129 | type: "Bool",
130 | default: false,
131 | },
132 | {
133 | name: "reMaximize",
134 | type: "Bool",
135 | default: false,
136 | },
137 | {
138 | name: "skipSwitcher",
139 | type: "Bool",
140 | default: false,
141 | },
142 | {
143 | name: "scrollingLazy",
144 | type: "Bool",
145 | default: true,
146 | },
147 | {
148 | name: "scrollingCentered",
149 | type: "Bool",
150 | default: false,
151 | },
152 | {
153 | name: "scrollingGrouped",
154 | type: "Bool",
155 | default: false,
156 | },
157 | {
158 | name: "gestureScroll",
159 | type: "Bool",
160 | default: false,
161 | },
162 | {
163 | name: "gestureScrollInvert",
164 | type: "Bool",
165 | default: false,
166 | },
167 | {
168 | name: "gestureScrollStep",
169 | type: "UInt",
170 | default: 1920,
171 | },
172 | {
173 | name: "tiledKeepBelow",
174 | type: "Bool",
175 | default: true,
176 | },
177 | {
178 | name: "floatingKeepAbove",
179 | type: "Bool",
180 | default: false,
181 | },
182 | {
183 | name: "noLayering",
184 | type: "Bool",
185 | default: false,
186 | },
187 | {
188 | name: "windowRules",
189 | type: "String",
190 | default: defaultWindowRules,
191 | }
192 | ];
193 |
--------------------------------------------------------------------------------
/src/lib/extern/dbuscall.ts:
--------------------------------------------------------------------------------
1 | interface DBusCall extends QmlObject {
2 | call(): void;
3 | }
4 |
--------------------------------------------------------------------------------
/src/lib/extern/global.d.ts:
--------------------------------------------------------------------------------
1 | declare const console: Console;
2 |
--------------------------------------------------------------------------------
/src/lib/extern/kwin.ts:
--------------------------------------------------------------------------------
1 | interface KWin {
2 | __brand: "KWin";
3 |
4 | readConfig(key: string, defaultValue: any): any;
5 | }
6 |
7 | interface Workspace {
8 | __brand: "Workspace";
9 |
10 | readonly activities: string[];
11 | readonly desktops: KwinDesktop[];
12 | readonly currentDesktop: KwinDesktop;
13 | readonly currentActivity: string;
14 | readonly activeScreen: Output;
15 | readonly windows: KwinClient[];
16 | readonly cursorPos: Readonly;
17 |
18 | activeWindow: KwinClient|null;
19 |
20 | readonly currentDesktopChanged: QSignal<[]>;
21 | readonly windowAdded: QSignal<[KwinClient]>;
22 | readonly windowRemoved: QSignal<[KwinClient]>;
23 | readonly windowActivated: QSignal<[KwinClient|null]>;
24 | readonly screensChanged: QSignal<[]>;
25 | readonly activitiesChanged: QSignal<[]>;
26 | readonly desktopsChanged: QSignal<[]>;
27 | readonly currentActivityChanged: QSignal<[]>;
28 | readonly virtualScreenSizeChanged: QSignal<[]>;
29 |
30 | clientArea(option: ClientAreaOption, output: Output, kwinDesktop: KwinDesktop): QmlRect;
31 | }
32 |
33 | const enum ClientAreaOption {
34 | PlacementArea,
35 | MovementArea,
36 | MaximizeArea,
37 | MaximizeFullArea,
38 | FullScreenArea,
39 | WorkArea,
40 | FullArea,
41 | ScreenArea,
42 | }
43 |
44 | const enum MaximizedMode {
45 | Unmaximized,
46 | Vertically,
47 | Horizontally,
48 | Maximized,
49 | }
50 |
51 | interface Tile { __brand: "Tile" }
52 | interface Output { __brand: "Output" }
53 |
54 | interface KwinClient {
55 | __brand: "KwinClient";
56 |
57 | readonly caption: string;
58 | readonly minSize: Readonly;
59 | readonly transient: boolean;
60 | readonly transientFor: KwinClient | null;
61 | readonly clientGeometry: Readonly;
62 | readonly move: boolean;
63 | readonly resize: boolean;
64 | readonly moveable: boolean;
65 | readonly resizeable: boolean;
66 | readonly fullScreenable: boolean;
67 | readonly maximizable: boolean;
68 | readonly output: Output;
69 | readonly resourceClass: string;
70 | readonly dock: boolean;
71 | readonly normalWindow: boolean;
72 | readonly managed: boolean;
73 | readonly popupWindow: boolean;
74 | readonly pid: number;
75 |
76 | fullScreen: boolean;
77 | activities: string[]; // empty array means all activities
78 | skipSwitcher: boolean;
79 | keepAbove: boolean;
80 | keepBelow: boolean;
81 | minimized: boolean;
82 | frameGeometry: QmlRect;
83 | desktops: KwinDesktop[]; // empty array means all desktops
84 | tile: Tile|null;
85 | opacity: number;
86 |
87 | readonly fullScreenChanged: QSignal<[]>;
88 | readonly desktopsChanged: QSignal<[]>;
89 | readonly activitiesChanged: QSignal<[]>;
90 | readonly minimizedChanged: QSignal<[]>;
91 | readonly maximizedAboutToChange: QSignal<[MaximizedMode]>;
92 | readonly captionChanged: QSignal<[]>;
93 | readonly tileChanged: QSignal<[]>;
94 | readonly interactiveMoveResizeStarted: QSignal<[]>;
95 | readonly interactiveMoveResizeFinished: QSignal<[]>;
96 | readonly frameGeometryChanged: QSignal<[oldGeometry: QmlRect]>;
97 |
98 | setMaximize(vertically: boolean, horizontally: boolean): void;
99 | }
100 |
101 | interface KwinDesktop {
102 | __brand: "KwinDesktop";
103 |
104 | readonly id: string;
105 | }
106 |
107 | interface ShortcutHandler extends QmlObject {
108 | readonly activated: QSignal<[]>;
109 | destroy(): void;
110 | }
111 |
--------------------------------------------------------------------------------
/src/lib/extern/notification.ts:
--------------------------------------------------------------------------------
1 | interface Notification extends QmlObject {
2 | sendEvent(): void;
3 | }
4 |
--------------------------------------------------------------------------------
/src/lib/extern/qt.ts:
--------------------------------------------------------------------------------
1 | interface Console {
2 | __brand: "Console";
3 |
4 | log(...args: any[]): void;
5 | assert(assertion: boolean, message?: string): void;
6 | }
7 |
8 | interface Qt {
9 | __brand: "Qt";
10 |
11 | rect(x: number, y: number, width: number, height: number): QmlRect;
12 | createQmlObject(qml: string, parent: QmlObject): QmlObject;
13 | }
14 |
15 | interface QmlObject { __brand: "QmlObject" }
16 |
17 | interface QmlPoint {
18 | __brand: "QmlPoint";
19 |
20 | x: number;
21 | y: number;
22 | }
23 |
24 | interface QmlRect {
25 | __brand: "QmlRect";
26 |
27 | x: number;
28 | y: number;
29 | width: number;
30 | height: number;
31 | readonly top: number;
32 | readonly bottom: number; // top + height
33 | readonly left: number;
34 | readonly right: number; // left + width
35 | }
36 |
37 | interface QmlSize {
38 | __brand: "QmlSize";
39 |
40 | width: number;
41 | height: number;
42 | }
43 |
44 | interface QSignal {
45 | __brand: "QSignal";
46 |
47 | connect(handler: (...args: [...T]) => void): void;
48 | disconnect(handler: (...args: [...T]) => void): void;
49 | }
50 |
51 | interface QmlTimer extends QmlObject {
52 | interval: number;
53 | readonly triggered: QSignal<[]>;
54 | restart(): void;
55 | destroy(): void;
56 | }
57 |
--------------------------------------------------------------------------------
/src/lib/keyBindings/loader.ts:
--------------------------------------------------------------------------------
1 | interface KeyBinding {
2 | name: string;
3 | description: string;
4 | comment?: string;
5 | defaultKeySequence?: string;
6 | action: () => void;
7 | }
8 |
9 | interface NumKeyBinding {
10 | name: string;
11 | description: string;
12 | comment?: string;
13 | defaultModifiers: string;
14 | fKeys: boolean;
15 | action: (i: number) => void;
16 | }
17 |
18 | function catchWrap(f: () => void) {
19 | return () => {
20 | try {
21 | f();
22 | } catch (error: any) {
23 | log(error);
24 | log(error.stack);
25 | }
26 | };
27 | }
28 |
29 | function registerKeyBinding(shortcutActions: ShortcutAction[], keyBinding: KeyBinding) {
30 | shortcutActions.push(new ShortcutAction(
31 | keyBinding,
32 | catchWrap(keyBinding.action),
33 | ));
34 | }
35 |
36 | function registerNumKeyBindings(shortcutActions: ShortcutAction[], numKeyBinding: NumKeyBinding) {
37 | const numPrefix = numKeyBinding.fKeys ? "F" : "";
38 | const n = numKeyBinding.fKeys ? 12 : 9;
39 | for (let i = 0; i < 12; i++) {
40 | const numKey = String(i + 1);
41 | const keySequence = i < n ?
42 | numKeyBinding.defaultModifiers + "+" + numPrefix + numKey :
43 | "";
44 | shortcutActions.push(new ShortcutAction(
45 | {
46 | name: applyMacro(numKeyBinding.name, numKey),
47 | description: applyMacro(numKeyBinding.description, numKey),
48 | defaultKeySequence: keySequence,
49 | },
50 | catchWrap(() => numKeyBinding.action(i)),
51 | ));
52 | }
53 | }
54 |
55 | function registerKeyBindings(world: World, config: Actions.Config) {
56 | const actions = new Actions(config);
57 | const shortcutActions: ShortcutAction[] = [];
58 |
59 | for (const keyBinding of getKeyBindings(world, actions)) {
60 | registerKeyBinding(shortcutActions, keyBinding);
61 | }
62 |
63 | for (const numKeyBinding of getNumKeyBindings(world, actions)) {
64 | registerNumKeyBindings(shortcutActions, numKeyBinding);
65 | }
66 |
67 | return shortcutActions;
68 | }
69 |
--------------------------------------------------------------------------------
/src/lib/layout/Grid.ts:
--------------------------------------------------------------------------------
1 | class Grid {
2 | public readonly desktop: Desktop;
3 | public readonly config: LayoutConfig;
4 | private readonly columns: LinkedList;
5 | private lastFocusedColumn: Column|null;
6 | private width: number;
7 | private userResize: boolean; // is any part of the grid being resized by the user
8 | private readonly userResizeFinishedDelayer: Delayer;
9 |
10 | constructor(desktop: Desktop, config: LayoutConfig) {
11 | this.desktop = desktop;
12 | this.config = config;
13 | this.columns = new LinkedList();
14 | this.lastFocusedColumn = null;
15 | this.width = 0;
16 | this.userResize = false;
17 | this.userResizeFinishedDelayer = new Delayer(50, () => {
18 | // this delay prevents windows' contents from freezing after resizing
19 | this.desktop.onLayoutChanged();
20 | this.desktop.autoAdjustScroll();
21 | this.desktop.arrange();
22 | });
23 | }
24 |
25 | public moveColumn(column: Column, leftColumn: Column|null) {
26 | if (column === leftColumn) {
27 | return;
28 | }
29 | const movedLeft = leftColumn === null ? true : column.isToTheRightOf(leftColumn);
30 | const firstMovedColumn = movedLeft ? column : this.getRightColumn(column);
31 | this.columns.move(column, leftColumn);
32 | this.columnsSetX(firstMovedColumn);
33 | this.desktop.onLayoutChanged();
34 | this.desktop.autoAdjustScroll();
35 | }
36 |
37 | public moveColumnLeft(column: Column) {
38 | this.columns.moveBack(column);
39 | this.columnsSetX(column);
40 | this.desktop.onLayoutChanged();
41 | this.desktop.autoAdjustScroll();
42 | }
43 |
44 | public moveColumnRight(column: Column) {
45 | const rightColumn = this.columns.getNext(column);
46 | if (rightColumn === null) {
47 | return;
48 | }
49 | this.moveColumnLeft(rightColumn);
50 | }
51 |
52 | public getWidth() {
53 | return this.width;
54 | }
55 |
56 | public isUserResizing() {
57 | return this.userResize;
58 | }
59 |
60 | public getLeftColumn(column: Column) {
61 | return this.columns.getPrev(column);
62 | }
63 |
64 | public getRightColumn(column: Column) {
65 | return this.columns.getNext(column);
66 | }
67 |
68 | public getFirstColumn() {
69 | return this.columns.getFirst();
70 | }
71 |
72 | public getLastColumn() {
73 | return this.columns.getLast();
74 | }
75 |
76 | public getColumnAtIndex(i: number) {
77 | return this.columns.getItemAtIndex(i);
78 | }
79 |
80 | public getLastFocusedColumn() {
81 | if (this.lastFocusedColumn === null || this.lastFocusedColumn.grid !== this) {
82 | return null;
83 | }
84 | return this.lastFocusedColumn;
85 | }
86 |
87 | public getLastFocusedWindow() {
88 | const lastFocusedColumn = this.getLastFocusedColumn();
89 | if (lastFocusedColumn === null) {
90 | return null;
91 | }
92 | return lastFocusedColumn.getFocusTaker();
93 | }
94 |
95 | private columnsSetX(firstMovedColumn: Column|null) {
96 | const lastUnmovedColumn = firstMovedColumn === null ? this.columns.getLast() : this.columns.getPrev(firstMovedColumn);
97 | let x = lastUnmovedColumn === null ? 0 : lastUnmovedColumn.getRight() + this.config.gapsInnerHorizontal;
98 | if (firstMovedColumn !== null) {
99 | for (const column of this.columns.iteratorFrom(firstMovedColumn)) {
100 | column.gridX = x;
101 | x += column.getWidth() + this.config.gapsInnerHorizontal;
102 | }
103 | }
104 | this.width = x - this.config.gapsInnerHorizontal;
105 | }
106 |
107 | public getLeftmostVisibleColumn(visibleRange: Range, fullyVisible: boolean) {
108 | for (const column of this.columns.iterator()) {
109 | if (Range.contains(visibleRange, column)) {
110 | return column;
111 | }
112 | }
113 | return null;
114 | }
115 |
116 | public getRightmostVisibleColumn(visibleRange: Range, fullyVisible: boolean) {
117 | let last = null;
118 | for (const column of this.columns.iterator()) {
119 | if (Range.contains(visibleRange, column)) {
120 | last = column;
121 | } else if (last !== null) {
122 | break;
123 | }
124 | }
125 | return last;
126 | }
127 |
128 | public *getVisibleColumns(visibleRange: Range, fullyVisible: boolean) {
129 | for (const column of this.columns.iterator()) {
130 | if (Range.contains(visibleRange, column)) {
131 | yield column;
132 | }
133 | }
134 | }
135 |
136 | public arrange(x: number, visibleRange: Range) {
137 | for (const column of this.columns.iterator()) {
138 | column.arrange(x, visibleRange, this.userResize);
139 | x += column.getWidth() + this.config.gapsInnerHorizontal;
140 | }
141 |
142 | const focusedWindow = this.getLastFocusedWindow();
143 | if (focusedWindow !== null) {
144 | focusedWindow.client.ensureTransientsVisible(this.desktop.clientArea);
145 | }
146 | }
147 |
148 | public onColumnAdded(column: Column, leftColumn: Column|null) {
149 | if (leftColumn === null) {
150 | this.columns.insertStart(column);
151 | } else {
152 | this.columns.insertAfter(column, leftColumn);
153 | }
154 | this.columnsSetX(column);
155 | this.desktop.onLayoutChanged();
156 | this.desktop.autoAdjustScroll();
157 | }
158 |
159 | public onColumnRemoved(column: Column, passFocus: boolean) {
160 | const isLastColumn = this.columns.length() === 1;
161 | const rightColumn = this.getRightColumn(column);
162 | const columnToFocus = isLastColumn ? null : this.getLeftColumn(column) ?? rightColumn;
163 | if (column === this.lastFocusedColumn) {
164 | this.lastFocusedColumn = columnToFocus;
165 | }
166 |
167 | this.columns.remove(column);
168 | this.columnsSetX(rightColumn);
169 |
170 | this.desktop.onLayoutChanged();
171 | if (passFocus && columnToFocus !== null) {
172 | columnToFocus.focus();
173 | } else {
174 | this.desktop.autoAdjustScroll();
175 | }
176 | }
177 |
178 | public onColumnWidthChanged(column: Column) {
179 | const rightColumn = this.columns.getNext(column);
180 | this.columnsSetX(rightColumn);
181 | this.desktop.onLayoutChanged();
182 | if (!this.userResize) {
183 | this.desktop.autoAdjustScroll();
184 | }
185 | }
186 |
187 | public onColumnFocused(column: Column, window: Window) {
188 | const lastFocusedColumn = this.getLastFocusedColumn();
189 | if (lastFocusedColumn !== null) {
190 | lastFocusedColumn.restoreToTiled(window);
191 | }
192 | this.lastFocusedColumn = column;
193 | this.desktop.scrollToColumn(column, false);
194 | }
195 |
196 | public onScreenSizeChanged() {
197 | for (const column of this.columns.iterator()) {
198 | column.updateWidth();
199 | column.resizeWindows();
200 | }
201 | }
202 |
203 | public onUserResizeStarted() {
204 | this.userResize = true;
205 | }
206 |
207 | public onUserResizeFinished() {
208 | this.userResize = false;
209 | this.userResizeFinishedDelayer.run();
210 | }
211 |
212 | public evacuateTail(targetGrid: Grid, startColumn: Column) {
213 | for (const column of this.columns.iteratorFrom(startColumn)) {
214 | column.moveToGrid(targetGrid, targetGrid.getLastColumn());
215 | }
216 | }
217 |
218 | public evacuate(targetGrid: Grid) {
219 | for (const column of this.columns.iterator()) {
220 | column.moveToGrid(targetGrid, targetGrid.getLastColumn());
221 | }
222 | }
223 |
224 | public destroy() {
225 | this.userResizeFinishedDelayer.destroy();
226 | }
227 | }
228 |
--------------------------------------------------------------------------------
/src/lib/layout/LayoutConfig.ts:
--------------------------------------------------------------------------------
1 | interface LayoutConfig {
2 | gapsInnerHorizontal: number;
3 | gapsInnerVertical: number;
4 | stackOffsetX: number;
5 | stackOffsetY: number;
6 | offScreenOpacity: number;
7 | stackColumnsByDefault: boolean;
8 | resizeNeighborColumn: boolean;
9 | reMaximize: boolean;
10 | skipSwitcher: boolean;
11 | tiledKeepBelow: boolean;
12 | maximizedKeepAbove: boolean;
13 | untileOnDrag: boolean;
14 | }
15 |
--------------------------------------------------------------------------------
/src/lib/layout/Range.ts:
--------------------------------------------------------------------------------
1 | interface Range {
2 | getLeft(): number;
3 | getRight(): number;
4 | getWidth(): number;
5 | }
6 |
7 | namespace Range {
8 | export function create(x: number, width: number) {
9 | return new Basic(x, width);
10 | }
11 |
12 | export function fromRanges(leftRange: Range, rightRange: Range) {
13 | const left = leftRange.getLeft();
14 | const right = rightRange.getRight();
15 | return new Basic(left, right - left);
16 | }
17 |
18 | export function contains(parent: Range, child: Range) {
19 | return child.getLeft() >= parent.getLeft() &&
20 | child.getRight() <= parent.getRight();
21 | }
22 |
23 | export function minus(a: Range, b: Range) {
24 | const aCenter = a.getLeft() + a.getWidth() / 2;
25 | const bCenter = b.getLeft() + b.getWidth() / 2;
26 | return Math.round(aCenter - bCenter);
27 | }
28 |
29 | class Basic {
30 | constructor(
31 | private readonly x: number,
32 | private readonly width: number,
33 | ) {}
34 |
35 | public getLeft() {
36 | return this.x;
37 | }
38 |
39 | public getRight() {
40 | return this.x + this.width;
41 | }
42 |
43 | public getWidth() {
44 | return this.width;
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/lib/layout/Window.ts:
--------------------------------------------------------------------------------
1 | class Window {
2 | public column: Column;
3 | public readonly client: ClientWrapper;
4 | public height: number;
5 | public readonly focusedState: Window.State;
6 | private skipArrange: boolean;
7 |
8 | constructor(client: ClientWrapper, column: Column) {
9 | this.client = client;
10 | this.height = client.kwinClient.frameGeometry.height;
11 |
12 | let maximizedMode = this.client.getMaximizedMode();
13 | if (maximizedMode === undefined) {
14 | maximizedMode = MaximizedMode.Unmaximized; // defaulting to unmaximized, as this is set in Tiled.prepareClientForTiling
15 | }
16 | this.focusedState = {
17 | fullScreen: this.client.kwinClient.fullScreen,
18 | maximizedMode: maximizedMode,
19 | };
20 |
21 | this.skipArrange = this.client.kwinClient.fullScreen || maximizedMode !== MaximizedMode.Unmaximized;
22 | this.column = column;
23 | column.onWindowAdded(this, true);
24 | }
25 |
26 | public moveToColumn(targetColumn: Column, bottom: boolean) {
27 | if (targetColumn === this.column) {
28 | return;
29 | }
30 | this.column.onWindowRemoved(this, this.isFocused() && targetColumn.grid !== this.column.grid);
31 | this.column = targetColumn;
32 | targetColumn.onWindowAdded(this, bottom);
33 | }
34 |
35 | public arrange(x: number, y: number, width: number, height: number) {
36 | if (this.skipArrange) {
37 | // window is maximized, fullscreen, or being manually resized, prevent fighting with the user
38 | return;
39 | }
40 |
41 | let maximized = false;
42 | if (this.column.grid.config.reMaximize && this.isFocused()) {
43 | // do this here rather than in `onFocused` to ensure it happens after placement
44 | // (otherwise placement may not happen at all)
45 | if (this.focusedState.maximizedMode !== MaximizedMode.Unmaximized) {
46 | this.client.setMaximize(
47 | this.focusedState.maximizedMode === MaximizedMode.Horizontally || this.focusedState.maximizedMode === MaximizedMode.Maximized,
48 | this.focusedState.maximizedMode === MaximizedMode.Vertically || this.focusedState.maximizedMode === MaximizedMode.Maximized,
49 | );
50 | maximized = true;
51 | }
52 | if (this.focusedState.fullScreen) {
53 | this.client.setFullScreen(true);
54 | maximized = true;
55 | }
56 | }
57 | if (!maximized) {
58 | this.client.place(x, y, width, height);
59 | }
60 | }
61 |
62 | public focus() {
63 | this.client.focus();
64 | }
65 |
66 | public isFocused() {
67 | return this.client.isFocused();
68 | }
69 |
70 | public onFocused() {
71 | if (this.column.grid.config.reMaximize && (
72 | this.focusedState.maximizedMode !== MaximizedMode.Unmaximized ||
73 | this.focusedState.fullScreen
74 | )) {
75 | // We need to maximize/fullscreen this window, but we can't do it here.
76 | // We need to do it in `arrange` to ensure it happens after placement.
77 | this.column.grid.desktop.forceArrange();
78 | }
79 | this.column.onWindowFocused(this);
80 | }
81 |
82 | public restoreToTiled() {
83 | if (this.isFocused()) {
84 | return;
85 | }
86 | this.client.setFullScreen(false);
87 | this.client.setMaximize(false, false);
88 | }
89 |
90 | public onMaximizedChanged(maximizedMode: MaximizedMode) {
91 | const maximized = maximizedMode !== MaximizedMode.Unmaximized;
92 | this.skipArrange = maximized;
93 | if (this.column.grid.config.tiledKeepBelow) {
94 | this.client.kwinClient.keepBelow = !maximized;
95 | }
96 | if (this.column.grid.config.maximizedKeepAbove) {
97 | this.client.kwinClient.keepAbove = maximized;
98 | }
99 | if (this.isFocused()) {
100 | this.focusedState.maximizedMode = maximizedMode;
101 | }
102 | this.column.grid.desktop.onLayoutChanged();
103 | }
104 |
105 | public onFullScreenChanged(fullScreen: boolean) {
106 | this.skipArrange = fullScreen;
107 | if (this.column.grid.config.tiledKeepBelow) {
108 | this.client.kwinClient.keepBelow = !fullScreen;
109 | }
110 | if (this.column.grid.config.maximizedKeepAbove) {
111 | this.client.kwinClient.keepAbove = fullScreen;
112 | }
113 | if (this.isFocused()) {
114 | this.focusedState.fullScreen = fullScreen;
115 | }
116 | this.column.grid.desktop.onLayoutChanged();
117 | }
118 |
119 | public onFrameGeometryChanged() {
120 | const newGeometry = this.client.kwinClient.frameGeometry;
121 | this.column.setWidth(newGeometry.width, true);
122 | this.column.grid.desktop.onLayoutChanged();
123 | }
124 |
125 | public destroy(passFocus: boolean) {
126 | this.column.onWindowRemoved(this, passFocus);
127 | }
128 | }
129 |
130 | namespace Window {
131 | export interface State {
132 | fullScreen: boolean;
133 | maximizedMode: MaximizedMode;
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/lib/rules/ClientMatcher.ts:
--------------------------------------------------------------------------------
1 | class ClientMatcher {
2 | private readonly regex: RegExp;
3 |
4 | constructor(regex: RegExp) {
5 | this.regex = regex;
6 | }
7 |
8 | public matches(kwinClient: KwinClient) {
9 | return this.regex.test(ClientMatcher.getClientString(kwinClient));
10 | }
11 |
12 | public static getClientString(kwinClient: KwinClient) {
13 | return ClientMatcher.getRuleString(kwinClient.resourceClass, kwinClient.caption);
14 | }
15 |
16 | public static getRuleString(ruleClass: string, ruleCaption: string) {
17 | return ruleClass + "\0" + ruleCaption;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/rules/WindowRule.ts:
--------------------------------------------------------------------------------
1 | interface WindowRule {
2 | class: string | undefined;
3 | caption: string | undefined;
4 | tile: boolean;
5 | }
6 |
--------------------------------------------------------------------------------
/src/lib/rules/WindowRuleEnforcer.ts:
--------------------------------------------------------------------------------
1 | class WindowRuleEnforcer {
2 | private readonly preferFloating: ClientMatcher;
3 | private readonly preferTiling: ClientMatcher;
4 | private readonly followCaption: RegExp;
5 |
6 | constructor(windowRules: WindowRule[]) {
7 | const [floatRegex, tileRegex, followCaptionRegex] = WindowRuleEnforcer.createWindowRuleRegexes(windowRules);
8 | this.preferFloating = new ClientMatcher(floatRegex);
9 | this.preferTiling = new ClientMatcher(tileRegex);
10 | this.followCaption = followCaptionRegex;
11 | }
12 |
13 | public shouldTile(kwinClient: KwinClient) {
14 | return this.preferTiling.matches(kwinClient) || (
15 | kwinClient.normalWindow &&
16 | !kwinClient.transient &&
17 | kwinClient.managed &&
18 | kwinClient.pid > -1 &&
19 | !kwinClient.fullScreen &&
20 | !Clients.isFullScreenGeometry(kwinClient) &&
21 | !this.preferFloating.matches(kwinClient)
22 | );
23 | }
24 |
25 | public initClientSignalManager(world: World, kwinClient: KwinClient) {
26 | if (!this.followCaption.test(kwinClient.resourceClass)) {
27 | return null;
28 | }
29 |
30 | const enforcer = this;
31 | const manager = new SignalManager();
32 | manager.connect(kwinClient.captionChanged, () => {
33 | const shouldTile = Clients.canTileNow(kwinClient) && enforcer.shouldTile(kwinClient);
34 | world.do((clientManager, desktopManager) => {
35 | const desktop = desktopManager.getDesktopForClient(kwinClient);
36 | if (shouldTile && desktop !== undefined) {
37 | clientManager.tileKwinClient(kwinClient, desktop.grid);
38 | } else {
39 | clientManager.floatKwinClient(kwinClient);
40 | }
41 | });
42 | });
43 | return manager;
44 | }
45 |
46 | private static createWindowRuleRegexes(windowRules: WindowRule[]) {
47 | const floatRegexes: string[] = [];
48 | const tileRegexes: string[] = [];
49 | const followCaptionRegexes: string[] = [];
50 | for (const windowRule of windowRules) {
51 | const ruleClass = WindowRuleEnforcer.parseRegex(windowRule.class);
52 | const ruleCaption = WindowRuleEnforcer.parseRegex(windowRule.caption);
53 | const ruleString = ClientMatcher.getRuleString(
54 | WindowRuleEnforcer.wrapParens(ruleClass),
55 | WindowRuleEnforcer.wrapParens(ruleCaption)
56 | );
57 |
58 | (windowRule.tile ? tileRegexes : floatRegexes).push(ruleString);
59 | if (ruleCaption !== ".*") {
60 | followCaptionRegexes.push(ruleClass);
61 | }
62 | }
63 |
64 | return [
65 | WindowRuleEnforcer.joinRegexes(floatRegexes),
66 | WindowRuleEnforcer.joinRegexes(tileRegexes),
67 | WindowRuleEnforcer.joinRegexes(followCaptionRegexes),
68 | ];
69 | }
70 |
71 | private static parseRegex(rawRule: string | undefined) {
72 | if (rawRule === undefined || rawRule === "" || rawRule === ".*") {
73 | return ".*";
74 | } else {
75 | return rawRule;
76 | }
77 | }
78 |
79 | private static joinRegexes(regexes: string[]) {
80 | if (regexes.length === 0) {
81 | return new RegExp("a^"); // match nothing
82 | }
83 |
84 | if (regexes.length === 1) {
85 | return new RegExp("^(" + regexes[0] + ")$");
86 | }
87 |
88 | const joinedRegexes = regexes.map(WindowRuleEnforcer.wrapParens).join("|");
89 | return new RegExp("^(" + joinedRegexes + ")$");
90 | }
91 |
92 | private static wrapParens(str: string) {
93 | return "(" + str + ")";
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/lib/utils/Delayer.ts:
--------------------------------------------------------------------------------
1 | class Delayer {
2 | private readonly timer: QmlTimer;
3 |
4 | constructor(delay: number, f: () => void) {
5 | this.timer = initQmlTimer();
6 | this.timer.interval = delay;
7 | this.timer.triggered.connect(f);
8 | }
9 |
10 | public run() {
11 | this.timer.restart();
12 | }
13 |
14 | public destroy() {
15 | this.timer.destroy();
16 | }
17 | }
18 |
19 | function initQmlTimer() {
20 | return Qt.createQmlObject(
21 | `import QtQuick 6.0
22 | Timer {}`,
23 | qmlBase
24 | ) as QmlTimer;
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/utils/Doer.ts:
--------------------------------------------------------------------------------
1 | class Doer {
2 | private nCalls: number;
3 |
4 | constructor() {
5 | this.nCalls = 0;
6 | }
7 |
8 | public do (f: () => void) {
9 | this.nCalls++;
10 | f();
11 | this.nCalls--;
12 | }
13 |
14 | public isDoing() {
15 | return this.nCalls > 0;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/utils/LinkedList.ts:
--------------------------------------------------------------------------------
1 | class LinkedList {
2 | private firstNode: LinkedList.Node|null;
3 | private lastNode: LinkedList.Node|null;
4 | private readonly itemMap: Map>;
5 |
6 | constructor() {
7 | this.firstNode = null;
8 | this.lastNode = null;
9 | this.itemMap = new Map();
10 | }
11 |
12 | private getNode(item: T) {
13 | const node = this.itemMap.get(item);
14 | if (node === undefined) {
15 | throw new Error("item not in list");
16 | }
17 | return node;
18 | }
19 |
20 | public insertBefore(item: T, nextItem: T) {
21 | const nextNode = this.getNode(nextItem);
22 | this.insert(item, nextNode.prev, nextNode);
23 | }
24 |
25 | public insertAfter(item: T, prevItem: T) {
26 | const prevNode = this.getNode(prevItem);
27 | this.insert(item, prevNode, prevNode.next);
28 | }
29 |
30 | public insertStart(item: T) {
31 | this.insert(item, null, this.firstNode);
32 | }
33 |
34 | public insertEnd(item: T) {
35 | this.insert(item, this.lastNode, null);
36 | }
37 |
38 | private insert(item: T, prevNode: LinkedList.Node|null, nextNode: LinkedList.Node|null) {
39 | const node = new LinkedList.Node(item);
40 | this.itemMap.set(item, node);
41 | this.insertNode(node, prevNode, nextNode);
42 | }
43 |
44 | private insertNode(node: LinkedList.Node, prevNode: LinkedList.Node|null, nextNode: LinkedList.Node|null) {
45 | node.prev = prevNode;
46 | node.next = nextNode;
47 | if (nextNode !== null) {
48 | console.assert(nextNode.prev === prevNode);
49 | nextNode.prev = node;
50 | }
51 | if (prevNode !== null) {
52 | console.assert(prevNode.next === nextNode);
53 | prevNode.next = node;
54 | }
55 | if (this.firstNode === nextNode) {
56 | this.firstNode = node;
57 | }
58 | if (this.lastNode === prevNode) {
59 | this.lastNode = node;
60 | }
61 | }
62 |
63 | public getPrev(item: T) {
64 | const prevNode = this.getNode(item).prev;
65 | return prevNode === null ? null : prevNode.item;
66 | }
67 |
68 | public getNext(item: T) {
69 | const nextNode = this.getNode(item).next;
70 | return nextNode === null ? null : nextNode.item;
71 | }
72 |
73 | public getFirst() {
74 | if (this.firstNode === null) {
75 | return null;
76 | }
77 | return this.firstNode.item;
78 | }
79 |
80 | public getLast() {
81 | if (this.lastNode === null) {
82 | return null;
83 | }
84 | return this.lastNode.item;
85 | }
86 |
87 | public getItemAtIndex(index: number) {
88 | let node = this.firstNode;
89 | if (node === null) {
90 | return null;
91 | }
92 | for (let i = 0; i < index; i++) {
93 | node = node.next;
94 | if (node === null) {
95 | return null;
96 | }
97 | }
98 | return node.item;
99 | }
100 |
101 | public remove(item: T) {
102 | const node = this.getNode(item);
103 | this.itemMap.delete(item);
104 | this.removeNode(node);
105 | }
106 |
107 | private removeNode(node: LinkedList.Node) {
108 | const prevNode = node.prev;
109 | const nextNode = node.next;
110 | if (prevNode !== null) {
111 | prevNode.next = nextNode;
112 | }
113 | if (nextNode !== null) {
114 | nextNode.prev = prevNode;
115 | }
116 | if (this.firstNode === node) {
117 | this.firstNode = nextNode;
118 | }
119 | if (this.lastNode === node) {
120 | this.lastNode = prevNode;
121 | }
122 | }
123 |
124 | public contains(item: T) {
125 | return this.itemMap.has(item);
126 | }
127 |
128 | private swap(node0: LinkedList.Node, node1: LinkedList.Node) {
129 | console.assert(node0.next === node1 && node1.prev === node0);
130 | const prevNode = node0.prev;
131 | const nextNode = node1.next;
132 |
133 | if (prevNode !== null) {
134 | prevNode.next = node1;
135 | }
136 | node1.next = node0;
137 | node0.next = nextNode;
138 |
139 | if (nextNode !== null) {
140 | nextNode.prev = node0;
141 | }
142 | node0.prev = node1;
143 | node1.prev = prevNode;
144 |
145 | if (this.firstNode === node0) {
146 | this.firstNode = node1;
147 | }
148 | if (this.lastNode === node1) {
149 | this.lastNode = node0;
150 | }
151 | }
152 |
153 | public move(item: T, prevItem: T|null) {
154 | const node = this.getNode(item);
155 | this.removeNode(node);
156 | if (prevItem === null) {
157 | this.insertNode(node, null, this.firstNode);
158 | } else {
159 | const prevNode = this.getNode(prevItem);
160 | this.insertNode(node, prevNode, prevNode.next);
161 | }
162 | }
163 |
164 | public moveBack(item: T) {
165 | const node = this.getNode(item);
166 | if (node.prev !== null) {
167 | console.assert(node !== this.firstNode);
168 | this.swap(node.prev, node);
169 | }
170 | }
171 |
172 | public moveForward(item: T) {
173 | const node = this.getNode(item);
174 | if (node.next !== null) {
175 | console.assert(node !== this.lastNode);
176 | this.swap(node, node.next);
177 | }
178 | }
179 |
180 | public length() {
181 | return this.itemMap.size;
182 | }
183 |
184 | public *iterator() {
185 | for (let node = this.firstNode; node !== null; node = node.next) {
186 | yield node.item;
187 | }
188 | }
189 |
190 | public *iteratorFrom(startItem: T) {
191 | for (let node: LinkedList.Node|null = this.getNode(startItem); node !== null; node = node.next) {
192 | yield node.item;
193 | }
194 | }
195 | }
196 |
197 | namespace LinkedList {
198 | // TODO (optimization): reuse nodes
199 | export class Node {
200 | public readonly item: T;
201 | public prev: Node | null;
202 | public next: Node | null;
203 |
204 | constructor(item: T) {
205 | this.item = item;
206 | this.prev = null;
207 | this.next = null;
208 | }
209 | }
210 | }
211 |
--------------------------------------------------------------------------------
/src/lib/utils/RateLimiter.ts:
--------------------------------------------------------------------------------
1 | class RateLimiter {
2 | private i = 0;
3 | private intervalStart = 0;
4 |
5 | constructor(
6 | private readonly n: number,
7 | private readonly intervalMs: number,
8 | ) {}
9 |
10 | public acquire() {
11 | const now = Date.now();
12 | if (now - this.intervalStart >= this.intervalMs) {
13 | this.i = 0;
14 | this.intervalStart = now;
15 | }
16 |
17 | if (this.i < this.n) {
18 | this.i++;
19 | return true;
20 | } else {
21 | return false;
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/lib/utils/ShortcutAction.ts:
--------------------------------------------------------------------------------
1 | class ShortcutAction {
2 | private readonly shortcutHandler: ShortcutHandler;
3 |
4 | constructor(keyBinding: ShortcutAction.KeyBinding, f: () => void) {
5 | this.shortcutHandler = ShortcutAction.initShortcutHandler(keyBinding);
6 | this.shortcutHandler.activated.connect(f);
7 | }
8 |
9 | public destroy() {
10 | this.shortcutHandler.destroy();
11 | }
12 |
13 | private static initShortcutHandler(keyBinding: ShortcutAction.KeyBinding) {
14 | const sequenceLine = keyBinding.defaultKeySequence !== undefined ?
15 | ` sequence: "${keyBinding.defaultKeySequence}";
16 | ` :
17 | "";
18 |
19 | return Qt.createQmlObject(
20 | `import QtQuick 6.0
21 | import org.kde.kwin 3.0
22 | ShortcutHandler {
23 | name: "karousel-${keyBinding.name}";
24 | text: "Karousel: ${keyBinding.description}";
25 | ${sequenceLine}}`,
26 | qmlBase,
27 | ) as ShortcutHandler;
28 | }
29 | }
30 |
31 | namespace ShortcutAction {
32 | export interface KeyBinding {
33 | name: string;
34 | description: string;
35 | defaultKeySequence?: string;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/lib/utils/SignalManager.ts:
--------------------------------------------------------------------------------
1 | class SignalManager {
2 | private connections: { signal: QSignal, handler: (...args: any) => void }[];
3 |
4 | constructor() {
5 | this.connections = [];
6 | }
7 |
8 | public connect(signal: QSignal, handler: (...args: [...T]) => void) {
9 | signal.connect(handler);
10 | this.connections.push({ signal: signal, handler: handler });
11 | }
12 |
13 | public destroy() {
14 | for (const connection of this.connections) {
15 | connection.signal.disconnect(connection.handler);
16 | }
17 | this.connections = [];
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/utils/collections.ts:
--------------------------------------------------------------------------------
1 | function union(array0: T[], array1: T[]) {
2 | const set = new Set([...array0, ...array1]);
3 | return [...set];
4 | }
5 |
6 | function uniq(sortedArray: any[]) {
7 | const filtered = [];
8 | let lastItem;
9 | for (const item of sortedArray) {
10 | if (item !== lastItem) {
11 | filtered.push(item);
12 | lastItem = item;
13 | }
14 | }
15 | return filtered;
16 | }
17 |
18 | function mapGetOrInit(map: Map, key: K, defaultItem: V) {
19 | const item = map.get(key);
20 | if (item !== undefined) {
21 | return item;
22 | } else {
23 | map.set(key, defaultItem);
24 | return defaultItem;
25 | }
26 | }
27 |
28 | function findMinPositive(items: T[], evaluate: (item: T) => number) {
29 | let bestScore = Infinity;
30 | let bestItem = undefined;
31 | for (const item of items) {
32 | const score = evaluate(item);
33 | if (score > 0 && score < bestScore) {
34 | bestScore = score;
35 | bestItem = item;
36 | }
37 | }
38 | return bestItem;
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/utils/fillSpace.ts:
--------------------------------------------------------------------------------
1 | function fillSpace(availableSpace: number, items: { min: number, max: number }[]) {
2 | if (items.length === 0) {
3 | return [];
4 | }
5 |
6 | const middleSize = findMiddleSize(availableSpace, items);
7 | const sizes = items.map(item => clamp(middleSize, item.min, item.max));
8 | if (middleSize !== Math.floor(availableSpace / items.length)) {
9 | distributeRemainder(availableSpace, middleSize, sizes, items);
10 | }
11 | return sizes;
12 |
13 | function findMiddleSize(availableSpace: number, items: { min: number, max: number }[]) {
14 | const ranges = buildRanges(items);
15 | let requiredSpace = items.reduce((acc, item) => acc + item.min, 0);
16 | for (const range of ranges) {
17 | const rangeSize = range.end - range.start;
18 | const maxRequiredSpaceDelta = rangeSize * range.n;
19 | if (requiredSpace + maxRequiredSpaceDelta >= availableSpace) {
20 | const positionInRange = (availableSpace - requiredSpace) / maxRequiredSpaceDelta;
21 | return Math.floor(range.start + rangeSize * positionInRange);
22 | }
23 | requiredSpace += maxRequiredSpaceDelta;
24 | }
25 | return ranges[ranges.length-1].end;
26 | }
27 |
28 | function buildRanges(items: { min: number, max: number }[]) {
29 | const fenceposts = extractFenceposts(items);
30 | if (fenceposts.length === 1) {
31 | return [{
32 | start: fenceposts[0].value,
33 | end: fenceposts[0].value,
34 | n: items.length,
35 | }];
36 | }
37 |
38 | const ranges: Range[] = [];
39 | let n = 0;
40 | for (let i = 1; i < fenceposts.length; i++) {
41 | const startFencepost = fenceposts[i-1];
42 | const endFencepost = fenceposts[i];
43 | n = n - startFencepost.nMax + startFencepost.nMin;
44 | ranges.push({
45 | start: startFencepost.value,
46 | end: endFencepost.value,
47 | n: n,
48 | });
49 | }
50 | return ranges;
51 | }
52 |
53 | function extractFenceposts(items: { min: number, max: number }[]) {
54 | const fenceposts = new Map();
55 | for (const item of items) {
56 | mapGetOrInit(fenceposts, item.min, { value: item.min, nMin: 0, nMax: 0 }).nMin++;
57 | mapGetOrInit(fenceposts, item.max, { value: item.max, nMin: 0, nMax: 0 }).nMax++;
58 | }
59 |
60 | const array = Array.from(fenceposts.values());
61 | array.sort((a, b) => a.value - b.value);
62 | return array;
63 | }
64 |
65 | function distributeRemainder(availableSpace: number, middleSize: number, sizes: number[], constraints: { max: number }[]) {
66 | const indexes = Array.from(sizes.keys())
67 | .filter(i => sizes[i] === middleSize);
68 | indexes.sort((a, b) => constraints[a].max - constraints[b].max);
69 |
70 | const requiredSpace = sum(...sizes);
71 | let remainder = availableSpace - requiredSpace;
72 | let n = indexes.length;
73 | for (const i of indexes) {
74 | if (remainder <= 0) {
75 | break;
76 | }
77 | const enlargable = constraints[i].max - sizes[i];
78 | if (enlargable > 0) {
79 | const enlarge = Math.min(enlargable, Math.ceil(remainder / n));
80 | sizes[i] += enlarge;
81 | remainder -= enlarge;
82 | }
83 | n--;
84 | }
85 | }
86 |
87 | interface Range {
88 | start: number,
89 | end: number,
90 | n: number,
91 | }
92 |
93 | interface Fencepost {
94 | value: number,
95 | nMin: number,
96 | nMax: number,
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/lib/utils/functions.ts:
--------------------------------------------------------------------------------
1 | interface Function {
2 | partial(
3 | this: (...args: [...H, ...T]) => R,
4 | ...head: H
5 | ) : (...tail: T) => R;
6 | }
7 |
8 | Function.prototype.partial = function(...head: H) {
9 | return (...tail: T) => this(...head, ...tail);
10 | };
11 |
--------------------------------------------------------------------------------
/src/lib/utils/log.ts:
--------------------------------------------------------------------------------
1 | function log(...args: any[]) {
2 | console.log("Karousel:", ...args);
3 | }
4 |
--------------------------------------------------------------------------------
/src/lib/utils/math.ts:
--------------------------------------------------------------------------------
1 | function clamp(value: number, min: number, max: number) {
2 | if (value < min) {
3 | return min;
4 | }
5 | if (value > max) {
6 | return max;
7 | }
8 | return value;
9 | }
10 |
11 | function sum(...list: number[]) {
12 | return list.reduce((acc, val) => acc + val);
13 | }
14 |
15 | function rectEquals(a: QmlRect, b: QmlRect) {
16 | return a.x === b.x &&
17 | a.y === b.y &&
18 | a.width === b.width &&
19 | a.height === b.height;
20 | }
21 |
22 | function pointEquals(a: QmlPoint, b: QmlPoint) {
23 | return a.x === b.x &&
24 | a.y === b.y;
25 | }
26 |
27 | function rectContainsPoint(rect: QmlRect, point: QmlPoint) {
28 | return rect.left <= point.x &&
29 | rect.right >= point.x &&
30 | rect.top <= point.y &&
31 | rect.bottom >= point.y;
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/utils/strings.ts:
--------------------------------------------------------------------------------
1 | function applyMacro(base: string, value: string) {
2 | return base.replace("{}", String(value));
3 | }
4 |
--------------------------------------------------------------------------------
/src/lib/workspace.ts:
--------------------------------------------------------------------------------
1 | function initWorkspaceSignalHandlers(world: World) {
2 | const manager = new SignalManager();
3 |
4 | manager.connect(Workspace.windowAdded, (kwinClient: KwinClient) => {
5 | world.do((clientManager, desktopManager) => {
6 | clientManager.addClient(kwinClient);
7 | });
8 | });
9 |
10 | manager.connect(Workspace.windowRemoved, (kwinClient: KwinClient) => {
11 | world.do((clientManager, desktopManager) => {
12 | clientManager.removeClient(kwinClient, true);
13 | });
14 | });
15 |
16 | manager.connect(Workspace.windowActivated, (kwinClient: KwinClient|null) => {
17 | if (kwinClient === null) {
18 | return;
19 | }
20 | world.do((clientManager, desktopManager) => {
21 | clientManager.onClientFocused(kwinClient);
22 | });
23 | });
24 |
25 | manager.connect(Workspace.currentDesktopChanged, () => {
26 | world.do(() => {}); // re-arrange desktop
27 | });
28 |
29 | manager.connect(Workspace.currentActivityChanged, () => {
30 | world.do(() => {}); // re-arrange desktop
31 | });
32 |
33 | manager.connect(Workspace.screensChanged, () => {
34 | world.do((clientManager, desktopManager) => {
35 | desktopManager.selectScreen(Workspace.activeScreen);
36 | });
37 | });
38 |
39 | manager.connect(Workspace.activitiesChanged, () => {
40 | world.do((clientManager, desktopManager) => {
41 | desktopManager.updateActivities();
42 | });
43 | });
44 |
45 | manager.connect(Workspace.desktopsChanged, () => {
46 | world.do((clientManager, desktopManager) => {
47 | desktopManager.updateDesktops();
48 | });
49 | });
50 |
51 | manager.connect(Workspace.virtualScreenSizeChanged, () => {
52 | world.onScreenResized();
53 | });
54 |
55 | return manager;
56 | }
57 |
--------------------------------------------------------------------------------
/src/lib/world/ClientManager.ts:
--------------------------------------------------------------------------------
1 | class ClientManager {
2 | private readonly config: ClientManager.Config;
3 | private readonly clientMap: Map;
4 | private lastFocusedClient: KwinClient|null;
5 | private readonly windowRuleEnforcer: WindowRuleEnforcer;
6 |
7 | constructor(
8 | config: Config,
9 | private readonly world: World,
10 | private readonly desktopManager: DesktopManager,
11 | private readonly pinManager: PinManager,
12 | ) {
13 | this.world = world;
14 | this.config = config;
15 | this.desktopManager = desktopManager;
16 | this.pinManager = pinManager;
17 | this.clientMap = new Map();
18 | this.lastFocusedClient = null;
19 |
20 | let parsedWindowRules: WindowRule[] = [];
21 | try {
22 | parsedWindowRules = JSON.parse(config.windowRules);
23 | } catch (error: any) {
24 | notificationInvalidWindowRules.sendEvent();
25 | log("failed to parse windowRules:", error);
26 | }
27 | this.windowRuleEnforcer = new WindowRuleEnforcer(parsedWindowRules);
28 | }
29 |
30 | public addClient(kwinClient: KwinClient) {
31 | console.assert(!this.hasClient(kwinClient));
32 |
33 | let constructState: (client: ClientWrapper) => ClientState.State;
34 | if (kwinClient.dock) {
35 | constructState = () => new ClientState.Docked(this.world, kwinClient);
36 | } else if (
37 | Clients.canTileEver(kwinClient) &&
38 | this.windowRuleEnforcer.shouldTile(kwinClient)
39 | ) {
40 | Clients.makeTileable(kwinClient);
41 | console.assert(Clients.canTileNow(kwinClient));
42 | const desktop = this.desktopManager.getDesktopForClient(kwinClient);
43 | console.assert(desktop !== undefined);
44 | constructState = (client: ClientWrapper) => new ClientState.Tiled(this.world, client, desktop!.grid);
45 | } else {
46 | constructState = (client: ClientWrapper) => new ClientState.Floating(this.world, client, this.config, false);
47 | }
48 |
49 | const client = new ClientWrapper(
50 | kwinClient,
51 | constructState,
52 | this.findTransientFor(kwinClient),
53 | this.windowRuleEnforcer.initClientSignalManager(this.world, kwinClient),
54 | );
55 | this.clientMap.set(kwinClient, client);
56 | }
57 |
58 | public removeClient(kwinClient: KwinClient, passFocus: boolean) {
59 | console.assert(this.hasClient(kwinClient));
60 | const client = this.clientMap.get(kwinClient);
61 | if (client === undefined) {
62 | return;
63 | }
64 | client.destroy(passFocus && kwinClient === this.lastFocusedClient);
65 | this.clientMap.delete(kwinClient);
66 | }
67 |
68 | private findTransientFor(kwinClient: KwinClient) {
69 | if (!kwinClient.transient || kwinClient.transientFor === null) {
70 | return null;
71 | }
72 |
73 | const transientFor = this.clientMap.get(kwinClient.transientFor);
74 | if (transientFor === undefined) {
75 | return null;
76 | }
77 |
78 | return transientFor;
79 | }
80 |
81 | public minimizeClient(kwinClient: KwinClient) {
82 | const client = this.clientMap.get(kwinClient);
83 | if (client === undefined) {
84 | return;
85 | }
86 | if (client.stateManager.getState() instanceof ClientState.Tiled) {
87 | client.stateManager.setState(
88 | () => new ClientState.TiledMinimized(this.world, client),
89 | kwinClient === this.lastFocusedClient,
90 | );
91 | }
92 | }
93 |
94 | public tileClient(client: ClientWrapper, grid: Grid) {
95 | if (client.stateManager.getState() instanceof ClientState.Tiled) {
96 | return;
97 | }
98 | client.stateManager.setState(() => new ClientState.Tiled(this.world, client, grid), false);
99 | }
100 |
101 | public floatClient(client: ClientWrapper) {
102 | if (client.stateManager.getState() instanceof ClientState.Floating) {
103 | return;
104 | }
105 | client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), false);
106 | }
107 |
108 | public tileKwinClient(kwinClient: KwinClient, grid: Grid) {
109 | const client = this.clientMap.get(kwinClient);
110 | if (client === undefined) {
111 | return;
112 | }
113 | this.tileClient(client, grid);
114 | }
115 |
116 | public floatKwinClient(kwinClient: KwinClient) {
117 | const client = this.clientMap.get(kwinClient);
118 | if (client === undefined) {
119 | return;
120 | }
121 | this.floatClient(client);
122 | }
123 |
124 | public pinClient(kwinClient: KwinClient) {
125 | const client = this.clientMap.get(kwinClient);
126 | if (client === undefined) {
127 | return;
128 | }
129 | if (client.getMaximizedMode() !== MaximizedMode.Unmaximized) {
130 | // the client is not really kwin-tiled, just maximized
131 | kwinClient.tile = null;
132 | return;
133 | }
134 | client.stateManager.setState(() => new ClientState.Pinned(this.world, this.pinManager, this.desktopManager, kwinClient, this.config), false);
135 | this.pinManager.addClient(kwinClient);
136 | for (const desktop of this.desktopManager.getDesktopsForClient(kwinClient)) {
137 | desktop.onPinsChanged();
138 | }
139 | }
140 |
141 | public unpinClient(kwinClient: KwinClient) {
142 | const client = this.clientMap.get(kwinClient);
143 | if (client === undefined) {
144 | return;
145 | }
146 | console.assert(client.stateManager.getState() instanceof ClientState.Pinned);
147 | client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, false), false);
148 | this.pinManager.removeClient(kwinClient);
149 | for (const desktop of this.desktopManager.getDesktopsForClient(kwinClient)) {
150 | desktop.onPinsChanged();
151 | }
152 | }
153 |
154 | public toggleFloatingClient(kwinClient: KwinClient) {
155 | const client = this.clientMap.get(kwinClient);
156 | if (client === undefined) {
157 | return;
158 | }
159 |
160 | const clientState = client.stateManager.getState();
161 | if ((clientState instanceof ClientState.Floating || clientState instanceof ClientState.Pinned) && Clients.canTileEver(kwinClient)) {
162 | Clients.makeTileable(kwinClient);
163 | const desktop = this.desktopManager.getDesktopForClient(kwinClient);
164 | if (desktop === undefined) {
165 | return;
166 | }
167 | client.stateManager.setState(() => new ClientState.Tiled(this.world, client, desktop.grid), false);
168 | } else if (clientState instanceof ClientState.Tiled) {
169 | client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), false);
170 | }
171 | }
172 |
173 | public hasClient(kwinClient: KwinClient) {
174 | return this.clientMap.has(kwinClient);
175 | }
176 |
177 | public onClientFocused(kwinClient: KwinClient) {
178 | this.lastFocusedClient = kwinClient;
179 | const window = this.findTiledWindow(kwinClient);
180 | if (window !== null) {
181 | window.onFocused();
182 | }
183 | }
184 |
185 | public findTiledWindow(kwinClient: KwinClient) {
186 | const client = this.clientMap.get(kwinClient);
187 | if (client === undefined) {
188 | return null;
189 | }
190 |
191 | return this.findTiledWindowOfClient(client);
192 | }
193 |
194 | private findTiledWindowOfClient(client: ClientWrapper): Window|null {
195 | const clientState = client.stateManager.getState();
196 | if (clientState instanceof ClientState.Tiled) {
197 | return clientState.window;
198 | } else if (client.transientFor !== null) {
199 | return this.findTiledWindowOfClient(client.transientFor);
200 | } else {
201 | return null;
202 | }
203 | }
204 |
205 | private removeAllClients() {
206 | for (const kwinClient of Array.from(this.clientMap.keys())) {
207 | this.removeClient(kwinClient, false);
208 | }
209 | }
210 |
211 | public destroy() {
212 | this.removeAllClients();
213 | }
214 | }
215 |
216 | namespace ClientManager {
217 | export interface Config {
218 | floatingKeepAbove: boolean;
219 | }
220 | }
221 |
--------------------------------------------------------------------------------
/src/lib/world/ClientWrapper.ts:
--------------------------------------------------------------------------------
1 | class ClientWrapper {
2 | public readonly stateManager: ClientState.Manager;
3 | private readonly transients: ClientWrapper[];
4 | private readonly signalManager: SignalManager;
5 | public preferredWidth: number;
6 | private maximizedMode: MaximizedMode | undefined;
7 | private readonly manipulatingGeometry: Doer;
8 | private lastPlacement: QmlRect | null; // workaround for issue #19
9 |
10 | constructor(
11 | public readonly kwinClient: KwinClient,
12 | constructInitialState: (client: ClientWrapper) => ClientState.State,
13 | public transientFor: ClientWrapper | null,
14 | private readonly rulesSignalManager: SignalManager | null,
15 | ) {
16 | this.kwinClient = kwinClient;
17 | this.transientFor = transientFor;
18 | this.transients = [];
19 | if (transientFor !== null) {
20 | transientFor.addTransient(this);
21 | }
22 | this.signalManager = ClientWrapper.initSignalManager(this);
23 | this.rulesSignalManager = rulesSignalManager;
24 | this.preferredWidth = kwinClient.frameGeometry.width;
25 | this.manipulatingGeometry = new Doer();
26 | this.lastPlacement = null;
27 | this.stateManager = new ClientState.Manager(constructInitialState(this));
28 | }
29 |
30 | public place(x: number, y: number, width: number, height: number) {
31 | this.manipulatingGeometry.do(() => {
32 | if (this.kwinClient.resize) {
33 | // window is being manually resized, prevent fighting with the user
34 | return;
35 | }
36 | this.lastPlacement = Qt.rect(x, y, width, height);
37 | this.kwinClient.frameGeometry = this.lastPlacement;
38 | if (this.kwinClient.frameGeometry !== this.lastPlacement) {
39 | // frameGeometry assignment failed. This sometimes happens on Wayland
40 | // when a window is off-screen, effectively making it stuck there.
41 | this.kwinClient.frameGeometry.x = x; // This makes it unstuck.
42 | this.kwinClient.frameGeometry = this.lastPlacement;
43 | }
44 | });
45 | }
46 |
47 | private moveTransient(dx: number, dy: number, kwinDesktops: KwinDesktop[]) {
48 | if (this.stateManager.getState() instanceof ClientState.Floating) {
49 | if (Clients.isOnOneOfVirtualDesktops(this.kwinClient, kwinDesktops)) {
50 | const frame = this.kwinClient.frameGeometry;
51 | this.kwinClient.frameGeometry = Qt.rect(
52 | frame.x + dx,
53 | frame.y + dy,
54 | frame.width,
55 | frame.height,
56 | );
57 | }
58 |
59 | for (const transient of this.transients) {
60 | transient.moveTransient(dx, dy, kwinDesktops);
61 | }
62 | }
63 | }
64 |
65 | public moveTransients(dx: number, dy: number) {
66 | for (const transient of this.transients) {
67 | transient.moveTransient(dx, dy, this.kwinClient.desktops);
68 | }
69 | }
70 |
71 | public focus() {
72 | Workspace.activeWindow = this.kwinClient;
73 | }
74 |
75 | public isFocused() {
76 | return Workspace.activeWindow === this.kwinClient;
77 | }
78 |
79 | public setMaximize(horizontally: boolean, vertically: boolean) {
80 | if (!this.kwinClient.maximizable) {
81 | this.maximizedMode = MaximizedMode.Unmaximized;
82 | return;
83 | }
84 |
85 | if (this.maximizedMode === undefined) {
86 | if (horizontally && vertically) {
87 | this.maximizedMode = MaximizedMode.Maximized;
88 | } else if (horizontally) {
89 | this.maximizedMode = MaximizedMode.Horizontally;
90 | } else if (vertically) {
91 | this.maximizedMode = MaximizedMode.Vertically;
92 | } else {
93 | this.maximizedMode = MaximizedMode.Unmaximized;
94 | }
95 | }
96 |
97 | this.manipulatingGeometry.do(() => {
98 | this.kwinClient.setMaximize(vertically, horizontally);
99 | });
100 | }
101 |
102 | public setFullScreen(fullScreen: boolean) {
103 | if (!this.kwinClient.fullScreenable) {
104 | return;
105 | }
106 |
107 | this.manipulatingGeometry.do(() => {
108 | this.kwinClient.fullScreen = fullScreen;
109 | });
110 | }
111 |
112 | public getMaximizedMode() {
113 | return this.maximizedMode;
114 | }
115 |
116 | public isManipulatingGeometry(newGeometry: QmlRect | null) {
117 | if (newGeometry !== null && newGeometry === this.lastPlacement) {
118 | return true;
119 | }
120 | return this.manipulatingGeometry.isDoing();
121 | }
122 |
123 | private addTransient(transient: ClientWrapper) {
124 | this.transients.push(transient);
125 | }
126 |
127 | private removeTransient(transient: ClientWrapper) {
128 | const i = this.transients.indexOf(transient);
129 | this.transients.splice(i, 1);
130 | }
131 |
132 | public ensureTransientsVisible(screenSize: QmlRect) {
133 | for (const transient of this.transients) {
134 | if (transient.stateManager.getState() instanceof ClientState.Floating) {
135 | transient.ensureVisible(screenSize);
136 | transient.ensureTransientsVisible(screenSize);
137 | }
138 | }
139 | }
140 |
141 | public ensureVisible(screenSize: QmlRect) {
142 | if (!Clients.isOnVirtualDesktop(this.kwinClient, Workspace.currentDesktop)) {
143 | return;
144 | }
145 | const frame = this.kwinClient.frameGeometry;
146 | if (frame.left < screenSize.left) {
147 | frame.x = screenSize.left;
148 | } else if (frame.right > screenSize.right) {
149 | frame.x = screenSize.right - frame.width;
150 | }
151 | }
152 |
153 | public destroy(passFocus: boolean) {
154 | this.stateManager.destroy(passFocus);
155 | this.signalManager.destroy();
156 | if (this.rulesSignalManager !== null) {
157 | this.rulesSignalManager.destroy();
158 | }
159 | if (this.transientFor !== null) {
160 | this.transientFor.removeTransient(this);
161 | }
162 | for (const transient of this.transients) {
163 | transient.transientFor = null;
164 | }
165 | }
166 |
167 | private static initSignalManager(client: ClientWrapper) {
168 | const manager = new SignalManager();
169 |
170 | manager.connect(client.kwinClient.maximizedAboutToChange, (maximizedMode: MaximizedMode) => {
171 | if (maximizedMode !== MaximizedMode.Unmaximized && client.kwinClient.tile !== null) {
172 | client.kwinClient.tile = null;
173 | }
174 | client.maximizedMode = maximizedMode;
175 | });
176 |
177 | return manager;
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/src/lib/world/Clients.ts:
--------------------------------------------------------------------------------
1 | namespace Clients {
2 | const prohibitedClasses = [
3 | "ksmserver-logout-greeter",
4 | "xwaylandvideobridge",
5 | ];
6 |
7 | export function canTileEver(kwinClient: KwinClient) {
8 | const shapeable = (kwinClient.moveable && kwinClient.resizeable) || kwinClient.fullScreen; // full-screen windows may become shapeable after exiting full-screen mode
9 | return shapeable &&
10 | !kwinClient.popupWindow &&
11 | !prohibitedClasses.includes(kwinClient.resourceClass);
12 | }
13 |
14 | export function canTileNow(kwinClient: KwinClient) {
15 | return canTileEver(kwinClient) &&
16 | !kwinClient.minimized &&
17 | kwinClient.desktops.length === 1 &&
18 | kwinClient.activities.length === 1;
19 | }
20 |
21 | export function makeTileable(kwinClient: KwinClient) {
22 | if (kwinClient.minimized) {
23 | kwinClient.minimized = false;
24 | }
25 | if (kwinClient.desktops.length !== 1) {
26 | kwinClient.desktops = [Workspace.currentDesktop];
27 | }
28 | if (kwinClient.activities.length !== 1) {
29 | kwinClient.activities = [Workspace.currentActivity];
30 | }
31 | }
32 |
33 | export function getKwinDesktopApprox(kwinClient: KwinClient) {
34 | switch (kwinClient.desktops.length) {
35 | case 0:
36 | return Workspace.currentDesktop;
37 | case 1:
38 | return kwinClient.desktops[0];
39 | default:
40 | if (kwinClient.desktops.includes(Workspace.currentDesktop)) {
41 | return Workspace.currentDesktop;
42 | } else {
43 | return kwinClient.desktops[0];
44 | }
45 | }
46 | }
47 |
48 | export function isFullScreenGeometry(kwinClient: KwinClient) {
49 | const fullScreenArea = Workspace.clientArea(ClientAreaOption.FullScreenArea, kwinClient.output, getKwinDesktopApprox(kwinClient));
50 | return kwinClient.clientGeometry.width === fullScreenArea.width &&
51 | kwinClient.clientGeometry.height === fullScreenArea.height;
52 | }
53 |
54 | export function isOnVirtualDesktop(kwinClient: KwinClient, kwinDesktop: KwinDesktop) {
55 | return kwinClient.desktops.length === 0 || kwinClient.desktops.includes(kwinDesktop);
56 | }
57 |
58 | export function isOnOneOfVirtualDesktops(kwinClient: KwinClient, kwinDesktops: KwinDesktop[]) {
59 | return kwinClient.desktops.length === 0 || kwinClient.desktops.some(d => kwinDesktops.includes(d));
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/lib/world/DesktopManager.ts:
--------------------------------------------------------------------------------
1 | class DesktopManager {
2 | private readonly desktops: Map; // key is activityId|desktopId
3 | private selectedScreen: Output;
4 | private kwinActivities: Set;
5 | private kwinDesktops: Set;
6 |
7 | constructor(
8 | private readonly pinManager: PinManager,
9 | private readonly config: Desktop.Config,
10 | public readonly layoutConfig: LayoutConfig,
11 | currentActivity: string,
12 | currentDesktop: KwinDesktop,
13 | ) {
14 | this.pinManager = pinManager;
15 | this.config = config;
16 | this.layoutConfig = layoutConfig;
17 | this.desktops = new Map();
18 | this.selectedScreen = Workspace.activeScreen;
19 | this.kwinActivities = new Set(Workspace.activities);
20 | this.kwinDesktops = new Set(Workspace.desktops);
21 | this.addDesktop(currentActivity, currentDesktop);
22 | }
23 |
24 | public getDesktop(activity: string, kwinDesktop: KwinDesktop) {
25 | const desktopKey = DesktopManager.getDesktopKey(activity, kwinDesktop);
26 | const desktop = this.desktops.get(desktopKey);
27 | if (desktop !== undefined) {
28 | return desktop;
29 | } else {
30 | return this.addDesktop(activity, kwinDesktop);
31 | }
32 | }
33 |
34 | public getCurrentDesktop() {
35 | return this.getDesktop(Workspace.currentActivity, Workspace.currentDesktop);
36 | }
37 |
38 | public getDesktopInCurrentActivity(kwinDesktop: KwinDesktop) {
39 | return this.getDesktop(Workspace.currentActivity, kwinDesktop);
40 | }
41 |
42 | public getDesktopForClient(kwinClient: KwinClient) {
43 | if (kwinClient.activities.length !== 1 || kwinClient.desktops.length !== 1) {
44 | return undefined;
45 | }
46 | return this.getDesktop(kwinClient.activities[0], kwinClient.desktops[0]);
47 | }
48 |
49 | private addDesktop(activity: string, kwinDesktop: KwinDesktop) {
50 | const desktopKey = DesktopManager.getDesktopKey(activity, kwinDesktop);
51 | const desktop = new Desktop(
52 | kwinDesktop,
53 | this.pinManager,
54 | this.config,
55 | () => this.selectedScreen,
56 | this.layoutConfig,
57 | );
58 | this.desktops.set(desktopKey, desktop);
59 | return desktop;
60 | }
61 |
62 | private static getDesktopKey(activity: string, kwinDesktop: KwinDesktop) {
63 | return activity + "|" + kwinDesktop.id;
64 | }
65 |
66 | public updateActivities() {
67 | const newActivities = new Set(Workspace.activities);
68 | for (const activity of this.kwinActivities) {
69 | if (!newActivities.has(activity)) {
70 | this.removeActivity(activity);
71 | }
72 | }
73 | this.kwinActivities = newActivities;
74 | }
75 |
76 | public updateDesktops() {
77 | const newDesktops = new Set(Workspace.desktops);
78 | for (const desktop of this.kwinDesktops) {
79 | if (!newDesktops.has(desktop)) {
80 | this.removeKwinDesktop(desktop);
81 | }
82 | }
83 | this.kwinDesktops = newDesktops;
84 | }
85 |
86 | public selectScreen(screen: Output) {
87 | this.selectedScreen = screen;
88 | }
89 |
90 | private removeActivity(activity: string) {
91 | for (const kwinDesktop of this.kwinDesktops) {
92 | this.destroyDesktop(activity, kwinDesktop);
93 | }
94 | }
95 |
96 | private removeKwinDesktop(kwinDesktop: KwinDesktop) {
97 | for (const activity of this.kwinActivities) {
98 | this.destroyDesktop(activity, kwinDesktop);
99 | }
100 | }
101 |
102 | private destroyDesktop(activity: string, kwinDesktop: KwinDesktop) {
103 | const desktopKey = DesktopManager.getDesktopKey(activity, kwinDesktop);
104 | const desktop = this.desktops.get(desktopKey);
105 | if (desktop !== undefined) {
106 | desktop.destroy();
107 | this.desktops.delete(desktopKey);
108 | }
109 | }
110 |
111 | public destroy() {
112 | for (const desktop of this.desktops.values()) {
113 | desktop.destroy();
114 | }
115 | }
116 |
117 | public *getAllDesktops() {
118 | for (const desktop of this.desktops.values()) {
119 | yield desktop;
120 | }
121 | }
122 |
123 | public getDesktopsForClient(kwinClient: KwinClient) {
124 | const desktops = this.getDesktops(kwinClient.activities, kwinClient.desktops); // workaround for QTBUG-109880
125 | return desktops;
126 | }
127 |
128 | // empty array means all
129 | public *getDesktops(activities: string[], kwinDesktops: KwinDesktop[]) {
130 | const matchedActivities = activities.length > 0 ? activities : this.kwinActivities.keys();
131 | const matchedDesktops = kwinDesktops.length > 0 ? kwinDesktops : this.kwinDesktops.keys();
132 | for (const matchedActivity of matchedActivities) {
133 | for (const matchedDesktop of matchedDesktops) {
134 | const desktopKey = DesktopManager.getDesktopKey(matchedActivity, matchedDesktop);
135 | const desktop = this.desktops.get(desktopKey);
136 | if (desktop !== undefined) {
137 | yield desktop;
138 | }
139 | }
140 | }
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/lib/world/PinManager.ts:
--------------------------------------------------------------------------------
1 | class PinManager {
2 | private readonly pinnedClients: Set;
3 |
4 | constructor() {
5 | this.pinnedClients = new Set();
6 | }
7 |
8 | public addClient(kwinClient: KwinClient) {
9 | this.pinnedClients.add(kwinClient);
10 | }
11 |
12 | public removeClient(kwinClient: KwinClient) {
13 | this.pinnedClients.delete(kwinClient);
14 | }
15 |
16 | public getAvailableSpace(kwinDesktop: KwinDesktop, screen: QmlRect) {
17 | const baseLot = new PinManager.Lot(screen.top, screen.bottom, screen.left, screen.right);
18 | let lots = [baseLot];
19 | for (const client of this.pinnedClients) {
20 | if (!Clients.isOnVirtualDesktop(client, kwinDesktop) || client.minimized) {
21 | continue;
22 | }
23 |
24 | const newLots: PinManager.Lot[] = [];
25 | for (const lot of lots) {
26 | lot.split(newLots, client.frameGeometry);
27 | }
28 | lots = newLots;
29 | }
30 |
31 | let largestLot = baseLot;
32 | let largestArea = 0;
33 | for (const lot of lots) {
34 | const area = lot.area();
35 | if (area > largestArea) {
36 | largestArea = area;
37 | largestLot = lot;
38 | }
39 | }
40 | return largestLot;
41 | }
42 | }
43 |
44 | namespace PinManager {
45 | export class Lot {
46 | private static readonly minWidth = 200;
47 | private static readonly minHeight = 200;
48 |
49 | constructor(
50 | public readonly top: number,
51 | public readonly bottom: number,
52 | public readonly left: number,
53 | public readonly right: number,
54 | ) {}
55 |
56 | public split(destLots: Lot[], obstacle: QmlRect) {
57 | if (!this.contains(obstacle)) {
58 | // don't split
59 | destLots.push(this);
60 | return;
61 | }
62 |
63 | if (obstacle.top - this.top >= Lot.minHeight) {
64 | destLots.push(new Lot(this.top, obstacle.top, this.left, this.right));
65 | }
66 | if (this.bottom - obstacle.bottom >= Lot.minHeight) {
67 | destLots.push(new Lot(obstacle.bottom, this.bottom, this.left, this.right));
68 | }
69 | if (obstacle.left - this.left >= Lot.minWidth) {
70 | destLots.push(new Lot(this.top, this.bottom, this.left, obstacle.left));
71 | }
72 | if (this.right - obstacle.right >= Lot.minWidth) {
73 | destLots.push(new Lot(this.top, this.bottom, obstacle.right, this.right));
74 | }
75 | }
76 |
77 | private contains(obstacle: QmlRect) {
78 | return obstacle.right > this.left && obstacle.left < this.right &&
79 | obstacle.bottom > this.top && obstacle.top < this.bottom;
80 | }
81 |
82 | public area() {
83 | return (this.bottom - this.top) * (this.right - this.left);
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/lib/world/World.ts:
--------------------------------------------------------------------------------
1 | class World {
2 | private readonly desktopManager: DesktopManager;
3 | private readonly clientManager: ClientManager;
4 | private readonly pinManager: PinManager;
5 | private readonly workspaceSignalManager: SignalManager;
6 | private readonly shortcutActions: ShortcutAction[];
7 | private readonly screenResizedDelayer: Delayer;
8 | private readonly cursorFollowsFocus: boolean;
9 |
10 | constructor(config: Config) {
11 | this.workspaceSignalManager = initWorkspaceSignalHandlers(this);
12 | this.cursorFollowsFocus = config.cursorFollowsFocus;
13 |
14 | let presetWidths = {
15 | next: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth,
16 | prev: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth,
17 | getWidths: (minWidth: number, maxWidth: number): number[] => [],
18 | };
19 | try {
20 | presetWidths = new PresetWidths(config.presetWidths, config.gapsInnerHorizontal);
21 | } catch (error: any) {
22 | notificationInvalidPresetWidths.sendEvent();
23 | log("failed to parse presetWidths:", error);
24 | }
25 |
26 | this.shortcutActions = registerKeyBindings(this, {
27 | manualScrollStep: config.manualScrollStep,
28 | presetWidths: presetWidths,
29 | columnResizer: config.scrollingCentered ? new RawResizer(presetWidths) : new ContextualResizer(presetWidths),
30 | });
31 |
32 | this.screenResizedDelayer = new Delayer(1000, () => {
33 | // this delay ensures that docks are taken into account by `Workspace.clientArea`
34 | for (const desktop of this.desktopManager.getAllDesktops()) {
35 | desktop.onLayoutChanged();
36 | }
37 | this.update();
38 | });
39 |
40 | this.pinManager = new PinManager();
41 |
42 | const layoutConfig = {
43 | gapsInnerHorizontal: config.gapsInnerHorizontal,
44 | gapsInnerVertical: config.gapsInnerVertical,
45 | stackOffsetX: config.stackOffsetX,
46 | stackOffsetY: config.stackOffsetY,
47 | offScreenOpacity: config.offScreenOpacity / 100.0,
48 | stackColumnsByDefault: config.stackColumnsByDefault,
49 | resizeNeighborColumn: config.resizeNeighborColumn,
50 | reMaximize: config.reMaximize,
51 | skipSwitcher: config.skipSwitcher,
52 | tiledKeepBelow: config.tiledKeepBelow,
53 | maximizedKeepAbove: config.floatingKeepAbove,
54 | untileOnDrag: config.untileOnDrag,
55 | };
56 |
57 | this.desktopManager = new DesktopManager(
58 | this.pinManager,
59 | {
60 | marginTop: config.gapsOuterTop,
61 | marginBottom: config.gapsOuterBottom,
62 | marginLeft: config.gapsOuterLeft,
63 | marginRight: config.gapsOuterRight,
64 | scroller: World.createScroller(config),
65 | clamper: config.scrollingLazy ? new EdgeClamper() : new CenterClamper(),
66 | gestureScroll: config.gestureScroll,
67 | gestureScrollInvert: config.gestureScrollInvert,
68 | gestureScrollStep: config.gestureScrollStep,
69 | },
70 | layoutConfig,
71 | Workspace.currentActivity,
72 | Workspace.currentDesktop,
73 | );
74 | this.clientManager = new ClientManager(config, this, this.desktopManager, this.pinManager);
75 | this.addExistingClients();
76 | this.update();
77 | }
78 |
79 | private static createScroller(config: Config) {
80 | if (config.scrollingLazy) {
81 | return new LazyScroller();
82 | } else if (config.scrollingCentered) {
83 | return new CenteredScroller();
84 | } else if (config.scrollingGrouped) {
85 | return new GroupedScroller();
86 | } else {
87 | log("No scrolling mode selected, using default");
88 | return new LazyScroller();
89 | }
90 | }
91 |
92 | private addExistingClients() {
93 | for (const kwinClient of Workspace.windows) {
94 | this.clientManager.addClient(kwinClient);
95 | }
96 | }
97 |
98 | private update() {
99 | this.desktopManager.getCurrentDesktop().arrange();
100 | this.moveCursorToFocus();
101 | }
102 |
103 | private moveCursorToFocus() {
104 | if (this.cursorFollowsFocus && Workspace.activeWindow !== null) {
105 | const cursorAlreadyInFocus = rectContainsPoint(Workspace.activeWindow.frameGeometry, Workspace.cursorPos);
106 | if (cursorAlreadyInFocus) {
107 | return;
108 | }
109 | moveCursorToFocus.call();
110 | }
111 | }
112 |
113 | public do(f: (clientManager: ClientManager, desktopManager: DesktopManager) => void) {
114 | f(this.clientManager, this.desktopManager);
115 | this.update();
116 | }
117 |
118 | public doIfTiled(
119 | kwinClient: KwinClient,
120 | f: (clientManager: ClientManager, desktopManager: DesktopManager, window: Window, column: Column, grid: Grid) => void,
121 | ) {
122 | const window = this.clientManager.findTiledWindow(kwinClient);
123 | if (window === null) {
124 | return;
125 | }
126 | const column = window.column;
127 | const grid = column.grid;
128 | f(this.clientManager, this.desktopManager, window, column, grid);
129 | this.update();
130 | }
131 |
132 | public doIfTiledFocused(
133 | f: (clientManager: ClientManager, desktopManager: DesktopManager, window: Window, column: Column, grid: Grid) => void,
134 | ) {
135 | if (Workspace.activeWindow === null) {
136 | return;
137 | }
138 | this.doIfTiled(Workspace.activeWindow, f);
139 | }
140 |
141 | public gestureScroll(amount: number) {
142 | this.do((clientManager, desktopManager) => desktopManager.getCurrentDesktop().gestureScroll(amount));
143 | }
144 |
145 | public gestureScrollFinish() {
146 | this.do((clientManager, desktopManager) => desktopManager.getCurrentDesktop().gestureScrollFinish());
147 | }
148 |
149 | public destroy() {
150 | this.workspaceSignalManager.destroy();
151 | for (const shortcutAction of this.shortcutActions) {
152 | shortcutAction.destroy();
153 | }
154 | this.clientManager.destroy();
155 | this.desktopManager.destroy();
156 | }
157 |
158 | public onScreenResized() {
159 | this.screenResizedDelayer.run();
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/src/lib/world/clientState/Docked.ts:
--------------------------------------------------------------------------------
1 | namespace ClientState {
2 | export class Docked implements State {
3 | private readonly world: World;
4 | private readonly signalManager: SignalManager;
5 |
6 | constructor(world: World, kwinClient: KwinClient) {
7 | this.world = world;
8 | this.signalManager = Docked.initSignalManager(world, kwinClient);
9 | world.onScreenResized();
10 | }
11 |
12 | public destroy(passFocus: boolean) {
13 | this.signalManager.destroy();
14 | this.world.onScreenResized();
15 | }
16 |
17 | private static initSignalManager(world: World, kwinClient: KwinClient) {
18 | const manager = new SignalManager();
19 | manager.connect(kwinClient.frameGeometryChanged, () => {
20 | world.onScreenResized();
21 | });
22 | return manager;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/world/clientState/Floating.ts:
--------------------------------------------------------------------------------
1 | namespace ClientState {
2 | export class Floating implements State {
3 | private readonly client: ClientWrapper;
4 | private readonly config: ClientManager.Config;
5 | private readonly signalManager: SignalManager;
6 |
7 | constructor(world: World, client: ClientWrapper, config: ClientManager.Config, limitHeight: boolean) {
8 | this.client = client;
9 | this.config = config;
10 | if (config.floatingKeepAbove) {
11 | client.kwinClient.keepAbove = true;
12 | }
13 | if (limitHeight && client.kwinClient.tile === null) {
14 | Floating.limitHeight(client);
15 | }
16 | this.signalManager = Floating.initSignalManager(world, client.kwinClient);
17 | }
18 |
19 | public destroy(passFocus: boolean) {
20 | this.signalManager.destroy();
21 | }
22 |
23 | // TODO: move to `Tiled.restoreClientAfterTiling`
24 | private static limitHeight(client: ClientWrapper) {
25 | const placementArea = Workspace.clientArea(
26 | ClientAreaOption.PlacementArea,
27 | client.kwinClient.output,
28 | Clients.getKwinDesktopApprox(client.kwinClient),
29 | );
30 | const clientRect = client.kwinClient.frameGeometry;
31 | const width = client.preferredWidth;
32 | client.place(
33 | clientRect.x,
34 | clientRect.y,
35 | width,
36 | Math.min(clientRect.height, Math.round(placementArea.height / 2)),
37 | );
38 | }
39 |
40 | private static initSignalManager(world: World, kwinClient: KwinClient) {
41 | const manager = new SignalManager();
42 |
43 | manager.connect(kwinClient.tileChanged, () => {
44 | // on X11, this fires after `frameGeometryChanged`
45 | if (kwinClient.tile !== null) {
46 | world.do((clientManager, desktopManager) => {
47 | clientManager.pinClient(kwinClient);
48 | });
49 | }
50 | });
51 |
52 | manager.connect(kwinClient.frameGeometryChanged, () => {
53 | // on Wayland, this fires after `tileChanged`
54 | if (kwinClient.tile !== null) {
55 | world.do((clientManager, desktopManager) => {
56 | clientManager.pinClient(kwinClient);
57 | });
58 | }
59 | });
60 |
61 | return manager;
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/lib/world/clientState/Manager.ts:
--------------------------------------------------------------------------------
1 | namespace ClientState {
2 | export class Manager {
3 | private state: State;
4 |
5 | constructor(initialState: State) {
6 | this.state = initialState;
7 | }
8 |
9 | public setState(constructNewState: () => State, passFocus: boolean) {
10 | this.state.destroy(passFocus);
11 | this.state = constructNewState();
12 | }
13 |
14 | public getState() {
15 | return this.state;
16 | }
17 |
18 | public destroy(passFocus: boolean) {
19 | this.state.destroy(passFocus);
20 | }
21 | }
22 |
23 | export interface State {
24 | destroy(passFocus: boolean): void;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/lib/world/clientState/Pinned.ts:
--------------------------------------------------------------------------------
1 | namespace ClientState {
2 | export class Pinned implements State {
3 | private readonly kwinClient: KwinClient;
4 | private readonly pinManager: PinManager;
5 | private readonly desktopManager: DesktopManager;
6 | private readonly config: ClientManager.Config;
7 | private readonly signalManager: SignalManager;
8 |
9 | constructor(world: World, pinManager: PinManager, desktopManager: DesktopManager, kwinClient: KwinClient, config: ClientManager.Config) {
10 | this.kwinClient = kwinClient;
11 | this.pinManager = pinManager;
12 | this.desktopManager = desktopManager;
13 | this.config = config;
14 | if (config.floatingKeepAbove) {
15 | kwinClient.keepAbove = true;
16 | }
17 | this.signalManager = Pinned.initSignalManager(world, pinManager, kwinClient);
18 | }
19 |
20 | public destroy(passFocus: boolean) {
21 | this.signalManager.destroy();
22 | this.pinManager.removeClient(this.kwinClient);
23 | for (const desktop of this.desktopManager.getDesktopsForClient(this.kwinClient)) {
24 | desktop.onPinsChanged();
25 | }
26 | }
27 |
28 | private static initSignalManager(world: World, pinManager: PinManager, kwinClient: KwinClient) {
29 | const manager = new SignalManager();
30 | let oldActivities = kwinClient.activities;
31 | let oldDesktops = kwinClient.desktops;
32 |
33 | manager.connect(kwinClient.tileChanged, () => {
34 | if (kwinClient.tile === null) {
35 | world.do((clientManager, desktopManager) => {
36 | clientManager.unpinClient(kwinClient);
37 | });
38 | }
39 | });
40 |
41 | manager.connect(kwinClient.frameGeometryChanged, () => {
42 | if (kwinClient.tile === null) {
43 | world.do((clientManager, desktopManager) => {
44 | clientManager.unpinClient(kwinClient);
45 | });
46 | return;
47 | }
48 |
49 | world.do((clientManager, desktopManager) => {
50 | for (const desktop of desktopManager.getDesktopsForClient(kwinClient)) {
51 | desktop.onPinsChanged();
52 | }
53 | });
54 | });
55 |
56 | manager.connect(kwinClient.minimizedChanged, () => {
57 | world.do((clientManager, desktopManager) => {
58 | for (const desktop of desktopManager.getDesktopsForClient(kwinClient)) {
59 | desktop.onPinsChanged();
60 | }
61 | });
62 | });
63 |
64 | manager.connect(kwinClient.desktopsChanged, () => {
65 | const changedDesktops = oldDesktops.length === 0 || kwinClient.desktops.length === 0 ?
66 | [] :
67 | union(oldDesktops, kwinClient.desktops);
68 | world.do((clientManager, desktopManager) => {
69 | for (const desktop of desktopManager.getDesktops(kwinClient.activities, changedDesktops)) {
70 | desktop.onPinsChanged();
71 | }
72 | });
73 | oldDesktops = kwinClient.desktops;
74 | });
75 |
76 | manager.connect(kwinClient.activitiesChanged, () => {
77 | const changedActivities = oldActivities.length === 0 || kwinClient.activities.length === 0 ?
78 | [] :
79 | union(oldActivities, kwinClient.activities);
80 | world.do((clientManager, desktopManager) => {
81 | for (const desktop of desktopManager.getDesktops(changedActivities, kwinClient.desktops)) {
82 | desktop.onPinsChanged();
83 | }
84 | });
85 | oldActivities = kwinClient.activities;
86 | });
87 |
88 | return manager;
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/lib/world/clientState/TiledMinimized.ts:
--------------------------------------------------------------------------------
1 | namespace ClientState {
2 | export class TiledMinimized implements State {
3 | private readonly signalManager: SignalManager;
4 |
5 | constructor(world: World, client: ClientWrapper) {
6 | this.signalManager = TiledMinimized.initSignalManager(world, client);
7 | }
8 |
9 | public destroy(passFocus: boolean) {
10 | this.signalManager.destroy();
11 | }
12 |
13 | private static initSignalManager(world: World, client: ClientWrapper) {
14 | const manager = new SignalManager();
15 |
16 | manager.connect(client.kwinClient.minimizedChanged, () => {
17 | console.assert(!client.kwinClient.minimized);
18 | world.do((clientManager, desktopManager) => {
19 | const desktop = desktopManager.getDesktopForClient(client.kwinClient);
20 | if (desktop !== undefined) {
21 | clientManager.tileClient(client, desktop.grid);
22 | } else {
23 | clientManager.floatClient(client);
24 | }
25 | });
26 | });
27 |
28 | return manager;
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/main/main.ts:
--------------------------------------------------------------------------------
1 | function init() {
2 | return new World(loadConfig());
3 | }
4 |
5 | function loadConfig(): Config {
6 | const config: any = {};
7 | for (const entry of configDef) {
8 | config[entry.name] = KWin.readConfig(entry.name, entry.default);
9 | }
10 | return config;
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": [
4 | "../extern/**/*",
5 | "../lib/**/*",
6 | "./**/*"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/src/tests/flows/centerFocused.ts:
--------------------------------------------------------------------------------
1 | tests.register("Center focused", 1, () => {
2 | const config = getDefaultConfig();
3 | const { qtMock, workspaceMock, world } = init(config);
4 |
5 | const [client0, client1, client2] = workspaceMock.createClientsWithWidths(300, 152, 300);
6 | world.do((clientManager, desktopManager) => {
7 | Assert.assert(clientManager.hasClient(client0));
8 | Assert.assert(clientManager.hasClient(client1));
9 | Assert.assert(clientManager.hasClient(client2));
10 | });
11 | Assert.assert(workspaceMock.activeWindow === client2);
12 | Assert.columnsFillTilingArea([client0, client1, client2]);
13 |
14 | // center client2
15 | qtMock.fireShortcut("karousel-grid-scroll-focused");
16 | Assert.centered(config, tilingArea, client2);
17 | Assert.fullyVisible(client1.frameGeometry);
18 | Assert.fullyVisible(client2.frameGeometry);
19 |
20 | // undo center client2
21 | qtMock.fireShortcut("karousel-grid-scroll-focused");
22 | Assert.columnsFillTilingArea([client0, client1, client2]);
23 |
24 | // center client2
25 | qtMock.fireShortcut("karousel-grid-scroll-focused");
26 | Assert.centered(config, tilingArea, client2);
27 | Assert.fullyVisible(client1.frameGeometry);
28 | Assert.fullyVisible(client2.frameGeometry);
29 |
30 | // focus client1 (no scrolling should occur)
31 | qtMock.fireShortcut("karousel-focus-left");
32 | Assert.centered(config, tilingArea, client2, { message: "No scrolling should have occured" });
33 | Assert.fullyVisible(client1.frameGeometry);
34 | Assert.fullyVisible(client2.frameGeometry);
35 |
36 | // center client1
37 | qtMock.fireShortcut("karousel-grid-scroll-focused");
38 | Assert.columnsFillTilingArea([client0, client1, client2]);
39 |
40 | // undo center client1 (no scrolling should occur, because all clients are already visible and centered)
41 | qtMock.fireShortcut("karousel-grid-scroll-focused");
42 | Assert.columnsFillTilingArea([client0, client1, client2]);
43 | });
44 |
--------------------------------------------------------------------------------
/src/tests/flows/columnsSqueezeSide.ts:
--------------------------------------------------------------------------------
1 | tests.register("columns squeeze side", 1, () => {
2 | const baseTestCases = [
3 | { widths: [500, 500], blocked: [false, false], possible: true },
4 | { widths: [500, 768], blocked: [false, false], possible: true },
5 | { widths: [500, 500], blocked: [false, true], possible: true },
6 | { widths: [500, 200, 200], blocked: [false, false, false], possible: true },
7 | { widths: [500, 200, 200], blocked: [false, false, true], possible: true },
8 | { widths: [500, 200, 200], blocked: [true, false, true], possible: true },
9 | { widths: [500, 500, 500], blocked: [false, true, true], possible: false },
10 | ];
11 |
12 | const testCasesLeft = baseTestCases.map((baseTestCase, i) => ({
13 | ...baseTestCase,
14 | name: "left " + i,
15 | action: "karousel-columns-squeeze-left",
16 | focus: baseTestCase.widths.length-1,
17 | }));
18 |
19 | const testCasesRight = baseTestCases.map((baseTestCase, i) => ({
20 | ...baseTestCase,
21 | widths: baseTestCase.widths.slice().reverse(),
22 | blocked: baseTestCase.blocked.slice().reverse(),
23 | name: "right " + i,
24 | action: "karousel-columns-squeeze-right",
25 | focus: 0,
26 | }));
27 |
28 | const testCases = [...testCasesLeft, ...testCasesRight];
29 |
30 | for (const testCase of testCases) {
31 | const assertOpt = { message: `Case: ${testCase.name}` };
32 |
33 | const config = getDefaultConfig();
34 | const { qtMock, workspaceMock, world } = init(config);
35 |
36 | const clients = workspaceMock.createClientsWithWidths(...testCase.widths);
37 | workspaceMock.activeWindow = clients[testCase.focus];
38 | for (let i = 0; i < clients.length; i++) {
39 | if (testCase.blocked[i]) {
40 | clients[i].minSize = new MockQmlSize(testCase.widths[i], 100);
41 | }
42 | }
43 |
44 | if (testCase.possible) {
45 | qtMock.fireShortcut(testCase.action);
46 | Assert.columnsFillTilingArea(clients, assertOpt);
47 | for (let i = 0; i < clients.length; i++) {
48 | if (testCase.blocked[i]) {
49 | Assert.equal(clients[i].frameGeometry.width, testCase.widths[i], assertOpt);
50 | }
51 | }
52 | }
53 |
54 | const frames = clients.map(client => client.frameGeometry);
55 | qtMock.fireShortcut(testCase.action);
56 | const newFrames = clients.map(client => client.frameGeometry);
57 | for (let i = 0; i < clients.length; i++) {
58 | Assert.equalRects(frames[i], newFrames[i], assertOpt);
59 | }
60 | }
61 | });
62 |
63 | tests.register("columns squeeze side (just scroll)", 1, () => {
64 | const baseTestCases = [
65 | { focus: 0, startVisible: [true, true, false], endVisible: [true, true, false] },
66 | { focus: 1, startVisible: [false, true, true], endVisible: [true, true, false] },
67 | { focus: 2, startVisible: [false, true, true], endVisible: [false, true, true] },
68 | ];
69 |
70 | const testCasesLeft = baseTestCases.map((baseTestCase, i) => ({
71 | ...baseTestCase,
72 | name: "left " + i,
73 | action: "karousel-columns-squeeze-left",
74 | scrollStart: false,
75 | }));
76 |
77 | const testCasesRight = baseTestCases.map((baseTestCase, i) => ({
78 | focus: 2 - baseTestCase.focus,
79 | startVisible: baseTestCase.startVisible.slice().reverse(),
80 | endVisible: baseTestCase.endVisible.slice().reverse(),
81 | name: "right " + i,
82 | action: "karousel-columns-squeeze-right",
83 | scrollStart: true,
84 | }));
85 |
86 | const testCases = [...testCasesLeft, ...testCasesRight];
87 |
88 | for (const testCase of testCases) {
89 | const assertMsg = `Case: ${testCase.name}`;
90 |
91 | const config = getDefaultConfig();
92 | const { qtMock, workspaceMock, world } = init(config);
93 |
94 | function assertVisible(clients: KwinClient[], visible: boolean[]) {
95 | for (let i = 0; i < clients.length; i++) {
96 | if (visible[i]) {
97 | Assert.fullyVisible(clients[i].frameGeometry, { message: assertMsg, skip: 1 });
98 | } else {
99 | Assert.notFullyVisible(clients[i].frameGeometry, { message: assertMsg, skip: 1 });
100 | }
101 | }
102 | }
103 |
104 | const clients = workspaceMock.createClientsWithWidths(300, 300, 300);
105 | for (const client of clients) {
106 | client.minSize = new MockQmlSize(300, 100);
107 | }
108 | if (testCase.scrollStart) {
109 | qtMock.fireShortcut("karousel-grid-scroll-start");
110 | }
111 | workspaceMock.activeWindow = clients[testCase.focus];
112 | assertVisible(clients, testCase.startVisible);
113 |
114 | qtMock.fireShortcut(testCase.action);
115 | assertVisible(clients, testCase.endVisible);
116 |
117 | const frames = clients.map(client => client.frameGeometry);
118 | qtMock.fireShortcut(testCase.action);
119 | const newFrames = clients.map(client => client.frameGeometry);
120 | for (let i = 0; i < clients.length; i++) {
121 | Assert.equalRects(frames[i], newFrames[i], { message: assertMsg });
122 | }
123 | }
124 | });
125 |
--------------------------------------------------------------------------------
/src/tests/flows/cursorFollowsFocus.ts:
--------------------------------------------------------------------------------
1 | tests.register("Drag tiled window, untile", 10, () => {
2 | const config = getDefaultConfig();
3 | config.cursorFollowsFocus = true;
4 | const { qtMock, workspaceMock, world } = init(config);
5 |
6 | const [client1, client2] = workspaceMock.createClients(2);
7 | const initialCursorPos = new MockQmlPoint(380, 20);
8 | Assert.assert(rectContainsPoint(client1.frameGeometry, initialCursorPos), { message: "invalid test setup" });
9 | workspaceMock.cursorPos = initialCursorPos.clone();
10 |
11 | runOneOf(
12 | () => Workspace.activeWindow = client1,
13 | () => qtMock.fireShortcut("karousel-focus-1"),
14 | );
15 | Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
16 | Assert.assert(!rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
17 | Assert.assert(pointEquals(Workspace.cursorPos, initialCursorPos), { message: "Cursor should not have been moved because it was already within the focused client" });
18 |
19 | runOneOf(
20 | () => Workspace.activeWindow = client2,
21 | () => qtMock.fireShortcut("karousel-focus-2"),
22 | );
23 | Assert.assert(!rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
24 | Assert.assert(rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
25 |
26 | runOneOf(
27 | () => Workspace.activeWindow = client1,
28 | () => qtMock.fireShortcut("karousel-focus-1"),
29 | );
30 | Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
31 | Assert.assert(!rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
32 | const lastCursorPos = workspaceMock.cursorPos.clone();
33 |
34 | Workspace.activeWindow = null;
35 | Assert.assert(pointEquals(Workspace.cursorPos, lastCursorPos), { message: "Cursor should not have been moved" });
36 | });
37 |
--------------------------------------------------------------------------------
/src/tests/flows/dragTiled.ts:
--------------------------------------------------------------------------------
1 | tests.register("Drag tiled window, untile", 20, () => {
2 | const config = getDefaultConfig();
3 | config.untileOnDrag = true;
4 | const { qtMock, workspaceMock, world } = init(config);
5 | const clientManager = getClientManager(world);
6 |
7 | const [client0, client1] = workspaceMock.createClients(2);
8 | Assert.tiledClient(clientManager, client0);
9 | Assert.tiledClient(clientManager, client1);
10 | Assert.grid(config, tilingArea, 100, [[client0], [client1]], true);
11 |
12 | workspaceMock.moveWindow(client0, new MockQmlPoint(10, 10));
13 | Assert.notTiledClient(clientManager, client0);
14 | Assert.tiledClient(clientManager, client1);
15 | Assert.grid(config, tilingArea, 100, [[client1]], true);
16 | });
17 |
18 | tests.register("Drag tiled window, keep tiled", 20, () => {
19 | const config = getDefaultConfig();
20 | config.untileOnDrag = false;
21 | const { qtMock, workspaceMock, world } = init(config);
22 | const clientManager = getClientManager(world);
23 |
24 | const [client0, client1] = workspaceMock.createClients(2);
25 | Assert.tiledClient(clientManager, client0);
26 | Assert.tiledClient(clientManager, client1);
27 | Assert.grid(config, tilingArea, 100, [[client0], [client1]], true);
28 |
29 | const move = new MockQmlPoint(10, 10);
30 | workspaceMock.moveWindow(client0, move, move, move, move, move, move, move, move, move); // many moves in order to trigger externalFrameGeometryChangedRateLimiter
31 | Assert.tiledClient(clientManager, client0);
32 | Assert.tiledClient(clientManager, client1);
33 | Assert.grid(config, tilingArea, 100, [[client0], [client1]], true);
34 | });
35 |
--------------------------------------------------------------------------------
/src/tests/flows/externalResize.ts:
--------------------------------------------------------------------------------
1 | tests.register("External resize", 1, () => {
2 | const config = getDefaultConfig();
3 | const { qtMock, workspaceMock, world } = init(config);
4 |
5 | function getClientDesiredFrame(width: number) {
6 | return new MockQmlRect(10, 10, width, 200);
7 | }
8 |
9 | function getTiledFrame(width: number) {
10 | return new MockQmlRect(
11 | tilingArea.left + Math.round((tilingArea.width - width) / 2),
12 | tilingArea.top,
13 | width,
14 | tilingArea.height,
15 | );
16 | }
17 |
18 | const [client] = workspaceMock.createClientsWithFrames(getClientDesiredFrame(100));
19 | Assert.equalRects(client.frameGeometry, getTiledFrame(100), { message: "We should tile the window, respecting its desired width" });
20 |
21 | function testExternalResizing() {
22 | client.frameGeometry = getClientDesiredFrame(110);
23 | Assert.equalRects(client.frameGeometry, getTiledFrame(110), { message: "We should re-arrange the window, respecting its new desired width" });
24 |
25 | client.frameGeometry = getClientDesiredFrame(120);
26 | Assert.equalRects(client.frameGeometry, getTiledFrame(120), { message: "We should re-arrange the window, respecting its new desired width" });
27 |
28 | client.frameGeometry = getClientDesiredFrame(130);
29 | Assert.equalRects(client.frameGeometry, getTiledFrame(130), { message: "We should re-arrange the window, respecting its new desired width" });
30 |
31 | client.frameGeometry = getClientDesiredFrame(140);
32 | Assert.equalRects(client.frameGeometry, getTiledFrame(140), { message: "We should re-arrange the window, respecting its new desired width" });
33 |
34 | client.frameGeometry = getClientDesiredFrame(200);
35 | Assert.equalRects(client.frameGeometry, getClientDesiredFrame(200), { message: "We should give up and let the client have its desired frame" });
36 | }
37 |
38 | timeControl(addTime => {
39 | testExternalResizing();
40 | addTime(1000);
41 | // the concession has expired, let's test again
42 | testExternalResizing();
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/src/tests/flows/layering.ts:
--------------------------------------------------------------------------------
1 | tests.register("tiledKeepBelow", 10, () => {
2 | const config = getDefaultConfig();
3 | config.tiledKeepBelow = true;
4 | config.floatingKeepAbove = false;
5 | const { qtMock, workspaceMock, world } = init(config);
6 |
7 | const pinGeometry = new MockQmlRect(0, 0, 200, screen.height);
8 |
9 | const [client] = workspaceMock.createClients(1);
10 | world.do((clientManager, desktopManager) => {
11 | Assert.assert(clientManager.findTiledWindow(client) !== null);
12 | });
13 | Assert.assert(client.keepBelow);
14 | Assert.assert(!client.keepAbove);
15 |
16 | qtMock.fireShortcut("karousel-window-toggle-floating");
17 | world.do((clientManager, desktopManager) => {
18 | Assert.assert(clientManager.findTiledWindow(client) === null);
19 | });
20 | Assert.assert(!client.keepBelow);
21 | Assert.assert(!client.keepAbove);
22 |
23 | client.pin(pinGeometry);
24 | world.do((clientManager, desktopManager) => {
25 | Assert.assert(clientManager.findTiledWindow(client) === null);
26 | });
27 | Assert.assert(!client.keepBelow);
28 | Assert.assert(!client.keepAbove);
29 |
30 | client.unpin();
31 | world.do((clientManager, desktopManager) => {
32 | Assert.assert(clientManager.findTiledWindow(client) === null);
33 | });
34 | Assert.assert(!client.keepBelow);
35 | Assert.assert(!client.keepAbove);
36 |
37 | qtMock.fireShortcut("karousel-window-toggle-floating");
38 | world.do((clientManager, desktopManager) => {
39 | Assert.assert(clientManager.findTiledWindow(client) !== null);
40 | });
41 | Assert.assert(client.keepBelow);
42 | Assert.assert(!client.keepAbove);
43 |
44 | client.pin(pinGeometry);
45 | world.do((clientManager, desktopManager) => {
46 | Assert.assert(clientManager.findTiledWindow(client) === null);
47 | });
48 | Assert.assert(!client.keepBelow);
49 | Assert.assert(!client.keepAbove);
50 |
51 | qtMock.fireShortcut("karousel-window-toggle-floating");
52 | world.do((clientManager, desktopManager) => {
53 | Assert.assert(clientManager.findTiledWindow(client) !== null);
54 | });
55 | Assert.assert(client.keepBelow);
56 | Assert.assert(!client.keepAbove);
57 | });
58 |
59 | tests.register("floatingKeepAbove", 10, () => {
60 | const config = getDefaultConfig();
61 | config.tiledKeepBelow = false;
62 | config.floatingKeepAbove = true;
63 | const { qtMock, workspaceMock, world } = init(config);
64 |
65 | const pinGeometry = new MockQmlRect(0, 0, 200, screen.height);
66 |
67 | const [client] = workspaceMock.createClients(1);
68 | world.do((clientManager, desktopManager) => {
69 | Assert.assert(clientManager.findTiledWindow(client) !== null);
70 | });
71 | Assert.assert(!client.keepBelow);
72 | Assert.assert(!client.keepAbove);
73 |
74 | qtMock.fireShortcut("karousel-window-toggle-floating");
75 | world.do((clientManager, desktopManager) => {
76 | Assert.assert(clientManager.findTiledWindow(client) === null);
77 | });
78 | Assert.assert(!client.keepBelow);
79 | Assert.assert(client.keepAbove);
80 |
81 | client.pin(pinGeometry);
82 | world.do((clientManager, desktopManager) => {
83 | Assert.assert(clientManager.findTiledWindow(client) === null);
84 | });
85 | Assert.assert(!client.keepBelow);
86 | Assert.assert(client.keepAbove);
87 |
88 | client.unpin();
89 | world.do((clientManager, desktopManager) => {
90 | Assert.assert(clientManager.findTiledWindow(client) === null);
91 | });
92 | Assert.assert(!client.keepBelow);
93 | Assert.assert(client.keepAbove);
94 |
95 | qtMock.fireShortcut("karousel-window-toggle-floating");
96 | world.do((clientManager, desktopManager) => {
97 | Assert.assert(clientManager.findTiledWindow(client) !== null);
98 | });
99 | Assert.assert(!client.keepBelow);
100 | Assert.assert(!client.keepAbove);
101 |
102 | client.pin(pinGeometry);
103 | world.do((clientManager, desktopManager) => {
104 | Assert.assert(clientManager.findTiledWindow(client) === null);
105 | });
106 | Assert.assert(!client.keepBelow);
107 | Assert.assert(client.keepAbove);
108 |
109 | qtMock.fireShortcut("karousel-window-toggle-floating");
110 | world.do((clientManager, desktopManager) => {
111 | Assert.assert(clientManager.findTiledWindow(client) !== null);
112 | });
113 | Assert.assert(!client.keepBelow);
114 | Assert.assert(!client.keepAbove);
115 | });
116 |
117 | tests.register("No layering", 10, () => {
118 | const config = getDefaultConfig();
119 | config.tiledKeepBelow = false;
120 | config.floatingKeepAbove = false;
121 | // In this mode, Karousel shouldn't change keepBelow or keepAbove.
122 | // Except when tiling a window, keepAbove should still be cleared.
123 |
124 | const pinGeometry = new MockQmlRect(0, 0, 200, screen.height);
125 |
126 | const testCases = [
127 | { keepBelow: false, keepAbove: false },
128 | { keepBelow: false, keepAbove: true },
129 | { keepBelow: true, keepAbove: false },
130 | { keepBelow: true, keepAbove: true },
131 | ];
132 |
133 | for (const testCase of testCases) {
134 | const assertOptions = { message: JSON.stringify(testCase) };
135 |
136 | const { qtMock, workspaceMock, world } = init(config);
137 |
138 | const [client] = workspaceMock.createClients(1);
139 | client.keepBelow = testCase.keepBelow;
140 | client.keepAbove = testCase.keepAbove;
141 |
142 | world.do((clientManager, desktopManager) => {
143 | Assert.assert(clientManager.findTiledWindow(client) !== null, assertOptions);
144 | });
145 | Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
146 | Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
147 |
148 | qtMock.fireShortcut("karousel-window-toggle-floating");
149 | world.do((clientManager, desktopManager) => {
150 | Assert.assert(clientManager.findTiledWindow(client) === null, assertOptions);
151 | });
152 | Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
153 | Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
154 |
155 | client.pin(pinGeometry);
156 | world.do((clientManager, desktopManager) => {
157 | Assert.assert(clientManager.findTiledWindow(client) === null, assertOptions);
158 | });
159 | Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
160 | Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
161 |
162 | client.unpin();
163 | world.do((clientManager, desktopManager) => {
164 | Assert.assert(clientManager.findTiledWindow(client) === null, assertOptions);
165 | });
166 | Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
167 | Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
168 |
169 | qtMock.fireShortcut("karousel-window-toggle-floating");
170 | world.do((clientManager, desktopManager) => {
171 | Assert.assert(clientManager.findTiledWindow(client) !== null, assertOptions);
172 | });
173 | Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
174 | Assert.assert(!client.keepAbove, assertOptions);
175 | client.keepAbove = testCase.keepAbove;
176 |
177 | client.pin(pinGeometry);
178 | world.do((clientManager, desktopManager) => {
179 | Assert.assert(clientManager.findTiledWindow(client) === null, assertOptions);
180 | });
181 | Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
182 | Assert.equal(client.keepAbove, testCase.keepAbove, assertOptions);
183 |
184 | qtMock.fireShortcut("karousel-window-toggle-floating");
185 | world.do((clientManager, desktopManager) => {
186 | Assert.assert(clientManager.findTiledWindow(client) !== null, assertOptions);
187 | });
188 | Assert.equal(client.keepBelow, testCase.keepBelow, assertOptions);
189 | Assert.assert(!client.keepAbove, assertOptions);
190 | }
191 | });
192 |
--------------------------------------------------------------------------------
/src/tests/flows/layout.ts:
--------------------------------------------------------------------------------
1 | tests.register("Focus and move windows", 1, () => {
2 | const config = getDefaultConfig();
3 | const { qtMock, workspaceMock, world } = init(config);
4 |
5 | const [client1, client2, client3] = workspaceMock.createClients(3);
6 | world.do((clientManager, desktopManager) => {
7 | Assert.assert(clientManager.hasClient(client1));
8 | Assert.assert(clientManager.hasClient(client2));
9 | Assert.assert(clientManager.hasClient(client3));
10 | });
11 | Assert.assert(workspaceMock.activeWindow === client3);
12 |
13 | function testLayout(shortcutName: string, grid: KwinClient[][]) {
14 | qtMock.fireShortcut(shortcutName);
15 | Assert.grid(config, tilingArea, 100, grid, true, [], { skip: 1 });
16 | }
17 |
18 | function testFocus(shortcutName: string, expectedFocus: KwinClient) {
19 | qtMock.fireShortcut(shortcutName);
20 | Assert.assert(workspaceMock.activeWindow === expectedFocus, {
21 | message: `wrong activeWindow: ${workspaceMock.activeWindow?.pid}`,
22 | skip: 1,
23 | });
24 | };
25 |
26 | testLayout("karousel-column-move-right", [ [client1], [client2], [client3] ]);
27 |
28 | testLayout("karousel-window-move-left", [ [client1], [client2,client3] ]);
29 | testLayout("karousel-window-move-left", [ [client1], [client3], [client2] ]);
30 | testLayout("karousel-window-move-left", [ [client1,client3], [client2] ]);
31 | testFocus("karousel-focus-right", client2);
32 | testLayout("karousel-window-move-left", [ [client1,client3,client2] ]);
33 | testLayout("karousel-window-move-left", [ [client2], [client1,client3] ]);
34 | testLayout("karousel-window-move-left", [ [client2], [client1,client3] ]);
35 | testFocus("karousel-focus-2", client3);
36 | testFocus("karousel-focus-up", client1);
37 | testLayout("karousel-column-move-left", [ [client1,client3], [client2] ]);
38 | testLayout("karousel-window-move-right", [ [client3], [client1], [client2] ]);
39 |
40 | testFocus("karousel-focus-3", client2);
41 | testLayout("karousel-window-move-start", [ [client2], [client3], [client1] ]);
42 | testLayout("karousel-window-move-to-column-3", [ [client3], [client1,client2] ]);
43 | testLayout("karousel-column-move-left", [ [client1,client2], [client3] ]);
44 | testLayout("karousel-column-move-end", [ [client3], [client1,client2] ]);
45 | testLayout("karousel-column-move-to-column-1", [ [client1,client2], [client3] ]);
46 | testLayout("karousel-column-move-right", [ [client3], [client1,client2] ]);
47 |
48 | testLayout("karousel-window-move-previous", [ [client3], [client2,client1] ]);
49 | testLayout("karousel-window-move-previous", [ [client3], [client2], [client1] ]);
50 | testLayout("karousel-window-move-previous", [ [client3,client2], [client1] ]);
51 | testLayout("karousel-window-move-previous", [ [client2,client3], [client1] ]);
52 | testLayout("karousel-window-move-previous", [ [client2], [client3], [client1] ]);
53 | testLayout("karousel-window-move-previous", [ [client2], [client3], [client1] ]);
54 | testLayout("karousel-window-move-next", [ [client2,client3], [client1] ]);
55 | testLayout("karousel-window-move-next", [ [client3,client2], [client1] ]);
56 | testLayout("karousel-window-move-next", [ [client3], [client2], [client1] ]);
57 | testLayout("karousel-window-move-next", [ [client3], [client2,client1] ]);
58 | testLayout("karousel-window-move-next", [ [client3], [client1,client2] ]);
59 | testLayout("karousel-window-move-next", [ [client3], [client1], [client2] ]);
60 | testLayout("karousel-window-move-next", [ [client3], [client1], [client2] ]);
61 | testLayout("karousel-window-move-left", [ [client3], [client1,client2] ]);
62 |
63 | const col1Win1 = client3;
64 | const col2Win1 = client1;
65 | const col2Win2 = client2;
66 |
67 | testFocus("karousel-focus-up", col2Win1);
68 | testFocus("karousel-focus-up", col2Win1);
69 | testFocus("karousel-focus-down", col2Win2);
70 | testFocus("karousel-focus-left", col1Win1);
71 | testFocus("karousel-focus-left", col1Win1);
72 | testFocus("karousel-focus-right", col2Win2);
73 | testFocus("karousel-focus-right", col2Win2);
74 |
75 | testFocus("karousel-focus-2", col2Win2);
76 | testFocus("karousel-focus-1", col1Win1);
77 | testFocus("karousel-focus-2", col2Win2);
78 | testFocus("karousel-focus-start", col1Win1);
79 | testFocus("karousel-focus-end", col2Win2);
80 |
81 | testFocus("karousel-focus-up", col2Win1);
82 | testFocus("karousel-focus-left", col1Win1);
83 | testFocus("karousel-focus-right", col2Win1);
84 | testFocus("karousel-focus-2", col2Win1);
85 | testFocus("karousel-focus-1", col1Win1);
86 | testFocus("karousel-focus-2", col2Win1);
87 | testFocus("karousel-focus-start", col1Win1);
88 | testFocus("karousel-focus-end", col2Win1);
89 |
90 | testFocus("karousel-focus-down", col2Win2);
91 | testFocus("karousel-focus-start", col1Win1);
92 | testFocus("karousel-focus-next", col2Win1);
93 | testFocus("karousel-focus-next", col2Win2);
94 | testFocus("karousel-focus-next", col2Win2);
95 | testFocus("karousel-focus-previous", col2Win1);
96 | testFocus("karousel-focus-previous", col1Win1);
97 | testFocus("karousel-focus-previous", col1Win1);
98 | });
99 |
--------------------------------------------------------------------------------
/src/tests/flows/lazyScroller.ts:
--------------------------------------------------------------------------------
1 | tests.register("LazyScroller", 20, () => {
2 | const config = getDefaultConfig();
3 | config.scrollingLazy = true;
4 | config.scrollingCentered = false;
5 | config.scrollingGrouped = false;
6 | const { qtMock, workspaceMock, world } = init(config);
7 |
8 | const [client1] = workspaceMock.createClientsWithWidths(300);
9 | Assert.grid(config, tilingArea, 300, [[client1]], true);
10 |
11 | const [client2] = workspaceMock.createClientsWithWidths(300);
12 | Assert.grid(config, tilingArea, 300, [[client1], [client2]], true);
13 |
14 | const [client3] = workspaceMock.createClientsWithWidths(300);
15 | Assert.grid(config, tilingArea, 300, [[client1], [client2], [client3]], false);
16 | Assert.equal(client3.frameGeometry.right, tilingArea.right);
17 |
18 | runOneOf(
19 | () => workspaceMock.activeWindow = client2,
20 | () => qtMock.fireShortcut("karousel-focus-2"),
21 | () => qtMock.fireShortcut("karousel-focus-left"),
22 | );
23 | Assert.grid(config, tilingArea, 300, [[client1], [client2], [client3]], false);
24 | Assert.equal(client3.frameGeometry.right, tilingArea.right);
25 |
26 | runOneOf(
27 | () => workspaceMock.activeWindow = client1,
28 | () => qtMock.fireShortcut("karousel-focus-1"),
29 | () => qtMock.fireShortcut("karousel-focus-left"),
30 | () => qtMock.fireShortcut("karousel-focus-start"),
31 | );
32 | workspaceMock.activeWindow = client1;
33 | Assert.grid(config, tilingArea, 300, [[client1], [client2], [client3]], false);
34 | Assert.equal(client1.frameGeometry.left, tilingArea.left);
35 |
36 | qtMock.fireShortcut("karousel-grid-scroll-focused");
37 | Assert.grid(config, tilingArea, 300, [[client1], [client2], [client3]], false);
38 | Assert.grid(config, tilingArea, 300, [[client1]], true);
39 |
40 | runOneOf(
41 | () => workspaceMock.activeWindow = client2,
42 | () => qtMock.fireShortcut("karousel-focus-2"),
43 | () => qtMock.fireShortcut("karousel-focus-right"),
44 | );
45 | Assert.grid(config, tilingArea, 300, [[client1], [client2], [client3]], false);
46 | Assert.equal(client1.frameGeometry.left, tilingArea.left);
47 | });
48 |
--------------------------------------------------------------------------------
/src/tests/flows/passFocus.ts:
--------------------------------------------------------------------------------
1 | tests.register("Pass focus", 20, () => {
2 | const config = getDefaultConfig();
3 | const { qtMock, workspaceMock, world } = init(config);
4 |
5 | const [client0, client1a, client1b, client1c, client4, client5, client6] = workspaceMock.createClients(7);
6 | workspaceMock.activeWindow = client1b;
7 | qtMock.fireShortcut("karousel-window-move-left");
8 | workspaceMock.activeWindow = client1c;
9 | qtMock.fireShortcut("karousel-window-move-left");
10 | workspaceMock.activeWindow = client1b;
11 | workspaceMock.activeWindow = client5;
12 |
13 | function removeWindow(client: MockKwinClient) {
14 | runOneOf(
15 | () => workspaceMock.removeWindow(client),
16 | () => client.desktops = [workspaceMock.desktops[1]],
17 | );
18 | }
19 |
20 | removeWindow(client5);
21 | Assert.equal(workspaceMock.activeWindow, client4);
22 |
23 | qtMock.fireShortcut("karousel-column-move-to-desktop-2");
24 | Assert.equal(workspaceMock.activeWindow, client1b);
25 |
26 | removeWindow(client1b);
27 | Assert.equal(workspaceMock.activeWindow, client1a);
28 |
29 | removeWindow(client1a);
30 | Assert.equal(workspaceMock.activeWindow, client1c);
31 |
32 | removeWindow(client1c);
33 | Assert.equal(workspaceMock.activeWindow, client0);
34 |
35 | removeWindow(client0);
36 | Assert.equal(workspaceMock.activeWindow, client6);
37 |
38 | removeWindow(client6);
39 | Assert.equal(workspaceMock.activeWindow, null);
40 | });
41 |
--------------------------------------------------------------------------------
/src/tests/flows/pinning.ts:
--------------------------------------------------------------------------------
1 | tests.register("Pin", 20, () => {
2 | const config = getDefaultConfig();
3 | const { qtMock, workspaceMock, world } = init(config);
4 |
5 | const screenHalfLeft = new MockQmlRect(0, 0, screen.width/2, screen.height);
6 | const screenHalfRight = new MockQmlRect(screen.width/2, 0, screen.width/2, screen.height);
7 |
8 | const tilingAreaHalfLeft = new MockQmlRect(
9 | tilingArea.x,
10 | tilingArea.y,
11 | screen.width/2 - config.gapsOuterLeft - config.gapsOuterRight,
12 | tilingArea.height,
13 | );
14 | const tilingAreaHalfRight = new MockQmlRect(
15 | screen.width/2 + config.gapsOuterLeft,
16 | tilingArea.y,
17 | screen.width/2 - config.gapsOuterLeft - config.gapsOuterRight,
18 | tilingArea.height,
19 | );
20 |
21 | const [pinned, tiled1, tiled2] = workspaceMock.createClients(3);
22 | Assert.grid(config, tilingArea, 100, [ [pinned], [tiled1], [tiled2] ], true);
23 |
24 | pinned.pin(screenHalfLeft);
25 | Assert.equalRects(pinned.frameGeometry, screenHalfLeft);
26 | Assert.grid(config, tilingAreaHalfRight, 100, [ [tiled1], [tiled2] ], true);
27 |
28 | pinned.pin(screenHalfRight);
29 | Assert.equalRects(pinned.frameGeometry, screenHalfRight);
30 | Assert.grid(config, tilingAreaHalfLeft, 100, [ [tiled1], [tiled2] ], true);
31 |
32 | pinned.unpin();
33 | Assert.equalRects(pinned.frameGeometry, screenHalfRight);
34 | Assert.grid(config, tilingArea, 100, [ [tiled1], [tiled2] ], true);
35 |
36 | pinned.pin(screenHalfRight);
37 | Assert.equalRects(pinned.frameGeometry, screenHalfRight);
38 | Assert.grid(config, tilingAreaHalfLeft, 100, [ [tiled1], [tiled2] ], true);
39 |
40 | pinned.minimized = true;
41 | Assert.grid(config, tilingArea, 100, [ [tiled1], [tiled2] ], true);
42 |
43 | pinned.minimized = false;
44 | Assert.equalRects(pinned.frameGeometry, screenHalfRight);
45 | Assert.grid(config, tilingAreaHalfLeft, 100, [ [tiled1], [tiled2] ], true);
46 |
47 | workspaceMock.activeWindow = pinned;
48 | qtMock.fireShortcut("karousel-window-toggle-floating");
49 | Assert.assert(pinned.tile === null);
50 | 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.
51 | Assert.grid(config, tilingArea, 100, [ [tiled1], [tiled2], [pinned] ], true);
52 | });
53 |
--------------------------------------------------------------------------------
/src/tests/flows/presetWidths.ts:
--------------------------------------------------------------------------------
1 | tests.register("Preset Widths default", 1, () => {
2 | const config = getDefaultConfig();
3 | const { qtMock, workspaceMock, world } = init(config);
4 |
5 | const maxWidth = tilingArea.width;
6 | const halfWidth = maxWidth/2 - config.gapsInnerHorizontal/2;
7 |
8 | function getRect(columnWidth: number) {
9 | return new MockQmlRect(
10 | tilingArea.left + (tilingArea.width - columnWidth) / 2,
11 | tilingArea.top,
12 | columnWidth,
13 | tilingArea.height,
14 | );
15 | }
16 |
17 | const [kwinClient] = workspaceMock.createClientsWithWidths(300);
18 | Assert.equalRects(kwinClient.frameGeometry, getRect(300));
19 |
20 | qtMock.fireShortcut("karousel-cycle-preset-widths");
21 | Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
22 |
23 | qtMock.fireShortcut("karousel-cycle-preset-widths");
24 | Assert.equalRects(kwinClient.frameGeometry, getRect(maxWidth));
25 |
26 | qtMock.fireShortcut("karousel-cycle-preset-widths");
27 | Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
28 |
29 | qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
30 | Assert.equalRects(kwinClient.frameGeometry, getRect(maxWidth));
31 |
32 | qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
33 | Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
34 | });
35 |
36 | tests.register("Preset Widths custom", 1, () => {
37 | const config = getDefaultConfig();
38 | config.presetWidths = "500px, 250px, 100px, 50%";
39 | const { qtMock, workspaceMock, world } = init(config);
40 |
41 | const maxWidth = tilingArea.width;
42 | const halfWidth = maxWidth/2 - config.gapsInnerHorizontal/2;
43 |
44 | function getRect(columnWidth: number) {
45 | return new MockQmlRect(
46 | tilingArea.left + (tilingArea.width - columnWidth) / 2,
47 | tilingArea.top,
48 | columnWidth,
49 | tilingArea.height,
50 | );
51 | }
52 |
53 | const [kwinClient] = workspaceMock.createClientsWithWidths(200);
54 | Assert.equalRects(kwinClient.frameGeometry, getRect(200));
55 |
56 | qtMock.fireShortcut("karousel-cycle-preset-widths");
57 | Assert.equalRects(kwinClient.frameGeometry, getRect(250));
58 |
59 | qtMock.fireShortcut("karousel-cycle-preset-widths");
60 | Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
61 |
62 | qtMock.fireShortcut("karousel-cycle-preset-widths");
63 | Assert.equalRects(kwinClient.frameGeometry, getRect(500));
64 |
65 | qtMock.fireShortcut("karousel-cycle-preset-widths");
66 | Assert.equalRects(kwinClient.frameGeometry, getRect(100));
67 |
68 | qtMock.fireShortcut("karousel-cycle-preset-widths");
69 | Assert.equalRects(kwinClient.frameGeometry, getRect(250));
70 |
71 | qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
72 | Assert.equalRects(kwinClient.frameGeometry, getRect(100));
73 |
74 | qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
75 | Assert.equalRects(kwinClient.frameGeometry, getRect(500));
76 |
77 | qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
78 | Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
79 | });
80 |
81 | tests.register("Preset Widths fill screen uniform", 1, () => {
82 | for (let nColumns = 1; nColumns < 10; nColumns++) {
83 | const config = getDefaultConfig();
84 | config.presetWidths = String(1 / nColumns);
85 | const { qtMock, workspaceMock, world } = init(config);
86 |
87 | let firstClient, lastClient;
88 | for (let i = 0; i < nColumns; i++) {
89 | const [kwinClient] = workspaceMock.createClientsWithWidths(300);
90 | if (i === 0) {
91 | firstClient = kwinClient;
92 | }
93 | if (i === nColumns-1) {
94 | lastClient = kwinClient;
95 | }
96 | qtMock.fireShortcut("karousel-cycle-preset-widths");
97 | }
98 |
99 | const left = tilingArea.left;
100 | const right = tilingArea.right;
101 | const maxLeftoverPx = nColumns - 1;
102 | const eps = Math.ceil(maxLeftoverPx / 2);
103 | Assert.between(firstClient!.frameGeometry.left, left, left+eps, { message: `nColumns: ${nColumns}` });
104 | Assert.between(lastClient!.frameGeometry.right, right-eps, right, { message: `nColumns: ${nColumns}` });
105 | }
106 | });
107 |
108 | tests.register("Preset Widths fill screen non-uniform", 1, () => {
109 | const config = getDefaultConfig();
110 | config.presetWidths = String("50%, 25%");
111 | const { qtMock, workspaceMock, world } = init(config);
112 |
113 | const [clientThin1] = workspaceMock.createClientsWithWidths(100);
114 | qtMock.fireShortcut("karousel-cycle-preset-widths");
115 |
116 | const [clientThin2] = workspaceMock.createClientsWithWidths(100);
117 | qtMock.fireShortcut("karousel-cycle-preset-widths");
118 |
119 | const [clientWide] = workspaceMock.createClientsWithWidths(300);
120 | qtMock.fireShortcut("karousel-cycle-preset-widths");
121 |
122 | const maxWidth = tilingArea.width;
123 | const halfWidth = maxWidth/2 - config.gapsInnerHorizontal/2;
124 | const quarterWidth = halfWidth/2 - config.gapsInnerHorizontal/2;
125 | const height = tilingArea.height;
126 | const left1 = tilingArea.left;
127 | const left2 = left1 + config.gapsInnerHorizontal + quarterWidth;
128 | const left3 = left2 + config.gapsInnerHorizontal + quarterWidth;
129 |
130 | Assert.rect(clientThin1.frameGeometry, left1, tilingArea.top, quarterWidth, height);
131 | Assert.rect(clientThin2.frameGeometry, left2, tilingArea.top, quarterWidth, height);
132 | Assert.rect(clientWide.frameGeometry, left3, tilingArea.top, halfWidth, height);
133 | Assert.equal(clientWide.frameGeometry.right, tilingArea.right);
134 | });
135 |
--------------------------------------------------------------------------------
/src/tests/flows/stacked.ts:
--------------------------------------------------------------------------------
1 | tests.register("Stacked", 5, () => {
2 | const config = getDefaultConfig();
3 | const { qtMock, workspaceMock, world } = init(config);
4 |
5 | const [leftTop, leftBottom, rightTop, rightBottom] = workspaceMock.createClients(4);
6 | const grid = [[leftTop, leftBottom], [rightTop, rightBottom]];
7 | workspaceMock.activeWindow = rightBottom;
8 | qtMock.fireShortcut("karousel-window-move-left");
9 | workspaceMock.activeWindow = leftBottom;
10 | qtMock.fireShortcut("karousel-window-move-left");
11 | Assert.grid(config, tilingArea, 100, grid, true);
12 |
13 | qtMock.fireShortcut("karousel-column-toggle-stacked");
14 | Assert.grid(config, tilingArea, 100, grid, true, [0]);
15 |
16 | qtMock.fireShortcut("karousel-focus-up");
17 | Assert.grid(config, tilingArea, 100, grid, true, [0]);
18 |
19 | qtMock.fireShortcut("karousel-focus-down");
20 | Assert.grid(config, tilingArea, 100, grid, true, [0]);
21 |
22 | qtMock.fireShortcut("karousel-window-move-up");
23 | Assert.grid(config, tilingArea, 100, [[leftBottom, leftTop], [rightTop, rightBottom]], true, [0]);
24 |
25 | qtMock.fireShortcut("karousel-window-move-down");
26 | Assert.grid(config, tilingArea, 100, grid, true, [0]);
27 |
28 | qtMock.fireShortcut("karousel-column-toggle-stacked");
29 | Assert.grid(config, tilingArea, 100, grid, true);
30 | });
31 |
--------------------------------------------------------------------------------
/src/tests/flows/userResize.ts:
--------------------------------------------------------------------------------
1 | tests.register("User resize", 10, () => {
2 | const config = getDefaultConfig();
3 | config.resizeNeighborColumn = true;
4 |
5 | const h = getWindowHeight(2);
6 | let clientLeft: MockKwinClient, clientRightTop: MockKwinClient, clientRightBottom: MockKwinClient;
7 | function assertSizes(leftWidth: number, rightWidth: number, topHeight: number, bottomHeight: number) {
8 | const { left, right } = getGridBounds(clientLeft, clientRightTop);
9 | Assert.rect(clientLeft.frameGeometry, left, tilingArea.top, leftWidth, tilingArea.height);
10 | Assert.rect(clientRightTop.frameGeometry, left+leftWidth+gapH, tilingArea.top, rightWidth, topHeight);
11 | Assert.rect(clientRightBottom.frameGeometry, left+leftWidth+gapH, tilingArea.top+topHeight+gapV, rightWidth, bottomHeight);
12 | }
13 |
14 | {
15 | const { qtMock, workspaceMock, world } = init(config);
16 | [clientLeft, clientRightTop, clientRightBottom] = workspaceMock.createClientsWithWidths(300, 300, 200);
17 | qtMock.fireShortcut("karousel-window-move-left");
18 | assertSizes(300, 300, h, h);
19 |
20 | workspaceMock.resizeWindow(clientLeft, false, false, false, new MockQmlSize(10, 20));
21 | assertSizes(310, 300, h, h);
22 |
23 | workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 0), new MockQmlSize(-10, 0));
24 | assertSizes(310, 300, h, h);
25 |
26 | workspaceMock.resizeWindow(clientRightTop, false, false, false, new MockQmlSize(-5, -10), new MockQmlSize(-5, -10));
27 | assertSizes(310, 290, h-20, h+20);
28 |
29 | workspaceMock.resizeWindow(clientRightBottom, false, false, false, new MockQmlSize(-10, 20));
30 | assertSizes(310, 280, h-20, h+20);
31 |
32 | workspaceMock.resizeWindow(clientRightBottom, false, false, true, new MockQmlSize(0, 20));
33 | assertSizes(310, 280, h-40, h+40);
34 | }
35 |
36 | {
37 | const { qtMock, workspaceMock, world } = init(config);
38 | [clientLeft, clientRightTop, clientRightBottom] = workspaceMock.createClientsWithWidths(300, 300, 200);
39 | qtMock.fireShortcut("karousel-window-move-left");
40 | assertSizes(300, 300, h, h);
41 |
42 | workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 20));
43 | assertSizes(310, 290, h, h);
44 |
45 | workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 0), new MockQmlSize(-10, 0));
46 | assertSizes(310, 290, h, h);
47 |
48 | workspaceMock.resizeWindow(clientRightTop, true, false, false, new MockQmlSize(-5, -10), new MockQmlSize(-5, -10));
49 | assertSizes(310, 280, h-20, h+20);
50 |
51 | workspaceMock.resizeWindow(clientRightBottom, true, true, false, new MockQmlSize(-10, 20));
52 | assertSizes(320, 270, h-20, h+20);
53 |
54 | workspaceMock.resizeWindow(clientRightBottom, true, false, true, new MockQmlSize(0, 20));
55 | assertSizes(320, 270, h-40, h+40);
56 | }
57 |
58 | {
59 | const { qtMock, workspaceMock, world } = init(config);
60 | [clientLeft, clientRightTop, clientRightBottom] = workspaceMock.createClientsWithWidths(300, 300, 200);
61 | clientRightBottom.minSize = new MockQmlSize(295, h-20);
62 | qtMock.fireShortcut("karousel-window-move-left");
63 | assertSizes(300, 300, h, h);
64 |
65 | workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 20));
66 | assertSizes(310, 295, h, h);
67 |
68 | workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 0), new MockQmlSize(-10, 0));
69 | assertSizes(310, 295, h, h);
70 |
71 | workspaceMock.resizeWindow(clientRightTop, true, false, false, new MockQmlSize(-5, -10), new MockQmlSize(-5, -10));
72 | assertSizes(310, 295, h-20, h+20);
73 |
74 | workspaceMock.resizeWindow(clientRightBottom, true, true, false, new MockQmlSize(-10, 20));
75 | assertSizes(310, 295, h-20, h+20);
76 |
77 | workspaceMock.resizeWindow(clientRightTop, true, true, false, new MockQmlSize(-10, 0));
78 | assertSizes(310, 295, h-20, h+20);
79 |
80 | // TODO
81 | // workspaceMock.resizeWindow(clientRightBottom, true, false, true, new MockQmlSize(0, -80));
82 | // assertSizes(310, 295, h+60, h-20);
83 | }
84 |
85 | {
86 | const { qtMock, workspaceMock, world } = init(config);
87 | const [clientLeftTop, clientLeftBottom, clientRight] = workspaceMock.createClientsWithWidths(300, 200, 300);
88 | clientLeftBottom.minSize = new MockQmlSize(295, h-20);
89 |
90 | function assertSizes(leftWidth: number, rightWidth: number, topHeight: number, bottomHeight: number) {
91 | const { left, right } = getGridBounds(clientLeftTop, clientRight);
92 | Assert.rect(clientLeftTop.frameGeometry, left, tilingArea.top, leftWidth, topHeight);
93 | Assert.rect(clientLeftBottom.frameGeometry, left, tilingArea.top+topHeight+gapV, leftWidth, bottomHeight);
94 | Assert.rect(clientRight.frameGeometry, left+leftWidth+gapH, tilingArea.top, rightWidth, tilingArea.height);
95 | }
96 |
97 | workspaceMock.activeWindow = clientLeftBottom;
98 | qtMock.fireShortcut("karousel-window-move-left");
99 | assertSizes(300, 300, h, h);
100 |
101 | workspaceMock.resizeWindow(clientLeftTop, true, false, false, new MockQmlSize(-10, 0));
102 | assertSizes(295, 305, h, h);
103 |
104 | workspaceMock.resizeWindow(clientLeftTop, true, false, false, new MockQmlSize(10, 0));
105 | assertSizes(305, 295, h, h);
106 |
107 | workspaceMock.resizeWindow(clientLeftTop, true, false, false, new MockQmlSize(-20, 0), new MockQmlSize(20, 0));
108 | assertSizes(305, 295, h, h);
109 | }
110 | });
111 |
--------------------------------------------------------------------------------
/src/tests/main.ts:
--------------------------------------------------------------------------------
1 | tests.run();
2 |
--------------------------------------------------------------------------------
/src/tests/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": [
4 | "../lib/**/*",
5 | "./utils/**/*",
6 | "./units/**/*",
7 | "./flows/**/*",
8 | "./main.ts"
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/src/tests/units/behavior/PresetWidths.ts:
--------------------------------------------------------------------------------
1 | tests.register("PresetWidths", 1, () => {
2 | const minWidth = 50;
3 | const maxWidth = 800;
4 | const spacing = 10;
5 |
6 | const testCases = [
7 | { str: "100%, 50%", result: [395, 800] },
8 | { str: "105%, 50%", result: [395, 800] },
9 | { str: "100px,50 px", result: [50, 100] },
10 | { str: "900px,25 px", result: [50, 800] },
11 | { str: " 100px, 25 % , 0.1 ", result: [71, 100, 192] },
12 | { str: "100px, 25%, 0.1, 100px", result: [71, 100, 192] },
13 | { str: "100px, -25 % , 0.1 ", error: true },
14 | { str: "100px, 25 % , -0.1 ", error: true },
15 | { str: "100px, 25 % , 0.1p", error: true },
16 | { str: "100px, % , 0.1 ", error: true },
17 | { str: "100px, , 0.1 ", error: true },
18 | { str: "100px, 0, 0.1 ", error: true },
19 | { str: "100px,, 0.1 ", error: true },
20 | { str: "100px, 25 % , ", error: true },
21 | { str: "asdf", error: true },
22 | { str: "", error: true },
23 | { str: " ", error: true },
24 | ];
25 |
26 | function assertWidths(presetWidths: PresetWidths, expectedWidths: number[]) {
27 | let currentWidth = 0;
28 | for (const expectedWidth of expectedWidths) {
29 | currentWidth = presetWidths.next(currentWidth, minWidth, maxWidth);
30 | Assert.equal(currentWidth, expectedWidth);
31 | }
32 | const repeatedWidth = presetWidths.next(currentWidth, minWidth, maxWidth);
33 | Assert.equal(repeatedWidth, expectedWidths[0]);
34 | }
35 |
36 | for (const testCase of testCases) {
37 | try {
38 | const presetWidths = new PresetWidths(testCase.str, spacing);
39 | Assert.assert(!testCase.error);
40 | assertWidths(presetWidths, testCase.result!);
41 | } catch (error) {
42 | Assert.assert(testCase.error === true);
43 | }
44 | }
45 | });
46 |
--------------------------------------------------------------------------------
/src/tests/units/rules/WindowRuleEnforcer.ts:
--------------------------------------------------------------------------------
1 | tests.register("WindowRuleEnforcer", 1, () => {
2 | screen = new MockQmlRect(0, 0, 800, 600);
3 | Workspace = new MockWorkspace();
4 |
5 | const testCases = [
6 | { tiledByDefault: true, resourceClass: "unknown", caption: "anything", shouldTile: true },
7 | { tiledByDefault: false, resourceClass: "unknown", caption: "anything", shouldTile: false },
8 | { tiledByDefault: true, resourceClass: "org.kde.plasmashell", caption: "something", shouldTile: false },
9 | { tiledByDefault: true, resourceClass: "plasmashell", caption: "something", shouldTile: false },
10 | { tiledByDefault: false, resourceClass: "org.kde.kfind", caption: "something", shouldTile: true },
11 | { tiledByDefault: false, resourceClass: "kfind", caption: "something", shouldTile: true },
12 | { tiledByDefault: true, resourceClass: "org.kde.kruler", caption: "anything", shouldTile: false },
13 | { tiledByDefault: true, resourceClass: "kruler", caption: "anything", shouldTile: false },
14 | { tiledByDefault: true, resourceClass: "zoom", caption: "something", shouldTile: true },
15 | { tiledByDefault: true, resourceClass: "zoom", caption: "zoom", shouldTile: false },
16 | ];
17 |
18 | const enforcer = new WindowRuleEnforcer(JSON.parse(defaultWindowRules));
19 | for (const testCase of testCases) {
20 | const kwinClient: any = createKwinClient(testCase.tiledByDefault, testCase.resourceClass, testCase.caption);
21 | Assert.assert(
22 | enforcer.shouldTile(kwinClient) === testCase.shouldTile,
23 | { message: "failed case: " + JSON.stringify(testCase) },
24 | );
25 | }
26 |
27 | function createKwinClient(normalWindow: boolean, resourceClass: string, caption: string) {
28 | return {
29 | normalWindow: normalWindow,
30 | transient: false,
31 | clientGeometry: new MockQmlRect(0, 0, 200, 200),
32 | managed: true,
33 | pid: 100,
34 | moveable: true,
35 | resizeable: true,
36 | popupWindow: false,
37 | minimized: false,
38 | desktops: [1],
39 | activities: [1],
40 | resourceClass: resourceClass,
41 | caption: caption,
42 | };
43 | }
44 | });
45 |
--------------------------------------------------------------------------------
/src/tests/units/utils/RateLimiter.ts:
--------------------------------------------------------------------------------
1 | tests.register("RateLimiter", 1, () => {
2 | const rateLimiter = new RateLimiter(3, 100);
3 |
4 | function testRateLimiter() {
5 | Assert.assert(rateLimiter.acquire());
6 | Assert.assert(rateLimiter.acquire());
7 | Assert.assert(rateLimiter.acquire());
8 | Assert.assert(!rateLimiter.acquire());
9 | Assert.assert(!rateLimiter.acquire());
10 | }
11 |
12 | timeControl(addTime => {
13 | testRateLimiter();
14 |
15 | addTime(10);
16 | Assert.assert(!rateLimiter.acquire(), { message: "The interval hasn't expired yet" });
17 |
18 | addTime(90);
19 | // the rate limiter interval has expired, let's test again
20 | testRateLimiter();
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/tests/units/utils/fillSpace.ts:
--------------------------------------------------------------------------------
1 | tests.register("fillSpace", 1, () => {
2 | const testCases: {
3 | availableSpace: number,
4 | items: { min: number, max: number }[],
5 | expected: number[],
6 | }[] = [
7 | {
8 | availableSpace: 600,
9 | items: [],
10 | expected: [],
11 | },
12 | {
13 | availableSpace: 600,
14 | items: [
15 | { min: 10, max: 600 },
16 | { min: 10, max: 600 },
17 | ],
18 | expected: [300, 300],
19 | },
20 | {
21 | availableSpace: 700,
22 | items: [
23 | { min: 300, max: 300 },
24 | { min: 300, max: 300 },
25 | ],
26 | expected: [300, 300],
27 | },
28 | {
29 | availableSpace: 700,
30 | items: [
31 | { min: 300, max: 300 },
32 | { min: 300, max: 300 },
33 | { min: 10, max: 900 },
34 | ],
35 | expected: [300, 300, 100],
36 | },
37 | {
38 | availableSpace: 600,
39 | items: [
40 | { min: 10, max: 250 },
41 | { min: 10, max: 500 },
42 | ],
43 | expected: [250, 350],
44 | },
45 | {
46 | availableSpace: 600,
47 | items: [
48 | { min: 10, max: 250 },
49 | { min: 400, max: 500 },
50 | ],
51 | expected: [200, 400],
52 | },
53 | {
54 | availableSpace: 765,
55 | items: [
56 | { min: 10, max: 250 },
57 | { min: 10, max: 254 },
58 | { min: 10, max: 500 },
59 | ],
60 | expected: [250, 254, 261],
61 | },
62 | {
63 | availableSpace: 600,
64 | items: [
65 | { min: 10, max: 150 },
66 | { min: 400, max: 500 },
67 | ],
68 | expected: [150, 450],
69 | },
70 | {
71 | availableSpace: 750,
72 | items: [
73 | { min: 10, max: 250 },
74 | { min: 10, max: 250 },
75 | { min: 400, max: 500 },
76 | { min: 10, max: 300 },
77 | ],
78 | expected: [117, 117, 400, 116],
79 | },
80 | {
81 | availableSpace: 750,
82 | items: [
83 | { min: 10, max: 250 },
84 | { min: 120, max: 250 },
85 | { min: 400, max: 500 },
86 | { min: 10, max: 300 },
87 | ],
88 | expected: [115, 120, 400, 115],
89 | },
90 | {
91 | availableSpace: 1200,
92 | items: [
93 | { min: 10, max: 250 },
94 | { min: 10, max: 500 },
95 | ],
96 | expected: [250, 500],
97 | },
98 | {
99 | availableSpace: 5,
100 | items: [
101 | { min: 10, max: 250 },
102 | { min: 10, max: 500 },
103 | ],
104 | expected: [10, 10],
105 | },
106 | {
107 | availableSpace: 800,
108 | items: [
109 | { min: 114, max: 800 },
110 | { min: 10, max: 93 },
111 | { min: 10, max: 93 },
112 | { min: 10, max: 93 },
113 | { min: 10, max: 93 },
114 | { min: 10, max: 93 },
115 | { min: 109, max: 800 },
116 | { min: 10, max: 800 },
117 | ],
118 | expected: [114, 93, 93, 93, 93, 93, 111, 110],
119 | },
120 | {
121 | availableSpace: 801,
122 | items: [
123 | { min: 114, max: 800 },
124 | { min: 10, max: 93 },
125 | { min: 10, max: 93 },
126 | { min: 10, max: 93 },
127 | { min: 10, max: 93 },
128 | { min: 10, max: 93 },
129 | { min: 109, max: 800 },
130 | { min: 10, max: 800 },
131 | ],
132 | expected: [114, 93, 93, 93, 93, 93, 111, 111],
133 | },
134 | {
135 | availableSpace: 801,
136 | items: [
137 | { min: 114, max: 800 },
138 | { min: 10, max: 93 },
139 | { min: 10, max: 93 },
140 | { min: 10, max: 93 },
141 | { min: 10, max: 93 },
142 | { min: 10, max: 93 },
143 | { min: 109, max: 800 },
144 | { min: 10, max: 95 },
145 | ],
146 | expected: [121, 93, 93, 93, 93, 93, 120, 95],
147 | },
148 | {
149 | availableSpace: 799,
150 | items: [
151 | { min: 10, max: 86 },
152 | { min: 107, max: 800 },
153 | { min: 107, max: 800 },
154 | { min: 107, max: 800 },
155 | { min: 107, max: 800 },
156 | { min: 107, max: 800 },
157 | { min: 10, max: 91},
158 | { min: 105, max: 800 },
159 | ],
160 | expected: [80, 107, 107, 107, 107, 107, 79, 105],
161 | },
162 | {
163 | availableSpace: 1029,
164 | items: [
165 | { min: 114, max: 800 },
166 | { min: 114, max: 800 },
167 | { min: 114, max: 800 },
168 | { min: 10, max: 93 },
169 | { min: 10, max: 93 },
170 | { min: 10, max: 93 },
171 | { min: 10, max: 93 },
172 | { min: 10, max: 93 },
173 | { min: 109, max: 800 },
174 | { min: 10, max: 800 },
175 | ],
176 | expected: [114, 114, 114, 93, 93, 93, 93, 93, 111, 111],
177 | },
178 | {
179 | availableSpace: 602,
180 | items: [
181 | { min: 10, max: 600 },
182 | { min: 10, max: 600 },
183 | { min: 10, max: 600 },
184 | ],
185 | expected: [200, 200, 200],
186 | },
187 | {
188 | availableSpace: 602,
189 | items: [
190 | { min: 204, max: 600 },
191 | { min: 202, max: 600 },
192 | { min: 10, max: 600 },
193 | ],
194 | expected: [204, 202, 196],
195 | },
196 | {
197 | availableSpace: 803,
198 | items: [
199 | { min: 204, max: 600 },
200 | { min: 10, max: 600 },
201 | { min: 10, max: 600 },
202 | { min: 10, max: 600 },
203 | ],
204 | expected: [204, 200, 200, 199],
205 | },
206 | {
207 | availableSpace: 900,
208 | items: [
209 | { min: 10, max: 120 },
210 | { min: 10, max: 250 },
211 | { min: 500, max: 500 },
212 | { min: 300, max: 500 },
213 | ],
214 | expected: [50, 50, 500, 300],
215 | },
216 | {
217 | availableSpace: 845,
218 | items: [
219 | { min: 5, max: 5 },
220 | { min: 10, max: 40 },
221 | { min: 500, max: 500 },
222 | { min: 300, max: 500 },
223 | ],
224 | expected: [5, 40, 500, 300],
225 | },
226 | {
227 | availableSpace: 800,
228 | items: [
229 | { min: 10, max: 20 },
230 | { min: 220, max: 221 },
231 | { min: 250, max: 260 },
232 | { min: 300, max: 305 },
233 | ],
234 | expected: [20, 221, 259, 300],
235 | },
236 | ];
237 |
238 | for (const testCase of testCases) {
239 | const result = fillSpace(testCase.availableSpace, testCase.items);
240 | Assert.equalArrays(
241 | result,
242 | testCase.expected,
243 | { message: JSON.stringify(testCase) },
244 | );
245 | }
246 | });
247 |
--------------------------------------------------------------------------------
/src/tests/units/utils/math.ts:
--------------------------------------------------------------------------------
1 | tests.register("math", 1, () => {
2 | const rect = new MockQmlRect(100, 200, 10, 20);
3 | const testCases: {
4 | rect: QmlRect,
5 | point: QmlPoint,
6 | contained: boolean,
7 | }[] = [
8 | {
9 | rect: rect,
10 | point: new MockQmlPoint(100, 200),
11 | contained: true,
12 | },
13 | {
14 | rect: rect,
15 | point: new MockQmlPoint(110, 220),
16 | contained: true,
17 | },
18 | {
19 | rect: rect,
20 | point: new MockQmlPoint(105, 205),
21 | contained: true,
22 | },
23 | {
24 | rect: rect,
25 | point: new MockQmlPoint(110.01, 205),
26 | contained: false,
27 | },
28 | {
29 | rect: rect,
30 | point: new MockQmlPoint(105, 220.01),
31 | contained: false,
32 | },
33 | {
34 | rect: rect,
35 | point: new MockQmlPoint(16, 205),
36 | contained: false,
37 | },
38 | {
39 | rect: rect,
40 | point: new MockQmlPoint(105, 16),
41 | contained: false,
42 | },
43 | ];
44 |
45 | for (const testCase of testCases) {
46 | const result = rectContainsPoint(testCase.rect, testCase.point);
47 | Assert.equal(
48 | result,
49 | testCase.contained,
50 | { message: JSON.stringify(testCase) },
51 | );
52 | }
53 | });
54 |
--------------------------------------------------------------------------------
/src/tests/units/world/Clients.ts:
--------------------------------------------------------------------------------
1 | tests.register("Clients.canTileEver", 1, () => {
2 | const testCases = [
3 | { clientProperties: { resourceClass: "app", caption: "Title" }, tileable: true },
4 | { clientProperties: { resourceClass: "app", caption: "Title", moveable: false }, tileable: false },
5 | { clientProperties: { resourceClass: "app", caption: "Caption", resizeable: false }, tileable: false },
6 | { clientProperties: { resourceClass: "app", caption: "Caption", normalWindow: false, popupWindow: true }, tileable: false },
7 | { clientProperties: { resourceClass: "app", caption: "Caption", moveable: false, resizeable: false, fullScreen: true }, tileable: true },
8 | { clientProperties: { resourceClass: "ksmserver-logout-greeter", caption: "Caption" }, tileable: false },
9 | { clientProperties: { resourceClass: "xwaylandvideobridge", caption: "" }, tileable: false },
10 | ];
11 |
12 | for (const testCase of testCases) {
13 | const kwinClient: any = createKwinClient(testCase.clientProperties);
14 | Assert.assert(
15 | Clients.canTileEver(kwinClient) === testCase.tileable,
16 | { message: "failed case: " + JSON.stringify(testCase) },
17 | );
18 | }
19 |
20 | function createKwinClient(properties: { resourceClass: string, caption: string }) {
21 | const defaultProperties = {
22 | normalWindow: true,
23 | transient: false,
24 | managed: true,
25 | pid: 100,
26 | moveable: true,
27 | resizeable: true,
28 | fullScreen: false,
29 | popupWindow: false,
30 | minimized: false,
31 | desktops: [1],
32 | activities: [1],
33 | };
34 | return { ...defaultProperties, ...properties };
35 | }
36 | });
37 |
--------------------------------------------------------------------------------
/src/tests/utils/TestRunner.ts:
--------------------------------------------------------------------------------
1 | class TestRunner {
2 | private readonly tests: TestRunner.Test[] = [];
3 |
4 | public register(name: string, count: number, f: () => void) {
5 | this.tests.push({ name: name, count: count, f: f });
6 | }
7 |
8 | public run() {
9 | for (const test of this.tests) {
10 | console.log("Running test " + test.name);
11 | for (let i = 0; i < test.count; i++) {
12 | test.f();
13 | }
14 | }
15 | }
16 | }
17 |
18 | namespace TestRunner {
19 | export interface Test {
20 | name: string,
21 | count: number,
22 | f: () => void,
23 | }
24 | }
25 |
26 | const tests = new TestRunner();
27 |
--------------------------------------------------------------------------------
/src/tests/utils/config.ts:
--------------------------------------------------------------------------------
1 | function getDefaultConfig(): Config {
2 | const config: any = {};
3 | for (const prop of configDef) {
4 | config[prop.name] = prop.default;
5 | }
6 | return config;
7 | }
8 |
--------------------------------------------------------------------------------
/src/tests/utils/extern/node.ts:
--------------------------------------------------------------------------------
1 | declare const process: {
2 | exit(code?: number): void,
3 | };
4 |
--------------------------------------------------------------------------------
/src/tests/utils/global.ts:
--------------------------------------------------------------------------------
1 | let Qt: Qt;
2 | let KWin: KWin;
3 | let Workspace: Workspace;
4 | let qmlBase: QmlObject;
5 | let notificationInvalidWindowRules: Notification;
6 | let notificationInvalidPresetWidths: Notification;
7 | let moveCursorToFocus: DBusCall;
8 |
9 | let screen: MockQmlRect;
10 | let tilingArea: MockQmlRect;
11 | let gapH: number;
12 | let gapV: number;
13 | let runLog: string[];
14 |
15 | function init(config: Config) {
16 | screen = new MockQmlRect(0, 0, 800, 600);
17 | tilingArea = new MockQmlRect(
18 | config.gapsOuterLeft,
19 | config.gapsOuterTop,
20 | screen.width - config.gapsOuterLeft - config.gapsOuterRight,
21 | screen.height - config.gapsOuterTop - config.gapsOuterBottom,
22 | );
23 | gapH = config.gapsInnerHorizontal;
24 | gapV = config.gapsInnerVertical;
25 | runLog = [];
26 |
27 | const qtMock = new MockQt();
28 | const workspaceMock = new MockWorkspace();
29 |
30 | Qt = qtMock;
31 | Workspace = workspaceMock;
32 | moveCursorToFocus = {
33 | __brand: "QmlObject",
34 | call: () => {
35 | Assert.assert(Workspace.activeWindow !== null, { message: "moveCursorToFocus should never be called if there's no focused window" });
36 | const frame = Workspace.activeWindow!.frameGeometry;
37 | workspaceMock.cursorPos.x = Math.floor(frame.x + frame.width/2);
38 | workspaceMock.cursorPos.y = Math.floor(frame.y + frame.height/2);
39 | },
40 | };
41 |
42 | const world = new World(config);
43 | return { qtMock, workspaceMock, world };
44 | }
45 |
46 | function getGridBounds(clientLeft: KwinClient, clientRight: KwinClient) {
47 | const columnsWidth = clientRight.frameGeometry.right - clientLeft.frameGeometry.left;
48 | const left = tilingArea.left + Math.floor((tilingArea.width - columnsWidth) / 2);
49 | const right = left + columnsWidth;
50 | return { left, right };
51 | }
52 |
53 | function getWindowHeight(windowsInColumn: number) {
54 | const totalGaps = (windowsInColumn-1) * gapV;
55 | return Math.round((tilingArea.height - totalGaps) / windowsInColumn);
56 | }
57 |
58 | function getClientManager(world: World): ClientManager {
59 | // don't do this outside of tests
60 | let clientManager;
61 | world.do((cm, dm) => clientManager = cm);
62 | return clientManager!;
63 | }
64 |
--------------------------------------------------------------------------------
/src/tests/utils/mocks/MockKwinClient.ts:
--------------------------------------------------------------------------------
1 | class MockKwinClient {
2 | public readonly __brand = "KwinClient";
3 |
4 | private static readonly borderThickness = 10;
5 |
6 | public caption = "App";
7 | public minSize: Readonly = new MockQmlSize(0, 0);
8 | public readonly transient: boolean;
9 | public move = false;
10 | public resize = false;
11 | public readonly fullScreenable: boolean = true;
12 | public readonly maximizable: boolean = true;
13 | public readonly output: Output = { __brand: "Output" };
14 | public resourceClass = "app";
15 | public readonly dock: boolean = false;
16 | public readonly normalWindow: boolean = true;
17 | public readonly managed: boolean = true;
18 | public readonly popupWindow: boolean = false;
19 | public readonly pid = 1;
20 |
21 | private _maximizedVertically = false;
22 | private _maximizedHorizontally = false;
23 | private _fullScreen = false;
24 | public activities: string[] = [];
25 | public skipSwitcher = false;
26 | public keepAbove = false;
27 | public keepBelow = false;
28 | private _minimized = false;
29 | private _desktops: KwinDesktop[] = [];
30 | private _tile: Tile|null = null;
31 | public opacity = 1.0;
32 |
33 | public readonly fullScreenChanged = new MockQSignal<[]>();
34 | public readonly desktopsChanged = new MockQSignal<[]>();
35 | public readonly activitiesChanged = new MockQSignal<[]>();
36 | public readonly minimizedChanged = new MockQSignal<[]>();
37 | public readonly maximizedAboutToChange = new MockQSignal<[MaximizedMode]>();
38 | public readonly captionChanged = new MockQSignal<[]>();
39 | public readonly tileChanged = new MockQSignal<[]>();
40 | public readonly interactiveMoveResizeStarted = new MockQSignal<[]>();
41 | public readonly interactiveMoveResizeFinished = new MockQSignal<[]>();
42 | public readonly frameGeometryChanged = new MockQSignal<[oldGeometry: QmlRect]>();
43 |
44 | private windowedFrameGeometry: MockQmlRect;
45 | private windowed = true;
46 | private hasBorder = true;
47 |
48 | constructor(
49 | private _frameGeometry: MockQmlRect = new MockQmlRect(10, 10, 100, 200),
50 | public readonly transientFor: MockKwinClient|null = null,
51 | ) {
52 | this.windowedFrameGeometry = _frameGeometry.clone();
53 | this.transient = transientFor !== null;
54 | this._desktops = [Workspace.currentDesktop];
55 | }
56 |
57 | setMaximize(vertically: boolean, horizontally: boolean) {
58 | this.windowed = !(vertically || horizontally);
59 |
60 | if (vertically === this._maximizedVertically && horizontally === this._maximizedHorizontally) {
61 | return;
62 | }
63 | this._maximizedVertically = vertically;
64 | this._maximizedHorizontally = horizontally;
65 |
66 | this.maximizedAboutToChange.fire(
67 | vertically ? (
68 | horizontally ? MaximizedMode.Maximized : MaximizedMode.Vertically
69 | ) : (
70 | horizontally ? MaximizedMode.Horizontally : MaximizedMode.Unmaximized
71 | )
72 | );
73 |
74 | this.frameGeometry = new MockQmlRect(
75 | horizontally ? 0 : this.windowedFrameGeometry.x,
76 | vertically ? 0 : this.windowedFrameGeometry.y,
77 | horizontally ? screen.width : this.windowedFrameGeometry.width,
78 | vertically ? screen.height : this.windowedFrameGeometry.height,
79 | );
80 | }
81 |
82 | public get clientGeometry() {
83 | if (this.hasBorder) {
84 | return new MockQmlRect(
85 | this.frameGeometry.x + MockKwinClient.borderThickness,
86 | this.frameGeometry.y + MockKwinClient.borderThickness,
87 | this.frameGeometry.width - 2 * MockKwinClient.borderThickness,
88 | this.frameGeometry.height - 2 * MockKwinClient.borderThickness,
89 | );
90 | } else {
91 | return this.frameGeometry;
92 | }
93 | }
94 |
95 | public get moveable() {
96 | return !this._fullScreen;
97 | }
98 |
99 | public get resizeable() {
100 | return !this._fullScreen;
101 | }
102 |
103 | public get fullScreen() {
104 | return this._fullScreen;
105 | }
106 |
107 | public set fullScreen(fullScreen: boolean) {
108 | const oldFullScreen = this._fullScreen;
109 | this.hasBorder = !fullScreen;
110 | const targetFrameGeometry = fullScreen ? screen : this.windowedFrameGeometry;
111 |
112 | runReorder(
113 | () => {
114 | this._fullScreen = fullScreen;
115 | if (fullScreen !== oldFullScreen) {
116 | this.fullScreenChanged.fire();
117 | }
118 | },
119 | () => {
120 | if (oldFullScreen && !fullScreen) {
121 | // when switching from full-screen to windowed, Kwin sometimes first adds the frame before changing the frameGeometry to the final value
122 | if (!rectEquals(this.frameGeometry, screen)) {
123 | // already has windowed frame geometry, don't undo that
124 | return;
125 | }
126 | runOneOf(
127 | () => this.frameGeometry = new MockQmlRect(
128 | 0,
129 | 0,
130 | screen.width + 2 * MockKwinClient.borderThickness,
131 | screen.height + 2 * MockKwinClient.borderThickness,
132 | ),
133 | () => this.frameGeometry = new MockQmlRect(
134 | -MockKwinClient.borderThickness,
135 | -MockKwinClient.borderThickness,
136 | screen.width + 2 * MockKwinClient.borderThickness,
137 | screen.height + 2 * MockKwinClient.borderThickness,
138 | ),
139 | () => {},
140 | );
141 | }
142 | },
143 | () => {
144 | this.windowed = !fullScreen;
145 | this.frameGeometry = targetFrameGeometry;
146 | },
147 | );
148 | }
149 |
150 | public get frameGeometry() {
151 | return this._frameGeometry;
152 | }
153 |
154 | public set frameGeometry(frameGeometry: MockQmlRect) {
155 | const oldFrameGeometry = this._frameGeometry;
156 | this._frameGeometry = new MockQmlRect(
157 | frameGeometry.x,
158 | frameGeometry.y,
159 | frameGeometry.width,
160 | frameGeometry.height,
161 | this.frameGeometryChanged.fire.bind(this.frameGeometryChanged),
162 | );
163 | if (this.windowed) {
164 | this.windowedFrameGeometry = this._frameGeometry.clone();
165 | }
166 | if (!rectEquals(frameGeometry, oldFrameGeometry)) {
167 | this.frameGeometryChanged.fire(oldFrameGeometry);
168 | }
169 | }
170 |
171 | public get minimized() {
172 | return this._minimized;
173 | }
174 |
175 | public set minimized(minimized: boolean) {
176 | this._minimized = minimized;
177 | this.minimizedChanged.fire();
178 | }
179 |
180 | public get desktops() {
181 | return this._desktops;
182 | }
183 |
184 | public set desktops(desktops: KwinDesktop[]) {
185 | this._desktops = desktops;
186 | this.desktopsChanged.fire();
187 | if (Workspace.activeWindow === this && !desktops.includes(Workspace.currentDesktop)) {
188 | Workspace.activeWindow = null;
189 | };
190 | }
191 |
192 | public get tile() {
193 | return this._tile;
194 | }
195 |
196 | public set tile(tile: Tile|null) {
197 | this._tile = tile;
198 | this.tileChanged.fire();
199 | }
200 |
201 | public pin(geometry: MockQmlRect) {
202 | runMaybe(() => this.frameGeometry = geometry);
203 | this.tile = { __brand: "Tile" };
204 | this.frameGeometry = geometry;
205 | }
206 |
207 | public unpin() {
208 | this.tile = null;
209 | }
210 |
211 | public getFrameGeometryCopy() {
212 | return this._frameGeometry.clone();
213 | }
214 |
215 | public toString() {
216 | return `MockKwinClient("${this.caption}")`;
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/src/tests/utils/mocks/MockQSignal.ts:
--------------------------------------------------------------------------------
1 | class MockQSignal {
2 | public readonly __brand = "QSignal";
3 |
4 | private readonly handlers = new Set<(...args: [...T]) => void>();
5 |
6 | public connect(handler: (...args: [...T]) => void) {
7 | this.handlers.add(handler);
8 | };
9 |
10 | public disconnect(handler: (...args: [...T]) => void) {
11 | this.handlers.delete(handler);
12 | };
13 |
14 | public fire(...args: [...T]) {
15 | for (const handler of this.handlers) {
16 | handler(...args);
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/tests/utils/mocks/MockQmlPoint.ts:
--------------------------------------------------------------------------------
1 | class MockQmlPoint {
2 | public readonly __brand = "QmlPoint";
3 |
4 | constructor(
5 | public x: number,
6 | public y: number,
7 | ) {}
8 |
9 | public clone() {
10 | return new MockQmlPoint(
11 | this.x,
12 | this.y,
13 | );
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/tests/utils/mocks/MockQmlRect.ts:
--------------------------------------------------------------------------------
1 | class MockQmlRect {
2 | public readonly __brand = "QmlRect";
3 |
4 | constructor(
5 | private _x: number,
6 | private _y: number,
7 | private _width: number,
8 | private _height: number,
9 | private readonly onChanged: (oldRect: MockQmlRect) => void = () => {},
10 | ) {}
11 |
12 | public get x() {
13 | return this._x;
14 | }
15 |
16 | public set x(x: number) {
17 | const oldRect = this.clone();
18 | this._x = x;
19 | this.onChanged(oldRect);
20 | }
21 |
22 | public get y() {
23 | return this._y;
24 | }
25 |
26 | public set y(y: number) {
27 | const oldRect = this.clone();
28 | this._y = y;
29 | this.onChanged(oldRect);
30 | }
31 |
32 | public get width() {
33 | return this._width;
34 | }
35 |
36 | public set width(width: number) {
37 | const oldRect = this.clone();
38 | this._width = width;
39 | this.onChanged(oldRect);
40 | }
41 |
42 | public get height() {
43 | return this._height;
44 | }
45 |
46 | public set height(height: number) {
47 | const oldRect = this.clone();
48 | this._height = height;
49 | this.onChanged(oldRect);
50 | }
51 |
52 | public get top() {
53 | return this.y;
54 | }
55 |
56 | public get bottom() {
57 | return this.y + this.height;
58 | }
59 |
60 | public get left() {
61 | return this.x;
62 | }
63 |
64 | public get right() {
65 | return this.x + this.width;
66 | }
67 |
68 | public set(target: QmlRect) {
69 | const oldRect = this.clone();
70 | this._x = target.x;
71 | this._y = target.y;
72 | this._width = target.width;
73 | this._height = target.height;
74 | this.onChanged(oldRect);
75 | }
76 |
77 | public clone() {
78 | return new MockQmlRect(
79 | this._x,
80 | this._y,
81 | this._width,
82 | this._height,
83 | );
84 | }
85 |
86 | public toString() {
87 | return `(${this.x} ${this.y} ${this.width} ${this.height})`;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/tests/utils/mocks/MockQmlSize.ts:
--------------------------------------------------------------------------------
1 | class MockQmlSize {
2 | public readonly __brand = "QmlSize";
3 |
4 | constructor(
5 | public width: number,
6 | public height: number,
7 | ) {}
8 | }
9 |
--------------------------------------------------------------------------------
/src/tests/utils/mocks/MockQmlTimer.ts:
--------------------------------------------------------------------------------
1 | class MockQmlTimer {
2 | public readonly __brand = "QmlObject";
3 |
4 | public interval = 0;
5 | public readonly triggered = new MockQSignal<[]>();
6 |
7 | public restart() {
8 | // no need to wait in tests, just fire immediately
9 | this.triggered.fire();
10 | };
11 |
12 | public destroy() {}
13 | }
14 |
--------------------------------------------------------------------------------
/src/tests/utils/mocks/MockQt.ts:
--------------------------------------------------------------------------------
1 | class MockQt {
2 | public readonly __brand = "Qt";
3 |
4 | private shortcuts = new Map();
5 |
6 | public point(x: number, y: number) {
7 | return new MockQmlPoint(x, y);
8 | }
9 |
10 | public rect(x: number, y: number, width: number, height: number) {
11 | return new MockQmlRect(x, y, width, height);
12 | }
13 |
14 | public createQmlObject(qml: string, parent: QmlObject): QmlObject {
15 | if (qml.includes("Timer")) {
16 | return new MockQmlTimer();
17 | } else if (qml.includes("ShortcutHandler")) {
18 | const shortcutName = MockQt.extractShortcutName(qml);
19 | const shortcutHandler = new MockShortcutHandler();
20 | this.shortcuts.set(shortcutName, shortcutHandler);
21 | return shortcutHandler;
22 | } else {
23 | throw new Error("Unexpected qml string: " + qml);
24 | }
25 | }
26 |
27 | public fireShortcut(shortcutName: string) {
28 | const shortcutHandler = this.shortcuts.get(shortcutName);
29 | if (shortcutHandler === undefined) {
30 | Assert.assert(false);
31 | return;
32 | }
33 | shortcutHandler.activated.fire();
34 | }
35 |
36 | private static extractShortcutName(qml: string) {
37 | const nameLine = qml.split("\n").find((line) => line.trimStart().startsWith("name:"));
38 | if (nameLine === undefined) {
39 | Assert.assert(false);
40 | return "";
41 | }
42 | return nameLine.substring(
43 | nameLine.indexOf('"') + 1,
44 | nameLine.lastIndexOf('"'),
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/tests/utils/mocks/MockShortcutHandler.ts:
--------------------------------------------------------------------------------
1 | class MockShortcutHandler {
2 | public readonly __brand = "QmlObject";
3 |
4 | public readonly activated: MockQSignal<[]> = new MockQSignal<[]>();
5 |
6 | public destroy() {}
7 | }
8 |
--------------------------------------------------------------------------------
/src/tests/utils/mocks/MockWorkspace.ts:
--------------------------------------------------------------------------------
1 | class MockWorkspace {
2 | public readonly __brand = "Workspace";
3 |
4 | public activities = ["test-activity"];
5 | public desktops: KwinDesktop[] = [
6 | { __brand: "KwinDesktop", id: "desktop1" },
7 | { __brand: "KwinDesktop", id: "desktop2" }
8 | ];
9 | public currentDesktop = this.desktops[0];
10 | public currentActivity = this.activities[0];
11 | public activeScreen: Output = { __brand: "Output" };
12 | public readonly windows: MockKwinClient[] = [];
13 | public cursorPos = new MockQmlPoint(0, 0);
14 |
15 | private _activeWindow: KwinClient|null = null;
16 |
17 | public readonly currentDesktopChanged = new MockQSignal<[]>();
18 | public readonly windowAdded = new MockQSignal<[KwinClient]>();
19 | public readonly windowRemoved = new MockQSignal<[KwinClient]>();
20 | public readonly windowActivated = new MockQSignal<[KwinClient|null]>();
21 | public readonly screensChanged = new MockQSignal<[]>();
22 | public readonly activitiesChanged = new MockQSignal<[]>();
23 | public readonly desktopsChanged = new MockQSignal<[]>();
24 | public readonly currentActivityChanged = new MockQSignal<[]>();
25 | public readonly virtualScreenSizeChanged = new MockQSignal<[]>();
26 |
27 | public clientArea(option: ClientAreaOption, output: Output, kwinDesktop: KwinDesktop) {
28 | return screen;
29 | }
30 |
31 | public createWindows(...kwinClients: MockKwinClient[]) {
32 | for (const kwinClient of kwinClients) {
33 | this.windows.push(kwinClient);
34 | this.windowAdded.fire(kwinClient);
35 | this.activeWindow = kwinClient;
36 | }
37 | }
38 |
39 | public createClients(n: number) {
40 | return this.createClientsWithWidths(...Array(n).fill(100));
41 | }
42 |
43 | public createClientsWithFrames(...frames: MockQmlRect[]) {
44 | const clients = frames.map(rect => new MockKwinClient(rect));
45 | clients.forEach((client, index) => client.caption = `Client ${index}`);
46 | this.createWindows(...clients);
47 | return clients;
48 | }
49 |
50 | public createClientsWithWidths(...widths: number[]) {
51 | return this.createClientsWithFrames(...widths.map(width => new MockQmlRect(randomInt(100), randomInt(100), width, 100+randomInt(400))));
52 | }
53 |
54 | public removeWindow(window: MockKwinClient) {
55 | runReorder(
56 | () => this.windows.splice(this.windows.indexOf(window), 1),
57 | () => this.windowRemoved.fire(window),
58 | );
59 | if (window === this.activeWindow) {
60 | const windows = this.windows.filter(w => w.desktops.includes(this.currentDesktop));
61 | Workspace.activeWindow = windows.length > 0 ? randomItem(windows) : null;
62 | };
63 | }
64 |
65 | public moveWindow(window: MockKwinClient, ...deltas: QmlPoint[]) {
66 | const frame = window.getFrameGeometryCopy();
67 | window.move = true;
68 | window.interactiveMoveResizeStarted.fire();
69 |
70 | for (const delta of deltas) {
71 | if (delta.x !== 0) {
72 | frame.x += delta.x;
73 | }
74 | if (delta.y !== 0) {
75 | frame.y += delta.y;
76 | }
77 | runOneOf(
78 | () => window.frameGeometry.set(frame),
79 | () => window.frameGeometry = frame,
80 | );
81 | }
82 |
83 | window.move = false;
84 | window.interactiveMoveResizeFinished.fire();
85 | }
86 |
87 | public resizeWindow(window: MockKwinClient, edgeResize: boolean, leftEdge: boolean, topEdge: boolean, ...deltas: QmlSize[]) {
88 | const frame = window.getFrameGeometryCopy();
89 | if (edgeResize) {
90 | this.cursorPos = new MockQmlPoint(
91 | leftEdge ? frame.left : frame.right,
92 | topEdge ? frame.top : frame.bottom,
93 | );
94 | } else {
95 | this.cursorPos = new MockQmlPoint(
96 | Math.round(frame.x + frame.width/2),
97 | Math.round(frame.y + frame.height/2),
98 | );
99 | }
100 | window.resize = true;
101 | window.interactiveMoveResizeStarted.fire();
102 |
103 | for (const delta of deltas) {
104 | if (delta.width !== 0) {
105 | frame.width += delta.width;
106 | if (leftEdge) {
107 | frame.x -= delta.width;
108 | }
109 | }
110 | if (delta.height !== 0) {
111 | frame.height += delta.height;
112 | if (topEdge) {
113 | frame.y -= delta.height;
114 | }
115 | }
116 | runOneOf(
117 | () => window.frameGeometry.set(frame),
118 | () => window.frameGeometry = frame,
119 | );
120 | }
121 |
122 | window.resize = false;
123 | window.interactiveMoveResizeFinished.fire();
124 | }
125 |
126 | public get activeWindow() {
127 | return this._activeWindow;
128 | }
129 |
130 | public set activeWindow(activeWindow: KwinClient|null) {
131 | this._activeWindow = activeWindow;
132 | this.windowActivated.fire(activeWindow);
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/tests/utils/random.ts:
--------------------------------------------------------------------------------
1 | function runMaybe(f: () => void) {
2 | if (Math.random() < 0.5) {
3 | f();
4 | }
5 | }
6 |
7 | function runOneOf(...fs: (() => void)[]) {
8 | const index = randomInt(fs.length);
9 | runLog.push(`${getStackFrame(1)} - Chose ${index}`);
10 | fs[index]();
11 | }
12 |
13 | function runReorder(...fs: (() => void)[]) {
14 | const fis = fs.map((f, index) => ({ f: f, index: index }));
15 | shuffle(fis);
16 |
17 | const indexes = fis.map((fi) => fi.index);
18 | runLog.push(`${getStackFrame(1)} - Order ${indexes}`);
19 |
20 | for (const fi of fis) {
21 | fi.f();
22 | }
23 | }
24 |
25 | function runReorderDebug(order: number[], ...fs: (() => void)[]) {
26 | for (const index of order) {
27 | fs[index]();
28 | }
29 | }
30 |
31 | function randomInt(n: number) {
32 | return Math.floor(Math.random() * n);
33 | }
34 |
35 | function randomItem(items: any[]) {
36 | Assert.assert(items.length > 0);
37 | const index = randomInt(items.length);
38 | return items[index];
39 | }
40 |
41 | function shuffle(items: any[]) {
42 | for (let n = items.length; n > 1; n--) {
43 | const i = n-1;
44 | const j = randomInt(n);
45 | [items[i], items[j]] = [items[j], items[i]];
46 | }
47 | }
48 |
49 | function getStackFrame(index: number) {
50 | return new Error().stack!.split("\n")[index+2].substring(7);
51 | }
52 |
--------------------------------------------------------------------------------
/src/tests/utils/timeControl.ts:
--------------------------------------------------------------------------------
1 | function timeControl(f: (addTime: (ms: number) => void) => void) {
2 | const originalDateNow = Date.now;
3 |
4 | let currentTime = Date.now();
5 | Date.now = () => currentTime;
6 |
7 | function addTime(ms: number) {
8 | currentTime += ms;
9 | }
10 | f(addTime);
11 |
12 | Date.now = originalDateNow;
13 | }
14 |
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "lib": ["es2020"],
5 | "module": "none",
6 | "allowJs": false,
7 | "esModuleInterop": false,
8 | "forceConsistentCasingInFileNames": true,
9 | "strict": true,
10 | "skipLibCheck": true
11 | }
12 | }
13 |
--------------------------------------------------------------------------------