179 Commits

Author SHA1 Message Date
Peter Fajdiga
a9c3aa7eae bump version to 0.10 2024-10-27 14:06:20 +01:00
Peter Fajdiga
3aec148f16 Actions.columnsWidthEqualize: use fillSpace 2024-10-26 21:19:17 +02:00
Peter Fajdiga
c60cfeb521 Actions.squeezeColumns: use fillSpace 2024-10-26 20:17:49 +02:00
Peter Fajdiga
0bbf82d84d fillSpace: handle unresizable columns 2024-10-26 19:31:17 +02:00
Peter Fajdiga
44c5c43c25 fillSpace: distribute remainder 2024-10-26 18:51:01 +02:00
Peter Fajdiga
98b2d8b882 tests: fillSpace: add test cases 2024-10-26 18:50:55 +02:00
Peter Fajdiga
4e78d27620 fillSpace: reduce function nesting 2024-10-26 18:48:17 +02:00
Peter Fajdiga
32700d1193 fillSpace: handle empty input 2024-10-25 21:52:54 +02:00
Peter Fajdiga
817351ec44 fillSpace: simplify loop 2024-10-25 21:46:26 +02:00
Peter Fajdiga
c2dd832e5c move functions from math.ts to collections.ts 2024-10-25 21:39:33 +02:00
Peter Fajdiga
2f78f9afb1 fillSpace: reimplement 2024-10-25 21:39:05 +02:00
Peter Fajdiga
2cc716f59e fillSpace: improve divisor selection 2024-10-25 17:08:40 +02:00
Peter Fajdiga
685323546a fillSpace: rename variable to size 2024-10-25 12:38:11 +02:00
Peter Fajdiga
c8e40185dc fillSpace: extract helper function findMeanSpaceFiller 2024-10-25 12:37:03 +02:00
Peter Fajdiga
0b0cb77e9e tests: Assert.equal: use === 2024-10-25 12:33:10 +02:00
Peter Fajdiga
ce4f810372 fillSpace: return array of values 2024-10-25 12:32:27 +02:00
Peter Fajdiga
3ccc6dd870 move fillSpace into its own file 2024-10-25 12:15:35 +02:00
Peter Fajdiga
92e1c2ffd4 tests: rename file to fillSpace.ts 2024-10-25 12:10:46 +02:00
Peter Fajdiga
3a1c911974 math: rename findMeanInt to fillSpace 2024-10-25 12:10:21 +02:00
Peter Fajdiga
e197c3200e tests: findMean: add test case 2024-10-25 12:03:02 +02:00
Peter Fajdiga
4fea81423a implement helper function findMeanInt 2024-10-20 23:44:41 +02:00
Peter Fajdiga
b459ba6d4e tests: Assert: ignore runLog if undefined 2024-10-20 23:43:45 +02:00
Peter Fajdiga
a16e2edb1e increase externalFrameGeometryChangedRateLimiter.n to 4 (for IntelliJ project selection window) 2024-10-19 19:27:15 +02:00
Peter Fajdiga
fb1047c8ba Destkop.scrollCenterRange: force scroll (resolves #35) 2024-10-19 17:16:14 +02:00
Peter Fajdiga
66bc14287d Makefile: enable skipping tests 2024-10-19 15:48:17 +02:00
Peter Fajdiga
86ab4a20f4 tests: Assert.grid: log failing window and column 2024-10-19 15:25:51 +02:00
Peter Fajdiga
f467d5a523 add Steam Big Picture mode to default window rules 2024-10-14 22:36:18 +02:00
Peter Fajdiga
082d4571d8 remove manualResizeStep 2024-10-14 20:35:11 +02:00
Peter Fajdiga
aa061e586e ContextualResizer: remove hard-coded max-width option 2024-10-14 20:31:56 +02:00
Peter Fajdiga
ff5121f1c4 RawResizer: use preset widths 2024-10-14 19:53:11 +02:00
Peter Fajdiga
984edbec90 move resize step search function to math.ts 2024-10-14 19:31:38 +02:00
Peter Fajdiga
ea27ce4a03 ContextualResizer: use preset widths 2024-10-14 01:10:16 +02:00
Peter Fajdiga
ae0a4e142a create PresetWidths stub 2024-10-14 01:05:57 +02:00
Peter Fajdiga
09b7b44e2c Revert "ContextualResizer: add step for min width"
This reverts commit 430f731804.
2024-10-14 00:47:23 +02:00
Peter Fajdiga
fb88300dbe remove Window.onUserResize 2024-10-13 20:47:17 +02:00
Peter Fajdiga
9dafb92f47 move user width resize logic to Column.onUserResizeWidth 2024-10-13 20:04:27 +02:00
Peter Fajdiga
92144e8e83 prevent widening left neighbor on edge resize with min-width 2024-10-13 19:41:37 +02:00
Peter Fajdiga
9b54dd543e Column: increase minWidth to 40 2024-10-13 13:37:25 +02:00
Peter Fajdiga
5145b04cb7 prevent pushing columns with min-width on edge resize 2024-10-13 13:33:02 +02:00
Peter Fajdiga
4372e13869 tests: userResize: add ineffectual resizes 2024-10-13 12:54:58 +02:00
Peter Fajdiga
7694bb1f8d tests: userResize: break some resizes into multiple steps 2024-10-13 12:14:49 +02:00
Peter Fajdiga
3615952bd7 tests: MockWorkspace.resizeWindow: set frameGeometry while keeping the same QmlRect instance 2024-10-13 12:09:29 +02:00
Peter Fajdiga
cba6ecbb3d tests: MockWorkspace.resizeWindow: support passing an array of changes 2024-10-13 01:44:10 +02:00
Peter Fajdiga
db55af462b tests: add user resize tests 2024-10-13 01:38:29 +02:00
Peter Fajdiga
32f384df75 tests: rewrite test "columns squeeze side (just scroll)" 2024-10-12 21:33:51 +02:00
Peter Fajdiga
25ac6d4ce8 tests: rename file to columnsSqueezeSide.ts 2024-10-12 21:12:42 +02:00
Peter Fajdiga
194f774519 Actions: columns-squeeze-*: squeeze all visible columns 2024-10-12 16:38:20 +02:00
Peter Fajdiga
430f731804 ContextualResizer: add step for min width 2024-10-12 14:05:32 +02:00
Peter Fajdiga
2cdf308a72 key bindings: shorten descriptions for column-shrink-left and column-shrink-right 2024-10-07 00:09:15 +02:00
Peter Fajdiga
acf4c5c6ae Actions: add actions columnShrinkLeft and columnShrinkRight 2024-10-07 00:08:39 +02:00
Peter Fajdiga
703ed2eb40 tests: move runLog to global 2024-10-06 22:31:36 +02:00
Peter Fajdiga
ec5e7002dc tests: Assert: extract helper function appendMessage 2024-10-05 20:44:03 +02:00
Peter Fajdiga
fe92b1aa13 tests: create and use helper functions for client creation 2024-10-05 20:43:57 +02:00
Peter Fajdiga
d812fa706f tests: rename function to Assert.assert 2024-10-05 17:17:44 +02:00
Peter Fajdiga
0bb9ae9e8d tests: eliminate waiting 2024-10-05 17:08:42 +02:00
Peter Fajdiga
315ea6b560 Tiled: allow two (instead of one) external frame changes per second 2024-10-05 15:45:28 +02:00
Peter Fajdiga
a4236534fc tests: externalResize: also test concession expiry 2024-10-05 14:36:49 +02:00
Peter Fajdiga
e95378c0dd tests: add external resize test 2024-10-05 14:14:55 +02:00
Peter Fajdiga
884dc9977d tests: MockKwinClient: only fire frameGeometryChanged and fullScreenChanged signals on actual changes 2024-10-05 13:43:32 +02:00
Peter Fajdiga
f3cf45e5f3 tests: add global variables and 2024-10-04 23:22:35 +02:00
Peter Fajdiga
5e7f191e95 config: reorder default presetWidths consistently with its behavior 2024-10-04 18:47:18 +02:00
Peter Fajdiga
ec14d5295f move preset width cycling logic into class 2024-10-04 18:44:19 +02:00
Peter Fajdiga
116372c954 PresetWidths: order widths from small to large 2024-10-04 18:23:48 +02:00
Peter Fajdiga
7290a0741d tests: presetWidths: add tests for filling the screen with columns 2024-10-04 15:08:15 +02:00
Peter Fajdiga
5bbd0da172 tests: Assert: rename function to truth 2024-10-04 14:35:00 +02:00
Peter Fajdiga
fb415042d8 tests: Assert: use parameter destructuring for optional parameters 2024-10-04 14:34:44 +02:00
Peter Fajdiga
ff5ba6b455 tests: rename file Assert.ts 2024-10-04 14:34:35 +02:00
Peter Fajdiga
ef0e840812 tests: assert: create namespace 2024-10-04 14:34:05 +02:00
Peter Fajdiga
b0cb9eaba0 tests: assert: extract helper function buildMessage 2024-10-04 14:30:17 +02:00
Peter Fajdiga
0eb624d6ff tests: assertGrid: correct comment 2024-10-04 14:30:17 +02:00
Peter Fajdiga
6e53bbad2f Actions: implement cyclePresetWidths (resolves #57) 2024-10-04 14:29:45 +02:00
Peter Fajdiga
218ab65d61 PresetWidths: clamp, sort, and deduplicate widths 2024-10-03 00:47:17 +02:00
Peter Fajdiga
e91c9c825e move presetWidths logic into new class PresetWidths 2024-10-03 00:43:36 +02:00
Peter Fajdiga
c2931c9fbe tests: parsePresetWidths: add test cases with non-positive widths 2024-10-03 00:05:16 +02:00
Peter Fajdiga
1c61f22353 pass spacing to parsePresetWidths 2024-10-03 00:03:18 +02:00
Peter Fajdiga
3ff7688e42 implement parsing of presetWidths 2024-10-02 23:36:58 +02:00
Peter Fajdiga
f5ca5e71c0 config: add presetWidths 2024-10-02 22:05:17 +02:00
Peter Fajdiga
3f222f0ef8 tests: extract assertGrid 2024-10-02 19:35:34 +02:00
Peter Fajdiga
9a08841bb8 ignore minimized pinned clients 2024-09-29 23:16:09 +02:00
Peter Fajdiga
2081b9997c use branded types for external types 2024-09-29 20:48:47 +02:00
Peter Fajdiga
93fe2a3cfd hard-code tiling prevention for ksmserver-logout-greeter and xwaylandvideobridge (fixes #61) 2024-09-29 18:27:12 +02:00
Peter Fajdiga
9cee3f2808 tests: WindowRuleEnforcer: reformat code 2024-09-29 18:21:06 +02:00
Peter Fajdiga
16618e6ad3 tests: WindowRuleEnforcer: add more test cases 2024-09-29 18:01:23 +02:00
Peter Fajdiga
90c27bc58b defaultWindowRules: reduce repetition by using regex 2024-09-29 17:53:38 +02:00
Peter Fajdiga
79055dcdf3 tests: layout: add more test steps, include a 3-window column 2024-09-25 00:28:46 +02:00
Peter Fajdiga
1e328c7b57 tests: layout: refactor code 2024-09-25 00:20:58 +02:00
Peter Fajdiga
2be6a04fc4 tests: layout: add helper function assertGrid 2024-09-24 23:51:52 +02:00
Peter Fajdiga
9b21a720be add key bindings for moving window to next/previous position (resolves #58) 2024-09-24 23:36:33 +02:00
Peter Fajdiga
fcfed28a0a docs: output unassigned key bindings as well 2024-09-24 23:36:33 +02:00
Peter Fajdiga
7dce802b70 docs: convert key bindings to new type DocsKeyBinding 2024-09-24 23:36:33 +02:00
Peter Fajdiga
94393393f5 add key bindings for next and previous window in grid (resolves #58) 2024-09-24 23:36:24 +02:00
Peter Fajdiga
1ba128af05 tests: assert: add missing semicolon 2024-09-24 19:50:17 +02:00
Peter Fajdiga
8094b64ce7 tests: rename layout.ts 2024-09-24 01:32:03 +02:00
Peter Fajdiga
335418e84b tests: add test for moving windows 2024-09-24 01:27:49 +02:00
Peter Fajdiga
c675fc63d1 tests: add test for focus shortcuts 2024-09-24 00:52:05 +02:00
Peter Fajdiga
9d81262b18 tests: maximization: rename clients 2024-09-24 00:29:42 +02:00
Peter Fajdiga
5d36f02631 tests: implement keyboard shortcuts mocks 2024-09-23 23:45:31 +02:00
Peter Fajdiga
977facbed7 tests: add re-maximize tests 2024-09-20 23:55:52 +02:00
Peter Fajdiga
278a2d9c7b prevent unmaximize after opening a transient window 2024-09-20 23:52:18 +02:00
Peter Fajdiga
8076635719 MockKwinClient: reduce indentation 2024-09-20 23:49:17 +02:00
Peter Fajdiga
c8f0ed87a4 MockKwinClient: fix set fullScreen 2024-09-20 22:27:37 +02:00
Peter Fajdiga
b3f581c386 tests: create TestRunner 2024-09-20 21:46:14 +02:00
Peter Fajdiga
279333dd1d tests: print selected branches on fail 2024-09-20 21:27:26 +02:00
Peter Fajdiga
dac1d488b7 tests: introduce randomness in mocks 2024-09-20 16:26:38 +02:00
Peter Fajdiga
df587fc37b MockKwinClient: introduce hasBorder 2024-09-20 16:02:02 +02:00
Peter Fajdiga
987a4c852f prevent setting column width on exiting full screen 2024-09-20 16:01:08 +02:00
Peter Fajdiga
ebff074a4c maximization test: correct expectations for tiled window 2024-09-20 12:24:48 +02:00
Peter Fajdiga
ef4c509e75 kwin.ts: allow null in activeWindow 2024-09-20 12:19:38 +02:00
Peter Fajdiga
d0c1438724 Desktop: use rectEquals 2024-09-20 12:02:38 +02:00
Peter Fajdiga
d926be2e12 assert.ts: skip own stack frames 2024-09-20 11:53:03 +02:00
Peter Fajdiga
b18516646d assert.ts: add functions for comparing QmlRect 2024-09-20 11:42:12 +02:00
Peter Fajdiga
b0405858c5 MockKwinClient: fix initial properties and signal binding 2024-09-20 11:41:38 +02:00
Peter Fajdiga
0cb3513532 tests: use rectEquals 2024-09-16 00:43:58 +02:00
Peter Fajdiga
0b2d876074 Clients: use safer QmlRect comparison 2024-09-16 00:40:59 +02:00
Peter Fajdiga
cb1b70d4f0 tests: handle setMaximize 2024-09-16 00:09:19 +02:00
Peter Fajdiga
da6b983e4a tests: fix MockWorkspace 2024-09-15 23:36:23 +02:00
Peter Fajdiga
6181057fa8 tests: extract 2024-09-15 23:27:44 +02:00
Peter Fajdiga
abc8671c6f tests/utils/mocks: rename files 2024-09-15 23:24:47 +02:00
Peter Fajdiga
36bf942266 tests/utils/mocks: remove Mocks namespace 2024-09-15 23:23:21 +02:00
Peter Fajdiga
d239ac24b3 tests: setters for full screen and frame geometry 2024-09-15 21:22:19 +02:00
Peter Fajdiga
79571146be Mocks.Workspace: implement createWindow 2024-09-14 23:43:00 +02:00
Peter Fajdiga
7273196e0c setup flow tests 2024-09-14 23:11:21 +02:00
Peter Fajdiga
212ade5ab6 extract global.d.ts into its own Typescript project 2024-09-14 23:11:14 +02:00
Peter Fajdiga
239f8d95cb KwinClient: allow null for transientFor 2024-09-14 23:09:35 +02:00
Peter Fajdiga
e430a20638 qt.ts: make QmlRect edge fields readonly 2024-09-13 21:22:33 +02:00
Peter Fajdiga
0beaebd874 tests: move assert.ts 2024-09-13 20:32:24 +02:00
Peter Fajdiga
6100090db3 tests: extract process definition into its own file 2024-09-13 20:32:01 +02:00
Peter Fajdiga
8d07958962 run-ts.sh: keep tmp js file 2024-09-10 23:55:11 +02:00
Peter Fajdiga
80bafee5ed ClientWrapper: define properties in constructor 2024-09-08 09:29:49 +02:00
Peter Fajdiga
38e72a9504 ClientManager, DesktopManager: define properties in constructor 2024-09-08 09:20:10 +02:00
Peter Fajdiga
d0c4cee63d move to src/main 2024-09-08 09:06:46 +02:00
Peter Fajdiga
494192179b run-ts.sh: refactor 2024-09-08 00:41:07 +02:00
Peter Fajdiga
0dcba7cbc0 move unit tests to tests/units 2024-09-07 22:18:11 +02:00
Peter Fajdiga
8312364202 Makefile: package: rename package dir to karousel 2024-09-07 20:27:30 +02:00
Peter Fajdiga
6d8dfad4e7 keyBindings/definition.ts: use macros in strings 2024-09-07 20:27:26 +02:00
Peter Fajdiga
0d970a8bec Function.partial: allow multiple head parameters 2024-09-07 20:27:01 +02:00
Peter Fajdiga
12e8d71ef3 Actions: replace composeNum with Function.partial 2024-09-07 20:27:01 +02:00
Peter Fajdiga
407b24df08 Actions: fix binding of this 2024-09-07 20:27:01 +02:00
Peter Fajdiga
4ecced369a Actions: shorten parameter names 2024-09-07 20:27:01 +02:00
Peter Fajdiga
e3979d94f7 move composeNum into getNumKeyBindings 2024-09-07 20:27:01 +02:00
Peter Fajdiga
b6a5080d5d docs: update key bindings usage 2024-09-07 20:27:01 +02:00
Peter Fajdiga
e974f0ebbd move Actions.ts to keyBindings dir 2024-09-07 20:27:01 +02:00
Peter Fajdiga
23e13436d8 merge types.ts into Actions.ts 2024-09-07 20:27:01 +02:00
Peter Fajdiga
aaef587c00 Actions: reorder code 2024-09-07 20:27:01 +02:00
Peter Fajdiga
8f4c20138e merge NumActions into Actions 2024-09-07 20:27:01 +02:00
Peter Fajdiga
a754da9b78 Actions: rename methods to use camelCase 2024-09-07 20:27:01 +02:00
Peter Fajdiga
b94d3e2304 call World methods outside of NumActions 2024-09-07 20:27:01 +02:00
Peter Fajdiga
11e1458180 call World methods outside of Actions 2024-09-07 20:27:01 +02:00
Peter Fajdiga
e7e68628dd remove Actions.Getter 2024-09-07 20:27:01 +02:00
Peter Fajdiga
edac1a679c use directional names 2024-09-07 20:27:01 +02:00
Peter Fajdiga
f711248619 Makefile: reorder lines 2024-09-07 20:27:01 +02:00
Peter Fajdiga
502f4bee26 Makefile: use long switches for kpackagetool6 2024-09-07 20:27:01 +02:00
Peter Fajdiga
35d2096811 remove parameter followTransient everywhere 2024-09-07 20:27:01 +02:00
Peter Fajdiga
f682c160db add TODOs 2024-09-07 20:27:01 +02:00
Peter Fajdiga
ef9cb01755 Tiled: call floatClient instead of floatKwinClient 2024-09-07 20:27:01 +02:00
Peter Fajdiga
d51b9caec5 Actions: enable followTransient for all actions 2024-09-07 20:27:01 +02:00
Peter Fajdiga
62bc96f205 move untileOnDrag from World to LayoutConfig 2024-09-07 20:27:01 +02:00
Peter Fajdiga
35802eead7 consistently use semicolons with type aliases 2024-09-07 20:27:01 +02:00
Peter Fajdiga
ba886fe5f6 ClientManager.Config: rename floatingKeepAbove 2024-09-07 20:27:01 +02:00
Peter Fajdiga
30e4e3e273 World: make clientManager private 2024-09-07 20:27:01 +02:00
Peter Fajdiga
6bcb30b0bd refactor actions from switch cases to class methods 2024-09-07 20:27:01 +02:00
Peter Fajdiga
747a90f5d7 extract Actions.NumActions 2024-09-07 20:27:01 +02:00
Peter Fajdiga
da899171c4 introduce Actions.Getter 2024-09-07 20:27:01 +02:00
Peter Fajdiga
109238e645 create actions dir 2024-09-07 20:27:01 +02:00
Peter Fajdiga
7d1ebcf126 Actions: turn into a class 2024-09-07 20:27:01 +02:00
Peter Fajdiga
07ce0cceb5 issue templates: rename Generic bug report 2024-09-07 10:31:12 +02:00
Peter Fajdiga
c7aaa66c5c issue templates: rename General bug report 2024-09-07 10:06:40 +02:00
Peter Fajdiga
5c77f4f276 issue templates: reorder templates 2024-09-07 10:05:01 +02:00
Peter Fajdiga
80608e721f issue templates: add compatibility issue template 2024-09-07 09:47:42 +02:00
Peter Fajdiga
54b07ebe56 Tiled: limit reactions to external frameGeometry changes (fixes #56) 2024-08-15 14:37:57 +02:00
Peter Fajdiga
aeba236720 ClientManager: don't tile fullscreen-shaped windows 2024-08-15 14:37:57 +02:00
Peter Fajdiga
1984442ed3 ClientManager: don't tile windows that start in fullscreen 2024-08-15 14:37:57 +02:00
Peter Fajdiga
2a4c5eac3b ClientManager.addClient: make client tilable here 2024-08-15 14:37:57 +02:00
Peter Fajdiga
266beb85ae WindowRuleEnforcer.shouldTile: remove Clients.canTileNow call 2024-08-15 14:37:57 +02:00
Peter Fajdiga
5877e466da issue templates: add [Bug] title prefix 2024-07-19 15:50:18 +02:00
Peter Fajdiga
9a60f94eda Update issue templates 2024-07-19 15:46:21 +02:00
Peter Fajdiga
0f7092a5b6 rename keyBindingsMarkdown 2024-07-10 09:57:28 +02:00
Peter Fajdiga
0b3608a530 readme: update key bindings 2024-07-10 09:52:47 +02:00
Peter Fajdiga
aaeac977e6 readme: shorten description 2024-07-09 22:42:46 +02:00
91 changed files with 3353 additions and 984 deletions

View File

@@ -0,0 +1,19 @@
---
name: Compatibility issue
about: Report an issue with a specific application or window
title: "[Compatibility]"
labels: ''
assignees: ''
---
Karousel version:
Plasma version:
X11 / Wayland:
Window class:
Window caption (title):
Window type:
(Get this info [here](https://github.com/peterfajdiga/karousel/wiki/Getting-window-info))
Description:

14
.github/ISSUE_TEMPLATE/2-bug-report.md vendored Normal file
View File

@@ -0,0 +1,14 @@
---
name: Generic bug report
about: Report a bug
title: "[Bug]"
labels: ''
assignees: ''
---
Karousel version:
Plasma version:
X11 / Wayland:
Description:

View File

@@ -1,6 +1,7 @@
.PHONY: *
VERSION = $(shell grep '"Version":' ./package/metadata.json | grep -o '[0-9\.]*')
TESTS := true
.PHONY: *
build: tests
tsc -p ./src/main --outFile ./package/contents/code/main.js
@@ -8,22 +9,24 @@ build: tests
./run-ts.sh ./src/generators/config > ./package/contents/config/main.xml
tests:
ifeq (${TESTS}, true)
./run-ts.sh ./src/tests
endif
install: build
kpackagetool6 --type=KWin/Script -i ./package || kpackagetool6 --type=KWin/Script -u ./package
kpackagetool6 --type=KWin/Script --install=./package || kpackagetool6 --type=KWin/Script --upgrade=./package
uninstall:
kpackagetool6 --type=KWin/Script -r karousel
kpackagetool6 --type=KWin/Script --remove=karousel
package: build
tar -czf ./karousel_${subst .,_,${VERSION}}.tar.gz ./package
tar -czf ./karousel_${subst .,_,${VERSION}}.tar.gz ./package --transform s/package/karousel/
docs-key-bindings-bbcode:
@./run-ts.sh ./src/generators/docs/keyBindingsBbcode
docs-key-bindings-table:
@./run-ts.sh ./src/generators/docs/keyBindingsTable
docs-key-bindings-markdown:
@./run-ts.sh ./src/generators/docs/keyBindingsMarkdown
docs-key-bindings-fmt:
@./run-ts.sh ./src/generators/docs/keyBindingsFmt

View File

@@ -1,17 +1,11 @@
# Karousel
KWin tiling script with scrolling. Works especially well with ultrawide screens.
Scrollable tiling Kwin script. Works especially well with ultrawide screens.
Use with [this](https://github.com/peterfajdiga/kwin4_effect_geometry_change) for animations.
https://github.com/peterfajdiga/karousel/assets/22796326/2ab62d18-09c7-45f9-8fda-e5e36b8d7a02
Karousel works differently from most tiling window managers in that it does not maximize the width
of windows, as this can be undesirable with wider screens, where it results in excessively wide
windows that require large return sweeps when reading their content.
Instead, it leaves the width of windows to the user's control. This additionally prevents
unprompted reflow of window content.
Windows are automatically centered when possible. And when running out of width, windows can be
scrolled through horizontally.
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.
Windows are automatically centered when possible. And when running out of width, windows can be scrolled through horizontally.
Similar window managers include [PaperWM](https://github.com/paperwm/PaperWM),
[Niri](https://github.com/YaLTeR/niri), and
@@ -61,6 +55,7 @@ Here's the default ones:
| Meta+Alt+PgDown | Scroll right |
| Meta+Alt+Home | Scroll to start |
| Meta+Alt+End | Scroll to end |
| Meta+Ctrl+Return | Move Karousel grid to the current screen |
| Meta+[N] | Move focus to column N (Clashes with default KDE shortcuts, may require manual remapping) |
| Meta+Shift+[N] | Move window to column N (Requires manual remapping according to your keyboard layout, e.g. Meta+Shift+1 -> Meta+!) |
| 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+!) |

View File

@@ -301,22 +301,19 @@
</item>
<item row="7" column="0">
<widget class="QLabel" name="label_manualResizeStep">
<widget class="QLabel" name="label_presetWidths">
<property name="text">
<string>Manual resize step size:</string>
<string>Preset widths:</string>
</property>
<property name="toolTip">
<string>Widths used for cycling through widths</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QSpinBox" name="kcfg_manualResizeStep">
<property name="suffix">
<string> px</string>
</property>
<property name="maximum">
<number>999</number>
</property>
<property name="value">
<number>0</number>
<widget class="QLineEdit" name="kcfg_presetWidths">
<property name="toolTip">
<string>Comma-separated list of widths. Supported units: "px" and "%".</string>
</property>
</widget>
</item>

View File

@@ -25,4 +25,14 @@ Item {
flags: Notification.Persistent
urgency: Notification.HighUrgency
}
Notification {
id: notificationInvalidPresetWidths
componentName: "plasma_workspace"
eventId: "notification"
title: "Karousel"
text: "Your preset widths are malformed, please review your Karousel configuration"
flags: Notification.Persistent
urgency: Notification.HighUrgency
}
}

View File

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

View File

@@ -1,2 +1,8 @@
#!/bin/bash
tsc -p "$1" --outFile ./run-ts-tmp.js && node ./run-ts-tmp.js && rm ./run-ts-tmp.js
set -e
set -o pipefail
JS_FILE='./run-ts-tmp.js'
tsc -p "$1" --outFile "$JS_FILE"
node "$JS_FILE"

6
src/extern/global.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare const Qt: Qt;
declare const KWin: KWin;
declare const Workspace: Workspace;
declare const qmlBase: QmlObject;
declare const notificationInvalidWindowRules: Notification;
declare const notificationInvalidPresetWidths: Notification;

View File

@@ -1,4 +1,8 @@
{
"extends": "../../tsconfig.json",
"include": ["../../lib/**/*", "./**/*"]
"include": [
"../../extern/**/*",
"../../lib/**/*",
"./**/*"
]
}

View File

@@ -1,5 +1,11 @@
function formatComment(comment: string | undefined) {
return comment === undefined ? "" : ` (${comment})`;
type DocsKeyBinding = {
description: string;
keySequence: string;
};
function formatDescription(item: {description: string, comment?: string}) {
const suffix = item.comment === undefined ? "" : ` (${item.comment})`;
return `${applyMacro(item.description, "N")}${suffix}`;
}
function printCols(...columns: (string[] | string)[]) {
@@ -48,3 +54,15 @@ function printCols(...columns: (string[] | string)[]) {
console.log(line);
}
}
const empty: any = {};
const keyBindings: DocsKeyBinding[] = Array.prototype.concat(
getKeyBindings(empty, empty).map(binding => ({
description: formatDescription(binding),
keySequence: binding.defaultKeySequence || "(unassigned)",
})),
getNumKeyBindings(empty, empty).map(binding => ({
description: formatDescription(binding),
keySequence: `${binding.defaultModifiers}+${binding.fKeys ? "F" : ""}[N]`,
})),
);

View File

@@ -1,12 +1,7 @@
console.log(`[list]`);
for (const binding of keyBindings) {
console.log(` [*] ${binding.defaultKeySequence}${binding.description}${formatComment(binding.comment)}`);
}
for (const binding of numKeyBindings) {
const numPrefix = binding.fKeys ? "F" : "";
console.log(` [*] ${binding.defaultModifiers}+${numPrefix}[N] — ${binding.description}N${formatComment(binding.comment)}`);
console.log(` [*] ${binding.keySequence}${binding.description}`);
}
console.log(`[/list]`);

View File

@@ -1,6 +1,7 @@
{
"extends": "../../../tsconfig.json",
"include": [
"../../../extern/**/*",
"../../../lib/**/*",
"../keyBindings.ts",
"./**/*"

View File

@@ -1,14 +1,9 @@
const colLeft = [
...keyBindings.map((binding: KeyBinding) => binding.defaultKeySequence),
...numKeyBindings.map((binding: NumKeyBinding) => {
const numPrefix = binding.fKeys ? "F" : "";
return `${binding.defaultModifiers}+${numPrefix}[N]`;
}),
...keyBindings.map(binding => binding.keySequence),
];
const colRight = [
...keyBindings.map((binding: KeyBinding) => `${binding.description}${formatComment(binding.comment)}`),
...numKeyBindings.map((binding: NumKeyBinding) => `${binding.description}N${formatComment(binding.comment)}`),
...keyBindings.map(binding => binding.description),
];
printCols(colLeft, " ", colRight);

View File

@@ -1,6 +1,7 @@
{
"extends": "../../../tsconfig.json",
"include": [
"../../../extern/**/*",
"../../../lib/**/*",
"../keyBindings.ts",
"./**/*"

View File

@@ -0,0 +1,13 @@
const colLeft = [
"Shortcut",
"---",
...keyBindings.map(binding => binding.keySequence),
];
const colRight = [
"Action",
"---",
...keyBindings.map(binding => binding.description),
];
printCols("| ", colLeft, " | ", colRight, " |");

View File

@@ -1,6 +1,7 @@
{
"extends": "../../../tsconfig.json",
"include": [
"../../../extern/**/*",
"../../../lib/**/*",
"../keyBindings.ts",
"./**/*"

View File

@@ -1,18 +0,0 @@
const colLeft = [
"Shortcut",
"---",
...keyBindings.map((binding: KeyBinding) => binding.defaultKeySequence),
...numKeyBindings.map((binding: NumKeyBinding) => {
const numPrefix = binding.fKeys ? "F" : "";
return `${binding.defaultModifiers}+${numPrefix}[N]`;
}),
];
const colRight = [
"Action",
"---",
...keyBindings.map((binding: KeyBinding) => `${binding.description}${formatComment(binding.comment)}`),
...numKeyBindings.map((binding: NumKeyBinding) => `${binding.description}N${formatComment(binding.comment)}`),
];
printCols("| ", colLeft, " | ", colRight, " |");

View File

@@ -1,352 +0,0 @@
namespace Actions {
export function getAction(world: World, config: Config, name: string) {
switch (name) {
case "focus-left": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
const prevColumn = grid.getPrevColumn(column);
if (prevColumn === null) {
return;
}
prevColumn.focus();
});
};
case "focus-right": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
const nextColumn = grid.getNextColumn(column);
if (nextColumn === null) {
return;
}
nextColumn.focus();
});
};
case "focus-up": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
const prevWindow = column.getPrevWindow(window);
if (prevWindow === null) {
return;
}
prevWindow.focus();
});
};
case "focus-down": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
const nextWindow = column.getNextWindow(window);
if (nextWindow === null) {
return;
}
nextWindow.focus();
});
};
case "focus-start": return () => {
world.do((clientManager, desktopManager) => {
const grid = desktopManager.getCurrentDesktop().grid;
const firstColumn = grid.getFirstColumn();
if (firstColumn === null) {
return;
}
firstColumn.focus();
});
};
case "focus-end": return () => {
world.do((clientManager, desktopManager) => {
const grid = desktopManager.getCurrentDesktop().grid;
const lastColumn = grid.getLastColumn();
if (lastColumn === null) {
return;
}
lastColumn.focus();
});
};
case "window-move-left": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
if (column.getWindowCount() === 1) {
// move from own column into existing column
const prevColumn = grid.getPrevColumn(column);
if (prevColumn === null) {
return;
}
window.moveToColumn(prevColumn);
grid.desktop.autoAdjustScroll();
} else {
// move from shared column into own column
const newColumn = new Column(grid, grid.getPrevColumn(column));
window.moveToColumn(newColumn);
}
});
};
case "window-move-right": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
if (column.getWindowCount() === 1) {
// move from own column into existing column
const nextColumn = grid.getNextColumn(column);
if (nextColumn === null) {
return;
}
window.moveToColumn(nextColumn);
grid.desktop.autoAdjustScroll();
} else {
// move from shared column into own column
const newColumn = new Column(grid, column);
window.moveToColumn(newColumn);
}
});
};
case "window-move-up": return () => {
// TODO (optimization): only arrange moved windows
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
column.moveWindowUp(window);
});
};
case "window-move-down": return () => {
// TODO (optimization): only arrange moved windows
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
column.moveWindowDown(window);
});
};
case "window-move-start": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
const newColumn = new Column(grid, null);
window.moveToColumn(newColumn);
});
};
case "window-move-end": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
const newColumn = new Column(grid, grid.getLastColumn());
window.moveToColumn(newColumn);
});
};
case "window-toggle-floating": return () => {
const kwinClient = Workspace.activeWindow;
world.do((clientManager, desktopManager) => {
clientManager.toggleFloatingClient(kwinClient);
});
};
case "column-move-left": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
grid.moveColumnLeft(column);
});
};
case "column-move-right": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
grid.moveColumnRight(column);
});
};
case "column-move-start": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
grid.moveColumn(column, null);
});
};
case "column-move-end": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
grid.moveColumn(column, grid.getLastColumn());
});
};
case "column-toggle-stacked": return () => {
world.doIfTiledFocused(false, (clientManager, desktopManager, window, column, grid) => {
column.toggleStacked();
});
};
case "column-width-increase": return () => {
world.doIfTiledFocused(false, (clientManager, desktopManager, window, column, grid) => {
config.columnResizer.increaseWidth(column, config.manualResizeStep);
});
};
case "column-width-decrease": return () => {
world.doIfTiledFocused(false, (clientManager, desktopManager, window, column, grid) => {
config.columnResizer.decreaseWidth(column, config.manualResizeStep);
});
};
case "columns-width-equalize": return () => {
world.do((clientManager, desktopManager) => {
desktopManager.getCurrentDesktop().equalizeVisibleColumnsWidths();
});
};
case "grid-scroll-left": return () => {
gridScroll(world, -config.manualScrollStep);
};
case "grid-scroll-right": return () => {
gridScroll(world, config.manualScrollStep);
};
case "grid-scroll-start": return () => {
world.do((clientManager, desktopManager) => {
const grid = desktopManager.getCurrentDesktop().grid;
const firstColumn = grid.getFirstColumn();
if (firstColumn === null) {
return;
}
grid.desktop.scrollToColumn(firstColumn);
});
};
case "grid-scroll-end": return () => {
world.do((clientManager, desktopManager) => {
const grid = desktopManager.getCurrentDesktop().grid;
const lastColumn = grid.getLastColumn();
if (lastColumn === null) {
return;
}
grid.desktop.scrollToColumn(lastColumn);
});
};
case "grid-scroll-focused": return () => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
grid.desktop.scrollCenterRange(column);
})
};
case "grid-scroll-left-column": return () => {
world.do((clientManager, desktopManager) => {
const grid = desktopManager.getCurrentDesktop().grid;
const column = grid.getLeftmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true);
if (column === null) {
return;
}
const prevColumn = grid.getPrevColumn(column);
if (prevColumn === null) {
return;
}
grid.desktop.scrollToColumn(prevColumn);
});
};
case "grid-scroll-right-column": return () => {
world.do((clientManager, desktopManager) => {
const grid = desktopManager.getCurrentDesktop().grid;
const column = grid.getRightmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true);
if (column === null) {
return;
}
const nextColumn = grid.getNextColumn(column);
if (nextColumn === null) {
return;
}
grid.desktop.scrollToColumn(nextColumn);
});
};
case "screen-switch": return () => {
world.do((clientManager, desktopManager) => {
desktopManager.selectScreen(Workspace.activeScreen);
});
};
default: throw new Error("unknown action: " + name);
}
}
export function getNumAction(world: World, name: string) {
switch (name) {
case "focus-": return (columnIndex: number) => {
world.do((clientManager, desktopManager) => {
const grid = desktopManager.getCurrentDesktop().grid;
const targetColumn = grid.getColumnAtIndex(columnIndex);
if (targetColumn === null) {
return;
}
targetColumn.focus();
});
};
case "window-move-to-column-": return (columnIndex: number) => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
const targetColumn = grid.getColumnAtIndex(columnIndex);
if (targetColumn === null) {
return;
}
window.moveToColumn(targetColumn);
grid.desktop.autoAdjustScroll();
});
};
case "column-move-to-column-": return (columnIndex: number) => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, grid) => {
const targetColumn = grid.getColumnAtIndex(columnIndex);
if (targetColumn === null || targetColumn === column) {
return;
}
if (targetColumn.isToTheRightOf(column)) {
grid.moveColumn(column, targetColumn);
} else {
grid.moveColumn(column, grid.getPrevColumn(targetColumn));
}
});
};
case "column-move-to-desktop-": return (desktopIndex: number) => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, oldGrid) => {
const kwinDesktop = Workspace.desktops[desktopIndex];
if (kwinDesktop === undefined) {
return;
}
const newGrid = desktopManager.getDesktopInCurrentActivity(kwinDesktop).grid;
if (newGrid === null || newGrid === oldGrid) {
return;
}
column.moveToGrid(newGrid, newGrid.getLastColumn());
});
};
case "tail-move-to-desktop-": return (desktopIndex: number) => {
world.doIfTiledFocused(true, (clientManager, desktopManager, window, column, oldGrid) => {
const kwinDesktop = Workspace.desktops[desktopIndex];
if (kwinDesktop === undefined) {
return;
}
const newGrid = desktopManager.getDesktopInCurrentActivity(kwinDesktop).grid;
if (newGrid === null || newGrid === oldGrid) {
return;
}
oldGrid.evacuateTail(newGrid, column);
});
};
default: throw new Error("unknown num action: " + name);
}
}
function gridScroll(world: World, amount: number) {
world.do((clientManager, desktopManager) => {
const grid = desktopManager.getCurrentDesktop().grid;
grid.desktop.adjustScroll(amount, false);
});
}
export type Config = {
manualScrollStep: number,
manualResizeStep: number,
columnResizer: ColumnResizer,
};
export type ColumnResizer = {
increaseWidth(column: Column, step: number): void,
decreaseWidth(column: Column, step: number): void,
}
}

