├── .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 | --------------------------------------------------------------------------------