View File

@@ -0,0 +1,56 @@
class PresetWidths {
private readonly presets: ((maxWidth: number) => number)[];
constructor(presetWidths: string, spacing: number) {
this.presets = PresetWidths.parsePresetWidths(presetWidths, spacing);
}
public next(currentWidth: number, minWidth: number, maxWidth: number) {
const widths = this.getWidths(minWidth, maxWidth);
const nextIndex = widths.findIndex(width => width > currentWidth);
return nextIndex >= 0 ? widths[nextIndex] : widths[0];
}
public getWidths(minWidth: number, maxWidth: number) {
const widths = this.presets.map(f => clamp(f(maxWidth), minWidth, maxWidth));
widths.sort((a, b) => a - b);
return uniq(widths);
}
private static parsePresetWidths(presetWidths: string, spacing: number): ((maxWidth: number) => number)[] {
function getRatioFunction(ratio: number) {
return (maxWidth: number) => Math.floor((maxWidth + spacing) * ratio - spacing);
}
return presetWidths.split(",").map((widthStr: string) => {
widthStr = widthStr.trim();
const widthPx = PresetWidths.parseNumberWithSuffix(widthStr, "px");
if (widthPx !== undefined) {
return () => widthPx;
}
const widthPct = PresetWidths.parseNumberWithSuffix(widthStr, "%");
if (widthPct !== undefined) {
return getRatioFunction(widthPct / 100.0);
}
return getRatioFunction(PresetWidths.parseNumberSafe(widthStr));
});
}
private static parseNumberSafe(str: string) {
const num = Number(str);
if (isNaN(num) || num <= 0) {
throw new Error("Invalid number: " + str);
}
return num;
}
private static parseNumberWithSuffix(str: string, suffix: string) {
if (!str.endsWith(suffix)) {
return undefined;
}
return PresetWidths.parseNumberSafe(str.substring(0, str.length-suffix.length).trim());
}
}

View File

@@ -1,9 +1,15 @@
class ContextualResizer {
public increaseWidth(column: Column, step: number) {
constructor(
private readonly presetWidths: { getWidths: (minWidth: number, maxWidth: number) => number[] },
) {}
public increaseWidth(column: Column) {
const grid = column.grid;
const desktop = grid.desktop;
const visibleRange = desktop.getCurrentVisibleRange();
if(!column.isVisible(visibleRange, true) || column.getWidth() >= column.getMaxWidth()) {
const minWidth = column.getMinWidth();
const maxWidth = column.getMaxWidth();
if(!column.isVisible(visibleRange, true) || column.getWidth() >= maxWidth) {
return;
}
@@ -17,13 +23,12 @@ class ContextualResizer {
const leftSpace = leftVisibleColumn.getLeft() - visibleRange.getLeft();
const rightSpace = visibleRange.getRight() - rightVisibleColumn.getRight();
const newWidth = ContextualResizer.findNextStep(
const newWidth = findMinPositive(
[
visibleRange.getWidth(),
column.getWidth() + step,
column.getWidth() + leftSpace + rightSpace,
column.getWidth() + leftSpace + rightSpace + leftVisibleColumn.getWidth() + grid.config.gapsInnerHorizontal,
column.getWidth() + leftSpace + rightSpace + rightVisibleColumn.getWidth() + grid.config.gapsInnerHorizontal,
...this.presetWidths.getWidths(minWidth, maxWidth),
],
width => width - column.getWidth(),
)
@@ -35,11 +40,13 @@ class ContextualResizer {
desktop.scrollCenterVisible(column);
}
public decreaseWidth(column: Column, step: number) {
public decreaseWidth(column: Column) {
const grid = column.grid;
const desktop = grid.desktop;
const visibleRange = desktop.getCurrentVisibleRange();
if(!column.isVisible(visibleRange, true) || column.getWidth() <= column.getMinWidth()) {
const minWidth = column.getMinWidth();
const maxWidth = column.getMaxWidth();
if(!column.isVisible(visibleRange, true) || column.getWidth() <= minWidth) {
return;
}
@@ -50,11 +57,11 @@ class ContextualResizer {
return;
}
let leftOffScreenColumn = grid.getPrevColumn(leftVisibleColumn);
let leftOffScreenColumn = grid.getLeftColumn(leftVisibleColumn);
if (leftOffScreenColumn === column) {
leftOffScreenColumn = null;
}
let rightOffScreenColumn = grid.getNextColumn(rightVisibleColumn);
let rightOffScreenColumn = grid.getRightColumn(rightVisibleColumn);
if (rightOffScreenColumn === column) {
rightOffScreenColumn = null;
}
@@ -64,12 +71,11 @@ class ContextualResizer {
const leftOffScreen = leftOffScreenColumn === null ? 0 : leftOffScreenColumn.getWidth() + grid.config.gapsInnerHorizontal - unusedWidth;
const rightOffScreen = rightOffScreenColumn === null ? 0 : rightOffScreenColumn.getWidth() + grid.config.gapsInnerHorizontal - unusedWidth;
const newWidth = ContextualResizer.findNextStep(
const newWidth = findMinPositive(
[
visibleRange.getWidth(),
column.getWidth() - step,
column.getWidth() - leftOffScreen,
column.getWidth() - rightOffScreen,
...this.presetWidths.getWidths(minWidth, maxWidth),
],
width => column.getWidth() - width,
)
@@ -80,17 +86,4 @@ class ContextualResizer {
column.setWidth(newWidth, true);
desktop.scrollCenterVisible(column);
}
private static findNextStep(steps: number[], evaluate: (step: number) => number) {
let bestScore = Infinity;
let bestStep = undefined;
for (const step of steps) {
const score = evaluate(step);
if (score > 0 && score < bestScore) {
bestScore = score;
bestStep = step;
}
}
return bestStep;
}
}

View File

@@ -1,9 +1,31 @@
class RawResizer {
public increaseWidth(column: Column, step: number) {
column.adjustWidth(step, true);
constructor(
private readonly presetWidths: { getWidths: (minWidth: number, maxWidth: number) => number[] },
) {}
public increaseWidth(column: Column) {
const newWidth = findMinPositive(
[
...this.presetWidths.getWidths(column.getMinWidth(), column.getMaxWidth()),
],
width => width - column.getWidth(),
);
if (newWidth === undefined) {
return;
}
column.setWidth(newWidth, true);
}
public decreaseWidth(column: Column, step: number) {
column.adjustWidth(-step, true);
public decreaseWidth(column: Column) {
const newWidth = findMinPositive(
[
...this.presetWidths.getWidths(column.getMinWidth(), column.getMaxWidth()),
],
width => column.getWidth() - width,
);
if (newWidth === undefined) {
return;
}
column.setWidth(newWidth, true);
}
}

View File

@@ -1,22 +1,22 @@
type Config = {
gapsOuterTop: number,
gapsOuterBottom: number,
gapsOuterLeft: number,
gapsOuterRight: number,
gapsInnerHorizontal: number,
gapsInnerVertical: number,
manualScrollStep: number,
manualResizeStep: number,
offScreenOpacity: number,
untileOnDrag: boolean,
stackColumnsByDefault: boolean,
resizeNeighborColumn: boolean,
reMaximize: boolean,
skipSwitcher: boolean,
scrollingLazy: boolean,
scrollingCentered: boolean,
scrollingGrouped: boolean,
tiledKeepBelow: boolean,
floatingKeepAbove: boolean,
windowRules: string,
gapsOuterTop: number;
gapsOuterBottom: number;
gapsOuterLeft: number;
gapsOuterRight: number;
gapsInnerHorizontal: number;
gapsInnerVertical: number;
manualScrollStep: number;
presetWidths: string;
offScreenOpacity: number;
untileOnDrag: boolean;
stackColumnsByDefault: boolean;
resizeNeighborColumn: boolean;
reMaximize: boolean;
skipSwitcher: boolean;
scrollingLazy: boolean;
scrollingCentered: boolean;
scrollingGrouped: boolean;
tiledKeepBelow: boolean;
floatingKeepAbove: boolean;
windowRules: string;
};

View File

@@ -1,12 +1,4 @@
const defaultWindowRules = `[
{
"class": "ksmserver-logout-greeter",
"tile": false
},
{
"class": "xwaylandvideobridge",
"tile": false
},
{
"class": "(org\\\\.kde\\\\.)?plasmashell",
"tile": false
@@ -35,28 +27,23 @@ const defaultWindowRules = `[
"class": "(org\\\\.kde\\\\.)?yakuake",
"tile": false
},
{
"class": "steam",
"caption": "Steam Big Picture Mode",
"tile": false
},
{
"class": "zoom",
"caption": "Zoom Cloud Meetings|zoom|zoom <2>",
"tile": false
},
{
"class": "jetbrains-idea",
"class": "jetbrains-.*",
"caption": "splash",
"tile": false
},
{
"class": "jetbrains-studio",
"caption": "splash",
"tile": false
},
{
"class": "jetbrains-idea",
"caption": "Unstash Changes|Paths Affected by stash@.*",
"tile": true
},
{
"class": "jetbrains-studio",
"class": "jetbrains-.*",
"caption": "Unstash Changes|Paths Affected by stash@.*",
"tile": true
}
@@ -99,9 +86,9 @@ const configDef = [
default: 200,
},
{
name: "manualResizeStep",
type: "UInt",
default: 600,
name: "presetWidths",
type: "String",
default: "50%, 100%",
},
{
name: "offScreenOpacity",

View File

@@ -1,7 +0,0 @@
function loadConfig(): Config {
const config: any = {};
for (const entry of configDef) {
config[entry.name] = KWin.readConfig(entry.name, entry.default);
}
return config;
}

View File

@@ -1,6 +1 @@
declare const console: Console;
declare const Qt: Qt;
declare const KWin: KWin;
declare const Workspace: Workspace;
declare const qmlBase: QmlObject;
declare const notificationInvalidWindowRules: Notification;

View File

@@ -1,8 +1,12 @@
type KWin = {
__brand: "KWin";
readConfig(key: string, defaultValue: any): any;
};
type Workspace = {
__brand: "Workspace";
readonly activities: string[];
readonly desktops: KwinDesktop[];
readonly currentDesktop: KwinDesktop;
@@ -11,12 +15,12 @@ type Workspace = {
readonly windows: KwinClient[];
readonly cursorPos: Readonly<QmlPoint>;
activeWindow: KwinClient;
activeWindow: KwinClient|null;
readonly currentDesktopChanged: QSignal<[]>
readonly currentDesktopChanged: QSignal<[]>;
readonly windowAdded: QSignal<[KwinClient]>;
readonly windowRemoved: QSignal<[KwinClient]>;
readonly windowActivated: QSignal<[KwinClient]>;
readonly windowActivated: QSignal<[KwinClient|null]>;
readonly screensChanged: QSignal<[]>;
readonly activitiesChanged: QSignal<[]>;
readonly desktopsChanged: QSignal<[]>;
@@ -44,15 +48,17 @@ const enum MaximizedMode {
Maximized,
}
type Tile = unknown;
type Output = unknown;
type Tile = { __brand: "Tile" };
type Output = { __brand: "Output" };
type KwinClient = {
__brand: "KwinClient";
interface KwinClient {
readonly shadeable: boolean;
readonly caption: string;
readonly minSize: Readonly<QmlSize>;
readonly transient: boolean;
readonly transientFor: KwinClient;
readonly transientFor: KwinClient | null;
readonly clientGeometry: Readonly<QmlRect>;
readonly move: boolean;
readonly resize: boolean;
@@ -77,14 +83,14 @@ interface KwinClient {
minimized: boolean;
frameGeometry: QmlRect;
desktops: KwinDesktop[]; // empty array means all desktops
tile: Tile;
tile: Tile|null;
opacity: number;
readonly fullScreenChanged: QSignal<[]>;
readonly desktopsChanged: QSignal<[]>;
readonly activitiesChanged: QSignal<[]>;
readonly minimizedChanged: QSignal<[]>;
readonly maximizedAboutToChange: QSignal<[MaximizedMode]>
readonly maximizedAboutToChange: QSignal<[MaximizedMode]>;
readonly captionChanged: QSignal<[]>;
readonly tileChanged: QSignal<[]>;
readonly interactiveMoveResizeStarted: QSignal<[]>;
@@ -92,13 +98,15 @@ interface KwinClient {
readonly frameGeometryChanged: QSignal<[oldGeometry: QmlRect]>;
setMaximize(vertically: boolean, horizontally: boolean): void;
}
};
type KwinDesktop = {
__brand: "KwinDesktop";
interface KwinDesktop {
readonly id: string;
}
};
type ShortcutHandler = {
type ShortcutHandler = QmlObject & {
readonly activated: QSignal<[]>;
destroy(): void;
};

View File

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

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

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

View File

@@ -0,0 +1,421 @@
class Actions {
constructor(
private readonly config: Actions.Config,
) {}
public readonly focusLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const leftColumn = grid.getLeftColumn(column);
if (leftColumn === null) {
return;
}
leftColumn.focus();
}
public readonly focusRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const rightColumn = grid.getRightColumn(column);
if (rightColumn === null) {
return;
}
rightColumn.focus();
}
public readonly focusUp = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const aboveWindow = column.getAboveWindow(window);
if (aboveWindow === null) {
return;
}
aboveWindow.focus();
}
public readonly focusDown = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const belowWindow = column.getBelowWindow(window);
if (belowWindow === null) {
return;
}
belowWindow.focus();
}
public readonly focusNext = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const belowWindow = column.getBelowWindow(window);
if (belowWindow !== null) {
belowWindow.focus();
} else {
const rightColumn = grid.getRightColumn(column);
if (rightColumn === null) {
return;
}
rightColumn.getFirstWindow().focus();
}
}
public readonly focusPrevious = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const aboveWindow = column.getAboveWindow(window);
if (aboveWindow !== null) {
aboveWindow.focus();
} else {
const leftColumn = grid.getLeftColumn(column);
if (leftColumn === null) {
return;
}
leftColumn.getLastWindow().focus();
}
}
public readonly focusStart = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const firstColumn = grid.getFirstColumn();
if (firstColumn === null) {
return;
}
firstColumn.focus();
}
public readonly focusEnd = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const lastColumn = grid.getLastColumn();
if (lastColumn === null) {
return;
}
lastColumn.focus();
}
public readonly windowMoveLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
if (column.getWindowCount() === 1) {
// move from own column into existing column
const leftColumn = grid.getLeftColumn(column);
if (leftColumn === null) {
return;
}
window.moveToColumn(leftColumn, true);
grid.desktop.autoAdjustScroll();
} else {
// move from shared column into own column
const newColumn = new Column(grid, grid.getLeftColumn(column));
window.moveToColumn(newColumn, true);
}
}
public readonly windowMoveRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid, bottom: boolean = true) => {
if (column.getWindowCount() === 1) {
// move from own column into existing column
const rightColumn = grid.getRightColumn(column);
if (rightColumn === null) {
return;
}
window.moveToColumn(rightColumn, bottom);
grid.desktop.autoAdjustScroll();
} else {
// move from shared column into own column
const newColumn = new Column(grid, column);
window.moveToColumn(newColumn, true);
}
}
// TODO (optimization): only arrange moved windows
public readonly windowMoveUp = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
column.moveWindowUp(window);
}
// TODO (optimization): only arrange moved windows
public readonly windowMoveDown = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
column.moveWindowDown(window);
}
public readonly windowMoveNext = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const canMoveDown = window !== column.getLastWindow();
if (canMoveDown) {
column.moveWindowDown(window);
} else {
this.windowMoveRight(cm, dm, window, column, grid, false);
}
}
public readonly windowMovePrevious = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const canMoveUp = window !== column.getFirstWindow();
if (canMoveUp) {
column.moveWindowUp(window);
} else {
this.windowMoveLeft(cm, dm, window, column, grid);
}
}
public readonly windowMoveStart = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const newColumn = new Column(grid, null);
window.moveToColumn(newColumn, true);
}
public readonly windowMoveEnd = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const newColumn = new Column(grid, grid.getLastColumn());
window.moveToColumn(newColumn, true);
}
public readonly windowToggleFloating = (cm: ClientManager, dm: DesktopManager) => {
if (Workspace.activeWindow === null) {
return;
}
cm.toggleFloatingClient(Workspace.activeWindow);
}
public readonly columnMoveLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumnLeft(column);
}
public readonly columnMoveRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumnRight(column);
}
public readonly columnMoveStart = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumn(column, null);
}
public readonly columnMoveEnd = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumn(column, grid.getLastColumn());
}
public readonly columnToggleStacked = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
column.toggleStacked();
}
public readonly columnWidthIncrease = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
this.config.columnResizer.increaseWidth(column);
}
public readonly columnWidthDecrease = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
this.config.columnResizer.decreaseWidth(column);
}
public readonly cyclePresetWidths = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const nextWidth = this.config.presetWidths.next(column.getWidth(), column.getMinWidth(), column.getMaxWidth());
column.setWidth(nextWidth, true);
}
public readonly columnsWidthEqualize = (cm: ClientManager, dm: DesktopManager) => {
const desktop = dm.getCurrentDesktop();
const visibleRange = desktop.getCurrentVisibleRange();
const visibleColumns = Array.from(desktop.grid.getVisibleColumns(visibleRange, true));
const availableSpace = desktop.tilingArea.width;
const gapsWidth = desktop.grid.config.gapsInnerHorizontal * (visibleColumns.length-1);
const widths = fillSpace(
availableSpace - gapsWidth,
visibleColumns.map(column => ({ min: column.getMinWidth(), max: column.getMaxWidth() })),
);
visibleColumns.forEach((column, index) => column.setWidth(widths[index], true));
desktop.scrollCenterRange(Desktop.RangeImpl.fromRanges(
visibleColumns[0],
visibleColumns[visibleColumns.length - 1],
));
}
public readonly columnsSqueezeLeft = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => {
const visibleRange = grid.desktop.getCurrentVisibleRange();
if (!focusedColumn.isVisible(visibleRange, true)) {
return;
}
const currentVisibleColumns = Array.from(grid.getVisibleColumns(visibleRange, true));
console.assert(currentVisibleColumns.includes(focusedColumn), "should at least contain the focused column");
const targetColumn = grid.getLeftColumn(currentVisibleColumns[0]);
if (targetColumn === null) {
return;
}
const wantedVisibleColumns = [targetColumn, ...currentVisibleColumns];
while (true) {
const success = this.squeezeColumns(wantedVisibleColumns);
if (success) {
break;
}
const removedColumn = wantedVisibleColumns.pop();
if (removedColumn === focusedColumn) {
break; // don't scroll past the currently focused column
}
}
}
public readonly columnsSqueezeRight = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => {
const visibleRange = grid.desktop.getCurrentVisibleRange();
if (!focusedColumn.isVisible(visibleRange, true)) {
return;
}
const currentVisibleColumns = Array.from(grid.getVisibleColumns(visibleRange, true));
console.assert(currentVisibleColumns.includes(focusedColumn), "should at least contain the focused column");
const targetColumn = grid.getRightColumn(currentVisibleColumns[currentVisibleColumns.length-1]);
if (targetColumn === null) {
return;
}
const wantedVisibleColumns = [...currentVisibleColumns, targetColumn];
while (true) {
const success = this.squeezeColumns(wantedVisibleColumns);
if (success) {
break;
}
const removedColumn = wantedVisibleColumns.shift();
if (removedColumn === focusedColumn) {
break; // don't scroll past the currently focused column
}
}
}
private readonly squeezeColumns = (columns: Column[]) => {
const firstColumn = columns[0];
const lastColumn = columns[columns.length-1];
const grid = firstColumn.grid;
const desktop = grid.desktop;
const availableSpace = desktop.tilingArea.width;
const gapsWidth = grid.config.gapsInnerHorizontal * (columns.length-1);
const columnConstraints = columns.map(column => ({ min: column.getMinWidth(), max: column.getWidth() }));
const minTotalWidth = gapsWidth + columnConstraints.reduce((acc, constraint) => acc + constraint.min, 0);
if (minTotalWidth > availableSpace) {
// there's nothing we can do
return false;
}
const widths = fillSpace(availableSpace - gapsWidth, columnConstraints);
columns.forEach((column, index) => column.setWidth(widths[index], true));
desktop.scrollCenterRange(Desktop.RangeImpl.fromRanges(firstColumn, lastColumn));
return true;
}
public readonly gridScrollLeft = (cm: ClientManager, dm: DesktopManager) => {
this.gridScroll(dm, -this.config.manualScrollStep);
}
public readonly gridScrollRight = (cm: ClientManager, dm: DesktopManager) => {
this.gridScroll(dm, this.config.manualScrollStep);
}
private readonly gridScroll = (desktopManager: DesktopManager, amount: number) => {
const grid = desktopManager.getCurrentDesktop().grid;
grid.desktop.adjustScroll(amount, false);
}
public readonly gridScrollStart = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const firstColumn = grid.getFirstColumn();
if (firstColumn === null) {
return;
}
grid.desktop.scrollToColumn(firstColumn);
}
public readonly gridScrollEnd = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const lastColumn = grid.getLastColumn();
if (lastColumn === null) {
return;
}
grid.desktop.scrollToColumn(lastColumn);
}
public readonly gridScrollFocused = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.desktop.scrollCenterRange(column);
}
public readonly gridScrollLeftColumn = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const column = grid.getLeftmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true);
if (column === null) {
return;
}
const leftColumn = grid.getLeftColumn(column);
if (leftColumn === null) {
return;
}
grid.desktop.scrollToColumn(leftColumn);
}
public readonly gridScrollRightColumn = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const column = grid.getRightmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true);
if (column === null) {
return;
}
const rightColumn = grid.getRightColumn(column);
if (rightColumn === null) {
return;
}
grid.desktop.scrollToColumn(rightColumn);
}
public readonly screenSwitch = (cm: ClientManager, dm: DesktopManager) => {
dm.selectScreen(Workspace.activeScreen);
}
public readonly focus = (columnIndex: number, cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid;
const targetColumn = grid.getColumnAtIndex(columnIndex);
if (targetColumn === null) {
return;
}
targetColumn.focus();
};
public readonly windowMoveToColumn = (columnIndex: number, cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const targetColumn = grid.getColumnAtIndex(columnIndex);
if (targetColumn === null) {
return;
}
window.moveToColumn(targetColumn, true);
grid.desktop.autoAdjustScroll();
};
public readonly columnMoveToColumn = (columnIndex: number, cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const targetColumn = grid.getColumnAtIndex(columnIndex);
if (targetColumn === null || targetColumn === column) {
return;
}
if (targetColumn.isToTheRightOf(column)) {
grid.moveColumn(column, targetColumn);
} else {
grid.moveColumn(column, grid.getLeftColumn(targetColumn));
}
};
public readonly columnMoveToDesktop = (desktopIndex: number, cm: ClientManager, dm: DesktopManager, window: Window, column: Column, oldGrid: Grid) => {
const kwinDesktop = Workspace.desktops[desktopIndex];
if (kwinDesktop === undefined) {
return;
}
const newGrid = dm.getDesktopInCurrentActivity(kwinDesktop).grid;
if (newGrid === null || newGrid === oldGrid) {
return;
}
column.moveToGrid(newGrid, newGrid.getLastColumn());
};
public readonly tailMoveToDesktop = (desktopIndex: number, cm: ClientManager, dm: DesktopManager, window: Window, column: Column, oldGrid: Grid) => {
const kwinDesktop = Workspace.desktops[desktopIndex];
if (kwinDesktop === undefined) {
return;
}
const newGrid = dm.getDesktopInCurrentActivity(kwinDesktop).grid;
if (newGrid === null || newGrid === oldGrid) {
return;
}
oldGrid.evacuateTail(newGrid, column);
};
}
namespace Actions {
export type Config = {
manualScrollStep: number;
presetWidths: { next: (currentWidth: number, minWidth: number, maxWidth: number) => number };
columnResizer: ColumnResizer;
};
export type ColumnResizer = {
increaseWidth(column: Column): void;
decreaseWidth(column: Column): void;
};
}

View File

@@ -1,190 +1,267 @@
const keyBindings: KeyBinding[] = [
{
name: "window-toggle-floating",
description: "Toggle floating",
defaultKeySequence: "Meta+Space",
},
{
name: "focus-left",
description: "Move focus left",
defaultKeySequence: "Meta+A",
},
{
name: "focus-right",
description: "Move focus right",
comment: "Clashes with default KDE shortcuts, may require manual remapping",
defaultKeySequence: "Meta+D",
},
{
name: "focus-up",
description: "Move focus up",
comment: "Clashes with default KDE shortcuts, may require manual remapping",
defaultKeySequence: "Meta+W",
},
{
name: "focus-down",
description: "Move focus down",
comment: "Clashes with default KDE shortcuts, may require manual remapping",
defaultKeySequence: "Meta+S",
},
{
name: "focus-start",
description: "Move focus to start",
defaultKeySequence: "Meta+Home",
},
{
name: "focus-end",
description: "Move focus to end",
defaultKeySequence: "Meta+End",
},
{
name: "window-move-left",
description: "Move window left",
comment: "Moves window out of and into columns",
defaultKeySequence: "Meta+Shift+A",
},
{
name: "window-move-right",
description: "Move window right",
comment: "Moves window out of and into columns",
defaultKeySequence: "Meta+Shift+D",
},
{
name: "window-move-up",
description: "Move window up",
defaultKeySequence: "Meta+Shift+W",
},
{
name: "window-move-down",
description: "Move window down",
defaultKeySequence: "Meta+Shift+S",
},
{
name: "window-move-start",
description: "Move window to start",
defaultKeySequence: "Meta+Shift+Home",
},
{
name: "window-move-end",
description: "Move window to end",
defaultKeySequence: "Meta+Shift+End",
},
{
name: "column-toggle-stacked",
description: "Toggle stacked layout for focused column",
comment: "One window in the column visible, others shaded; not supported on Wayland",
defaultKeySequence: "Meta+X",
},
{
name: "column-move-left",
description: "Move column left",
defaultKeySequence: "Meta+Ctrl+Shift+A",
},
{
name: "column-move-right",
description: "Move column right",
defaultKeySequence: "Meta+Ctrl+Shift+D",
},
{
name: "column-move-start",
description: "Move column to start",
defaultKeySequence: "Meta+Ctrl+Shift+Home",
},
{
name: "column-move-end",
description: "Move column to end",
defaultKeySequence: "Meta+Ctrl+Shift+End",
},
{
name: "column-width-increase",
description: "Increase column width",
defaultKeySequence: "Meta+Ctrl++",
},
{
name: "column-width-decrease",
description: "Decrease column width",
defaultKeySequence: "Meta+Ctrl+-",
},
{
name: "columns-width-equalize",
description: "Equalize widths of visible columns",
defaultKeySequence: "Meta+Ctrl+X",
},
{
name: "grid-scroll-focused",
description: "Center focused window",
comment: "Scrolls so that the focused window is centered in the screen",
defaultKeySequence: "Meta+Alt+Return",
},
{
name: "grid-scroll-left-column",
description: "Scroll one column to the left",
defaultKeySequence: "Meta+Alt+A",
},
{
name: "grid-scroll-right-column",
description: "Scroll one column to the right",
defaultKeySequence: "Meta+Alt+D",
},
{
name: "grid-scroll-left",
description: "Scroll left",
defaultKeySequence: "Meta+Alt+PgUp",
},
{
name: "grid-scroll-right",
description: "Scroll right",
defaultKeySequence: "Meta+Alt+PgDown",
},
{
name: "grid-scroll-start",
description: "Scroll to start",
defaultKeySequence: "Meta+Alt+Home",
},
{
name: "grid-scroll-end",
description: "Scroll to end",
defaultKeySequence: "Meta+Alt+End",
},
{
name: "screen-switch",
description: "Move Karousel grid to the current screen",
defaultKeySequence: "Meta+Ctrl+Return",
}
];
function getKeyBindings(world: World, actions: Actions): KeyBinding[] {
return [
{
name: "window-toggle-floating",
description: "Toggle floating",
defaultKeySequence: "Meta+Space",
action: () => world.do(actions.windowToggleFloating),
},
{
name: "focus-left",
description: "Move focus left",
defaultKeySequence: "Meta+A",
action: () => world.doIfTiledFocused(actions.focusLeft),
},
{
name: "focus-right",
description: "Move focus right",
comment: "Clashes with default KDE shortcuts, may require manual remapping",
defaultKeySequence: "Meta+D",
action: () => world.doIfTiledFocused(actions.focusRight),
},
{
name: "focus-up",
description: "Move focus up",
comment: "Clashes with default KDE shortcuts, may require manual remapping",
defaultKeySequence: "Meta+W",
action: () => world.doIfTiledFocused(actions.focusUp),
},
{
name: "focus-down",
description: "Move focus down",
comment: "Clashes with default KDE shortcuts, may require manual remapping",
defaultKeySequence: "Meta+S",
action: () => world.doIfTiledFocused(actions.focusDown),
},
{
name: "focus-next",
description: "Move focus to the next window in grid",
action: () => world.doIfTiledFocused(actions.focusNext),
},
{
name: "focus-previous",
description: "Move focus to the previous window in grid",
action: () => world.doIfTiledFocused(actions.focusPrevious),
},
{
name: "focus-start",
description: "Move focus to start",
defaultKeySequence: "Meta+Home",
action: () => world.do(actions.focusStart),
},
{
name: "focus-end",
description: "Move focus to end",
defaultKeySequence: "Meta+End",
action: () => world.do(actions.focusEnd),
},
{
name: "window-move-left",
description: "Move window left",
comment: "Moves window out of and into columns",
defaultKeySequence: "Meta+Shift+A",
action: () => world.doIfTiledFocused(actions.windowMoveLeft),
},
{
name: "window-move-right",
description: "Move window right",
comment: "Moves window out of and into columns",
defaultKeySequence: "Meta+Shift+D",
action: () => world.doIfTiledFocused(actions.windowMoveRight),
},
{
name: "window-move-up",
description: "Move window up",
defaultKeySequence: "Meta+Shift+W",
action: () => world.doIfTiledFocused(actions.windowMoveUp),
},
{
name: "window-move-down",
description: "Move window down",
defaultKeySequence: "Meta+Shift+S",
action: () => world.doIfTiledFocused(actions.windowMoveDown),
},
{
name: "window-move-next",
description: "Move window to the next position in grid",
action: () => world.doIfTiledFocused(actions.windowMoveNext),
},
{
name: "window-move-previous",
description: "Move window to the previous position in grid",
action: () => world.doIfTiledFocused(actions.windowMovePrevious),
},
{
name: "window-move-start",
description: "Move window to start",
defaultKeySequence: "Meta+Shift+Home",
action: () => world.doIfTiledFocused(actions.windowMoveStart),
},
{
name: "window-move-end",
description: "Move window to end",
defaultKeySequence: "Meta+Shift+End",
action: () => world.doIfTiledFocused(actions.windowMoveEnd),
},
{
name: "column-toggle-stacked",
description: "Toggle stacked layout for focused column",
comment: "One window in the column visible, others shaded; not supported on Wayland",
defaultKeySequence: "Meta+X",
action: () => world.doIfTiledFocused(actions.columnToggleStacked),
},
{
name: "column-move-left",
description: "Move column left",
defaultKeySequence: "Meta+Ctrl+Shift+A",
action: () => world.doIfTiledFocused(actions.columnMoveLeft),
},
{
name: "column-move-right",
description: "Move column right",
defaultKeySequence: "Meta+Ctrl+Shift+D",
action: () => world.doIfTiledFocused(actions.columnMoveRight),
},
{
name: "column-move-start",
description: "Move column to start",
defaultKeySequence: "Meta+Ctrl+Shift+Home",
action: () => world.doIfTiledFocused(actions.columnMoveStart),
},
{
name: "column-move-end",
description: "Move column to end",
defaultKeySequence: "Meta+Ctrl+Shift+End",
action: () => world.doIfTiledFocused(actions.columnMoveEnd),
},
{
name: "column-width-increase",
description: "Increase column width",
defaultKeySequence: "Meta+Ctrl++",
action: () => world.doIfTiledFocused(actions.columnWidthIncrease),
},
{
name: "column-width-decrease",
description: "Decrease column width",
defaultKeySequence: "Meta+Ctrl+-",
action: () => world.doIfTiledFocused(actions.columnWidthDecrease),
},
{
name: "cycle-preset-widths",
description: "Cycle through preset column widths",
defaultKeySequence: "Meta+R",
action: () => world.doIfTiledFocused(actions.cyclePresetWidths),
},
{
name: "columns-width-equalize",
description: "Equalize widths of visible columns",
defaultKeySequence: "Meta+Ctrl+X",
action: () => world.do(actions.columnsWidthEqualize),
},
{
name: "columns-squeeze-left",
description: "Squeeze left column onto the screen",
comment: "Clashes with default KDE shortcuts, may require manual remapping",
defaultKeySequence: "Meta+Ctrl+A",
action: () => world.doIfTiledFocused(actions.columnsSqueezeLeft),
},
{
name: "columns-squeeze-right",
description: "Squeeze right column onto the screen",
defaultKeySequence: "Meta+Ctrl+D",
action: () => world.doIfTiledFocused(actions.columnsSqueezeRight),
},
{
name: "grid-scroll-focused",
description: "Center focused window",
comment: "Scrolls so that the focused window is centered in the screen",
defaultKeySequence: "Meta+Alt+Return",
action: () => world.doIfTiledFocused(actions.gridScrollFocused),
},
{
name: "grid-scroll-left-column",
description: "Scroll one column to the left",
defaultKeySequence: "Meta+Alt+A",
action: () => world.do(actions.gridScrollLeftColumn),
},
{
name: "grid-scroll-right-column",
description: "Scroll one column to the right",
defaultKeySequence: "Meta+Alt+D",
action: () => world.do(actions.gridScrollRightColumn),
},
{
name: "grid-scroll-left",
description: "Scroll left",
defaultKeySequence: "Meta+Alt+PgUp",
action: () => world.do(actions.gridScrollLeft),
},
{
name: "grid-scroll-right",
description: "Scroll right",
defaultKeySequence: "Meta+Alt+PgDown",
action: () => world.do(actions.gridScrollRight),
},
{
name: "grid-scroll-start",
description: "Scroll to start",
defaultKeySequence: "Meta+Alt+Home",
action: () => world.do(actions.gridScrollStart),
},
{
name: "grid-scroll-end",
description: "Scroll to end",
defaultKeySequence: "Meta+Alt+End",
action: () => world.do(actions.gridScrollEnd),
},
{
name: "screen-switch",
description: "Move Karousel grid to the current screen",
defaultKeySequence: "Meta+Ctrl+Return",
action: () => world.do(actions.screenSwitch),
},
];
}
const numKeyBindings: NumKeyBinding[] = [
{
name: "focus-",
description: "Move focus to column ",
comment: "Clashes with default KDE shortcuts, may require manual remapping",
defaultModifiers: "Meta",
fKeys: false,
},
{
name: "window-move-to-column-",
description: "Move window to column ",
comment: "Requires manual remapping according to your keyboard layout, e.g. Meta+Shift+1 -> Meta+!",
defaultModifiers: "Meta+Shift",
fKeys: false,
},
{
name: "column-move-to-column-",
description: "Move column to position ",
comment: "Requires manual remapping according to your keyboard layout, e.g. Meta+Ctrl+Shift+1 -> Meta+Ctrl+!",
defaultModifiers: "Meta+Ctrl+Shift",
fKeys: false,
},
{
name: "column-move-to-desktop-",
description: "Move column to desktop ",
defaultModifiers: "Meta+Ctrl+Shift",
fKeys: true,
},
{
name: "tail-move-to-desktop-",
description: "Move this and all following columns to desktop ",
defaultModifiers: "Meta+Ctrl+Shift+Alt",
fKeys: true,
},
];
function getNumKeyBindings(world: World, actions: Actions): NumKeyBinding[] {
return [
{
name: "focus-{}",
description: "Move focus to column {}",
comment: "Clashes with default KDE shortcuts, may require manual remapping",
defaultModifiers: "Meta",
fKeys: false,
action: (i: number) => world.do(actions.focus.partial(i)),
},
{
name: "window-move-to-column-{}",
description: "Move window to column {}",
comment: "Requires manual remapping according to your keyboard layout, e.g. Meta+Shift+1 -> Meta+!",
defaultModifiers: "Meta+Shift",
fKeys: false,
action: (i: number) => world.doIfTiledFocused(actions.windowMoveToColumn.partial(i)),
},
{
name: "column-move-to-column-{}",
description: "Move column to position {}",
comment: "Requires manual remapping according to your keyboard layout, e.g. Meta+Ctrl+Shift+1 -> Meta+Ctrl+!",
defaultModifiers: "Meta+Ctrl+Shift",
fKeys: false,
action: (i: number) => world.doIfTiledFocused(actions.columnMoveToColumn.partial(i)),
},
{
name: "column-move-to-desktop-{}",
description: "Move column to desktop {}",
defaultModifiers: "Meta+Ctrl+Shift",
fKeys: true,
action: (i: number) => world.doIfTiledFocused(actions.columnMoveToDesktop.partial(i)),
},
{
name: "tail-move-to-desktop-{}",
description: "Move this and all following columns to desktop {}",
defaultModifiers: "Meta+Ctrl+Shift+Alt",
fKeys: true,
action: (i: number) => world.doIfTiledFocused(actions.tailMoveToDesktop.partial(i)),
},
];
}

View File

@@ -2,7 +2,8 @@ type KeyBinding = {
name: string;
description: string;
comment?: string;
defaultKeySequence: string;
defaultKeySequence?: string;
action: () => void;
};
type NumKeyBinding = {
@@ -11,6 +12,7 @@ type NumKeyBinding = {
comment?: string;
defaultModifiers: string;
fKeys: boolean;
action: (i: number) => void;
};
function catchWrap(f: () => void) {
@@ -24,14 +26,14 @@ function catchWrap(f: () => void) {
};
}
function registerKeyBinding(world: World, config: Actions.Config, shortcutActions: ShortcutAction[], keyBinding: KeyBinding) {
function registerKeyBinding(shortcutActions: ShortcutAction[], keyBinding: KeyBinding) {
shortcutActions.push(new ShortcutAction(
keyBinding,
catchWrap(Actions.getAction(world, config, keyBinding.name)),
catchWrap(keyBinding.action),
));
}
function registerNumKeyBindings(world: World, shortcutActions: ShortcutAction[], numKeyBinding: NumKeyBinding) {
function registerNumKeyBindings(shortcutActions: ShortcutAction[], numKeyBinding: NumKeyBinding) {
const numPrefix = numKeyBinding.fKeys ? "F" : "";
const n = numKeyBinding.fKeys ? 12 : 9;
for (let i = 0; i < 12; i++) {
@@ -39,28 +41,27 @@ function registerNumKeyBindings(world: World, shortcutActions: ShortcutAction[],
const keySequence = i < n ?
numKeyBinding.defaultModifiers + "+" + numPrefix + numKey :
"";
const action = Actions.getNumAction(world, numKeyBinding.name);
shortcutActions.push(new ShortcutAction(
{
name: numKeyBinding.name + numKey,
description: numKeyBinding.description + numKey,
name: applyMacro(numKeyBinding.name, numKey),
description: applyMacro(numKeyBinding.description, numKey),
defaultKeySequence: keySequence,
},
catchWrap(() => action(i)),
catchWrap(() => numKeyBinding.action(i)),
));
}
}
// TODO: refactor
function registerKeyBindings(world: World, config: Actions.Config) {
const actions = new Actions(config);
const shortcutActions: ShortcutAction[] = [];
for (const keyBinding of keyBindings) {
registerKeyBinding(world, config, shortcutActions, keyBinding);
for (const keyBinding of getKeyBindings(world, actions)) {
registerKeyBinding(shortcutActions, keyBinding);
}
for (const numKeyBinding of numKeyBindings) {
registerNumKeyBindings(world, shortcutActions, numKeyBinding);
for (const numKeyBinding of getNumKeyBindings(world, actions)) {
registerNumKeyBindings(shortcutActions, numKeyBinding);
}
return shortcutActions;

View File

@@ -5,25 +5,25 @@ class Column {
private readonly windows: LinkedList<Window>;
private stacked: boolean;
private focusTaker: Window|null;
private static readonly minWidth = 10;
private static readonly minWidth = 40;
constructor(grid: Grid, prevColumn: Column|null) {
constructor(grid: Grid, leftColumn: Column|null) {
this.gridX = 0;
this.width = 0;
this.windows = new LinkedList();
this.stacked = grid.config.stackColumnsByDefault;
this.focusTaker = null;
this.grid = grid;
this.grid.onColumnAdded(this, prevColumn);
this.grid.onColumnAdded(this, leftColumn);
}
public moveToGrid(targetGrid: Grid, prevColumn: Column|null) {
public moveToGrid(targetGrid: Grid, leftColumn: Column|null) {
if (targetGrid === this.grid) {
this.grid.moveColumn(this, prevColumn);
this.grid.moveColumn(this, leftColumn);
} else {
this.grid.onColumnRemoved(this, false);
this.grid = targetGrid;
targetGrid.onColumnAdded(this, prevColumn);
targetGrid.onColumnAdded(this, leftColumn);
for (const window of this.windows.iterator()) {
window.client.kwinClient.desktops = [targetGrid.desktop.kwinDesktop];
}
@@ -56,11 +56,19 @@ class Column {
return this.getWindowCount() === 0;
}
public getPrevWindow(window: Window) {
public getFirstWindow(): Window {
return this.windows.getFirst()!;
}
public getLastWindow(): Window {
return this.windows.getLast()!;
}
public getAboveWindow(window: Window) {
return this.windows.getPrev(window);
}
public getNextWindow(window: Window) {
public getBelowWindow(window: Window) {
return this.windows.getNext(window);
}
@@ -125,6 +133,27 @@ class Column {
return this.gridX + this.width;
}
public onUserResizeWidth(
startWidth: number,
currentDelta: number,
resizingLeftSide: boolean,
neighbor?: { column: Column, startWidth: number },
) {
const oldColumnWidth = this.getWidth();
this.setWidth(startWidth + currentDelta, true);
const actualDelta = this.getWidth() - startWidth;
let leftEdgeDeltaStep = resizingLeftSide ? oldColumnWidth - this.getWidth() : 0;
if (neighbor !== undefined) {
const oldNeighborWidth = neighbor.column.getWidth();
neighbor.column.setWidth(neighbor.startWidth - actualDelta, true);
if (resizingLeftSide) {
leftEdgeDeltaStep -= neighbor.column.getWidth() - oldNeighborWidth;
}
}
this.grid.desktop.adjustScroll(-leftEdgeDeltaStep, true);
}
public adjustWindowHeight(window: Window, heightDelta: number, top: boolean) {
const otherWindow = top ? this.windows.getPrev(window) : this.windows.getNext(window);
if (otherWindow === null) {
@@ -248,8 +277,13 @@ class Column {
}
}
public onWindowAdded(window: Window) {
this.windows.insertEnd(window);
public onWindowAdded(window: Window, bottom: boolean) {
if (bottom) {
this.windows.insertEnd(window);
} else {
this.windows.insertStart(window);
}
if (this.width === 0) {
this.setWidth(window.client.preferredWidth, false);
}
@@ -266,7 +300,7 @@ class Column {
public onWindowRemoved(window: Window, passFocus: boolean) {
const lastWindow = this.windows.length() === 1;
const windowToFocus = this.getPrevWindow(window) ?? this.getNextWindow(window);
const windowToFocus = this.getAboveWindow(window) ?? this.getBelowWindow(window);
this.windows.remove(window);

View File

@@ -25,7 +25,7 @@ class Desktop {
private updateArea() {
const newClientArea = Desktop.getClientArea(this.getScreen(), this.kwinDesktop);
if (newClientArea === this.clientArea && !this.dirtyPins) {
if (rectEquals(newClientArea, this.clientArea) && !this.dirtyPins) {
return;
}
this.clientArea = newClientArea;
@@ -75,7 +75,7 @@ class Desktop {
public scrollCenterRange(range: Desktop.Range) {
const windowCenter = range.getLeft() + range.getWidth() / 2;
const screenCenter = this.scrollX + this.tilingArea.width / 2;
this.adjustScroll(Math.round(windowCenter - screenCenter), false);
this.adjustScroll(Math.round(windowCenter - screenCenter), true);
}
public scrollCenterVisible(focusedColumn: Column) {
@@ -125,40 +125,6 @@ class Desktop {
this.setScroll(this.scrollX + dx, force);
}
public equalizeVisibleColumnsWidths() {
const visibleRange = this.getCurrentVisibleRange();
const visibleColumns = Array.from(this.grid.getVisibleColumns(visibleRange, true));
let remainingWidth = this.tilingArea.width - (visibleColumns.length-1) * this.grid.config.gapsInnerHorizontal;
let remainingColumns = visibleColumns.length;
const minWidths = visibleColumns.map(column => column.getMinWidth()).sort((a, b) => b - a);
for (const minWidth of minWidths) {
if (minWidth > remainingWidth / remainingColumns) {
remainingWidth -= minWidth;
remainingColumns--;
}
}
const avgWidth = remainingWidth / remainingColumns;
for (const column of visibleColumns) {
const minWidth = column.getMinWidth();
if (minWidth > avgWidth) {
column.setWidth(minWidth, true);
} else {
const columnWidth = Math.round(remainingWidth / remainingColumns);
column.setWidth(columnWidth, true);
remainingWidth -= column.getWidth();
remainingColumns--;
}
}
this.scrollCenterRange(Desktop.RangeImpl.fromRanges(
visibleColumns[0],
visibleColumns[visibleColumns.length - 1],
));
}
public arrange() {
// TODO (optimization): only arrange visible windows
this.updateArea();
@@ -187,19 +153,19 @@ class Desktop {
namespace Desktop {
export type Config = {
marginTop: number,
marginBottom: number,
marginLeft: number,
marginRight: number,
scroller: Desktop.Scroller,
clamper: Desktop.Clamper,
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
scroller: Desktop.Scroller;
clamper: Desktop.Clamper;
};
export type Range = {
getLeft(): number;
getRight(): number;
getWidth(): number;
}
};
export class RangeImpl {
private readonly x: number;
@@ -251,8 +217,8 @@ namespace Desktop {
return column !== null && canFit(column);
}
let leftColumn = grid.getPrevColumn(this.left);
let rightColumn = grid.getNextColumn(this.right);
let leftColumn = grid.getLeftColumn(this.left);
let rightColumn = grid.getRightColumn(this.right);
function checkColumns() {
if (!isUsable(leftColumn)) {
leftColumn = null;
@@ -269,10 +235,10 @@ namespace Desktop {
const rightToCenter = rightColumn === null ? Infinity : Math.abs(rightColumn.getRight() - visibleCenter);
if (leftToCenter < rightToCenter) {
this.addLeft(leftColumn!, gap);
leftColumn = grid.getPrevColumn(leftColumn!);
leftColumn = grid.getLeftColumn(leftColumn!);
} else {
this.addRight(rightColumn!, gap);
rightColumn = grid.getNextColumn(rightColumn!);
rightColumn = grid.getRightColumn(rightColumn!);
}
checkColumns();
}
@@ -303,9 +269,9 @@ namespace Desktop {
export type Scroller = {
scrollToColumn(desktop: Desktop, column: Column): void;
}
};
export type Clamper = {
clampScrollX(desktop: Desktop, x: number): number;
}
};
}

View File

@@ -24,13 +24,13 @@ class Grid {
});
}
public moveColumn(column: Column, prevColumn: Column|null) {
if (column === prevColumn) {
public moveColumn(column: Column, leftColumn: Column|null) {
if (column === leftColumn) {
return;
}
const movedLeft = prevColumn === null ? true : column.isToTheRightOf(prevColumn);
const firstMovedColumn = movedLeft ? column : this.getNextColumn(column);
this.columns.move(column, prevColumn);
const movedLeft = leftColumn === null ? true : column.isToTheRightOf(leftColumn);
const firstMovedColumn = movedLeft ? column : this.getRightColumn(column);
this.columns.move(column, leftColumn);
this.columnsSetX(firstMovedColumn);
this.desktop.onLayoutChanged();
this.desktop.autoAdjustScroll();
@@ -44,11 +44,11 @@ class Grid {
}
public moveColumnRight(column: Column) {
const nextColumn = this.columns.getNext(column);
if (nextColumn === null) {
const rightColumn = this.columns.getNext(column);
if (rightColumn === null) {
return;
}
this.moveColumnLeft(nextColumn);
this.moveColumnLeft(rightColumn);
}
public getWidth() {
@@ -59,11 +59,11 @@ class Grid {
return this.userResize;
}
public getPrevColumn(column: Column) {
public getLeftColumn(column: Column) {
return this.columns.getPrev(column);
}
public getNextColumn(column: Column) {
public getRightColumn(column: Column) {
return this.columns.getNext(column);
}
@@ -162,11 +162,11 @@ class Grid {
}
}
public onColumnAdded(column: Column, prevColumn: Column|null) {
if (prevColumn === null) {
public onColumnAdded(column: Column, leftColumn: Column|null) {
if (leftColumn === null) {
this.columns.insertStart(column);
} else {
this.columns.insertAfter(column, prevColumn);
this.columns.insertAfter(column, leftColumn);
}
this.columnsSetX(column);
this.desktop.onLayoutChanged();
@@ -175,14 +175,14 @@ class Grid {
public onColumnRemoved(column: Column, passFocus: boolean) {
const isLastColumn = this.columns.length() === 1;
const nextColumn = this.getNextColumn(column);
const columnToFocus = isLastColumn ? null : this.getPrevColumn(column) ?? nextColumn;
const rightColumn = this.getRightColumn(column);
const columnToFocus = isLastColumn ? null : this.getLeftColumn(column) ?? rightColumn;
if (column === this.lastFocusedColumn) {
this.lastFocusedColumn = columnToFocus;
}
this.columns.remove(column);
this.columnsSetX(nextColumn);
this.columnsSetX(rightColumn);
this.desktop.onLayoutChanged();
if (passFocus && columnToFocus !== null) {
@@ -193,8 +193,8 @@ class Grid {
}
public onColumnWidthChanged(column: Column) {
const nextColumn = this.columns.getNext(column);
this.columnsSetX(nextColumn);
const rightColumn = this.columns.getNext(column);
this.columnsSetX(rightColumn);
this.desktop.onLayoutChanged();
if (!this.userResize) {
this.desktop.autoAdjustScroll();
@@ -203,7 +203,7 @@ class Grid {
public onColumnFocused(column: Column) {
const lastFocusedColumn = this.getLastFocusedColumn();
if (lastFocusedColumn !== null) {
if (lastFocusedColumn !== null && lastFocusedColumn !== column) {
lastFocusedColumn.restoreToTiled();
}
this.lastFocusedColumn = column;

View File

@@ -1,11 +1,12 @@
type LayoutConfig = {
gapsInnerHorizontal: number,
gapsInnerVertical: number,
offScreenOpacity: number,
stackColumnsByDefault: boolean,
resizeNeighborColumn: boolean,
reMaximize: boolean,
skipSwitcher: boolean,
tiledKeepBelow: boolean,
maximizedKeepAbove: boolean,
gapsInnerHorizontal: number;
gapsInnerVertical: number;
offScreenOpacity: number;
stackColumnsByDefault: boolean;
resizeNeighborColumn: boolean;
reMaximize: boolean;
skipSwitcher: boolean;
tiledKeepBelow: boolean;
maximizedKeepAbove: boolean;
untileOnDrag: boolean;
};

View File

@@ -14,16 +14,16 @@ class Window {
};
this.skipArrange = false;
this.column = column;
column.onWindowAdded(this);
column.onWindowAdded(this, true);
}
public moveToColumn(targetColumn: Column) {
public moveToColumn(targetColumn: Column, bottom: boolean) {
if (targetColumn === this.column) {
return;
}
this.column.onWindowRemoved(this, false);
this.column = targetColumn;
targetColumn.onWindowAdded(this);
targetColumn.onWindowAdded(this, bottom);
}
public arrange(x: number, y: number, width: number, height: number) {
@@ -107,31 +107,6 @@ class Window {
this.column.grid.desktop.onLayoutChanged();
}
public onUserResize(oldGeometry: QmlRect, resizeNeighborColumn: boolean) {
const newGeometry = this.client.kwinClient.frameGeometry;
const widthDelta = newGeometry.width - oldGeometry.width;
const heightDelta = newGeometry.height - oldGeometry.height;
if (widthDelta !== 0) {
this.column.adjustWidth(widthDelta, true);
let leftEdgeDelta = newGeometry.left - oldGeometry.left;
const resizingLeftSide = leftEdgeDelta !== 0;
if (resizeNeighborColumn && this.column.grid.config.resizeNeighborColumn) {
const neighborColumn = resizingLeftSide ? this.column.grid.getPrevColumn(this.column) : this.column.grid.getNextColumn(this.column);
if (neighborColumn !== null) {
const oldNeighborWidth = neighborColumn.getWidth();
neighborColumn.adjustWidth(-widthDelta, true);
if (resizingLeftSide) {
leftEdgeDelta -= neighborColumn.getWidth() - oldNeighborWidth;
}
}
}
this.column.grid.desktop.adjustScroll(-leftEdgeDelta, true);
}
if (heightDelta !== 0) {
this.column.adjustWindowHeight(this, heightDelta, newGeometry.y !== oldGeometry.y);
}
}
public onFrameGeometryChanged() {
const newGeometry = this.client.kwinClient.frameGeometry;
this.column.setWidth(newGeometry.width, true);
@@ -145,7 +120,7 @@ class Window {
namespace Window {
export type State = {
fullScreen: boolean,
maximizedMode: MaximizedMode,
}
fullScreen: boolean;
maximizedMode: MaximizedMode;
};
}

View File

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

View File

@@ -11,14 +11,12 @@ class WindowRuleEnforcer {
}
public shouldTile(kwinClient: KwinClient) {
return Clients.canTileNow(kwinClient) && (
this.preferTiling.matches(kwinClient) || (
kwinClient.normalWindow &&
!kwinClient.transient &&
kwinClient.managed &&
kwinClient.pid > -1 &&
!this.preferFloating.matches(kwinClient)
)
return this.preferTiling.matches(kwinClient) || (
kwinClient.normalWindow &&
!kwinClient.transient &&
kwinClient.managed &&
kwinClient.pid > -1 &&
!this.preferFloating.matches(kwinClient)
);
}
@@ -30,7 +28,7 @@ class WindowRuleEnforcer {
const enforcer = this;
const manager = new SignalManager();
manager.connect(kwinClient.captionChanged, () => {
const shouldTile = enforcer.shouldTile(kwinClient);
const shouldTile = Clients.canTileNow(kwinClient) && enforcer.shouldTile(kwinClient);
world.do((clientManager, desktopManager) => {
const desktop = desktopManager.getDesktopForClient(kwinClient);
if (shouldTile && desktop !== undefined) {

View File

@@ -0,0 +1,24 @@
class RateLimiter {
private i = 0;
private intervalStart = 0;
constructor(
private readonly n: number,
private readonly intervalMs: number,
) {}
public acquire() {
const now = Date.now();
if (now - this.intervalStart >= this.intervalMs) {
this.i = 0;
this.intervalStart = now;
}
if (this.i < this.n) {
this.i++;
return true;
} else {
return false;
}
}
}

View File

@@ -1,7 +1,7 @@
class ShortcutAction {
private readonly shortcutHandler: ShortcutHandler;
constructor(keyBinding: KeyBinding, f: () => void) {
constructor(keyBinding: ShortcutAction.KeyBinding, f: () => void) {
this.shortcutHandler = ShortcutAction.initShortcutHandler(keyBinding);
this.shortcutHandler.activated.connect(f);
}
@@ -10,16 +10,28 @@ class ShortcutAction {
this.shortcutHandler.destroy();
}
private static initShortcutHandler(keyBinding: KeyBinding) {
private static initShortcutHandler(keyBinding: ShortcutAction.KeyBinding) {
const sequenceLine = keyBinding.defaultKeySequence !== undefined ?
` sequence: "${keyBinding.defaultKeySequence}";
` :
"";
return <ShortcutHandler>Qt.createQmlObject(
`import QtQuick 6.0
import org.kde.kwin 3.0
ShortcutHandler {
name: "karousel-${keyBinding.name}";
text: "Karousel: ${keyBinding.description}";
sequence: "${keyBinding.defaultKeySequence}";
}`,
${sequenceLine}}`,
qmlBase,
);
}
}
namespace ShortcutAction {
export type KeyBinding = {
name: string;
description: string;
defaultKeySequence?: string;
};
}

View File

@@ -0,0 +1,39 @@
function union<T>(array0: T[], array1: T[]) {
const set = new Set([...array0, ...array1]);
return [...set];
}
function uniq(sortedArray: any[]) {
const filtered = [];
let lastItem;
for (const item of sortedArray) {
if (item !== lastItem) {
filtered.push(item);
lastItem = item;
}
}
return filtered;
}
function mapGetOrInit<K, V>(map: Map<K, V>, key: K, defaultItem: V) {
const item = map.get(key);
if (item !== undefined) {
return item;
} else {
map.set(key, defaultItem);
return defaultItem;
}
}
function findMinPositive<T>(items: T[], evaluate: (item: T) => number) {
let bestScore = Infinity;
let bestItem = undefined;
for (const item of items) {
const score = evaluate(item);
if (score > 0 && score < bestScore) {
bestScore = score;
bestItem = item;
}
}
return bestItem;
}

View File

@@ -0,0 +1,98 @@
function fillSpace(availableSpace: number, items: { min: number, max: number }[]) {
if (items.length === 0) {
return [];
}
const middleSize = findMiddleSize(availableSpace, items);
const sizes = items.map(item => clamp(middleSize, item.min, item.max));
if (middleSize !== Math.floor(availableSpace / items.length)) {
distributeRemainder(availableSpace, middleSize, sizes, items);
}
return sizes;
function findMiddleSize(availableSpace: number, items: { min: number, max: number }[]) {
const ranges = buildRanges(items);
let requiredSpace = items.reduce((acc, item) => acc + item.min, 0);
for (const range of ranges) {
const rangeSize = range.end - range.start;
const maxRequiredSpaceDelta = rangeSize * range.n;
if (requiredSpace + maxRequiredSpaceDelta >= availableSpace) {
const positionInRange = (availableSpace - requiredSpace) / maxRequiredSpaceDelta;
return Math.floor(range.start + rangeSize * positionInRange);
}
requiredSpace += maxRequiredSpaceDelta;
}
return ranges[ranges.length-1].end;
}
function buildRanges(items: { min: number, max: number }[]) {
const landmarks = buildLandmarks(items);
if (landmarks.length === 1) {
return [{
start: landmarks[0].value,
end: landmarks[0].value,
n: items.length,
}];
}
const ranges: Range[] = [];
let n = 0;
for (let i = 1; i < landmarks.length; i++) {
const startLandmark = landmarks[i-1];
const endLandmark = landmarks[i];
n = n - startLandmark.nMax + startLandmark.nMin;
ranges.push({
start: startLandmark.value,
end: endLandmark.value,
n: n,
});
}
return ranges;
}
function buildLandmarks(items: { min: number, max: number }[]) {
const landmarks = new Map<number, Landmark>();
for (const item of items) {
mapGetOrInit(landmarks, item.min, { value: item.min, nMin: 0, nMax: 0 }).nMin++;
mapGetOrInit(landmarks, item.max, { value: item.max, nMin: 0, nMax: 0 }).nMax++;
}
const array = Array.from(landmarks.values());
array.sort((a, b) => a.value - b.value);
return array;
}
function distributeRemainder(availableSpace: number, middleSize: number, sizes: number[], constraints: { max: number }[]) {
const indexes = Array.from(sizes.keys())
.filter(i => sizes[i] === middleSize);
indexes.sort((a, b) => constraints[a].max - constraints[b].max);
const requiredSpace = sum(...sizes);
let remainder = availableSpace - requiredSpace;
let n = indexes.length;
for (const i of indexes) {
if (remainder <= 0) {
break;
}
const enlargable = constraints[i].max - sizes[i];
if (enlargable > 0) {
const enlarge = Math.min(enlargable, Math.ceil(remainder / n));
sizes[i] += enlarge;
remainder -= enlarge;
}
n--;
}
}
type Range = {
start: number,
end: number,
n: number,
};
type Landmark = {
value: number,
nMin: number,
nMax: number,
}
}

View File

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

View File

@@ -8,7 +8,13 @@ function clamp(value: number, min: number, max: number) {
return value;
}
function union<T>(array0: T[], array1: T[]) {
const set = new Set([...array0, ...array1]);
return [...set];
function sum(...list: number[]) {
return list.reduce((acc, val) => acc + val);
}
function rectEquals(a: QmlRect, b: QmlRect) {
return a.x === b.x &&
a.y === b.y &&
a.width === b.width &&
a.height === b.height;
}

3
src/lib/utils/strings.ts Normal file
View File

@@ -0,0 +1,3 @@
function applyMacro(base: string, value: string) {
return base.replace("{}", String(value));
}

View File

@@ -2,10 +2,6 @@ function initWorkspaceSignalHandlers(world: World) {
const manager = new SignalManager();
manager.connect(Workspace.windowAdded, (kwinClient: KwinClient) => {
if (Clients.canTileEver(kwinClient)) {
// never open new tileable clients on all desktops or activities
Clients.makeTileable(kwinClient);
}
world.do((clientManager, desktopManager) => {
clientManager.addClient(kwinClient)
});
@@ -17,7 +13,7 @@ function initWorkspaceSignalHandlers(world: World) {
});
});
manager.connect(Workspace.windowActivated, (kwinClient: KwinClient) => {
manager.connect(Workspace.windowActivated, (kwinClient: KwinClient|null) => {
if (kwinClient === null) {
return;
}

View File

@@ -1,15 +1,17 @@
class ClientManager {
private readonly world: World;
private readonly config: ClientManager.Config;
private readonly desktopManager: DesktopManager;
private readonly pinManager: PinManager;
private readonly clientMap: Map<KwinClient, ClientWrapper>;
private lastFocusedClient: KwinClient|null;
private readonly windowRuleEnforcer: WindowRuleEnforcer;
constructor(config: Config, world: World, desktopManager: DesktopManager, pinManager: PinManager) {
constructor(
config: Config,
private readonly world: World,
private readonly desktopManager: DesktopManager,
private readonly pinManager: PinManager,
) {
this.world = world;
this.config = { keepAbove: config.floatingKeepAbove };
this.config = config;
this.desktopManager = desktopManager;
this.pinManager = pinManager;
this.clientMap = new Map();
@@ -27,13 +29,21 @@ class ClientManager {
public addClient(kwinClient: KwinClient) {
console.assert(!this.hasClient(kwinClient));
const desktop = this.desktopManager.getDesktopForClient(kwinClient);
let constructState: (client: ClientWrapper) => ClientState.State;
if (kwinClient.dock) {
constructState = () => new ClientState.Docked(this.world, kwinClient);
} else if (this.windowRuleEnforcer.shouldTile(kwinClient) && desktop !== undefined) {
constructState = (client: ClientWrapper) => new ClientState.Tiled(this.world, client, desktop.grid);
} else if (
Clients.canTileEver(kwinClient) &&
!kwinClient.fullScreen &&
!Clients.isFullScreenGeometry(kwinClient) &&
this.windowRuleEnforcer.shouldTile(kwinClient)
) {
Clients.makeTileable(kwinClient);
console.assert(Clients.canTileNow(kwinClient));
const desktop = this.desktopManager.getDesktopForClient(kwinClient);
console.assert(desktop !== undefined);
constructState = (client: ClientWrapper) => new ClientState.Tiled(this.world, client, desktop!.grid);
} else {
constructState = (client: ClientWrapper) => new ClientState.Floating(this.world, client, this.config, false);
}
@@ -58,7 +68,7 @@ class ClientManager {
}
private findTransientFor(kwinClient: KwinClient) {
if (!kwinClient.transient) {
if (!kwinClient.transient || kwinClient.transientFor === null) {
return null;
}
@@ -168,27 +178,27 @@ class ClientManager {
public onClientFocused(kwinClient: KwinClient) {
this.lastFocusedClient = kwinClient;
const window = this.findTiledWindow(kwinClient, true);
const window = this.findTiledWindow(kwinClient);
if (window !== null) {
window.onFocused();
}
}
public findTiledWindow(kwinClient: KwinClient, followTransient: boolean) {
public findTiledWindow(kwinClient: KwinClient) {
const client = this.clientMap.get(kwinClient);
if (client === undefined) {
return null;
}
return this.findTiledWindowOfClient(client, followTransient);
return this.findTiledWindowOfClient(client);
}
private findTiledWindowOfClient(client: ClientWrapper, followTransient: boolean): Window|null {
private findTiledWindowOfClient(client: ClientWrapper): Window|null {
const clientState = client.stateManager.getState();
if (clientState instanceof ClientState.Tiled) {
return clientState.window;
} else if (followTransient && client.transientFor !== null) {
return this.findTiledWindowOfClient(client.transientFor, true);
} else if (client.transientFor !== null) {
return this.findTiledWindowOfClient(client.transientFor);
} else {
return null;
}
@@ -207,6 +217,6 @@ class ClientManager {
namespace ClientManager {
export type Config = {
keepAbove: boolean,
}
floatingKeepAbove: boolean;
};
}

View File

@@ -1,20 +1,17 @@
class ClientWrapper {
public readonly kwinClient: KwinClient;
public readonly stateManager: ClientState.Manager;
public transientFor: ClientWrapper | null;
private readonly transients: ClientWrapper[];
private readonly signalManager: SignalManager;
private readonly rulesSignalManager: SignalManager | null;
public preferredWidth: number;
private maximizedMode: MaximizedMode | undefined;
private readonly manipulatingGeometry: Doer;
private lastPlacement: QmlRect | null; // workaround for issue #19
constructor(
kwinClient: KwinClient,
public readonly kwinClient: KwinClient,
constructInitialState: (client: ClientWrapper) => ClientState.State,
transientFor: ClientWrapper | null,
rulesSignalManager: SignalManager | null,
public transientFor: ClientWrapper | null,
private readonly rulesSignalManager: SignalManager | null,
) {
this.kwinClient = kwinClient;
this.transientFor = transientFor;

View File

@@ -1,10 +1,21 @@
namespace Clients {
const prohibitedClasses = [
"ksmserver-logout-greeter",
"xwaylandvideobridge",
];
export function canTileEver(kwinClient: KwinClient) {
return kwinClient.moveable && kwinClient.resizeable && !kwinClient.popupWindow;
return kwinClient.moveable &&
kwinClient.resizeable &&
!kwinClient.popupWindow &&
!prohibitedClasses.includes(kwinClient.resourceClass);
}
export function canTileNow(kwinClient: KwinClient) {
return canTileEver(kwinClient) && !kwinClient.minimized && kwinClient.desktops.length === 1 && kwinClient.activities.length === 1;
return canTileEver(kwinClient) &&
!kwinClient.minimized &&
kwinClient.desktops.length === 1 &&
kwinClient.activities.length === 1;
}
export function makeTileable(kwinClient: KwinClient) {
@@ -36,7 +47,8 @@ namespace Clients {
export function isFullScreenGeometry(kwinClient: KwinClient) {
const fullScreenArea = Workspace.clientArea(ClientAreaOption.FullScreenArea, kwinClient.output, getKwinDesktopApprox(kwinClient));
return kwinClient.frameGeometry === fullScreenArea;
return kwinClient.clientGeometry.width === fullScreenArea.width &&
kwinClient.clientGeometry.height === fullScreenArea.height;
}
export function isOnVirtualDesktop(kwinClient: KwinClient, kwinDesktop: KwinDesktop) {

View File

@@ -1,13 +1,16 @@
class DesktopManager {
private readonly pinManager: PinManager;
private readonly config: Desktop.Config;
public readonly layoutConfig: LayoutConfig;
private readonly desktops: Map<string, Desktop>; // key is activityId|desktopId
private selectedScreen: Output;
private kwinActivities: Set<string>;
private kwinDesktops: Set<KwinDesktop>;
constructor(pinManager: PinManager, config: Desktop.Config, layoutConfig: LayoutConfig, currentActivity: string, currentDesktop: KwinDesktop) {
constructor(
private readonly pinManager: PinManager,
private readonly config: Desktop.Config,
public readonly layoutConfig: LayoutConfig,
currentActivity: string,
currentDesktop: KwinDesktop,
) {
this.pinManager = pinManager;
this.config = config;
this.layoutConfig = layoutConfig;

View File

@@ -17,7 +17,7 @@ class PinManager {
const baseLot = new PinManager.Lot(screen.top, screen.bottom, screen.left, screen.right);
let lots = [baseLot];
for (const client of this.pinnedClients) {
if (!Clients.isOnVirtualDesktop(client, kwinDesktop)) {
if (!Clients.isOnVirtualDesktop(client, kwinDesktop) || client.minimized) {
continue;
}

View File

@@ -1,19 +1,29 @@
class World {
public readonly untileOnDrag: boolean;
private readonly desktopManager: DesktopManager;
public readonly clientManager: ClientManager;
private readonly clientManager: ClientManager;
private readonly pinManager: PinManager;
private readonly workspaceSignalManager: SignalManager;
private readonly shortcutActions: ShortcutAction[];
private readonly screenResizedDelayer: Delayer;
constructor(config: Config) {
this.untileOnDrag = config.untileOnDrag;
this.workspaceSignalManager = initWorkspaceSignalHandlers(this);
let presetWidths = {
next: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth,
getWidths: (minWidth: number, maxWidth: number): number[] => [],
};
try {
presetWidths = new PresetWidths(config.presetWidths, config.gapsInnerHorizontal);
} catch (error: any) {
notificationInvalidPresetWidths.sendEvent();
log("failed to parse presetWidths:", error);
}
this.shortcutActions = registerKeyBindings(this, {
manualScrollStep: config.manualScrollStep,
manualResizeStep: config.manualResizeStep,
columnResizer: config.scrollingCentered ? new RawResizer() : new ContextualResizer(),
presetWidths: presetWidths,
columnResizer: config.scrollingCentered ? new RawResizer(presetWidths) : new ContextualResizer(presetWidths),
});
this.screenResizedDelayer = new Delayer(1000, () => {
@@ -36,6 +46,7 @@ class World {
skipSwitcher: config.skipSwitcher,
tiledKeepBelow: config.tiledKeepBelow,
maximizedKeepAbove: config.floatingKeepAbove,
untileOnDrag: config.untileOnDrag,
};
this.desktopManager = new DesktopManager(
@@ -89,10 +100,9 @@ class World {
public doIfTiled(
kwinClient: KwinClient,
followTransient: boolean,
f: (clientManager: ClientManager, desktopManager: DesktopManager, window: Window, column: Column, grid: Grid) => void,
) {
const window = this.clientManager.findTiledWindow(kwinClient, followTransient);
const window = this.clientManager.findTiledWindow(kwinClient);
if (window === null) {
return;
}
@@ -103,10 +113,12 @@ class World {
}
public doIfTiledFocused(
followTransient: boolean,
f: (clientManager: ClientManager, desktopManager: DesktopManager, window: Window, column: Column, grid: Grid) => void,
) {
this.doIfTiled(Workspace.activeWindow, followTransient, f);
if (Workspace.activeWindow === null) {
return;
}
this.doIfTiled(Workspace.activeWindow, f);
}
public destroy() {

View File

@@ -7,7 +7,7 @@ namespace ClientState {
constructor(world: World, client: ClientWrapper, config: ClientManager.Config, limitHeight: boolean) {
this.client = client;
this.config = config;
if (config.keepAbove) {
if (config.floatingKeepAbove) {
client.kwinClient.keepAbove = true;
}
if (limitHeight && client.kwinClient.tile === null) {
@@ -18,11 +18,12 @@ namespace ClientState {
public destroy(passFocus: boolean) {
this.signalManager.destroy();
if (this.config.keepAbove) {
if (this.config.floatingKeepAbove) {
this.client.kwinClient.keepAbove = false;
}
}
// TODO: move to `Tiled.restoreClientAfterTiling`
private static limitHeight(client: ClientWrapper) {
const placementArea = Workspace.clientArea(
ClientAreaOption.PlacementArea,
@@ -50,7 +51,7 @@ namespace ClientState {
});
}
});
manager.connect(kwinClient.frameGeometryChanged, () => {
// on Wayland, this fires after `tileChanged`
if (kwinClient.tile !== null) {

View File

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

View File

@@ -11,7 +11,7 @@ namespace ClientState {
this.pinManager = pinManager;
this.desktopManager = desktopManager;
this.config = config;
if (config.keepAbove) {
if (config.floatingKeepAbove) {
kwinClient.keepAbove = true;
}
this.signalManager = Pinned.initSignalManager(world, pinManager, kwinClient);
@@ -19,7 +19,7 @@ namespace ClientState {
public destroy(passFocus: boolean) {
this.signalManager.destroy();
if (this.config.keepAbove) {
if (this.config.floatingKeepAbove) {
this.kwinClient.keepAbove = true;
}
this.pinManager.removeClient(this.kwinClient);
@@ -53,7 +53,15 @@ namespace ClientState {
for (const desktop of desktopManager.getDesktopsForClient(kwinClient)) {
desktop.onPinsChanged();
}
})
});
});
manager.connect(kwinClient.minimizedChanged, () => {
world.do((clientManager, desktopManager) => {
for (const desktop of desktopManager.getDesktopsForClient(kwinClient)) {
desktop.onPinsChanged();
}
});
});
manager.connect(kwinClient.desktopsChanged, () => {

View File

@@ -3,6 +3,7 @@ namespace ClientState {
public readonly window: Window;
private readonly defaultState: Tiled.WindowState;
private readonly signalManager: SignalManager;
private static readonly maxExternalFrameGeometryChangedIntervalMs = 1000;
constructor(world: World, client: ClientWrapper, grid: Grid) {
this.defaultState = { skipSwitcher: client.kwinClient.skipSwitcher };
@@ -12,7 +13,7 @@ namespace ClientState {
const window = new Window(client, column);
this.window = window;
this.signalManager = Tiled.initSignalManager(world, window);
this.signalManager = Tiled.initSignalManager(world, window, grid.config);
}
public destroy(passFocus: boolean) {
@@ -26,7 +27,7 @@ namespace ClientState {
Tiled.restoreClientAfterTiling(client, grid.config, this.defaultState, grid.desktop.clientArea);
}
private static initSignalManager(world: World, window: Window) {
private static initSignalManager(world: World, window: Window, config: LayoutConfig) {
const client = window.client;
const kwinClient = client.kwinClient;
const manager = new SignalManager();
@@ -36,7 +37,7 @@ namespace ClientState {
const desktop = desktopManager.getDesktopForClient(kwinClient);
if (desktop === undefined) {
// windows on multiple desktops are not supported
clientManager.floatKwinClient(kwinClient);
clientManager.floatClient(client);
return;
}
Tiled.moveWindowToGrid(window, desktop.grid);
@@ -48,7 +49,7 @@ namespace ClientState {
const desktop = desktopManager.getDesktopForClient(kwinClient);
if (desktop === undefined) {
// windows on multiple activities are not supported
clientManager.floatKwinClient(kwinClient);
clientManager.floatClient(client);
return;
}
Tiled.moveWindowToGrid(window, desktop.grid);
@@ -69,12 +70,13 @@ namespace ClientState {
});
let resizing = false;
let resizingBorder = false;
let resizeStartWidth = 0;
let resizeNeighbor: { column: Column, startWidth: number } | undefined;
manager.connect(kwinClient.interactiveMoveResizeStarted, () => {
if (kwinClient.move) {
if (world.untileOnDrag) {
if (config.untileOnDrag) {
world.do((clientManager, desktopManager) => {
clientManager.floatKwinClient(kwinClient);
clientManager.floatClient(client);
});
}
return;
@@ -82,8 +84,16 @@ namespace ClientState {
if (kwinClient.resize) {
resizing = true;
resizingBorder = Workspace.cursorPos.x > kwinClient.clientGeometry.right ||
Workspace.cursorPos.x < kwinClient.clientGeometry.left;
resizeStartWidth = window.column.getWidth();
if (config.resizeNeighborColumn) {
const resizeNeighborColumn = Tiled.getResizeNeighborColumn(window);
if (resizeNeighborColumn !== null) {
resizeNeighbor = {
column: resizeNeighborColumn,
startWidth: resizeNeighborColumn.getWidth(),
};
}
}
window.column.grid.onUserResizeStarted();
}
});
@@ -91,10 +101,12 @@ namespace ClientState {
manager.connect(kwinClient.interactiveMoveResizeFinished, () => {
if (resizing) {
resizing = false;
resizeNeighbor = undefined;
window.column.grid.onUserResizeFinished();
}
});
let externalFrameGeometryChangedRateLimiter = new RateLimiter(4, Tiled.maxExternalFrameGeometryChangedIntervalMs);
manager.connect(kwinClient.frameGeometryChanged, (oldGeometry: QmlRect) => {
// on Wayland, this fires after `tileChanged`
if (kwinClient.tile !== null) {
@@ -112,18 +124,38 @@ namespace ClientState {
const dx = Math.round(newCenterX - oldCenterX);
const dy = Math.round(newCenterY - oldCenterY);
if (dx !== 0 || dy !== 0) {
// TODO: instead of passing dx and dy, remember relative (to the parent) x and y for each
// transient window and use them for `moveTransients` and `ensureTransientsVisible`
client.moveTransients(dx, dy);
}
if (kwinClient.resize) {
world.do(() => window.onUserResize(oldGeometry, resizingBorder));
world.do(() => {
if (newGeometry.width !== oldGeometry.width) {
window.column.onUserResizeWidth(
resizeStartWidth,
newGeometry.width - resizeStartWidth,
newGeometry.left !== oldGeometry.left,
resizeNeighbor,
);
}
if (newGeometry.height !== oldGeometry.height) {
window.column.adjustWindowHeight(
window,
newGeometry.height - oldGeometry.height,
newGeometry.y !== oldGeometry.y,
);
}
});
} else if (
!window.column.grid.isUserResizing() &&
!client.isManipulatingGeometry(newGeometry) &&
client.getMaximizedMode() === MaximizedMode.Unmaximized &&
!Clients.isFullScreenGeometry(kwinClient) // not using `kwinClient.fullScreen` because it may not be set yet at this point
) {
world.do(() => window.onFrameGeometryChanged());
if (externalFrameGeometryChangedRateLimiter.acquire()) {
world.do(() => window.onFrameGeometryChanged());
}
}
});
@@ -143,6 +175,18 @@ namespace ClientState {
return manager;
}
private static getResizeNeighborColumn(window: Window) {
const kwinClient = window.client.kwinClient;
const column = window.column;
if (Workspace.cursorPos.x > kwinClient.clientGeometry.right) {
return column.grid.getRightColumn(column);
} else if (Workspace.cursorPos.x < kwinClient.clientGeometry.left) {
return column.grid.getLeftColumn(column);
} else {
return null;
}
}
private static moveWindowToGrid(window: Window, grid: Grid) {
if (grid === window.column.grid) {
// window already on the given grid
@@ -150,7 +194,7 @@ namespace ClientState {
}
const newColumn = new Column(grid, grid.getLastFocusedColumn() ?? grid.getLastColumn());
window.moveToColumn(newColumn);
window.moveToColumn(newColumn, true);
}
private static prepareClientForTiling(client: ClientWrapper, config: LayoutConfig) {
@@ -188,7 +232,7 @@ namespace ClientState {
namespace Tiled {
export type WindowState = {
skipSwitcher: boolean,
}
skipSwitcher: boolean;
};
}
}

View File

@@ -1,3 +1,11 @@
function init() {
return new World(loadConfig());
}
function loadConfig(): Config {
const config: any = {};
for (const entry of configDef) {
config[entry.name] = KWin.readConfig(entry.name, entry.default);
}
return config;
}

View File

@@ -1,4 +1,8 @@
{
"extends": "../tsconfig.json",
"include": ["../lib/**/*", "./**/*"]
"include": [
"../extern/**/*",
"../lib/**/*",
"./**/*"
]
}

View File

@@ -0,0 +1,124 @@
tests.register("columns squeeze side", 1, () => {
const baseTestCases = [
{ widths: [500, 500], blocked: [false, false], possible: true },
{ widths: [500, 768], blocked: [false, false], possible: true },
{ widths: [500, 500], blocked: [false, true], possible: true },
{ widths: [500, 200, 200], blocked: [false, false, false], possible: true },
{ widths: [500, 200, 200], blocked: [false, false, true], possible: true },
{ widths: [500, 200, 200], blocked: [true, false, true], possible: true },
{ widths: [500, 500, 500], blocked: [false, true, true], possible: false },
];
const testCasesLeft = baseTestCases.map((baseTestCase, i) => ({
...baseTestCase,
name: "left " + i,
action: "karousel-columns-squeeze-left",
focus: baseTestCase.widths.length-1,
}));
const testCasesRight = baseTestCases.map((baseTestCase, i) => ({
...baseTestCase,
widths: baseTestCase.widths.slice().reverse(),
blocked: baseTestCase.blocked.slice().reverse(),
name: "right " + i,
action: "karousel-columns-squeeze-right",
focus: 0,
}));
const testCases = [...testCasesLeft, ...testCasesRight];
for (const testCase of testCases) {
const assertOpt = { message: `Case: ${testCase.name}` };
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const clients = workspaceMock.createClientsWithWidths(...testCase.widths);
workspaceMock.activeWindow = clients[testCase.focus];
for (let i = 0; i < clients.length; i++) {
if (testCase.blocked[i]) {
clients[i].minSize = new MockQmlSize(testCase.widths[i], 100);
}
}
if (testCase.possible) {
qtMock.fireShortcut(testCase.action);
Assert.columnsFillTilingArea(clients, assertOpt);
for (let i = 0; i < clients.length; i++) {
if (testCase.blocked[i]) {
Assert.equal(clients[i].frameGeometry.width, testCase.widths[i], assertOpt);
}
}
}
const frames = clients.map(client => client.frameGeometry);
qtMock.fireShortcut(testCase.action);
const newFrames = clients.map(client => client.frameGeometry);
for (let i = 0; i < clients.length; i++) {
Assert.equalRects(frames[i], newFrames[i], assertOpt);
}
}
});
tests.register("columns squeeze side (just scroll)", 1, () => {
const baseTestCases = [
{ focus: 0, startVisible: [true, true, false], endVisible: [true, true, false] },
{ focus: 1, startVisible: [false, true, true], endVisible: [true, true, false] },
{ focus: 2, startVisible: [false, true, true], endVisible: [false, true, true] },
];
const testCasesLeft = baseTestCases.map((baseTestCase, i) => ({
...baseTestCase,
name: "left " + i,
action: "karousel-columns-squeeze-left",
scrollStart: false,
}));
const testCasesRight = baseTestCases.map((baseTestCase, i) => ({
focus: 2 - baseTestCase.focus,
startVisible: baseTestCase.startVisible.slice().reverse(),
endVisible: baseTestCase.endVisible.slice().reverse(),
name: "right " + i,
action: "karousel-columns-squeeze-right",
scrollStart: true,
}));
const testCases = [...testCasesLeft, ...testCasesRight];
for (const testCase of testCases) {
const assertMsg = `Case: ${testCase.name}`;
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
function assertVisible(clients: KwinClient[], visible: boolean[]) {
for (let i = 0; i < clients.length; i++) {
if (visible[i]) {
Assert.fullyVisible(clients[i].frameGeometry, { message: assertMsg, skip: 1 });
} else {
Assert.notFullyVisible(clients[i].frameGeometry, { message: assertMsg, skip: 1 });
}
}
}
const clients = workspaceMock.createClientsWithWidths(300, 300, 300);
for (const client of clients) {
client.minSize = new MockQmlSize(300, 100);
}
if (testCase.scrollStart) {
qtMock.fireShortcut("karousel-grid-scroll-start");
}
workspaceMock.activeWindow = clients[testCase.focus];
assertVisible(clients, testCase.startVisible);
qtMock.fireShortcut(testCase.action);
assertVisible(clients, testCase.endVisible);
const frames = clients.map(client => client.frameGeometry);
qtMock.fireShortcut(testCase.action);
const newFrames = clients.map(client => client.frameGeometry);
for (let i = 0; i < clients.length; i++) {
Assert.equalRects(frames[i], newFrames[i], { message: assertMsg });
}
}
});

View File

@@ -0,0 +1,44 @@
tests.register("External resize", 1, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
function getClientDesiredFrame(width: number) {
return new MockQmlRect(10, 10, width, 200);
}
function getTiledFrame(width: number) {
return new MockQmlRect(
Math.round((screen.width - width) / 2),
tilingArea.top,
width,
tilingArea.height,
);
}
const [client] = workspaceMock.createClientsWithFrames(getClientDesiredFrame(100));
Assert.equalRects(client.frameGeometry, getTiledFrame(100), { message: "We should tile the window, respecting its desired width" });
function testExternalResizing() {
client.frameGeometry = getClientDesiredFrame(110);
Assert.equalRects(client.frameGeometry, getTiledFrame(110), { message: "We should re-arrange the window, respecting its new desired width" });
client.frameGeometry = getClientDesiredFrame(120);
Assert.equalRects(client.frameGeometry, getTiledFrame(120), { message: "We should re-arrange the window, respecting its new desired width" });
client.frameGeometry = getClientDesiredFrame(130);
Assert.equalRects(client.frameGeometry, getTiledFrame(130), { message: "We should re-arrange the window, respecting its new desired width" });
client.frameGeometry = getClientDesiredFrame(140);
Assert.equalRects(client.frameGeometry, getTiledFrame(140), { message: "We should re-arrange the window, respecting its new desired width" });
client.frameGeometry = getClientDesiredFrame(200);
Assert.equalRects(client.frameGeometry, getClientDesiredFrame(200), { message: "We should give up and let the client have its desired frame" });
}
timeControl(addTime => {
testExternalResizing();
addTime(1000);
// the concession has expired, let's test again
testExternalResizing();
});
});

98
src/tests/flows/layout.ts Normal file
View File

@@ -0,0 +1,98 @@
tests.register("Focus and move windows", 1, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const [client1, client2, client3] = workspaceMock.createClients(3);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(client1));
Assert.assert(clientManager.hasClient(client2));
Assert.assert(clientManager.hasClient(client3));
});
Assert.assert(workspaceMock.activeWindow === client3);
function testLayout(shortcutName: string, grid: KwinClient[][]) {
qtMock.fireShortcut(shortcutName);
Assert.grid(config, screen, grid, { skip: 1 });
}
function testFocus(shortcutName: string, expectedFocus: KwinClient) {
qtMock.fireShortcut(shortcutName);
Assert.assert(workspaceMock.activeWindow === expectedFocus, {
message: `wrong activeWindow: ${workspaceMock.activeWindow?.pid}`,
skip: 1,
});
};
testLayout("karousel-column-move-right", [ [client1], [client2], [client3] ]);
testLayout("karousel-window-move-left", [ [client1], [client2,client3] ]);
testLayout("karousel-window-move-left", [ [client1], [client3], [client2] ]);
testLayout("karousel-window-move-left", [ [client1,client3], [client2] ]);
testFocus("karousel-focus-right", client2);
testLayout("karousel-window-move-left", [ [client1,client3,client2] ]);
testLayout("karousel-window-move-left", [ [client2], [client1,client3] ]);
testLayout("karousel-window-move-left", [ [client2], [client1,client3] ]);
testFocus("karousel-focus-2", client3);
testFocus("karousel-focus-up", client1);
testLayout("karousel-column-move-left", [ [client1,client3], [client2] ]);
testLayout("karousel-window-move-right", [ [client3], [client1], [client2] ]);
testFocus("karousel-focus-3", client2);
testLayout("karousel-window-move-start", [ [client2], [client3], [client1] ]);
testLayout("karousel-window-move-to-column-3", [ [client3], [client1,client2] ]);
testLayout("karousel-column-move-left", [ [client1,client2], [client3] ]);
testLayout("karousel-column-move-end", [ [client3], [client1,client2] ]);
testLayout("karousel-column-move-to-column-1", [ [client1,client2], [client3] ]);
testLayout("karousel-column-move-right", [ [client3], [client1,client2] ]);
testLayout("karousel-window-move-previous", [ [client3], [client2,client1] ]);
testLayout("karousel-window-move-previous", [ [client3], [client2], [client1] ]);
testLayout("karousel-window-move-previous", [ [client3,client2], [client1] ]);
testLayout("karousel-window-move-previous", [ [client2,client3], [client1] ]);
testLayout("karousel-window-move-previous", [ [client2], [client3], [client1] ]);
testLayout("karousel-window-move-previous", [ [client2], [client3], [client1] ]);
testLayout("karousel-window-move-next", [ [client2,client3], [client1] ]);
testLayout("karousel-window-move-next", [ [client3,client2], [client1] ]);
testLayout("karousel-window-move-next", [ [client3], [client2], [client1] ]);
testLayout("karousel-window-move-next", [ [client3], [client2,client1] ]);
testLayout("karousel-window-move-next", [ [client3], [client1,client2] ]);
testLayout("karousel-window-move-next", [ [client3], [client1], [client2] ]);
testLayout("karousel-window-move-next", [ [client3], [client1], [client2] ]);
testLayout("karousel-window-move-left", [ [client3], [client1,client2] ]);
const col1Win1 = client3;
const col2Win1 = client1;
const col2Win2 = client2;
testFocus("karousel-focus-up", col2Win1);
testFocus("karousel-focus-up", col2Win1);
testFocus("karousel-focus-down", col2Win2);
testFocus("karousel-focus-left", col1Win1);
testFocus("karousel-focus-left", col1Win1);
testFocus("karousel-focus-right", col2Win2);
testFocus("karousel-focus-right", col2Win2);
testFocus("karousel-focus-2", col2Win2);
testFocus("karousel-focus-1", col1Win1);
testFocus("karousel-focus-2", col2Win2);
testFocus("karousel-focus-start", col1Win1);
testFocus("karousel-focus-end", col2Win2);
testFocus("karousel-focus-up", col2Win1);
testFocus("karousel-focus-left", col1Win1);
testFocus("karousel-focus-right", col2Win1);
testFocus("karousel-focus-2", col2Win1);
testFocus("karousel-focus-1", col1Win1);
testFocus("karousel-focus-2", col2Win1);
testFocus("karousel-focus-start", col1Win1);
testFocus("karousel-focus-end", col2Win1);
testFocus("karousel-focus-down", col2Win2);
testFocus("karousel-focus-start", col1Win1);
testFocus("karousel-focus-next", col2Win1);
testFocus("karousel-focus-next", col2Win2);
testFocus("karousel-focus-next", col2Win2);
testFocus("karousel-focus-previous", col2Win1);
testFocus("karousel-focus-previous", col1Win1);
testFocus("karousel-focus-previous", col1Win1);
});

View File

@@ -0,0 +1,145 @@
tests.register("Maximization", 100, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const [kwinClient] = workspaceMock.createClientsWithWidths(300);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(kwinClient));
});
const columnLeftX = screen.width/2 - 300/2;
const columnTopY = tilingArea.top;
const columnHeight = tilingArea.height;
Assert.rect(kwinClient.frameGeometry, columnLeftX, columnTopY, 300, columnHeight);
kwinClient.fullScreen = true;
Assert.equalRects(kwinClient.frameGeometry, screen);
kwinClient.fullScreen = false;
Assert.rect(kwinClient.frameGeometry, columnLeftX, columnTopY, 300, columnHeight);
kwinClient.setMaximize(true, true);
Assert.equalRects(kwinClient.frameGeometry, screen);
kwinClient.setMaximize(true, false);
Assert.rect(kwinClient.frameGeometry, columnLeftX, 0, 300, screen.height);
kwinClient.setMaximize(false, false);
Assert.rect(kwinClient.frameGeometry, columnLeftX, columnTopY, 300, columnHeight);
});
tests.register("Maximize with transient", 100, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const parent = new MockKwinClient(new MockQmlRect(10, 20, 300, 200));
const child = new MockKwinClient(new MockQmlRect(14, 24, 50, 50), parent);
workspaceMock.createWindows(parent);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(parent));
});
runOneOf(
() => parent.fullScreen = true,
() => parent.setMaximize(true, true),
);
Assert.equalRects(parent.frameGeometry, screen);
workspaceMock.createWindows(child);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(child));
});
Assert.rect(child.frameGeometry, 14, 24, 50, 50);
Assert.equalRects(parent.frameGeometry, screen);
});
tests.register("Re-maximize disabled", 100, () => {
const config = getDefaultConfig();
config.reMaximize = false;
const { qtMock, workspaceMock, world } = init(config);
const [client1, client2] = workspaceMock.createClientsWithWidths(300, 400);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(client1));
Assert.assert(clientManager.hasClient(client2));
});
const columnsWidth = 300 + 400 + config.gapsInnerHorizontal;
const column1LeftX = screen.width/2 - columnsWidth/2;
const column2LeftX = column1LeftX + 300 + config.gapsInnerHorizontal;
const columnTopY = tilingArea.top;
const columnHeight = tilingArea.height;
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
runOneOf(
() => client2.fullScreen = true,
() => client2.setMaximize(true, true),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.equalRects(client2.frameGeometry, screen);
runOneOf(
() => workspaceMock.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
runOneOf(
() => workspaceMock.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
() => qtMock.fireShortcut("karousel-focus-end"),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
});
tests.register("Re-maximize enabled", 100, () => {
const config = getDefaultConfig();
config.reMaximize = true;
const { qtMock, workspaceMock, world } = init(config);
const [client1, client2] = workspaceMock.createClientsWithWidths(300, 400);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(client1));
Assert.assert(clientManager.hasClient(client2));
});
const columnsWidth = 300 + 400 + config.gapsInnerHorizontal;
const column1LeftX = screen.width/2 - columnsWidth/2;
const column2LeftX = column1LeftX + 300 + config.gapsInnerHorizontal;
const columnTopY = tilingArea.top;
const columnHeight = tilingArea.height;
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
runOneOf(
() => client2.fullScreen = true,
() => client2.setMaximize(true, true),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.equalRects(client2.frameGeometry, screen);
runOneOf(
() => workspaceMock.activeWindow = client1,
() => qtMock.fireShortcut("karousel-focus-1"),
() => qtMock.fireShortcut("karousel-focus-left"),
() => qtMock.fireShortcut("karousel-focus-start"),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.rect(client2.frameGeometry, column2LeftX, columnTopY, 400, columnHeight);
runOneOf(
() => workspaceMock.activeWindow = client2,
() => qtMock.fireShortcut("karousel-focus-2"),
() => qtMock.fireShortcut("karousel-focus-right"),
() => qtMock.fireShortcut("karousel-focus-end"),
);
Assert.rect(client1.frameGeometry, column1LeftX, columnTopY, 300, columnHeight);
Assert.equalRects(client2.frameGeometry, screen);
});

View File

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

View File

@@ -0,0 +1,119 @@
tests.register("Preset Widths default", 1, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const maxWidth = tilingArea.width;
const halfWidth = maxWidth/2 - config.gapsInnerHorizontal/2;
function getRect(columnWidth: number) {
return new MockQmlRect(
(screen.width - columnWidth) / 2,
tilingArea.top,
columnWidth,
tilingArea.height,
);
}
const [kwinClient] = workspaceMock.createClientsWithWidths(300);
Assert.equalRects(kwinClient.frameGeometry, getRect(300));
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(maxWidth));
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
});
tests.register("Preset Widths custom", 1, () => {
const config = getDefaultConfig();
config.presetWidths = "500px, 250px, 100px, 50%";
const { qtMock, workspaceMock, world } = init(config);
const maxWidth = tilingArea.width;
const halfWidth = maxWidth/2 - config.gapsInnerHorizontal/2;
function getRect(columnWidth: number) {
return new MockQmlRect(
(screen.width - columnWidth) / 2,
tilingArea.top,
columnWidth,
tilingArea.height,
);
}
const [kwinClient] = workspaceMock.createClientsWithWidths(200);
Assert.equalRects(kwinClient.frameGeometry, getRect(200));
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(250));
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(500));
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(100));
qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(250));
});
tests.register("Preset Widths fill screen uniform", 1, () => {
for (let nColumns = 1; nColumns < 10; nColumns++) {
const config = getDefaultConfig();
config.presetWidths = String(1 / nColumns);
const { qtMock, workspaceMock, world } = init(config);
let firstClient, lastClient;
for (let i = 0; i < nColumns; i++) {
const [kwinClient] = workspaceMock.createClientsWithWidths(300);
if (i === 0) {
firstClient = kwinClient;
}
if (i === nColumns-1) {
lastClient = kwinClient;
}
qtMock.fireShortcut("karousel-cycle-preset-widths");
}
const left = tilingArea.left;
const right = tilingArea.right;
const maxLeftoverPx = nColumns - 1;
const eps = Math.ceil(maxLeftoverPx / 2);
Assert.between(firstClient!.frameGeometry.left, left, left+eps, { message: `nColumns: ${nColumns}` });
Assert.between(lastClient!.frameGeometry.right, right-eps, right, { message: `nColumns: ${nColumns}` });
}
});
tests.register("Preset Widths fill screen non-uniform", 1, () => {
const config = getDefaultConfig();
config.presetWidths = String("50%, 25%");
const { qtMock, workspaceMock, world } = init(config);
const [clientThin1] = workspaceMock.createClientsWithWidths(100);
qtMock.fireShortcut("karousel-cycle-preset-widths");
const [clientThin2] = workspaceMock.createClientsWithWidths(100);
qtMock.fireShortcut("karousel-cycle-preset-widths");
const [clientWide] = workspaceMock.createClientsWithWidths(300);
qtMock.fireShortcut("karousel-cycle-preset-widths");
const maxWidth = tilingArea.width;
const halfWidth = maxWidth/2 - config.gapsInnerHorizontal/2;
const quarterWidth = halfWidth/2 - config.gapsInnerHorizontal/2;
const height = tilingArea.height;
const left1 = tilingArea.left;
const left2 = left1 + config.gapsInnerHorizontal + quarterWidth;
const left3 = left2 + config.gapsInnerHorizontal + quarterWidth;
Assert.rect(clientThin1.frameGeometry, left1, tilingArea.top, quarterWidth, height);
Assert.rect(clientThin2.frameGeometry, left2, tilingArea.top, quarterWidth, height);
Assert.rect(clientWide.frameGeometry, left3, tilingArea.top, halfWidth, height);
Assert.equal(clientWide.frameGeometry.right, tilingArea.right);
});

View File

@@ -0,0 +1,110 @@
tests.register("User resize", 10, () => {
const config = getDefaultConfig();
config.resizeNeighborColumn = true;
const h = getWindowHeight(2);
let clientLeft: MockKwinClient, clientRightTop: MockKwinClient, clientRightBottom: MockKwinClient;
function assertSizes(leftWidth: number, rightWidth: number, topHeight: number, bottomHeight: number) {
const { left, right } = getGridBounds(clientLeft, clientRightTop);
Assert.rect(clientLeft.frameGeometry, left, tilingArea.top, leftWidth, tilingArea.height);
Assert.rect(clientRightTop.frameGeometry, left+leftWidth+gapH, tilingArea.top, rightWidth, topHeight);
Assert.rect(clientRightBottom.frameGeometry, left+leftWidth+gapH, tilingArea.top+topHeight+gapV, rightWidth, bottomHeight);
}
{
const { qtMock, workspaceMock, world } = init(config);
[clientLeft, clientRightTop, clientRightBottom] = workspaceMock.createClientsWithWidths(300, 300, 200);
qtMock.fireShortcut("karousel-window-move-left");
assertSizes(300, 300, h, h);
workspaceMock.resizeWindow(clientLeft, false, false, false, new MockQmlSize(10, 20));
assertSizes(310, 300, h, h);
workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 0), new MockQmlSize(-10, 0));
assertSizes(310, 300, h, h);
workspaceMock.resizeWindow(clientRightTop, false, false, false, new MockQmlSize(-5, -10), new MockQmlSize(-5, -10));
assertSizes(310, 290, h-20, h+20);
workspaceMock.resizeWindow(clientRightBottom, false, false, false, new MockQmlSize(-10, 20));
assertSizes(310, 280, h-20, h+20);
workspaceMock.resizeWindow(clientRightBottom, false, false, true, new MockQmlSize(0, 20));
assertSizes(310, 280, h-40, h+40);
}
{
const { qtMock, workspaceMock, world } = init(config);
[clientLeft, clientRightTop, clientRightBottom] = workspaceMock.createClientsWithWidths(300, 300, 200);
qtMock.fireShortcut("karousel-window-move-left");
assertSizes(300, 300, h, h);
workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 20));
assertSizes(310, 290, h, h);
workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 0), new MockQmlSize(-10, 0));
assertSizes(310, 290, h, h);
workspaceMock.resizeWindow(clientRightTop, true, false, false, new MockQmlSize(-5, -10), new MockQmlSize(-5, -10));
assertSizes(310, 280, h-20, h+20);
workspaceMock.resizeWindow(clientRightBottom, true, true, false, new MockQmlSize(-10, 20));
assertSizes(320, 270, h-20, h+20);
workspaceMock.resizeWindow(clientRightBottom, true, false, true, new MockQmlSize(0, 20));
assertSizes(320, 270, h-40, h+40);
}
{
const { qtMock, workspaceMock, world } = init(config);
[clientLeft, clientRightTop, clientRightBottom] = workspaceMock.createClientsWithWidths(300, 300, 200);
clientRightBottom.minSize = new MockQmlSize(295, h-20);
qtMock.fireShortcut("karousel-window-move-left");
assertSizes(300, 300, h, h);
workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 20));
assertSizes(310, 295, h, h);
workspaceMock.resizeWindow(clientLeft, true, false, false, new MockQmlSize(10, 0), new MockQmlSize(-10, 0));
assertSizes(310, 295, h, h);
workspaceMock.resizeWindow(clientRightTop, true, false, false, new MockQmlSize(-5, -10), new MockQmlSize(-5, -10));
assertSizes(310, 295, h-20, h+20);
workspaceMock.resizeWindow(clientRightBottom, true, true, false, new MockQmlSize(-10, 20));
assertSizes(310, 295, h-20, h+20);
workspaceMock.resizeWindow(clientRightTop, true, true, false, new MockQmlSize(-10, 0));
assertSizes(310, 295, h-20, h+20);
// TODO
// workspaceMock.resizeWindow(clientRightBottom, true, false, true, new MockQmlSize(0, -80));
// assertSizes(310, 295, h+60, h-20);
}
{
const { qtMock, workspaceMock, world } = init(config);
const [clientLeftTop, clientLeftBottom, clientRight] = workspaceMock.createClientsWithWidths(300, 200, 300);
clientLeftBottom.minSize = new MockQmlSize(295, h-20);
function assertSizes(leftWidth: number, rightWidth: number, topHeight: number, bottomHeight: number) {
const { left, right } = getGridBounds(clientLeftTop, clientRight);
Assert.rect(clientLeftTop.frameGeometry, left, tilingArea.top, leftWidth, topHeight);
Assert.rect(clientLeftBottom.frameGeometry, left, tilingArea.top+topHeight+gapV, leftWidth, bottomHeight);
Assert.rect(clientRight.frameGeometry, left+leftWidth+gapH, tilingArea.top, rightWidth, tilingArea.height);
}
workspaceMock.activeWindow = clientLeftBottom;
qtMock.fireShortcut("karousel-window-move-left");
assertSizes(300, 300, h, h);
workspaceMock.resizeWindow(clientLeftTop, true, false, false, new MockQmlSize(-10, 0));
assertSizes(295, 305, h, h);
workspaceMock.resizeWindow(clientLeftTop, true, false, false, new MockQmlSize(10, 0));
assertSizes(305, 295, h, h);
workspaceMock.resizeWindow(clientLeftTop, true, false, false, new MockQmlSize(-20, 0), new MockQmlSize(20, 0));
assertSizes(305, 295, h, h);
}
});

1
src/tests/main.ts Normal file
View File

@@ -0,0 +1 @@
tests.run();

View File

@@ -1,36 +0,0 @@
{
const testCases = [
{tiledByDefault: true, resourceClass: "unknown", caption: "anything", shouldTile: true},
{tiledByDefault: false, resourceClass: "unknown", caption: "anything", shouldTile: false},
{tiledByDefault: true, resourceClass: "ksmserver-logout-greeter", caption: "anything", shouldTile: false},
{tiledByDefault: true, resourceClass: "org.kde.plasmashell", caption: "something", shouldTile: false},
{tiledByDefault: true, resourceClass: "plasmashell", caption: "something", shouldTile: false},
{tiledByDefault: false, resourceClass: "org.kde.kfind", caption: "something", shouldTile: true},
{tiledByDefault: false, resourceClass: "kfind", caption: "something", shouldTile: true},
{tiledByDefault: true, resourceClass: "zoom", caption: "something", shouldTile: true},
{tiledByDefault: true, resourceClass: "zoom", caption: "zoom", shouldTile: false},
];
const enforcer = new WindowRuleEnforcer(JSON.parse(defaultWindowRules));
for (const testCase of testCases) {
const kwinClient: any = createKwinClient(testCase.tiledByDefault, testCase.resourceClass, testCase.caption);
assert(enforcer.shouldTile(kwinClient) === testCase.shouldTile, "failed case: " + JSON.stringify(testCase));
}
function createKwinClient(normalWindow: boolean, resoureClass: string, caption: string) {
return {
normalWindow: normalWindow,
transient: false,
managed: true,
pid: 100,
moveable: true,
resizeable: true,
popupWindow: false,
minimized: false,
desktops: [1],
activities: [1],
resourceClass: resoureClass,
caption: caption,
}
}
}

View File

@@ -1,17 +0,0 @@
declare const process: {
exit(code?: number): void,
};
function assert(assertion: boolean, message?: string) {
if (assertion) {
return;
}
if (message != undefined) {
console.assert(assertion, message);
} else {
console.assert(assertion);
}
console.trace();
process.exit(1);
}

View File

@@ -1,4 +1,10 @@
{
"extends": "../tsconfig.json",
"include": ["../lib/**/*", "./**/*"]
"include": [
"../lib/**/*",
"./utils/**/*",
"./units/**/*",
"./flows/**/*",
"./main.ts"
]
}

View File

@@ -0,0 +1,45 @@
tests.register("PresetWidths", 1, () => {
const minWidth = 50;
const maxWidth = 800;
const spacing = 10;
const testCases = [
{ str: "100%, 50%", result: [395, 800] },
{ str: "105%, 50%", result: [395, 800] },
{ str: "100px,50 px", result: [50, 100] },
{ str: "900px,25 px", result: [50, 800] },
{ str: " 100px, 25 % , 0.1 ", result: [71, 100, 192] },
{ str: "100px, 25%, 0.1, 100px", result: [71, 100, 192] },
{ str: "100px, -25 % , 0.1 ", error: true },
{ str: "100px, 25 % , -0.1 ", error: true },
{ str: "100px, 25 % , 0.1p", error: true },
{ str: "100px, % , 0.1 ", error: true },
{ str: "100px, , 0.1 ", error: true },
{ str: "100px, 0, 0.1 ", error: true },
{ str: "100px,, 0.1 ", error: true },
{ str: "100px, 25 % , ", error: true },
{ str: "asdf", error: true },
{ str: "", error: true },
{ str: " ", error: true },
];
function assertWidths(presetWidths: PresetWidths, expectedWidths: number[]) {
let currentWidth = 0;
for (const expectedWidth of expectedWidths) {
currentWidth = presetWidths.next(currentWidth, minWidth, maxWidth);
Assert.equal(currentWidth, expectedWidth);
}
const repeatedWidth = presetWidths.next(currentWidth, minWidth, maxWidth);
Assert.equal(repeatedWidth, expectedWidths[0]);
}
for (const testCase of testCases) {
try {
const presetWidths = new PresetWidths(testCase.str, spacing);
Assert.assert(!testCase.error);
assertWidths(presetWidths, testCase.result!);
} catch (error) {
Assert.assert(testCase.error === true);
}
}
});

View File

@@ -0,0 +1,40 @@
tests.register("WindowRuleEnforcer", 1, () => {
const testCases = [
{ tiledByDefault: true, resourceClass: "unknown", caption: "anything", shouldTile: true },
{ tiledByDefault: false, resourceClass: "unknown", caption: "anything", shouldTile: false },
{ tiledByDefault: true, resourceClass: "org.kde.plasmashell", caption: "something", shouldTile: false },
{ tiledByDefault: true, resourceClass: "plasmashell", caption: "something", shouldTile: false },
{ tiledByDefault: false, resourceClass: "org.kde.kfind", caption: "something", shouldTile: true },
{ tiledByDefault: false, resourceClass: "kfind", caption: "something", shouldTile: true },
{ tiledByDefault: true, resourceClass: "org.kde.kruler", caption: "anything", shouldTile: false },
{ tiledByDefault: true, resourceClass: "kruler", caption: "anything", shouldTile: false },
{ tiledByDefault: true, resourceClass: "zoom", caption: "something", shouldTile: true },
{ tiledByDefault: true, resourceClass: "zoom", caption: "zoom", shouldTile: false },
];
const enforcer = new WindowRuleEnforcer(JSON.parse(defaultWindowRules));
for (const testCase of testCases) {
const kwinClient: any = createKwinClient(testCase.tiledByDefault, testCase.resourceClass, testCase.caption);
Assert.assert(
enforcer.shouldTile(kwinClient) === testCase.shouldTile,
{ message: "failed case: " + JSON.stringify(testCase) },
);
}
function createKwinClient(normalWindow: boolean, resourceClass: string, caption: string) {
return {
normalWindow: normalWindow,
transient: false,
managed: true,
pid: 100,
moveable: true,
resizeable: true,
popupWindow: false,
minimized: false,
desktops: [1],
activities: [1],
resourceClass: resourceClass,
caption: caption,
};
}
});

View File

@@ -0,0 +1,22 @@
tests.register("RateLimiter", 1, () => {
const rateLimiter = new RateLimiter(3, 100);
function testRateLimiter() {
Assert.assert(rateLimiter.acquire());
Assert.assert(rateLimiter.acquire());
Assert.assert(rateLimiter.acquire());
Assert.assert(!rateLimiter.acquire());
Assert.assert(!rateLimiter.acquire());
}
timeControl(addTime => {
testRateLimiter();
addTime(10);
Assert.assert(!rateLimiter.acquire(), { message: "The interval hasn't expired yet" });
addTime(90);
// the rate limiter interval has expired, let's test again
testRateLimiter();
});
});

View File

@@ -0,0 +1,246 @@
tests.register("fillSpace", 1, () => {
const testCases: {
availableSpace: number,
items: { min: number, max: number }[],
expected: number[],
}[] = [
{
availableSpace: 600,
items: [],
expected: [],
},
{
availableSpace: 600,
items: [
{ min: 10, max: 600 },
{ min: 10, max: 600 },
],
expected: [300, 300],
},
{
availableSpace: 700,
items: [
{ min: 300, max: 300 },
{ min: 300, max: 300 },
],
expected: [300, 300],
},
{
availableSpace: 700,
items: [
{ min: 300, max: 300 },
{ min: 300, max: 300 },
{ min: 10, max: 900 },
],
expected: [300, 300, 100],
},
{
availableSpace: 600,
items: [
{ min: 10, max: 250 },
{ min: 10, max: 500 },
],
expected: [250, 350],
},
{
availableSpace: 600,
items: [
{ min: 10, max: 250 },
{ min: 400, max: 500 },
],
expected: [200, 400],
},
{
availableSpace: 765,
items: [
{ min: 10, max: 250 },
{ min: 10, max: 254 },
{ min: 10, max: 500 },
],
expected: [250, 254, 261],
},
{
availableSpace: 600,
items: [
{ min: 10, max: 150 },
{ min: 400, max: 500 },
],
expected: [150, 450],
},
{
availableSpace: 750,
items: [
{ min: 10, max: 250 },
{ min: 10, max: 250 },
{ min: 400, max: 500 },
{ min: 10, max: 300 },
],
expected: [117, 117, 400, 116],
},
{
availableSpace: 750,
items: [
{ min: 10, max: 250 },
{ min: 120, max: 250 },
{ min: 400, max: 500 },
{ min: 10, max: 300 },
],
expected: [115, 120, 400, 115],
},
{
availableSpace: 1200,
items: [
{ min: 10, max: 250 },
{ min: 10, max: 500 },
],
expected: [250, 500],
},
{
availableSpace: 5,
items: [
{ min: 10, max: 250 },
{ min: 10, max: 500 },
],
expected: [10, 10],
},
{
availableSpace: 800,
items: [
{ min: 114, max: 800 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 109, max: 800 },
{ min: 10, max: 800 },
],
expected: [114, 93, 93, 93, 93, 93, 111, 110],
},
{
availableSpace: 801,
items: [
{ min: 114, max: 800 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 109, max: 800 },
{ min: 10, max: 800 },
],
expected: [114, 93, 93, 93, 93, 93, 111, 111],
},
{
availableSpace: 801,
items: [
{ min: 114, max: 800 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 109, max: 800 },
{ min: 10, max: 95 },
],
expected: [121, 93, 93, 93, 93, 93, 120, 95],
},
{
availableSpace: 799,
items: [
{ min: 10, max: 86 },
{ min: 107, max: 800 },
{ min: 107, max: 800 },
{ min: 107, max: 800 },
{ min: 107, max: 800 },
{ min: 107, max: 800 },
{ min: 10, max: 91},
{ min: 105, max: 800 },
],
expected: [80, 107, 107, 107, 107, 107, 79, 105],
},
{
availableSpace: 1029,
items: [
{ min: 114, max: 800 },
{ min: 114, max: 800 },
{ min: 114, max: 800 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 10, max: 93 },
{ min: 109, max: 800 },
{ min: 10, max: 800 },
],
expected: [114, 114, 114, 93, 93, 93, 93, 93, 111, 111],
},
{
availableSpace: 602,
items: [
{ min: 10, max: 600 },
{ min: 10, max: 600 },
{ min: 10, max: 600 },
],
expected: [200, 200, 200],
},
{
availableSpace: 602,
items: [
{ min: 204, max: 600 },
{ min: 202, max: 600 },
{ min: 10, max: 600 },
],
expected: [204, 202, 196],
},
{
availableSpace: 803,
items: [
{ min: 204, max: 600 },
{ min: 10, max: 600 },
{ min: 10, max: 600 },
{ min: 10, max: 600 },
],
expected: [204, 200, 200, 199],
},
{
availableSpace: 900,
items: [
{ min: 10, max: 120 },
{ min: 10, max: 250 },
{ min: 500, max: 500 },
{ min: 300, max: 500 },
],
expected: [50, 50, 500, 300],
},
{
availableSpace: 845,
items: [
{ min: 5, max: 5 },
{ min: 10, max: 40 },
{ min: 500, max: 500 },
{ min: 300, max: 500 },
],
expected: [5, 40, 500, 300],
},
{
availableSpace: 800,
items: [
{ min: 10, max: 20 },
{ min: 220, max: 221 },
{ min: 250, max: 260 },
{ min: 300, max: 305 },
],
expected: [20, 221, 259, 300],
},
];
for (const testCase of testCases) {
const result = fillSpace(testCase.availableSpace, testCase.items);
Assert.equalArrays(
result,
testCase.expected,
{ message: JSON.stringify(testCase) },
);
}
});

View File

@@ -0,0 +1,34 @@
tests.register("Clients.canTileEver", 1, () => {
const testCases = [
{ clientProperties: { resourceClass: "app", caption: "Title" }, tileable: true },
{ clientProperties: { resourceClass: "app", caption: "Title", moveable: false }, tileable: false },
{ clientProperties: { resourceClass: "app", caption: "Caption", resizeable: false }, tileable: false },
{ clientProperties: { resourceClass: "app", caption: "Caption", normalWindow: false, popupWindow: true }, tileable: false },
{ clientProperties: { resourceClass: "ksmserver-logout-greeter", caption: "Caption" }, tileable: false },
{ clientProperties: { resourceClass: "xwaylandvideobridge", caption: "" }, tileable: false },
];
for (const testCase of testCases) {
const kwinClient: any = createKwinClient(testCase.clientProperties);
Assert.assert(
Clients.canTileEver(kwinClient) === testCase.tileable,
{ message: "failed case: " + JSON.stringify(testCase) },
);
}
function createKwinClient(properties: { resourceClass: string, caption: string }) {
const defaultProperties = {
normalWindow: true,
transient: false,
managed: true,
pid: 100,
moveable: true,
resizeable: true,
popupWindow: false,
minimized: false,
desktops: [1],
activities: [1],
};
return { ...defaultProperties, ...properties };
}
});

200
src/tests/utils/Assert.ts Normal file
View File

@@ -0,0 +1,200 @@
namespace Assert {
type Options = {
message?: string,
skip?: number,
}
export function assert(
assertion: boolean,
{ message, skip=0 }: Options = {},
) {
if (assertion) {
return;
}
if (message != undefined) {
console.assert(assertion, message);
} else {
console.assert(assertion);
}
console.log(getStackTrace(skip+1));
console.log("Random branches:");
if (runLog !== undefined) {
for (const message of runLog) {
console.log(" " + message);
}
}
process.exit(1);
}
function getStackTrace(skip: number) {
return new Error().stack!.split("\n").slice(skip+2).join("\n");
}
function appendMessage(base: string, message?: string) {
if (message === undefined) {
return base;
}
return `${base}
Message: ${message}`;
}
function buildMessage(actual: any, expected: any, header: string, message?: string) {
return appendMessage(
`${header}
Expected: ${expected}
Actual: ${actual}`,
message,
);
}
export function equal(
actual: any,
expected: any,
{ message, skip=0 }: Options = {},
) {
assert(
expected === actual,
{
message: buildMessage(actual, expected, "Values not equal", message),
skip: skip + 1,
},
);
}
export function equalArrays(
actual: any[],
expected: any[],
{ message, skip=0 }: Options = {},
) {
assert(
actual.length === expected.length && actual.every((item, index) => item === expected[index]),
{
message: buildMessage(actual, expected, "Arrays not equal", message),
skip: skip + 1,
},
);
}
export function between(
actual: any,
min: any,
max: any,
{ message, skip=0 }: Options = {},
) {
assert(
actual >= min && actual <= max,
{
message: buildMessage(actual, `[${min}, ${max}]`, "Value not in range", message),
skip: skip + 1,
},
);
}
export function equalRects(
actual: QmlRect,
expected: QmlRect,
{ message, skip=0 }: Options = {},
) {
assert(
rectEquals(expected, actual),
{
message: buildMessage(actual, expected, "QmlRect not equal", message),
skip: skip + 1,
},
);
}
export function rect(
actual: QmlRect,
x: number,
y: number,
width: number,
height: number,
{ message, skip=0 }: Options = {},
) {
equalRects(
actual,
new MockQmlRect(x, y, width, height),
{ message: message, skip: skip+1 },
);
}
export function grid(
config: Config,
screen: QmlRect,
grid: KwinClient[][],
{ message, skip=0 }: Options = {},
) {
// assumes uniformly sized windows within columns of width 100
function getRectInGrid(column: number, window: number, nColumns: number, nWindows: number) {
const columnHeight = screen.height - config.gapsOuterTop - config.gapsOuterBottom;
const columnsWidth = nColumns * 100 + (nColumns-1) * config.gapsInnerHorizontal;
const windowHeight = (columnHeight - config.gapsInnerVertical * (nWindows-1)) / nWindows;
return new MockQmlRect(
screen.x + column * (100 + config.gapsInnerHorizontal) + (screen.width-columnsWidth) / 2,
screen.y + config.gapsOuterTop + (windowHeight + config.gapsInnerVertical) * window,
100,
(columnHeight - config.gapsInnerVertical * (nWindows-1)) / nWindows,
);
}
const nColumns = grid.length;
for (let iColumn = 0; iColumn < nColumns; iColumn++) {
const column = grid[iColumn];
const nWindows = column.length;
for (let iWindow = 0; iWindow < nWindows; iWindow++) {
const window = column[iWindow];
equalRects(
window.frameGeometry,
getRectInGrid(iColumn, iWindow, nColumns, nWindows),
{ message: appendMessage(`window ${iWindow}, column ${iColumn}`, message), skip: skip+1 },
);
}
}
}
export function fullyVisible(
rect: QmlRect,
{ message, skip=0 }: Options = {},
) {
assert(
rect.left >= tilingArea.left && rect.right <= tilingArea.right,
{
message: appendMessage(`Rect ${rect} not fully visible`, message),
skip: skip + 1,
},
);
}
export function notFullyVisible(
rect: QmlRect,
{ message, skip=0 }: Options = {},
) {
assert(
rect.left < tilingArea.left || rect.right > tilingArea.right,
{
message: appendMessage(`Rect ${rect} is fully visible, but shouldn't be`, message),
skip: skip + 1,
},
);
}
export function columnsFillTilingArea(
columns: KwinClient[],
{ message, skip=0 }: Options = {},
) {
const options = { message: message, skip: skip+1 };
let x = tilingArea.left;
for (const column of columns) {
const width = column.frameGeometry.width;
fullyVisible(column.frameGeometry, options);
rect(column.frameGeometry, x, tilingArea.top, width, tilingArea.height, options);
x += width + gapH;
}
equal(columns[columns.length-1].frameGeometry.right, tilingArea.right, options);
}
}

View File

@@ -0,0 +1,26 @@
class TestRunner {
private readonly tests: TestRunner.Test[] = [];
public register(name: string, count: number, f: () => void) {
this.tests.push({ name: name, count: count, f: f });
}
public run() {
for (const test of this.tests) {
console.log("Running test " + test.name);
for (let i = 0; i < test.count; i++) {
test.f();
}
}
}
}
namespace TestRunner {
export type Test = {
name: string,
count: number,
f: () => void,
}
}
const tests = new TestRunner();

View File

@@ -0,0 +1,7 @@
function getDefaultConfig(): Config {
const config: any = {};
for (const prop of configDef) {
config[prop.name] = prop.default;
}
return config;
}

3
src/tests/utils/extern/node.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare const process: {
exit(code?: number): void,
};

46
src/tests/utils/global.ts Normal file
View File

@@ -0,0 +1,46 @@
let Qt: Qt;
let KWin: KWin;
let Workspace: Workspace;
let qmlBase: QmlObject;
let notificationInvalidWindowRules: Notification;
let notificationInvalidPresetWidths: Notification;
let screen: MockQmlRect;
let tilingArea: MockQmlRect;
let gapH: number;
let gapV: number;
let runLog: string[];
function init(config: Config) {
screen = new MockQmlRect(0, 0, 800, 600);
tilingArea = new MockQmlRect(
config.gapsOuterLeft,
config.gapsOuterTop,
screen.width - config.gapsOuterLeft - config.gapsOuterRight,
screen.height - config.gapsOuterTop - config.gapsOuterBottom,
);
gapH = config.gapsInnerHorizontal;
gapV = config.gapsInnerVertical;
runLog = [];
const qtMock = new MockQt();
const workspaceMock = new MockWorkspace();
Qt = qtMock;
Workspace = workspaceMock;
const world = new World(config);
return { qtMock, workspaceMock, world };
}
function getGridBounds(clientLeft: KwinClient, clientRight: KwinClient) {
const columnsWidth = clientRight.frameGeometry.right - clientLeft.frameGeometry.left;
const left = Math.floor((screen.width - columnsWidth) / 2);
const right = left + columnsWidth;
return { left, right };
}
function getWindowHeight(windowsInColumn: number) {
const totalGaps = (windowsInColumn-1) * gapV;
return Math.round((tilingArea.height - totalGaps) / windowsInColumn);
}

View File

@@ -0,0 +1,192 @@
class MockKwinClient {
public readonly __brand = "KwinClient";
private static readonly borderThickness = 10;
public readonly shadeable: boolean = false;
public readonly caption = "App";
public minSize: Readonly<QmlSize> = new MockQmlSize(0, 0);
public readonly transient: boolean;
public readonly move: boolean = false;
public resize: boolean = false;
public readonly moveable: boolean = true;
public readonly resizeable: boolean = true;
public readonly fullScreenable: boolean = true;
public readonly maximizable: boolean = true;
public readonly output: Output = { __brand: "Output" };
public readonly resourceClass = "app";
public readonly dock: boolean = false;
public readonly normalWindow: boolean = true;
public readonly managed: boolean = true;
public readonly popupWindow: boolean = false;
public readonly pid = 1;
private _fullScreen: boolean = false;
public activities: string[] = [];
public skipSwitcher: boolean = false;
public keepAbove: boolean = false;
public keepBelow: boolean = false;
public shade: boolean = false;
public _minimized: boolean = false;
public desktops: KwinDesktop[] = [];
public _tile: Tile|null = null;
public opacity: number = 1.0;
public readonly fullScreenChanged = new MockQSignal();
public readonly desktopsChanged = new MockQSignal();
public readonly activitiesChanged = new MockQSignal();
public readonly minimizedChanged = new MockQSignal();
public readonly maximizedAboutToChange = new MockQSignal<[MaximizedMode]>();
public readonly captionChanged = new MockQSignal();
public readonly tileChanged = new MockQSignal();
public readonly interactiveMoveResizeStarted = new MockQSignal();
public readonly interactiveMoveResizeFinished = new MockQSignal();
public readonly frameGeometryChanged = new MockQSignal<[oldGeometry: QmlRect]>();
private windowedFrameGeometry: MockQmlRect;
private windowed: boolean = false;
private hasBorder: boolean = true;
constructor(
private _frameGeometry: MockQmlRect = new MockQmlRect(10, 10, 100, 200),
public readonly transientFor: MockKwinClient|null = null,
) {
this.windowedFrameGeometry = _frameGeometry.clone();
this.transient = transientFor !== null;
}
setMaximize(vertically: boolean, horizontally: boolean) {
this.windowed = !(vertically || horizontally);
this.maximizedAboutToChange.fire(
vertically ? (
horizontally ? MaximizedMode.Maximized : MaximizedMode.Vertically
) : (
horizontally ? MaximizedMode.Horizontally : MaximizedMode.Unmaximized
)
);
this.frameGeometry = new MockQmlRect(
horizontally ? 0 : this.windowedFrameGeometry.x,
vertically ? 0 : this.windowedFrameGeometry.y,
horizontally ? screen.width : this.windowedFrameGeometry.width,
vertically ? screen.height : this.windowedFrameGeometry.height,
);
}
public get clientGeometry() {
if (this.hasBorder) {
return new MockQmlRect(
this.frameGeometry.x + MockKwinClient.borderThickness,
this.frameGeometry.y + MockKwinClient.borderThickness,
this.frameGeometry.width - 2 * MockKwinClient.borderThickness,
this.frameGeometry.height - 2 * MockKwinClient.borderThickness,
);
} else {
return this.frameGeometry;
}
}
public get fullScreen() {
return this._fullScreen;
}
public set fullScreen(fullScreen: boolean) {
const oldFullScreen = this._fullScreen;
this.hasBorder = !fullScreen;
runReorder(
() => {
this._fullScreen = fullScreen;
if (fullScreen !== oldFullScreen) {
this.fullScreenChanged.fire();
}
},
() => {
if (oldFullScreen && !fullScreen) {
// when switching from full-screen to windowed, Kwin sometimes first adds the frame before changing the frameGeometry to the final value
if (rectEquals(this.frameGeometry, this.windowedFrameGeometry)) {
// already has windowed frame geometry, don't undo that
return;
}
runOneOf(
() => this.frameGeometry = new MockQmlRect(
0,
0,
screen.width + 2 * MockKwinClient.borderThickness,
screen.height + 2 * MockKwinClient.borderThickness,
),
() => this.frameGeometry = new MockQmlRect(
-MockKwinClient.borderThickness,
-MockKwinClient.borderThickness,
screen.width + 2 * MockKwinClient.borderThickness,
screen.height + 2 * MockKwinClient.borderThickness,
),
() => {},
);
}
},
() => {
this.windowed = !fullScreen;
if (fullScreen) {
this.frameGeometry = screen;
} else {
this.frameGeometry = this.windowedFrameGeometry;
}
},
);
}
public get frameGeometry() {
return this._frameGeometry;
}
public set frameGeometry(frameGeometry: MockQmlRect) {
const oldFrameGeometry = this._frameGeometry;
this._frameGeometry = new MockQmlRect(
frameGeometry.x,
frameGeometry.y,
frameGeometry.width,
frameGeometry.height,
this.frameGeometryChanged.fire.bind(this.frameGeometryChanged),
);
if (this.windowed) {
this.windowedFrameGeometry = this._frameGeometry.clone();
}
if (!rectEquals(frameGeometry, oldFrameGeometry)) {
this.frameGeometryChanged.fire(oldFrameGeometry);
}
}
public get minimized() {
return this._minimized;
}
public set minimized(minimized: boolean) {
this._minimized = minimized;
this.minimizedChanged.fire();
}
public get tile() {
return this._tile;
}
public set tile(tile: Tile|null) {
this._tile = tile;
this.tileChanged.fire();
}
public pin(geometry: MockQmlRect) {
runMaybe(() => this.frameGeometry = geometry);
this.tile = { __brand: "Tile" };
this.frameGeometry = geometry;
}
public unpin() {
this.tile = null;
}
public getFrameGeometryCopy() {
return this._frameGeometry.clone();
}
}

View File

@@ -0,0 +1,19 @@
class MockQSignal<T extends unknown[]> {
public readonly __brand = "QSignal";
private readonly handlers: Set<(...args: [...T]) => void> = new Set();
public connect(handler: (...args: [...T]) => void) {
this.handlers.add(handler);
};
public disconnect(handler: (...args: [...T]) => void) {
this.handlers.delete(handler);
};
public fire(...args: [...T]) {
for (const handler of this.handlers) {
handler(...args);
}
}
}

View File

@@ -0,0 +1,8 @@
class MockQmlPoint {
public readonly __brand = "QmlPoint";
constructor(
public x: number,
public y: number,
) {}
}

View File

@@ -0,0 +1,89 @@
class MockQmlRect {
public readonly __brand = "QmlRect";
constructor(
private _x: number,
private _y: number,
private _width: number,
private _height: number,
private readonly onChanged: (oldRect: MockQmlRect) => void = () => {},
) {}
public get x() {
return this._x;
}
public set x(x: number) {
const oldRect = this.clone();
this._x = x;
this.onChanged(oldRect);
}
public get y() {
return this._y;
}
public set y(y: number) {
const oldRect = this.clone();
this._y = y;
this.onChanged(oldRect);
}
public get width() {
return this._width;
}
public set width(width: number) {
const oldRect = this.clone();
this._width = width;
this.onChanged(oldRect);
}
public get height() {
return this._height;
}
public set height(height: number) {
const oldRect = this.clone();
this._height = height;
this.onChanged(oldRect);
}
public get top() {
return this.y;
}
public get bottom() {
return this.y + this.height;
}
public get left() {
return this.x;
}
public get right() {
return this.x + this.width;
}
public set(target: QmlRect) {
const oldRect = this.clone();
this._x = target.x;
this._y = target.y;
this._width = target.width;
this._height = target.height;
this.onChanged(oldRect);
}
public clone() {
return new MockQmlRect(
this._x,
this._y,
this._width,
this._height,
);
}
public toString() {
return `(${this.x} ${this.y} ${this.width} ${this.height})`;
}
}

View File

@@ -0,0 +1,8 @@
class MockQmlSize {
public readonly __brand = "QmlSize";
constructor(
public width: number,
public height: number,
) {}
}

View File

@@ -0,0 +1,13 @@
class MockQmlTimer {
public readonly __brand = "QmlObject";
public interval = 0;
public readonly triggered = new MockQSignal();
public restart() {
// no need to wait in tests, just fire immediately
this.triggered.fire();
};
public destroy() {}
}

View File

@@ -0,0 +1,47 @@
class MockQt {
public readonly __brand = "Qt";
private shortcuts = new Map<string, MockShortcutHandler>();
public point(x: number, y: number) {
return new MockQmlPoint(x, y);
}
public rect(x: number, y: number, width: number, height: number) {
return new MockQmlRect(x, y, width, height);
}
public createQmlObject(qml: string, parent: QmlObject): QmlObject {
if (qml.includes("Timer")) {
return new MockQmlTimer();
} else if (qml.includes("ShortcutHandler")) {
const shortcutName = MockQt.extractShortcutName(qml);
const shortcutHandler = new MockShortcutHandler();
this.shortcuts.set(shortcutName, shortcutHandler);
return shortcutHandler;
} else {
throw new Error("Unexpected qml string: " + qml);
}
}
public fireShortcut(shortcutName: string) {
const shortcutHandler = this.shortcuts.get(shortcutName);
if (shortcutHandler === undefined) {
Assert.assert(false);
return;
}
shortcutHandler.activated.fire();
}
private static extractShortcutName(qml: string) {
const nameLine = qml.split("\n").find((line) => line.trimStart().startsWith("name:"));
if (nameLine === undefined) {
Assert.assert(false);
return "";
}
return nameLine.substring(
nameLine.indexOf('"') + 1,
nameLine.lastIndexOf('"'),
);
}
}

View File

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

View File

@@ -0,0 +1,99 @@
class MockWorkspace {
public readonly __brand = "Workspace";
public activities = ["test-activity"];
public desktops: KwinDesktop[] = [
{ __brand: "KwinDesktop", id: "desktop1" },
{ __brand: "KwinDesktop", id: "desktop2" }
];
public currentDesktop = this.desktops[0];
public currentActivity = this.activities[0];
public activeScreen: Output = { __brand: "Output" };
public windows = [];
public cursorPos = new MockQmlPoint(0, 0);
private _activeWindow: KwinClient|null = null;
public readonly currentDesktopChanged = new MockQSignal<[]>();
public readonly windowAdded = new MockQSignal<[KwinClient]>();
public readonly windowRemoved = new MockQSignal<[KwinClient]>();
public readonly windowActivated = new MockQSignal<[KwinClient|null]>();
public readonly screensChanged = new MockQSignal<[]>();
public readonly activitiesChanged = new MockQSignal<[]>();
public readonly desktopsChanged = new MockQSignal<[]>();
public readonly currentActivityChanged = new MockQSignal<[]>();
public readonly virtualScreenSizeChanged = new MockQSignal<[]>();
public clientArea(option: ClientAreaOption, output: Output, kwinDesktop: KwinDesktop) {
return screen;
}
public createWindows(...kwinClients: MockKwinClient[]) {
for (const kwinClient of kwinClients) {
this.windowAdded.fire(kwinClient);
this.activeWindow = kwinClient;
}
}
public createClients(n: number) {
return this.createClientsWithWidths(...Array(n).fill(100));
}
public createClientsWithFrames(...frames: MockQmlRect[]) {
const clients = frames.map(rect => new MockKwinClient(rect));
this.createWindows(...clients);
return clients;
}
public createClientsWithWidths(...widths: number[]) {
return this.createClientsWithFrames(...widths.map(width => new MockQmlRect(randomInt(100), randomInt(100), width, 100+randomInt(400))));
}
public resizeWindow(window: MockKwinClient, edgeResize: boolean, leftEdge: boolean, topEdge: boolean, ...deltas: QmlSize[]) {
const frame = window.getFrameGeometryCopy();
if (edgeResize) {
this.cursorPos = new MockQmlPoint(
leftEdge ? frame.left : frame.right,
topEdge ? frame.top : frame.bottom,
);
} else {
this.cursorPos = new MockQmlPoint(
Math.round(frame.x + frame.width/2),
Math.round(frame.y + frame.height/2),
);
}
window.resize = true;
window.interactiveMoveResizeStarted.fire();
for (const delta of deltas) {
if (delta.width !== 0) {
frame.width += delta.width;
if (leftEdge) {
frame.x -= delta.width;
}
}
if (delta.height !== 0) {
frame.height += delta.height;
if (topEdge) {
frame.y -= delta.height;
}
}
runOneOf(
() => window.frameGeometry.set(frame),
() => window.frameGeometry = frame,
)
}
window.resize = false;
window.interactiveMoveResizeFinished.fire();
}
public get activeWindow() {
return this._activeWindow;
}
public set activeWindow(activeWindow: KwinClient|null) {
this._activeWindow = activeWindow;
this.windowActivated.fire(activeWindow);
}
}

39
src/tests/utils/random.ts Normal file
View File

@@ -0,0 +1,39 @@
function runMaybe(f: () => void) {
if (Math.random() < 0.5) {
f();
}
}
function runOneOf(...fs: (() => void)[]) {
const index = randomInt(fs.length);
runLog.push(`${getStackFrame(1)} - Chose ${index}`);
fs[index]();
}
function runReorder(...fs: (() => void)[]) {
const fis = fs.map((f, index) => ({ f: f, index: index }));
shuffle(fis);
const indexes = fis.map((fi) => fi.index);
runLog.push(`${getStackFrame(1)} - Order ${indexes}`);
for (const fi of fis) {
fi.f();
}
}
function randomInt(n: number) {
return Math.floor(Math.random() * n);
}
function shuffle(items: any[]) {
for (let n = items.length; n > 1; n--) {
const i = n-1;
const j = randomInt(n);
[items[i], items[j]] = [items[j], items[i]];
}
}
function getStackFrame(index: number) {
return new Error().stack!.split("\n")[index+2].substring(7);
}

View File

@@ -0,0 +1,13 @@
function timeControl(f: (addTime: (ms: number) => void) => void) {
const originalDateNow = Date.now;
let currentTime = Date.now();
Date.now = () => currentTime;
function addTime(ms: number) {
currentTime += ms;
}
f(addTime);
Date.now = originalDateNow;
}