113 Commits
v0.10 ... v0.15

Author SHA1 Message Date
Peter Fajdiga
7d4eab03b9 bump version to 0.15 2025-11-09 20:57:31 +01:00
Peter Fajdiga
7070e59044 mark cursorFollowsFocus setting as experimental 2025-10-22 21:53:09 +02:00
Peter Fajdiga
7f5745b2cf fix cursorFollowsFocus setting after c7effc8913 2025-10-22 21:01:06 +02:00
Peter Fajdiga
c7752bf20a add error notification for invalid tiled desktops regex 2025-10-21 23:01:00 +02:00
Peter Fajdiga
8149100aac DesktopManager: remove addDesktop call in constructor 2025-10-21 22:57:02 +02:00
Peter Fajdiga
99bf71f0b9 config.ui: shorten tooltip for kcfg_tiledDesktops 2025-10-21 22:48:13 +02:00
Peter Fajdiga
2b882768d9 config.ui: don't use monospace font for kcfg_tiledDesktops editbox 2025-10-21 22:45:42 +02:00
Peter Fajdiga
bb42e4d3ad clean whitespace 2025-10-21 22:16:12 +02:00
SR_team
c7effc8913 Add Desktops settings tab to control which virtual desktops Karousel operates on (#133)
* Add Desktops settings tab to control which virtual desktops Karousel operates on

* Fix cursor follow focus to only work on matched desktops

* Resolve review comments

- Implement a RegExp-based DesktopFilter.
- Add new config key tiledDesktops (String) with default ".*" (match all).
- Make DesktopManager return undefined for desktops that should not be tiled.
- Mark KWinDesktop.name as readonly.
- Replace multiline desktop editor in settings UI with a single-line QLineEdit (moved to top of Window Rules tab), shorten label to "Tiled desktops:" and add a tooltip with examples.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-10-21 21:06:25 +02:00
Peter Fajdiga
2c433867f3 bump package to 0.14 2025-09-28 09:39:17 +02:00
Peter Fajdiga
e995555074 tests: passFocus: remove assertion for null activeWindow 2025-09-13 17:31:12 +02:00
Peter Fajdiga
6a1e018df1 tests: MockWorkspace: only focus a different window if none is focused 2025-09-13 16:58:44 +02:00
Peter Fajdiga
872a67e6e1 clear Focus Passer if another window is focused by anyone 2025-09-13 15:32:19 +02:00
Peter Fajdiga
5a57ba76d8 only clear Focus Passer if another window is focused by Karousel 2025-09-13 15:31:44 +02:00
Peter Fajdiga
55c6932338 always pass Window to Focus Passer 2025-09-13 15:02:27 +02:00
Peter Fajdiga
b1d6193e48 tests: make mocks more accurately mimic kwin behaviour 2025-09-13 12:42:22 +02:00
Peter Fajdiga
456bbf30b4 fix focus passing when moving a column to another desktop 2025-09-13 12:41:21 +02:00
Peter Fajdiga
24c1fa0a38 uncomment passFocus test 2025-09-13 12:26:57 +02:00
Peter Fajdiga
ac7566d2cf fix focus passing when closing windows 2025-09-07 20:37:00 +02:00
Peter Fajdiga
195f4e6d30 don't pass focus when window is moved and followed to a different desktop (issue 116) 2025-09-07 20:37:00 +02:00
Peter Fajdiga
e8f2a50420 tests: add test for kwin shortucts for moving windows to adjacent desktops 2025-09-07 20:37:00 +02:00
Peter Fajdiga
9910bc7041 tests: MockWorkspace.removeWindow: unfocus before focusing 2025-09-07 20:37:00 +02:00
Peter Fajdiga
1b592c5b4b fix detection of full-screen windows for apps that manage their own window decorations 2025-09-07 19:45:36 +02:00
Peter Fajdiga
75384d9fb4 tests: make runOneOf functions optionally return a value 2025-09-07 19:45:36 +02:00
Peter Fajdiga
dba92d3826 upgrade node modules 2025-09-07 19:45:36 +02:00
Peter Fajdiga
dbb95e0470 enable eslint comma-dangle 2025-09-07 19:45:34 +02:00
Peter Fajdiga
056149440d readme: add mention of npm requirement 2025-05-14 21:22:05 +02:00
Peter Fajdiga
33be23c6dc bump version to 0.13 2025-05-03 21:54:42 +02:00
Peter Fajdiga
e31669e499 config.ui: gestureScroll: add note regarding disabling KDE workspace switching gestures 2025-04-30 20:25:36 +02:00
Peter Fajdiga
caf2b5a146 Makefile: add ability to skip the linter 2025-04-17 21:11:24 +02:00
Peter Fajdiga
b7f1876a84 Makefile: add lint 2025-04-17 21:08:29 +02:00
Peter Fajdiga
f108c4a45e require indent of 4 spaces 2025-04-17 21:02:49 +02:00
Peter Fajdiga
0aa5d8c3fa require semicolons 2025-04-17 20:55:03 +02:00
Peter Fajdiga
1674d14453 eslint: allow empty functions 2025-04-17 20:45:39 +02:00
Peter Fajdiga
ca8b78ef04 configure eslint 2025-04-17 20:45:39 +02:00
Peter Fajdiga
877767cea3 add eslint 2025-04-17 20:45:39 +02:00
Peter Fajdiga
f1a18b8276 World: simplify addExistingClients 2025-04-17 20:45:09 +02:00
Peter Fajdiga
8725bc84e0 use interface instead of type 2025-04-17 20:44:59 +02:00
Peter Fajdiga
8c23535e86 use as type assertions 2025-04-17 20:44:56 +02:00
Peter Fajdiga
c0e7234fec remove type annotations for trivially inferred types 2025-04-17 20:44:50 +02:00
Peter Fajdiga
03acbe1280 use const where possible 2025-04-17 20:44:28 +02:00
Peter Fajdiga
7ef0c60cb8 un-maximize when swithing to another window in the same column (issue 93) 2025-04-04 13:59:39 +02:00
Peter Fajdiga
7e1517bbcb tests: maximization: simplify test cases' code by passing functions with fewer parameters 2025-04-04 13:48:30 +02:00
Peter Fajdiga
a23acd056b tests: maximization: combine files 2025-04-04 13:37:45 +02:00
Peter Fajdiga
e908f7fe8a tests: combine re-maximize tests 2025-04-04 13:02:04 +02:00
Peter Fajdiga
ba63c1d4e7 tests: re-maximize: simplify and add another window to column 2025-04-04 12:48:36 +02:00
Peter Fajdiga
bf060cef17 tests: "Re-maximize enabled": simplify and add another window to column 2025-04-04 12:11:11 +02:00
Peter Fajdiga
0f21f94d02 add test for cursorFollowsFocus and add check whether cursor already within focused client 2025-04-04 11:36:04 +02:00
Himadri Bhattacharjee
6dd356dc53 add option for moving cursor to the focused window (#89) 2025-04-04 11:33:39 +02:00
Himadri Bhattacharjee
c99cad96c3 add swipe gesture scrolling support (Wayland only) (#90) 2025-04-04 09:56:07 +02:00
Peter Fajdiga
b4fe71f91b ClientWrapper: set maximizedMode to Unmaximized for non-maximizable clients (issue 79) 2025-04-03 20:25:03 +02:00
Peter Fajdiga
099b9f5d6a tests: "Start full-screen (force tiling)": add case where full-screen exit is initiated by the client 2025-04-03 17:04:04 +02:00
Peter Fajdiga
92f6942eef tests: add debug function runReorderDebug 2025-04-03 16:58:47 +02:00
Peter Fajdiga
9621c2a75b tests: "Start full-screen (force tiling)": add assert for windowed client 2025-04-03 16:58:47 +02:00
Peter Fajdiga
36bc1be8c0 tests: enable passing different column widths to Assert.grid 2025-04-03 16:58:47 +02:00
Peter Fajdiga
68b659744c MockKwinClient: when switching out of full-screen mode, remember the target windowed frame geometry throughout all the steps 2025-04-03 16:58:20 +02:00
Peter Fajdiga
c715289282 fix keepBelow and keepAbove properties for windows that start in full-screen mode (issue 79) 2025-04-03 12:23:24 +02:00
Peter Fajdiga
465945429a bump version to 0.12 2025-03-14 12:33:19 +01:00
Peter Fajdiga
1d7636508b Column: make stack offsets configurable 2025-03-10 23:00:19 +01:00
Peter Fajdiga
47213a71f5 tests: fix tests that were using screen.width/2 2025-03-08 19:48:56 +01:00
Peter Fajdiga
75a548977c tests: Assert.grid: pass tilingArea instead of screen 2025-03-08 19:38:23 +01:00
Peter Fajdiga
d746b91a88 tests: add test for stacked columns 2025-03-08 17:34:51 +01:00
Peter Fajdiga
a0d9c49287 remove all uses of window shading 2025-03-08 17:18:27 +01:00
Peter Fajdiga
862cc445bd stack without shading 2025-03-08 17:18:27 +01:00
Peter Fajdiga
5019a5d702 re-arrange after dragging a tiled window 2025-03-08 17:18:22 +01:00
Peter Fajdiga
36c7cab137 tests: add test for dragging a tiled window 2025-03-07 20:12:37 +01:00
Peter Fajdiga
df3c1f4512 allow tiling windows that start in full-screen (fixes #79) 2025-03-07 16:48:48 +01:00
Peter Fajdiga
5f3eaf1eec MockKwinClient: make moveable and resizable dependent on fullscreen 2025-03-07 16:48:48 +01:00
Peter Fajdiga
4a680177f6 respect window rules for full-screen windows (fixes #79) 2025-03-07 16:48:48 +01:00
Peter Fajdiga
8d807c979b tests: add test for windows that start in full-screen 2025-03-07 16:48:44 +01:00
Peter Fajdiga
c8e37aeb87 Tiled: allow new windows to stay in fullScreen 2025-03-07 16:43:50 +01:00
Peter Fajdiga
ad0fe7472c Window: store initial maximized and fullScreen state 2025-03-07 16:43:50 +01:00
Peter Fajdiga
a51e45667c Assert.grid: reorder message 2025-03-07 16:43:50 +01:00
Himadri Bhattacharjee
6615fe6f93 fix: float polkit authentication window (#88)
* fix: float polkit authentication window

* fix: ignore polkit windows on X11

Co-authored-by: Peter Fajdiga <peter.fajdiga@gmail.com>

---------

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

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

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

15
eslint.config.mjs Normal file
View File

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

1464
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

6
package.json Normal file
View File

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

View File

@@ -29,13 +29,23 @@
</property> </property>
</widget> </widget>
</item> </item>
<item>
<widget class="QCheckBox" name="kcfg_cursorFollowsFocus">
<property name="text">
<string>Cursor follows focus (experimental)</string>
</property>
<property name="toolTip">
<string>When a window gains focus, move the cursor to it</string>
</property>
</widget>
</item>
<item> <item>
<widget class="QCheckBox" name="kcfg_stackColumnsByDefault"> <widget class="QCheckBox" name="kcfg_stackColumnsByDefault">
<property name="text"> <property name="text">
<string>Stack columns by default</string> <string>Stack columns by default</string>
</property> </property>
<property name="toolTip"> <property name="toolTip">
<string>New columns start in stacked mode (one window in the column visible, others shaded). Not supported on Wayland.</string> <string>New columns start in stacked mode (only the active window visible)</string>
</property> </property>
</widget> </widget>
</item> </item>
@@ -101,6 +111,35 @@
</widget> </widget>
</item> </item>
<item>
<widget class="QGroupBox">
<property name="title">
<string>Touchpad scrolling (Wayland only)</string>
</property>
<layout class="QVBoxLayout">
<item>
<widget class="QCheckBox" name="kcfg_gestureScroll">
<property name="text">
<string>Enable scrolling with touchpad gestures
(please don't forget to disable KDE's workspace switching gestures)</string>
</property>
<property name="toolTip">
<string>Scroll with a three-finger horizontal swipe gesture</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="kcfg_gestureScrollInvert">
<property name="text">
<string>Invert scroll direction (Natural scrolling)</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item> <item>
<widget class="QGroupBox"> <widget class="QGroupBox">
<property name="title"> <property name="title">
@@ -280,14 +319,14 @@
</item> </item>
<item row="6" column="0"> <item row="6" column="0">
<widget class="QLabel" name="label_manualScrollStep"> <widget class="QLabel" name="label_stackOffsetX">
<property name="text"> <property name="text">
<string>Manual scroll step size:</string> <string>Horizontal offset for stacked columns:</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="6" column="1"> <item row="6" column="1">
<widget class="QSpinBox" name="kcfg_manualScrollStep"> <widget class="QSpinBox" name="kcfg_stackOffsetX">
<property name="suffix"> <property name="suffix">
<string> px</string> <string> px</string>
</property> </property>
@@ -301,6 +340,78 @@
</item> </item>
<item row="7" column="0"> <item row="7" column="0">
<widget class="QLabel" name="label_stackOffsetY">
<property name="text">
<string>Vertical offset for stacked columns:</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QSpinBox" name="kcfg_stackOffsetY">
<property name="suffix">
<string> px</string>
</property>
<property name="maximum">
<number>999</number>
</property>
<property name="value">
<number>0</number>
</property>
</widget>
</item>
<item row="8" column="0">
<widget class="QLabel" name="label_manualScrollStep">
<property name="text">
<string>Manual scroll step size:</string>
</property>
</widget>
</item>
<item row="8" column="1">
<widget class="QSpinBox" name="kcfg_manualScrollStep">
<property name="suffix">
<string> px</string>
</property>
<property name="maximum">
<number>999</number>
</property>
<property name="value">
<number>0</number>
</property>
</widget>
</item>
<item row="9" column="0">
<widget class="QLabel" name="label_gestureScrollStep">
<property name="text">
<string>Touchpad gesture scrolling speed:</string>
</property>
<property name="toolTip">
<string>The amount to scroll per edge-to-edge gesture</string>
</property>
</widget>
</item>
<item row="9" column="1">
<widget class="QSpinBox" name="kcfg_gestureScrollStep">
<property name="suffix">
<string> px</string>
</property>
<property name="maximum">
<number>10000</number>
</property>
<property name="minimum">
<number>100</number>
</property>
<property name="singleStep">
<number>100</number>
</property>
<property name="value">
<number>1920</number>
</property>
</widget>
</item>
<item row="10" column="0">
<widget class="QLabel" name="label_presetWidths"> <widget class="QLabel" name="label_presetWidths">
<property name="text"> <property name="text">
<string>Preset widths:</string> <string>Preset widths:</string>
@@ -310,7 +421,7 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="7" column="1"> <item row="10" column="1">
<widget class="QLineEdit" name="kcfg_presetWidths"> <widget class="QLineEdit" name="kcfg_presetWidths">
<property name="toolTip"> <property name="toolTip">
<string>Comma-separated list of widths. Supported units: "px" and "%".</string> <string>Comma-separated list of widths. Supported units: "px" and "%".</string>
@@ -318,14 +429,14 @@
</widget> </widget>
</item> </item>
<item row="8" column="0"> <item row="11" column="0">
<widget class="QLabel" name="label_offScreenOpacity"> <widget class="QLabel" name="label_offScreenOpacity">
<property name="text"> <property name="text">
<string>Obscured window opacity:</string> <string>Obscured window opacity:</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="8" column="1"> <item row="11" column="1">
<widget class="QSpinBox" name="kcfg_offScreenOpacity"> <widget class="QSpinBox" name="kcfg_offScreenOpacity">
<property name="suffix"> <property name="suffix">
<string> %</string> <string> %</string>
@@ -338,6 +449,7 @@
</property> </property>
</widget> </widget>
</item> </item>
</layout> </layout>
</widget> </widget>
@@ -346,6 +458,27 @@
<string>Window Rules</string> <string>Window Rules</string>
</attribute> </attribute>
<layout class="QVBoxLayout"> <layout class="QVBoxLayout">
<item>
<layout class="QHBoxLayout">
<item>
<widget class="QLabel" name="label_desktops">
<property name="text">
<string>Tiled desktops:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="kcfg_tiledDesktops">
<property name="toolTip">
<string>Regex string to match desktops by desktop name"</string>
</property>
</widget>
</item>
</layout>
</item>
<item> <item>
<widget class="QPlainTextEdit" name="kcfg_windowRules"> <widget class="QPlainTextEdit" name="kcfg_windowRules">
<property name="tabChangesFocus"> <property name="tabChangesFocus">

View File

@@ -16,6 +16,16 @@ Item {
qmlBase.karouselInstance.destroy(); qmlBase.karouselInstance.destroy();
} }
Notification {
id: notificationInvalidTiledDesktops
componentName: "plasma_workspace"
eventId: "notification"
title: "Karousel"
text: "Your Tiled Desktops regex is malformed, please review your Karousel configuration"
flags: Notification.Persistent
urgency: Notification.HighUrgency
}
Notification { Notification {
id: notificationInvalidWindowRules id: notificationInvalidWindowRules
componentName: "plasma_workspace" componentName: "plasma_workspace"
@@ -35,4 +45,29 @@ Item {
flags: Notification.Persistent flags: Notification.Persistent
urgency: Notification.HighUrgency urgency: Notification.HighUrgency
} }
SwipeGestureHandler {
direction: SwipeGestureHandler.Direction.Left
fingerCount: 3
onActivated: qmlBase.karouselInstance.gestureScrollFinish()
onCancelled: qmlBase.karouselInstance.gestureScrollFinish()
onProgressChanged: qmlBase.karouselInstance.gestureScroll(-progress)
}
SwipeGestureHandler {
direction: SwipeGestureHandler.Direction.Right
fingerCount: 3
onActivated: qmlBase.karouselInstance.gestureScrollFinish()
onCancelled: qmlBase.karouselInstance.gestureScrollFinish()
onProgressChanged: qmlBase.karouselInstance.gestureScroll(progress)
}
DBusCall {
id: moveCursorToFocus
service: "org.kde.kglobalaccel"
path: "/component/kwin"
method: "invokeShortcut"
arguments: ["MoveMouseToFocus"]
}
} }

View File

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

View File

@@ -2,5 +2,7 @@ declare const Qt: Qt;
declare const KWin: KWin; declare const KWin: KWin;
declare const Workspace: Workspace; declare const Workspace: Workspace;
declare const qmlBase: QmlObject; declare const qmlBase: QmlObject;
declare const notificationInvalidTiledDesktops: Notification;
declare const notificationInvalidWindowRules: Notification; declare const notificationInvalidWindowRules: Notification;
declare const notificationInvalidPresetWidths: Notification; declare const notificationInvalidPresetWidths: Notification;
declare const moveCursorToFocus: DBusCall;

View File

@@ -1,7 +1,7 @@
type DocsKeyBinding = { interface DocsKeyBinding {
description: string; description: string;
keySequence: string; keySequence: string;
}; }
function formatDescription(item: {description: string, comment?: string}) { function formatDescription(item: {description: string, comment?: string}) {
const suffix = item.comment === undefined ? "" : ` (${item.comment})`; const suffix = item.comment === undefined ? "" : ` (${item.comment})`;
@@ -15,9 +15,9 @@ function printCols(...columns: (string[] | string)[]) {
} }
let nRows = Math.min(...columns.filter( let nRows = Math.min(...columns.filter(
(column: string[] | string) => column instanceof Array (column: string[] | string) => column instanceof Array,
).map( ).map(
(column: string[] | string) => column.length (column: string[] | string) => column.length,
)); ));
if (nRows === Infinity) { if (nRows === Infinity) {
// we only have single string columns // we only have single string columns
@@ -28,12 +28,12 @@ function printCols(...columns: (string[] | string)[]) {
(column: string[] | string) => { (column: string[] | string) => {
if (column instanceof Array) { if (column instanceof Array) {
return Math.max(...column.map( return Math.max(...column.map(
(cell: string) => cell.length (cell: string) => cell.length,
)) ));
} else { } else {
return column.length; return column.length;
} }
} },
); );
function getCell(col: number, row: number) { function getCell(col: number, row: number) {

View File

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

View File

@@ -9,12 +9,12 @@ class ContextualResizer {
const visibleRange = desktop.getCurrentVisibleRange(); const visibleRange = desktop.getCurrentVisibleRange();
const minWidth = column.getMinWidth(); const minWidth = column.getMinWidth();
const maxWidth = column.getMaxWidth(); const maxWidth = column.getMaxWidth();
if(!column.isVisible(visibleRange, true) || column.getWidth() >= maxWidth) { if(!Range.contains(visibleRange, column) || column.getWidth() >= maxWidth) {
return; return;
} }
let leftVisibleColumn = grid.getLeftmostVisibleColumn(visibleRange, true); const leftVisibleColumn = grid.getLeftmostVisibleColumn(visibleRange, true);
let rightVisibleColumn = grid.getRightmostVisibleColumn(visibleRange, true); const rightVisibleColumn = grid.getRightmostVisibleColumn(visibleRange, true);
if (leftVisibleColumn === null || rightVisibleColumn === null) { if (leftVisibleColumn === null || rightVisibleColumn === null) {
console.assert(false); // should at least see self console.assert(false); // should at least see self
return; return;
@@ -31,7 +31,7 @@ class ContextualResizer {
...this.presetWidths.getWidths(minWidth, maxWidth), ...this.presetWidths.getWidths(minWidth, maxWidth),
], ],
width => width - column.getWidth(), width => width - column.getWidth(),
) );
if (newWidth === undefined) { if (newWidth === undefined) {
return; return;
} }
@@ -46,7 +46,7 @@ class ContextualResizer {
const visibleRange = desktop.getCurrentVisibleRange(); const visibleRange = desktop.getCurrentVisibleRange();
const minWidth = column.getMinWidth(); const minWidth = column.getMinWidth();
const maxWidth = column.getMaxWidth(); const maxWidth = column.getMaxWidth();
if(!column.isVisible(visibleRange, true) || column.getWidth() <= minWidth) { if(!Range.contains(visibleRange, column) || column.getWidth() <= minWidth) {
return; return;
} }
@@ -78,7 +78,7 @@ class ContextualResizer {
...this.presetWidths.getWidths(minWidth, maxWidth), ...this.presetWidths.getWidths(minWidth, maxWidth),
], ],
width => column.getWidth() - width, width => column.getWidth() - width,
) );
if (newWidth === undefined) { if (newWidth === undefined) {
return; return;
} }

View File

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

View File

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

View File

@@ -1,14 +1,17 @@
type Config = { interface Config {
gapsOuterTop: number; gapsOuterTop: number;
gapsOuterBottom: number; gapsOuterBottom: number;
gapsOuterLeft: number; gapsOuterLeft: number;
gapsOuterRight: number; gapsOuterRight: number;
gapsInnerHorizontal: number; gapsInnerHorizontal: number;
gapsInnerVertical: number; gapsInnerVertical: number;
stackOffsetX: number;
stackOffsetY: number;
manualScrollStep: number; manualScrollStep: number;
presetWidths: string; presetWidths: string;
offScreenOpacity: number; offScreenOpacity: number;
untileOnDrag: boolean; untileOnDrag: boolean;
cursorFollowsFocus: boolean;
stackColumnsByDefault: boolean; stackColumnsByDefault: boolean;
resizeNeighborColumn: boolean; resizeNeighborColumn: boolean;
reMaximize: boolean; reMaximize: boolean;
@@ -16,7 +19,11 @@ type Config = {
scrollingLazy: boolean; scrollingLazy: boolean;
scrollingCentered: boolean; scrollingCentered: boolean;
scrollingGrouped: boolean; scrollingGrouped: boolean;
gestureScroll: boolean;
gestureScrollInvert: boolean;
gestureScrollStep: number;
tiledKeepBelow: boolean; tiledKeepBelow: boolean;
floatingKeepAbove: boolean; floatingKeepAbove: boolean;
windowRules: string; windowRules: string;
}; tiledDesktops: string;
}

View File

@@ -3,6 +3,10 @@ const defaultWindowRules = `[
"class": "(org\\\\.kde\\\\.)?plasmashell", "class": "(org\\\\.kde\\\\.)?plasmashell",
"tile": false "tile": false
}, },
{
"class": "(org\\\\.kde\\\\.)?polkit-kde-authentication-agent-1",
"tile": false
},
{ {
"class": "(org\\\\.kde\\\\.)?kded6", "class": "(org\\\\.kde\\\\.)?kded6",
"tile": false "tile": false
@@ -80,6 +84,16 @@ const configDef = [
type: "UInt", type: "UInt",
default: 8, default: 8,
}, },
{
name: "stackOffsetX",
type: "UInt",
default: 8,
},
{
name: "stackOffsetY",
type: "UInt",
default: 32,
},
{ {
name: "manualScrollStep", name: "manualScrollStep",
type: "UInt", type: "UInt",
@@ -100,6 +114,11 @@ const configDef = [
type: "Bool", type: "Bool",
default: true, default: true,
}, },
{
name: "cursorFollowsFocus",
type: "Bool",
default: false,
},
{ {
name: "stackColumnsByDefault", name: "stackColumnsByDefault",
type: "Bool", type: "Bool",
@@ -135,6 +154,21 @@ const configDef = [
type: "Bool", type: "Bool",
default: false, default: false,
}, },
{
name: "gestureScroll",
type: "Bool",
default: false,
},
{
name: "gestureScrollInvert",
type: "Bool",
default: false,
},
{
name: "gestureScrollStep",
type: "UInt",
default: 1920,
},
{ {
name: "tiledKeepBelow", name: "tiledKeepBelow",
type: "Bool", type: "Bool",
@@ -154,5 +188,10 @@ const configDef = [
name: "windowRules", name: "windowRules",
type: "String", type: "String",
default: defaultWindowRules, default: defaultWindowRules,
} },
{
name: "tiledDesktops",
type: "String",
default: ".*",
},
]; ];

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

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

View File

@@ -1,10 +1,10 @@
type KWin = { interface KWin {
__brand: "KWin"; __brand: "KWin";
readConfig(key: string, defaultValue: any): any; readConfig(key: string, defaultValue: any): any;
}; }
type Workspace = { interface Workspace {
__brand: "Workspace"; __brand: "Workspace";
readonly activities: string[]; readonly activities: string[];
@@ -28,7 +28,7 @@ type Workspace = {
readonly virtualScreenSizeChanged: QSignal<[]>; readonly virtualScreenSizeChanged: QSignal<[]>;
clientArea(option: ClientAreaOption, output: Output, kwinDesktop: KwinDesktop): QmlRect; clientArea(option: ClientAreaOption, output: Output, kwinDesktop: KwinDesktop): QmlRect;
}; }
const enum ClientAreaOption { const enum ClientAreaOption {
PlacementArea, PlacementArea,
@@ -48,13 +48,12 @@ const enum MaximizedMode {
Maximized, Maximized,
} }
type Tile = { __brand: "Tile" }; interface Tile { __brand: "Tile" }
type Output = { __brand: "Output" }; interface Output { __brand: "Output" }
type KwinClient = { interface KwinClient {
__brand: "KwinClient"; __brand: "KwinClient";
readonly shadeable: boolean;
readonly caption: string; readonly caption: string;
readonly minSize: Readonly<QmlSize>; readonly minSize: Readonly<QmlSize>;
readonly transient: boolean; readonly transient: boolean;
@@ -79,7 +78,6 @@ type KwinClient = {
skipSwitcher: boolean; skipSwitcher: boolean;
keepAbove: boolean; keepAbove: boolean;
keepBelow: boolean; keepBelow: boolean;
shade: boolean;
minimized: boolean; minimized: boolean;
frameGeometry: QmlRect; frameGeometry: QmlRect;
desktops: KwinDesktop[]; // empty array means all desktops desktops: KwinDesktop[]; // empty array means all desktops
@@ -98,15 +96,16 @@ type KwinClient = {
readonly frameGeometryChanged: QSignal<[oldGeometry: QmlRect]>; readonly frameGeometryChanged: QSignal<[oldGeometry: QmlRect]>;
setMaximize(vertically: boolean, horizontally: boolean): void; setMaximize(vertically: boolean, horizontally: boolean): void;
}; }
type KwinDesktop = { interface KwinDesktop {
__brand: "KwinDesktop"; __brand: "KwinDesktop";
readonly id: string; readonly id: string;
}; readonly name: string;
}
type ShortcutHandler = QmlObject & { interface ShortcutHandler extends QmlObject {
readonly activated: QSignal<[]>; readonly activated: QSignal<[]>;
destroy(): void; destroy(): void;
}; }

View File

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

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

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

View File

@@ -8,16 +8,16 @@ class Actions {
if (leftColumn === null) { if (leftColumn === null) {
return; return;
} }
leftColumn.focus(); leftColumn.getWindowToFocus().focus();
} };
public readonly focusRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly focusRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const rightColumn = grid.getRightColumn(column); const rightColumn = grid.getRightColumn(column);
if (rightColumn === null) { if (rightColumn === null) {
return; return;
} }
rightColumn.focus(); rightColumn.getWindowToFocus().focus();
} };
public readonly focusUp = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly focusUp = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const aboveWindow = column.getAboveWindow(window); const aboveWindow = column.getAboveWindow(window);
@@ -25,7 +25,7 @@ class Actions {
return; return;
} }
aboveWindow.focus(); aboveWindow.focus();
} };
public readonly focusDown = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly focusDown = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const belowWindow = column.getBelowWindow(window); const belowWindow = column.getBelowWindow(window);
@@ -33,7 +33,7 @@ class Actions {
return; return;
} }
belowWindow.focus(); belowWindow.focus();
} };
public readonly focusNext = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly focusNext = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const belowWindow = column.getBelowWindow(window); const belowWindow = column.getBelowWindow(window);
@@ -46,7 +46,7 @@ class Actions {
} }
rightColumn.getFirstWindow().focus(); rightColumn.getFirstWindow().focus();
} }
} };
public readonly focusPrevious = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly focusPrevious = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const aboveWindow = column.getAboveWindow(window); const aboveWindow = column.getAboveWindow(window);
@@ -59,25 +59,33 @@ class Actions {
} }
leftColumn.getLastWindow().focus(); leftColumn.getLastWindow().focus();
} }
} };
public readonly focusStart = (cm: ClientManager, dm: DesktopManager) => { public readonly focusStart = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid; const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const firstColumn = grid.getFirstColumn(); const firstColumn = grid.getFirstColumn();
if (firstColumn === null) { if (firstColumn === null) {
return; return;
} }
firstColumn.focus(); firstColumn.getWindowToFocus().focus();
} };
public readonly focusEnd = (cm: ClientManager, dm: DesktopManager) => { public readonly focusEnd = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid; const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const lastColumn = grid.getLastColumn(); const lastColumn = grid.getLastColumn();
if (lastColumn === null) { if (lastColumn === null) {
return; return;
} }
lastColumn.focus(); lastColumn.getWindowToFocus().focus();
} };
public readonly windowMoveLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly windowMoveLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
if (column.getWindowCount() === 1) { if (column.getWindowCount() === 1) {
@@ -86,40 +94,40 @@ class Actions {
if (leftColumn === null) { if (leftColumn === null) {
return; return;
} }
window.moveToColumn(leftColumn, true); window.moveToColumn(leftColumn, true, FocusPassing.Type.None);
grid.desktop.autoAdjustScroll(); grid.desktop.autoAdjustScroll();
} else { } else {
// move from shared column into own column // move from shared column into own column
const newColumn = new Column(grid, grid.getLeftColumn(column)); const newColumn = new Column(grid, grid.getLeftColumn(column));
window.moveToColumn(newColumn, true); window.moveToColumn(newColumn, true, FocusPassing.Type.None);
} }
} };
public readonly windowMoveRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid, bottom: boolean = true) => { public readonly windowMoveRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid, bottom = true) => {
if (column.getWindowCount() === 1) { if (column.getWindowCount() === 1) {
// move from own column into existing column // move from own column into existing column
const rightColumn = grid.getRightColumn(column); const rightColumn = grid.getRightColumn(column);
if (rightColumn === null) { if (rightColumn === null) {
return; return;
} }
window.moveToColumn(rightColumn, bottom); window.moveToColumn(rightColumn, bottom, FocusPassing.Type.None);
grid.desktop.autoAdjustScroll(); grid.desktop.autoAdjustScroll();
} else { } else {
// move from shared column into own column // move from shared column into own column
const newColumn = new Column(grid, column); const newColumn = new Column(grid, column);
window.moveToColumn(newColumn, true); window.moveToColumn(newColumn, true, FocusPassing.Type.None);
} }
} };
// TODO (optimization): only arrange moved windows // TODO (optimization): only arrange moved windows
public readonly windowMoveUp = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly windowMoveUp = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
column.moveWindowUp(window); column.moveWindowUp(window);
} };
// TODO (optimization): only arrange moved windows // TODO (optimization): only arrange moved windows
public readonly windowMoveDown = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly windowMoveDown = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
column.moveWindowDown(window); column.moveWindowDown(window);
} };
public readonly windowMoveNext = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly windowMoveNext = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const canMoveDown = window !== column.getLastWindow(); const canMoveDown = window !== column.getLastWindow();
@@ -128,7 +136,7 @@ class Actions {
} else { } else {
this.windowMoveRight(cm, dm, window, column, grid, false); this.windowMoveRight(cm, dm, window, column, grid, false);
} }
} };
public readonly windowMovePrevious = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly windowMovePrevious = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const canMoveUp = window !== column.getFirstWindow(); const canMoveUp = window !== column.getFirstWindow();
@@ -137,60 +145,68 @@ class Actions {
} else { } else {
this.windowMoveLeft(cm, dm, window, column, grid); this.windowMoveLeft(cm, dm, window, column, grid);
} }
} };
public readonly windowMoveStart = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly windowMoveStart = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const newColumn = new Column(grid, null); const newColumn = new Column(grid, null);
window.moveToColumn(newColumn, true); window.moveToColumn(newColumn, true, FocusPassing.Type.None);
} };
public readonly windowMoveEnd = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly windowMoveEnd = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const newColumn = new Column(grid, grid.getLastColumn()); const newColumn = new Column(grid, grid.getLastColumn());
window.moveToColumn(newColumn, true); window.moveToColumn(newColumn, true, FocusPassing.Type.None);
} };
public readonly windowToggleFloating = (cm: ClientManager, dm: DesktopManager) => { public readonly windowToggleFloating = (cm: ClientManager, dm: DesktopManager) => {
if (Workspace.activeWindow === null) { if (Workspace.activeWindow === null) {
return; return;
} }
cm.toggleFloatingClient(Workspace.activeWindow); cm.toggleFloatingClient(Workspace.activeWindow);
} };
public readonly columnMoveLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly columnMoveLeft = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumnLeft(column); grid.moveColumnLeft(column);
} };
public readonly columnMoveRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly columnMoveRight = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumnRight(column); grid.moveColumnRight(column);
} };
public readonly columnMoveStart = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly columnMoveStart = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumn(column, null); grid.moveColumn(column, null);
} };
public readonly columnMoveEnd = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly columnMoveEnd = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.moveColumn(column, grid.getLastColumn()); grid.moveColumn(column, grid.getLastColumn());
} };
public readonly columnToggleStacked = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly columnToggleStacked = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
column.toggleStacked(); column.toggleStacked();
} };
public readonly columnWidthIncrease = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly columnWidthIncrease = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
this.config.columnResizer.increaseWidth(column); this.config.columnResizer.increaseWidth(column);
} };
public readonly columnWidthDecrease = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly columnWidthDecrease = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
this.config.columnResizer.decreaseWidth(column); this.config.columnResizer.decreaseWidth(column);
} };
public readonly cyclePresetWidths = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { 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()); const nextWidth = this.config.presetWidths.next(column.getWidth(), column.getMinWidth(), column.getMaxWidth());
column.setWidth(nextWidth, true); column.setWidth(nextWidth, true);
} };
public readonly cyclePresetWidthsReverse = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
const nextWidth = this.config.presetWidths.prev(column.getWidth(), column.getMinWidth(), column.getMaxWidth());
column.setWidth(nextWidth, true);
};
public readonly columnsWidthEqualize = (cm: ClientManager, dm: DesktopManager) => { public readonly columnsWidthEqualize = (cm: ClientManager, dm: DesktopManager) => {
const desktop = dm.getCurrentDesktop(); const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const visibleRange = desktop.getCurrentVisibleRange(); const visibleRange = desktop.getCurrentVisibleRange();
const visibleColumns = Array.from(desktop.grid.getVisibleColumns(visibleRange, true)); const visibleColumns = Array.from(desktop.grid.getVisibleColumns(visibleRange, true));
@@ -202,15 +218,15 @@ class Actions {
); );
visibleColumns.forEach((column, index) => column.setWidth(widths[index], true)); visibleColumns.forEach((column, index) => column.setWidth(widths[index], true));
desktop.scrollCenterRange(Desktop.RangeImpl.fromRanges( desktop.scrollCenterRange(Range.fromRanges(
visibleColumns[0], visibleColumns[0],
visibleColumns[visibleColumns.length - 1], visibleColumns[visibleColumns.length - 1],
)); ));
} };
public readonly columnsSqueezeLeft = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => { public readonly columnsSqueezeLeft = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => {
const visibleRange = grid.desktop.getCurrentVisibleRange(); const visibleRange = grid.desktop.getCurrentVisibleRange();
if (!focusedColumn.isVisible(visibleRange, true)) { if (!Range.contains(visibleRange, focusedColumn)) {
return; return;
} }
@@ -233,11 +249,11 @@ class Actions {
break; // don't scroll past the currently focused column break; // don't scroll past the currently focused column
} }
} }
} };
public readonly columnsSqueezeRight = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => { public readonly columnsSqueezeRight = (cm: ClientManager, dm: DesktopManager, window: Window, focusedColumn: Column, grid: Grid) => {
const visibleRange = grid.desktop.getCurrentVisibleRange(); const visibleRange = grid.desktop.getCurrentVisibleRange();
if (!focusedColumn.isVisible(visibleRange, true)) { if (!Range.contains(visibleRange, focusedColumn)) {
return; return;
} }
@@ -260,7 +276,7 @@ class Actions {
break; // don't scroll past the currently focused column break; // don't scroll past the currently focused column
} }
} }
} };
private readonly squeezeColumns = (columns: Column[]) => { private readonly squeezeColumns = (columns: Column[]) => {
const firstColumn = columns[0]; const firstColumn = columns[0];
@@ -279,47 +295,66 @@ class Actions {
const widths = fillSpace(availableSpace - gapsWidth, columnConstraints); const widths = fillSpace(availableSpace - gapsWidth, columnConstraints);
columns.forEach((column, index) => column.setWidth(widths[index], true)); columns.forEach((column, index) => column.setWidth(widths[index], true));
desktop.scrollCenterRange(Desktop.RangeImpl.fromRanges(firstColumn, lastColumn)); desktop.scrollCenterRange(Range.fromRanges(firstColumn, lastColumn));
return true; return true;
} };
public readonly gridScrollLeft = (cm: ClientManager, dm: DesktopManager) => { public readonly gridScrollLeft = (cm: ClientManager, dm: DesktopManager) => {
this.gridScroll(dm, -this.config.manualScrollStep); this.gridScroll(dm, -this.config.manualScrollStep);
} };
public readonly gridScrollRight = (cm: ClientManager, dm: DesktopManager) => { public readonly gridScrollRight = (cm: ClientManager, dm: DesktopManager) => {
this.gridScroll(dm, this.config.manualScrollStep); this.gridScroll(dm, this.config.manualScrollStep);
} };
private readonly gridScroll = (desktopManager: DesktopManager, amount: number) => { private readonly gridScroll = (desktopManager: DesktopManager, amount: number) => {
const grid = desktopManager.getCurrentDesktop().grid; const desktop = desktopManager.getCurrentDesktop();
grid.desktop.adjustScroll(amount, false); if (desktop !== undefined) {
} desktop.adjustScroll(amount, false);
}
};
public readonly gridScrollStart = (cm: ClientManager, dm: DesktopManager) => { public readonly gridScrollStart = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid; const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const firstColumn = grid.getFirstColumn(); const firstColumn = grid.getFirstColumn();
if (firstColumn === null) { if (firstColumn === null) {
return; return;
} }
grid.desktop.scrollToColumn(firstColumn); grid.desktop.scrollToColumn(firstColumn, false);
} };
public readonly gridScrollEnd = (cm: ClientManager, dm: DesktopManager) => { public readonly gridScrollEnd = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid; const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const lastColumn = grid.getLastColumn(); const lastColumn = grid.getLastColumn();
if (lastColumn === null) { if (lastColumn === null) {
return; return;
} }
grid.desktop.scrollToColumn(lastColumn); grid.desktop.scrollToColumn(lastColumn, false);
} };
public readonly gridScrollFocused = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly gridScrollFocused = (cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
grid.desktop.scrollCenterRange(column); const scrollAmount = Range.minus(column, grid.desktop.getCurrentVisibleRange());
} if (scrollAmount !== 0) {
grid.desktop.adjustScroll(scrollAmount, true);
} else {
grid.desktop.scrollToColumn(column, true);
}
};
public readonly gridScrollLeftColumn = (cm: ClientManager, dm: DesktopManager) => { public readonly gridScrollLeftColumn = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid; const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const column = grid.getLeftmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true); const column = grid.getLeftmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true);
if (column === null) { if (column === null) {
return; return;
@@ -330,11 +365,15 @@ class Actions {
return; return;
} }
grid.desktop.scrollToColumn(leftColumn); grid.desktop.scrollToColumn(leftColumn, false);
} };
public readonly gridScrollRightColumn = (cm: ClientManager, dm: DesktopManager) => { public readonly gridScrollRightColumn = (cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid; const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const column = grid.getRightmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true); const column = grid.getRightmostVisibleColumn(grid.desktop.getCurrentVisibleRange(), true);
if (column === null) { if (column === null) {
return; return;
@@ -345,20 +384,24 @@ class Actions {
return; return;
} }
grid.desktop.scrollToColumn(rightColumn); grid.desktop.scrollToColumn(rightColumn, false);
} };
public readonly screenSwitch = (cm: ClientManager, dm: DesktopManager) => { public readonly screenSwitch = (cm: ClientManager, dm: DesktopManager) => {
dm.selectScreen(Workspace.activeScreen); dm.selectScreen(Workspace.activeScreen);
} };
public readonly focus = (columnIndex: number, cm: ClientManager, dm: DesktopManager) => { public readonly focus = (columnIndex: number, cm: ClientManager, dm: DesktopManager) => {
const grid = dm.getCurrentDesktop().grid; const desktop = dm.getCurrentDesktop();
if (desktop === undefined) {
return;
}
const grid = desktop.grid;
const targetColumn = grid.getColumnAtIndex(columnIndex); const targetColumn = grid.getColumnAtIndex(columnIndex);
if (targetColumn === null) { if (targetColumn === null) {
return; return;
} }
targetColumn.focus(); targetColumn.getWindowToFocus().focus();
}; };
public readonly windowMoveToColumn = (columnIndex: number, cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => { public readonly windowMoveToColumn = (columnIndex: number, cm: ClientManager, dm: DesktopManager, window: Window, column: Column, grid: Grid) => {
@@ -366,7 +409,7 @@ class Actions {
if (targetColumn === null) { if (targetColumn === null) {
return; return;
} }
window.moveToColumn(targetColumn, true); window.moveToColumn(targetColumn, true, FocusPassing.Type.None);
grid.desktop.autoAdjustScroll(); grid.desktop.autoAdjustScroll();
}; };
@@ -387,7 +430,11 @@ class Actions {
if (kwinDesktop === undefined) { if (kwinDesktop === undefined) {
return; return;
} }
const newGrid = dm.getDesktopInCurrentActivity(kwinDesktop).grid; const newDesktop = dm.getDesktopInCurrentActivity(kwinDesktop);
if (newDesktop === undefined) {
return;
}
const newGrid = newDesktop.grid;
if (newGrid === null || newGrid === oldGrid) { if (newGrid === null || newGrid === oldGrid) {
return; return;
} }
@@ -399,7 +446,11 @@ class Actions {
if (kwinDesktop === undefined) { if (kwinDesktop === undefined) {
return; return;
} }
const newGrid = dm.getDesktopInCurrentActivity(kwinDesktop).grid; const newDesktop = dm.getDesktopInCurrentActivity(kwinDesktop);
if (newDesktop === undefined) {
return;
}
const newGrid = newDesktop.grid;
if (newGrid === null || newGrid === oldGrid) { if (newGrid === null || newGrid === oldGrid) {
return; return;
} }
@@ -408,14 +459,17 @@ class Actions {
} }
namespace Actions { namespace Actions {
export type Config = { export interface Config {
manualScrollStep: number; manualScrollStep: number;
presetWidths: { next: (currentWidth: number, minWidth: number, maxWidth: number) => number }; presetWidths: {
next: (currentWidth: number, minWidth: number, maxWidth: number) => number;
prev: (currentWidth: number, minWidth: number, maxWidth: number) => number
};
columnResizer: ColumnResizer; columnResizer: ColumnResizer;
}; }
export type ColumnResizer = { export interface ColumnResizer {
increaseWidth(column: Column): void; increaseWidth(column: Column): void;
decreaseWidth(column: Column): void; decreaseWidth(column: Column): void;
}; }
} }

View File

@@ -106,7 +106,7 @@ function getKeyBindings(world: World, actions: Actions): KeyBinding[] {
{ {
name: "column-toggle-stacked", name: "column-toggle-stacked",
description: "Toggle stacked layout for focused column", description: "Toggle stacked layout for focused column",
comment: "One window in the column visible, others shaded; not supported on Wayland", comment: "Only the active window visible",
defaultKeySequence: "Meta+X", defaultKeySequence: "Meta+X",
action: () => world.doIfTiledFocused(actions.columnToggleStacked), action: () => world.doIfTiledFocused(actions.columnToggleStacked),
}, },
@@ -152,6 +152,12 @@ function getKeyBindings(world: World, actions: Actions): KeyBinding[] {
defaultKeySequence: "Meta+R", defaultKeySequence: "Meta+R",
action: () => world.doIfTiledFocused(actions.cyclePresetWidths), action: () => world.doIfTiledFocused(actions.cyclePresetWidths),
}, },
{
name: "cycle-preset-widths-reverse",
description: "Cycle through preset column widths in reverse",
defaultKeySequence: "Meta+Shift+R",
action: () => world.doIfTiledFocused(actions.cyclePresetWidthsReverse),
},
{ {
name: "columns-width-equalize", name: "columns-width-equalize",
description: "Equalize widths of visible columns", description: "Equalize widths of visible columns",

View File

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

View File

@@ -21,7 +21,7 @@ class Column {
if (targetGrid === this.grid) { if (targetGrid === this.grid) {
this.grid.moveColumn(this, leftColumn); this.grid.moveColumn(this, leftColumn);
} else { } else {
this.grid.onColumnRemoved(this, false); this.grid.onColumnRemoved(this, this.isFocused() ? FocusPassing.Type.Immediate : FocusPassing.Type.None);
this.grid = targetGrid; this.grid = targetGrid;
targetGrid.onColumnAdded(this, leftColumn); targetGrid.onColumnAdded(this, leftColumn);
for (const window of this.windows.iterator()) { for (const window of this.windows.iterator()) {
@@ -195,58 +195,48 @@ class Column {
return this.focusTaker; return this.focusTaker;
} }
public focus() { public getWindowToFocus() {
const window = this.getFocusTaker() ?? this.windows.getFirst(); return this.getFocusTaker() ?? this.windows.getFirst()!;
if (window === null) { }
return;
public isFocused() {
const lastFocusedWindow = this.grid.getLastFocusedWindow();
if (lastFocusedWindow === null) {
return false;
} }
window.focus(); return lastFocusedWindow.column === this && lastFocusedWindow.isFocused();
} }
public arrange(x: number, visibleRange: Range, forceOpaque: boolean) { public arrange(x: number, visibleRange: Range, forceOpaque: boolean) {
if (this.grid.config.offScreenOpacity < 1.0 && !forceOpaque) { if (this.grid.config.offScreenOpacity < 1.0 && !forceOpaque) {
const opacity = this.isVisible(visibleRange, true) ? 100 : this.grid.config.offScreenOpacity; const opacity = Range.contains(visibleRange, this) ? 100 : this.grid.config.offScreenOpacity;
for (const window of this.windows.iterator()) { for (const window of this.windows.iterator()) {
window.client.kwinClient.opacity = opacity; window.client.kwinClient.opacity = opacity;
} }
} }
if (this.stacked && this.windows.length() >= 2 && this.canStack()) { if (this.stacked && this.windows.length() >= 2) {
this.arrangeStacked(x); this.arrangeStacked(x);
return; return;
} }
let y = this.grid.desktop.tilingArea.y; let y = this.grid.desktop.tilingArea.y;
for (const window of this.windows.iterator()) { for (const window of this.windows.iterator()) {
window.client.setShade(false);
window.arrange(x, y, this.width, window.height); window.arrange(x, y, this.width, window.height);
y += window.height + this.grid.config.gapsInnerVertical; y += window.height + this.grid.config.gapsInnerVertical;
} }
} }
public arrangeStacked(x: number) { public arrangeStacked(x: number) {
const expandedWindow = this.getFocusTaker(); const nWindows = this.windows.length();
let collapsedHeight; const windowWidth = this.width - (nWindows - 1) * this.grid.config.stackOffsetX;
for (const window of this.windows.iterator()) { const windowHeight = this.grid.desktop.tilingArea.height - (nWindows - 1) * this.grid.config.stackOffsetY;
if (window === expandedWindow) {
window.client.setShade(false);
} else {
window.client.setShade(true);
collapsedHeight = window.client.kwinClient.frameGeometry.height;
}
}
const nCollapsed = this.getWindowCount() - 1; let windowX = x;
const expandedHeight = this.grid.desktop.tilingArea.height - nCollapsed * (collapsedHeight! + this.grid.config.gapsInnerVertical); let windowY = this.grid.desktop.tilingArea.y;
let y = this.grid.desktop.tilingArea.y;
for (const window of this.windows.iterator()) { for (const window of this.windows.iterator()) {
if (window === expandedWindow) { window.arrange(windowX, windowY, windowWidth, windowHeight);
window.arrange(x, y, this.width, expandedHeight); windowX += this.grid.config.stackOffsetX;
y += expandedHeight; windowY += this.grid.config.stackOffsetY;
} else {
window.arrange(x, y, this.width, window.height);
y += collapsedHeight!;
}
y += this.grid.config.gapsInnerVertical;
} }
} }
@@ -258,25 +248,6 @@ class Column {
this.grid.desktop.onLayoutChanged(); this.grid.desktop.onLayoutChanged();
} }
private canStack() {
for (const window of this.windows.iterator()) {
if (!window.client.kwinClient.shadeable) {
return false;
}
}
return true;
}
public isVisible(visibleRange: Desktop.Range, fullyVisible: boolean) {
if (fullyVisible) {
return this.getLeft() >= visibleRange.getLeft() &&
this.getRight() <= visibleRange.getRight();
} else {
return this.getRight() + this.grid.config.gapsInnerHorizontal > visibleRange.getLeft() &&
this.getLeft() - this.grid.config.gapsInnerHorizontal < visibleRange.getRight();
}
}
public onWindowAdded(window: Window, bottom: boolean) { public onWindowAdded(window: Window, bottom: boolean) {
if (bottom) { if (bottom) {
this.windows.insertEnd(window); this.windows.insertEnd(window);
@@ -298,7 +269,7 @@ class Column {
this.grid.desktop.onLayoutChanged(); this.grid.desktop.onLayoutChanged();
} }
public onWindowRemoved(window: Window, passFocus: boolean) { public onWindowRemoved(window: Window, passFocus: FocusPassing.Type) {
const lastWindow = this.windows.length() === 1; const lastWindow = this.windows.length() === 1;
const windowToFocus = this.getAboveWindow(window) ?? this.getBelowWindow(window); const windowToFocus = this.getAboveWindow(window) ?? this.getBelowWindow(window);
@@ -313,8 +284,15 @@ class Column {
this.destroy(passFocus); this.destroy(passFocus);
} else { } else {
this.resizeWindows(); this.resizeWindows();
if (passFocus && windowToFocus !== null) { if (windowToFocus !== null) {
windowToFocus.focus(); switch (passFocus) {
case FocusPassing.Type.Immediate:
windowToFocus.focus();
break;
case FocusPassing.Type.OnUnfocus:
this.grid.focusPasser.request(windowToFocus.client.kwinClient);
break;
}
} }
} }
@@ -322,18 +300,18 @@ class Column {
} }
public onWindowFocused(window: Window) { public onWindowFocused(window: Window) {
this.grid.onColumnFocused(this); this.grid.onColumnFocused(this, window);
this.focusTaker = window; this.focusTaker = window;
} }
public restoreToTiled() { public restoreToTiled(focusedWindow: Window) {
const lastFocusedWindow = this.getFocusTaker(); const lastFocusedWindow = this.getFocusTaker();
if (lastFocusedWindow !== null) { if (lastFocusedWindow !== null && lastFocusedWindow !== focusedWindow) {
lastFocusedWindow.restoreToTiled(); lastFocusedWindow.restoreToTiled();
} }
} }
private destroy(passFocus: boolean) { private destroy(passFocus: FocusPassing.Type) {
this.grid.onColumnRemoved(this, passFocus); this.grid.onColumnRemoved(this, passFocus);
} }
} }

View File

@@ -1,6 +1,7 @@
class Desktop { class Desktop {
public readonly grid: Grid; public readonly grid: Grid;
private scrollX: number; private scrollX: number;
private gestureScrollXInitial: number | null;
private dirty: boolean; private dirty: boolean;
private dirtyScroll: boolean; private dirtyScroll: boolean;
private dirtyPins: boolean; private dirtyPins: boolean;
@@ -13,12 +14,14 @@ class Desktop {
private readonly config: Desktop.Config, private readonly config: Desktop.Config,
private readonly getScreen: () => Output, private readonly getScreen: () => Output,
layoutConfig: LayoutConfig, layoutConfig: LayoutConfig,
focusPasser: FocusPassing.Passer,
) { ) {
this.scrollX = 0; this.scrollX = 0;
this.gestureScrollXInitial = null;
this.dirty = true; this.dirty = true;
this.dirtyScroll = true; this.dirtyScroll = true;
this.dirtyPins = true; this.dirtyPins = true;
this.grid = new Grid(this, layoutConfig); this.grid = new Grid(this, layoutConfig, focusPasser);
this.clientArea = Desktop.getClientArea(this.getScreen(), kwinDesktop); this.clientArea = Desktop.getClientArea(this.getScreen(), kwinDesktop);
this.tilingArea = Desktop.getTilingArea(this.clientArea, kwinDesktop, pinManager, config); this.tilingArea = Desktop.getTilingArea(this.clientArea, kwinDesktop, pinManager, config);
} }
@@ -52,10 +55,10 @@ class Desktop {
top, top,
right - left, right - left,
bottom - top, bottom - top,
) );
} }
public scrollIntoView(range: Desktop.Range) { public scrollIntoView(range: Range) {
const left = range.getLeft(); const left = range.getLeft();
const right = range.getRight(); const right = range.getRight();
const initialVisibleRange = this.getCurrentVisibleRange(); const initialVisibleRange = this.getCurrentVisibleRange();
@@ -72,10 +75,9 @@ class Desktop {
this.setScroll(targetScrollX, false); this.setScroll(targetScrollX, false);
} }
public scrollCenterRange(range: Desktop.Range) { public scrollCenterRange(range: Range) {
const windowCenter = range.getLeft() + range.getWidth() / 2; const scrollAmount = Range.minus(range, this.getCurrentVisibleRange());
const screenCenter = this.scrollX + this.tilingArea.width / 2; this.adjustScroll(scrollAmount, true);
this.adjustScroll(Math.round(windowCenter - screenCenter), true);
} }
public scrollCenterVisible(focusedColumn: Column) { public scrollCenterVisible(focusedColumn: Column) {
@@ -91,17 +93,17 @@ class Desktop {
return; return;
} }
this.scrollToColumn(focusedColumn); this.scrollToColumn(focusedColumn, false);
} }
public scrollToColumn(column: Column) { public scrollToColumn(column: Column, force: boolean) {
if (this.dirtyScroll || !column.isVisible(this.getCurrentVisibleRange(), true)) { if (force || this.dirtyScroll || !Range.contains(this.getCurrentVisibleRange(), column)) {
this.config.scroller.scrollToColumn(this, column); this.config.scroller.scrollToColumn(this, column);
} }
} }
private getVisibleRange(scrollX: number) { private getVisibleRange(scrollX: number) {
return new Desktop.RangeImpl(scrollX, this.tilingArea.width); return Range.create(scrollX, this.tilingArea.width);
} }
public getCurrentVisibleRange() { public getCurrentVisibleRange() {
@@ -125,6 +127,24 @@ class Desktop {
this.setScroll(this.scrollX + dx, force); this.setScroll(this.scrollX + dx, force);
} }
public gestureScroll(amount: number) {
if (!this.config.gestureScroll) {
return;
}
if (this.gestureScrollXInitial === null) {
this.gestureScrollXInitial = this.scrollX;
}
if (this.config.gestureScrollInvert) {
amount = -amount;
}
this.setScroll(this.gestureScrollXInitial + this.config.gestureScrollStep * amount, false);
}
public gestureScrollFinish() {
this.gestureScrollXInitial = null;
}
public arrange() { public arrange() {
// TODO (optimization): only arrange visible windows // TODO (optimization): only arrange visible windows
this.updateArea(); this.updateArea();
@@ -135,6 +155,10 @@ class Desktop {
this.dirty = false; this.dirty = false;
} }
public forceArrange() {
this.dirty = true;
}
public onLayoutChanged() { public onLayoutChanged() {
this.dirty = true; this.dirty = true;
this.dirtyScroll = true; this.dirtyScroll = true;
@@ -152,47 +176,16 @@ class Desktop {
} }
namespace Desktop { namespace Desktop {
export type Config = { export interface Config {
marginTop: number; marginTop: number;
marginBottom: number; marginBottom: number;
marginLeft: number; marginLeft: number;
marginRight: number; marginRight: number;
gestureScroll: boolean;
gestureScrollInvert: boolean;
gestureScrollStep: number;
scroller: Desktop.Scroller; scroller: Desktop.Scroller;
clamper: Desktop.Clamper; clamper: Desktop.Clamper;
};
export type Range = {
getLeft(): number;
getRight(): number;
getWidth(): number;
};
export class RangeImpl {
private readonly x: number;
private readonly width: number;
constructor(x: number, width: number) {
this.x = x;
this.width = width;
}
public getLeft() {
return this.x;
}
public getRight() {
return this.x + this.width;
}
public getWidth() {
return this.width;
}
public static fromRanges(leftRange: Range, rightRange: Range) {
const left = leftRange.getLeft();
const right = rightRange.getRight();
return new RangeImpl(left, right - left);
}
} }
export class ColumnRange { export class ColumnRange {
@@ -206,7 +199,7 @@ namespace Desktop {
this.width = initialColumn.getWidth(); this.width = initialColumn.getWidth();
} }
public addNeighbors(visibleRange: Desktop.Range, gap: number) { public addNeighbors(visibleRange: Range, gap: number) {
const grid = this.left.grid; const grid = this.left.grid;
const columnRange = this; const columnRange = this;
@@ -267,11 +260,11 @@ namespace Desktop {
} }
} }
export type Scroller = { export interface Scroller {
scrollToColumn(desktop: Desktop, column: Column): void; scrollToColumn(desktop: Desktop, column: Column): void;
}; }
export type Clamper = { export interface Clamper {
clampScrollX(desktop: Desktop, x: number): number; clampScrollX(desktop: Desktop, x: number): number;
}; }
} }

View File

@@ -1,17 +1,17 @@
import Range = Desktop.Range;
class Grid { class Grid {
public readonly desktop: Desktop; public readonly desktop: Desktop;
public readonly config: LayoutConfig; public readonly config: LayoutConfig;
public readonly focusPasser: FocusPassing.Passer;
private readonly columns: LinkedList<Column>; private readonly columns: LinkedList<Column>;
private lastFocusedColumn: Column|null; private lastFocusedColumn: Column|null;
private width: number; private width: number;
private userResize: boolean; // is any part of the grid being resized by the user private userResize: boolean; // is any part of the grid being resized by the user
private readonly userResizeFinishedDelayer: Delayer; private readonly userResizeFinishedDelayer: Delayer;
constructor(desktop: Desktop, config: LayoutConfig) { constructor(desktop: Desktop, config: LayoutConfig, focusPasser: FocusPassing.Passer) {
this.desktop = desktop; this.desktop = desktop;
this.config = config; this.config = config;
this.focusPasser = focusPasser;
this.columns = new LinkedList(); this.columns = new LinkedList();
this.lastFocusedColumn = null; this.lastFocusedColumn = null;
this.width = 0; this.width = 0;
@@ -106,19 +106,19 @@ class Grid {
this.width = x - this.config.gapsInnerHorizontal; this.width = x - this.config.gapsInnerHorizontal;
} }
public getLeftmostVisibleColumn(visibleRange: Desktop.Range, fullyVisible: boolean) { public getLeftmostVisibleColumn(visibleRange: Range, fullyVisible: boolean) {
for (const column of this.columns.iterator()) { for (const column of this.columns.iterator()) {
if (column.isVisible(visibleRange, fullyVisible)) { if (Range.contains(visibleRange, column)) {
return column; return column;
} }
} }
return null; return null;
} }
public getRightmostVisibleColumn(visibleRange: Desktop.Range, fullyVisible: boolean) { public getRightmostVisibleColumn(visibleRange: Range, fullyVisible: boolean) {
let last = null; let last = null;
for (const column of this.columns.iterator()) { for (const column of this.columns.iterator()) {
if (column.isVisible(visibleRange, fullyVisible)) { if (Range.contains(visibleRange, column)) {
last = column; last = column;
} else if (last !== null) { } else if (last !== null) {
break; break;
@@ -127,29 +127,14 @@ class Grid {
return last; return last;
} }
public *getVisibleColumns(visibleRange: Desktop.Range, fullyVisible: boolean) { public *getVisibleColumns(visibleRange: Range, fullyVisible: boolean) {
for (const column of this.columns.iterator()) { for (const column of this.columns.iterator()) {
if (column.isVisible(visibleRange, fullyVisible)) { if (Range.contains(visibleRange, column)) {
yield column; yield column;
} }
} }
} }
public getVisibleColumnsWidth(visibleRange: Desktop.Range, fullyVisible: boolean) {
let width = 0;
let nVisible = 0;
for (const column of this.getVisibleColumns(visibleRange, fullyVisible)) {
width += column.getWidth();
nVisible++;
}
if (nVisible > 0) {
width += (nVisible-1) * this.config.gapsInnerHorizontal;
}
return width;
}
public arrange(x: number, visibleRange: Range) { public arrange(x: number, visibleRange: Range) {
for (const column of this.columns.iterator()) { for (const column of this.columns.iterator()) {
column.arrange(x, visibleRange, this.userResize); column.arrange(x, visibleRange, this.userResize);
@@ -173,7 +158,7 @@ class Grid {
this.desktop.autoAdjustScroll(); this.desktop.autoAdjustScroll();
} }
public onColumnRemoved(column: Column, passFocus: boolean) { public onColumnRemoved(column: Column, passFocus: FocusPassing.Type) {
const isLastColumn = this.columns.length() === 1; const isLastColumn = this.columns.length() === 1;
const rightColumn = this.getRightColumn(column); const rightColumn = this.getRightColumn(column);
const columnToFocus = isLastColumn ? null : this.getLeftColumn(column) ?? rightColumn; const columnToFocus = isLastColumn ? null : this.getLeftColumn(column) ?? rightColumn;
@@ -185,11 +170,17 @@ class Grid {
this.columnsSetX(rightColumn); this.columnsSetX(rightColumn);
this.desktop.onLayoutChanged(); this.desktop.onLayoutChanged();
if (passFocus && columnToFocus !== null) { if (columnToFocus !== null) {
columnToFocus.focus(); switch (passFocus) {
} else { case FocusPassing.Type.Immediate:
this.desktop.autoAdjustScroll(); columnToFocus.getWindowToFocus().focus();
return;
case FocusPassing.Type.OnUnfocus:
this.focusPasser.request(columnToFocus.getWindowToFocus().client.kwinClient);
return;
}
} }
this.desktop.autoAdjustScroll();
} }
public onColumnWidthChanged(column: Column) { public onColumnWidthChanged(column: Column) {
@@ -201,13 +192,13 @@ class Grid {
} }
} }
public onColumnFocused(column: Column) { public onColumnFocused(column: Column, window: Window) {
const lastFocusedColumn = this.getLastFocusedColumn(); const lastFocusedColumn = this.getLastFocusedColumn();
if (lastFocusedColumn !== null && lastFocusedColumn !== column) { if (lastFocusedColumn !== null) {
lastFocusedColumn.restoreToTiled(); lastFocusedColumn.restoreToTiled(window);
} }
this.lastFocusedColumn = column; this.lastFocusedColumn = column;
this.desktop.scrollToColumn(column); this.desktop.scrollToColumn(column, false);
} }
public onScreenSizeChanged() { public onScreenSizeChanged() {

View File

@@ -1,6 +1,8 @@
type LayoutConfig = { interface LayoutConfig {
gapsInnerHorizontal: number; gapsInnerHorizontal: number;
gapsInnerVertical: number; gapsInnerVertical: number;
stackOffsetX: number;
stackOffsetY: number;
offScreenOpacity: number; offScreenOpacity: number;
stackColumnsByDefault: boolean; stackColumnsByDefault: boolean;
resizeNeighborColumn: boolean; resizeNeighborColumn: boolean;
@@ -9,4 +11,4 @@ type LayoutConfig = {
tiledKeepBelow: boolean; tiledKeepBelow: boolean;
maximizedKeepAbove: boolean; maximizedKeepAbove: boolean;
untileOnDrag: boolean; untileOnDrag: boolean;
}; }

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

@@ -0,0 +1,47 @@
interface Range {
getLeft(): number;
getRight(): number;
getWidth(): number;
}
namespace Range {
export function create(x: number, width: number) {
return new Basic(x, width);
}
export function fromRanges(leftRange: Range, rightRange: Range) {
const left = leftRange.getLeft();
const right = rightRange.getRight();
return new Basic(left, right - left);
}
export function contains(parent: Range, child: Range) {
return child.getLeft() >= parent.getLeft() &&
child.getRight() <= parent.getRight();
}
export function minus(a: Range, b: Range) {
const aCenter = a.getLeft() + a.getWidth() / 2;
const bCenter = b.getLeft() + b.getWidth() / 2;
return Math.round(aCenter - bCenter);
}
class Basic {
constructor(
private readonly x: number,
private readonly width: number,
) {}
public getLeft() {
return this.x;
}
public getRight() {
return this.x + this.width;
}
public getWidth() {
return this.width;
}
}
}

View File

@@ -8,20 +8,26 @@ class Window {
constructor(client: ClientWrapper, column: Column) { constructor(client: ClientWrapper, column: Column) {
this.client = client; this.client = client;
this.height = client.kwinClient.frameGeometry.height; this.height = client.kwinClient.frameGeometry.height;
let maximizedMode = this.client.getMaximizedMode();
if (maximizedMode === undefined) {
maximizedMode = MaximizedMode.Unmaximized; // defaulting to unmaximized, as this is set in Tiled.prepareClientForTiling
}
this.focusedState = { this.focusedState = {
fullScreen: false, fullScreen: this.client.kwinClient.fullScreen,
maximizedMode: MaximizedMode.Unmaximized, maximizedMode: maximizedMode,
}; };
this.skipArrange = false;
this.skipArrange = this.client.kwinClient.fullScreen || maximizedMode !== MaximizedMode.Unmaximized;
this.column = column; this.column = column;
column.onWindowAdded(this, true); column.onWindowAdded(this, true);
} }
public moveToColumn(targetColumn: Column, bottom: boolean) { public moveToColumn(targetColumn: Column, bottom: boolean, passFocus: FocusPassing.Type) {
if (targetColumn === this.column) { if (targetColumn === this.column) {
return; return;
} }
this.column.onWindowRemoved(this, false); this.column.onWindowRemoved(this, passFocus);
this.column = targetColumn; this.column = targetColumn;
targetColumn.onWindowAdded(this, bottom); targetColumn.onWindowAdded(this, bottom);
} }
@@ -54,11 +60,12 @@ class Window {
} }
public focus() { public focus() {
if (this.client.isShaded()) {
// workaround for KWin deactivating clients when unshading immediately after activation
this.client.setShade(false);
}
this.client.focus(); this.client.focus();
const kwinClient = this.client.kwinClient;
if (!this.isFocused()) {
// in some situations focus assignment just doesn't work, let's do it later
this.column.grid.focusPasser.request(kwinClient);
}
} }
public isFocused() { public isFocused() {
@@ -66,6 +73,14 @@ class Window {
} }
public onFocused() { public onFocused() {
if (this.column.grid.config.reMaximize && (
this.focusedState.maximizedMode !== MaximizedMode.Unmaximized ||
this.focusedState.fullScreen
)) {
// We need to maximize/fullscreen this window, but we can't do it here.
// We need to do it in `arrange` to ensure it happens after placement.
this.column.grid.desktop.forceArrange();
}
this.column.onWindowFocused(this); this.column.onWindowFocused(this);
} }
@@ -75,7 +90,6 @@ class Window {
} }
this.client.setFullScreen(false); this.client.setFullScreen(false);
this.client.setMaximize(false, false); this.client.setMaximize(false, false);
this.column.grid.desktop.onLayoutChanged();
} }
public onMaximizedChanged(maximizedMode: MaximizedMode) { public onMaximizedChanged(maximizedMode: MaximizedMode) {
@@ -113,14 +127,14 @@ class Window {
this.column.grid.desktop.onLayoutChanged(); this.column.grid.desktop.onLayoutChanged();
} }
public destroy(passFocus: boolean) { public destroy(passFocus: FocusPassing.Type) {
this.column.onWindowRemoved(this, passFocus); this.column.onWindowRemoved(this, passFocus);
} }
} }
namespace Window { namespace Window {
export type State = { export interface State {
fullScreen: boolean; fullScreen: boolean;
maximizedMode: MaximizedMode; maximizedMode: MaximizedMode;
}; }
} }

View File

@@ -0,0 +1,30 @@
class DesktopFilter {
private readonly desktopRegex: RegExp | null; // null means all desktops
constructor(desktopsConfig: string) {
this.desktopRegex = DesktopFilter.parseDesktopConfig(desktopsConfig);
}
public shouldWorkOnDesktop(kwinDesktop: KwinDesktop): boolean {
if (this.desktopRegex === null) {
return true; // Work on all desktops
}
return this.desktopRegex.test(kwinDesktop.name);
}
private static parseDesktopConfig(config: string): RegExp | null {
const trimmed = config.trim();
if (trimmed.length === 0) {
return null; // Empty config means work on all desktops
}
try {
return new RegExp(`^${trimmed}$`);
} catch (e) {
notificationInvalidTiledDesktops.sendEvent();
log(`Invalid regex pattern in tiledDesktops config: ${trimmed}. Working on all desktops.`);
return null; // Invalid regex means work on all desktops as fallback
}
}
}

View File

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

View File

@@ -16,6 +16,8 @@ class WindowRuleEnforcer {
!kwinClient.transient && !kwinClient.transient &&
kwinClient.managed && kwinClient.managed &&
kwinClient.pid > -1 && kwinClient.pid > -1 &&
!kwinClient.fullScreen &&
!Clients.isFullScreenGeometry(kwinClient) &&
!this.preferFloating.matches(kwinClient) !this.preferFloating.matches(kwinClient)
); );
} }
@@ -50,7 +52,7 @@ class WindowRuleEnforcer {
const ruleCaption = WindowRuleEnforcer.parseRegex(windowRule.caption); const ruleCaption = WindowRuleEnforcer.parseRegex(windowRule.caption);
const ruleString = ClientMatcher.getRuleString( const ruleString = ClientMatcher.getRuleString(
WindowRuleEnforcer.wrapParens(ruleClass), WindowRuleEnforcer.wrapParens(ruleClass),
WindowRuleEnforcer.wrapParens(ruleCaption) WindowRuleEnforcer.wrapParens(ruleCaption),
); );
(windowRule.tile ? tileRegexes : floatRegexes).push(ruleString); (windowRule.tile ? tileRegexes : floatRegexes).push(ruleString);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,27 @@
function initWorkspaceSignalHandlers(world: World) { function initWorkspaceSignalHandlers(world: World, focusPasser: FocusPassing.Passer) {
const manager = new SignalManager(); const manager = new SignalManager();
manager.connect(Workspace.windowAdded, (kwinClient: KwinClient) => { manager.connect(Workspace.windowAdded, (kwinClient: KwinClient) => {
world.do((clientManager, desktopManager) => { world.do((clientManager, desktopManager) => {
clientManager.addClient(kwinClient) clientManager.addClient(kwinClient);
}); });
}); });
manager.connect(Workspace.windowRemoved, (kwinClient: KwinClient) => { manager.connect(Workspace.windowRemoved, (kwinClient: KwinClient) => {
world.do((clientManager, desktopManager) => { world.do((clientManager, desktopManager) => {
clientManager.removeClient(kwinClient, true); clientManager.removeClient(kwinClient, FocusPassing.Type.Immediate);
}); });
}); });
manager.connect(Workspace.windowActivated, (kwinClient: KwinClient|null) => { manager.connect(Workspace.windowActivated, (kwinClient: KwinClient|null) => {
if (kwinClient === null) { if (kwinClient === null) {
return; focusPasser.activate();
} else {
focusPasser.clearIfDifferent(kwinClient);
world.do((clientManager, desktopManager) => {
clientManager.onClientFocused(kwinClient);
});
} }
world.do((clientManager, desktopManager) => {
clientManager.onClientFocused(kwinClient);
});
}); });
manager.connect(Workspace.currentDesktopChanged, () => { manager.connect(Workspace.currentDesktopChanged, () => {

View File

@@ -31,18 +31,16 @@ class ClientManager {
console.assert(!this.hasClient(kwinClient)); console.assert(!this.hasClient(kwinClient));
let constructState: (client: ClientWrapper) => ClientState.State; let constructState: (client: ClientWrapper) => ClientState.State;
let desktop: Desktop | undefined;
if (kwinClient.dock) { if (kwinClient.dock) {
constructState = () => new ClientState.Docked(this.world, kwinClient); constructState = () => new ClientState.Docked(this.world, kwinClient);
} else if ( } else if (
Clients.canTileEver(kwinClient) && Clients.canTileEver(kwinClient) &&
!kwinClient.fullScreen && this.windowRuleEnforcer.shouldTile(kwinClient) &&
!Clients.isFullScreenGeometry(kwinClient) && (desktop = this.desktopManager.getDesktopForClient(kwinClient)) !== undefined
this.windowRuleEnforcer.shouldTile(kwinClient)
) { ) {
Clients.makeTileable(kwinClient); Clients.makeTileable(kwinClient);
console.assert(Clients.canTileNow(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); constructState = (client: ClientWrapper) => new ClientState.Tiled(this.world, client, desktop!.grid);
} else { } else {
constructState = (client: ClientWrapper) => new ClientState.Floating(this.world, client, this.config, false); constructState = (client: ClientWrapper) => new ClientState.Floating(this.world, client, this.config, false);
@@ -57,13 +55,16 @@ class ClientManager {
this.clientMap.set(kwinClient, client); this.clientMap.set(kwinClient, client);
} }
public removeClient(kwinClient: KwinClient, passFocus: boolean) { public removeClient(kwinClient: KwinClient, passFocus: FocusPassing.Type) {
console.assert(this.hasClient(kwinClient)); console.assert(this.hasClient(kwinClient));
const client = this.clientMap.get(kwinClient); const client = this.clientMap.get(kwinClient);
if (client === undefined) { if (client === undefined) {
return; return;
} }
client.destroy(passFocus && kwinClient === this.lastFocusedClient); if (kwinClient !== this.lastFocusedClient) {
passFocus = FocusPassing.Type.None;
}
client.destroy(passFocus);
this.clientMap.delete(kwinClient); this.clientMap.delete(kwinClient);
} }
@@ -86,9 +87,10 @@ class ClientManager {
return; return;
} }
if (client.stateManager.getState() instanceof ClientState.Tiled) { if (client.stateManager.getState() instanceof ClientState.Tiled) {
const passFocus = kwinClient === this.lastFocusedClient ? FocusPassing.Type.Immediate : FocusPassing.Type.None;
client.stateManager.setState( client.stateManager.setState(
() => new ClientState.TiledMinimized(this.world, client), () => new ClientState.TiledMinimized(this.world, client),
kwinClient === this.lastFocusedClient, passFocus,
); );
} }
} }
@@ -97,14 +99,14 @@ class ClientManager {
if (client.stateManager.getState() instanceof ClientState.Tiled) { if (client.stateManager.getState() instanceof ClientState.Tiled) {
return; return;
} }
client.stateManager.setState(() => new ClientState.Tiled(this.world, client, grid), false); client.stateManager.setState(() => new ClientState.Tiled(this.world, client, grid), FocusPassing.Type.None);
} }
public floatClient(client: ClientWrapper) { public floatClient(client: ClientWrapper) {
if (client.stateManager.getState() instanceof ClientState.Floating) { if (client.stateManager.getState() instanceof ClientState.Floating) {
return; return;
} }
client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), false); client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), FocusPassing.Type.None);
} }
public tileKwinClient(kwinClient: KwinClient, grid: Grid) { public tileKwinClient(kwinClient: KwinClient, grid: Grid) {
@@ -133,7 +135,7 @@ class ClientManager {
kwinClient.tile = null; kwinClient.tile = null;
return; return;
} }
client.stateManager.setState(() => new ClientState.Pinned(this.world, this.pinManager, this.desktopManager, kwinClient, this.config), false); client.stateManager.setState(() => new ClientState.Pinned(this.world, this.pinManager, this.desktopManager, kwinClient, this.config), FocusPassing.Type.None);
this.pinManager.addClient(kwinClient); this.pinManager.addClient(kwinClient);
for (const desktop of this.desktopManager.getDesktopsForClient(kwinClient)) { for (const desktop of this.desktopManager.getDesktopsForClient(kwinClient)) {
desktop.onPinsChanged(); desktop.onPinsChanged();
@@ -146,7 +148,7 @@ class ClientManager {
return; return;
} }
console.assert(client.stateManager.getState() instanceof ClientState.Pinned); console.assert(client.stateManager.getState() instanceof ClientState.Pinned);
client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, false), false); client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, false), FocusPassing.Type.None);
this.pinManager.removeClient(kwinClient); this.pinManager.removeClient(kwinClient);
for (const desktop of this.desktopManager.getDesktopsForClient(kwinClient)) { for (const desktop of this.desktopManager.getDesktopsForClient(kwinClient)) {
desktop.onPinsChanged(); desktop.onPinsChanged();
@@ -166,9 +168,9 @@ class ClientManager {
if (desktop === undefined) { if (desktop === undefined) {
return; return;
} }
client.stateManager.setState(() => new ClientState.Tiled(this.world, client, desktop.grid), false); client.stateManager.setState(() => new ClientState.Tiled(this.world, client, desktop.grid), FocusPassing.Type.None);
} else if (clientState instanceof ClientState.Tiled) { } else if (clientState instanceof ClientState.Tiled) {
client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), false); client.stateManager.setState(() => new ClientState.Floating(this.world, client, this.config, true), FocusPassing.Type.None);
} }
} }
@@ -206,7 +208,7 @@ class ClientManager {
private removeAllClients() { private removeAllClients() {
for (const kwinClient of Array.from(this.clientMap.keys())) { for (const kwinClient of Array.from(this.clientMap.keys())) {
this.removeClient(kwinClient, false); this.removeClient(kwinClient, FocusPassing.Type.None);
} }
} }
@@ -216,7 +218,7 @@ class ClientManager {
} }
namespace ClientManager { namespace ClientManager {
export type Config = { export interface Config {
floatingKeepAbove: boolean; floatingKeepAbove: boolean;
}; }
} }

View File

@@ -78,6 +78,7 @@ class ClientWrapper {
public setMaximize(horizontally: boolean, vertically: boolean) { public setMaximize(horizontally: boolean, vertically: boolean) {
if (!this.kwinClient.maximizable) { if (!this.kwinClient.maximizable) {
this.maximizedMode = MaximizedMode.Unmaximized;
return; return;
} }
@@ -108,16 +109,6 @@ class ClientWrapper {
}); });
} }
public setShade(shade: boolean) {
this.manipulatingGeometry.do(() => {
this.kwinClient.shade = shade;
});
}
public isShaded() {
return this.kwinClient.shade;
}
public getMaximizedMode() { public getMaximizedMode() {
return this.maximizedMode; return this.maximizedMode;
} }
@@ -159,7 +150,7 @@ class ClientWrapper {
} }
} }
public destroy(passFocus: boolean) { public destroy(passFocus: FocusPassing.Type) {
this.stateManager.destroy(passFocus); this.stateManager.destroy(passFocus);
this.signalManager.destroy(); this.signalManager.destroy();
if (this.rulesSignalManager !== null) { if (this.rulesSignalManager !== null) {

View File

@@ -5,8 +5,8 @@ namespace Clients {
]; ];
export function canTileEver(kwinClient: KwinClient) { export function canTileEver(kwinClient: KwinClient) {
return kwinClient.moveable && const shapeable = (kwinClient.moveable && kwinClient.resizeable) || kwinClient.fullScreen; // full-screen windows may become shapeable after exiting full-screen mode
kwinClient.resizeable && return shapeable &&
!kwinClient.popupWindow && !kwinClient.popupWindow &&
!prohibitedClasses.includes(kwinClient.resourceClass); !prohibitedClasses.includes(kwinClient.resourceClass);
} }
@@ -32,23 +32,23 @@ namespace Clients {
export function getKwinDesktopApprox(kwinClient: KwinClient) { export function getKwinDesktopApprox(kwinClient: KwinClient) {
switch (kwinClient.desktops.length) { switch (kwinClient.desktops.length) {
case 0: case 0:
return Workspace.currentDesktop;
case 1:
return kwinClient.desktops[0];
default:
if (kwinClient.desktops.includes(Workspace.currentDesktop)) {
return Workspace.currentDesktop; return Workspace.currentDesktop;
case 1: } else {
return kwinClient.desktops[0]; return kwinClient.desktops[0];
default: }
if (kwinClient.desktops.includes(Workspace.currentDesktop)) {
return Workspace.currentDesktop;
} else {
return kwinClient.desktops[0];
}
} }
} }
export function isFullScreenGeometry(kwinClient: KwinClient) { export function isFullScreenGeometry(kwinClient: KwinClient) {
const fullScreenArea = Workspace.clientArea(ClientAreaOption.FullScreenArea, kwinClient.output, getKwinDesktopApprox(kwinClient)); const fullScreenArea = Workspace.clientArea(ClientAreaOption.FullScreenArea, kwinClient.output, getKwinDesktopApprox(kwinClient));
return kwinClient.clientGeometry.width === fullScreenArea.width && return kwinClient.clientGeometry.width >= fullScreenArea.width &&
kwinClient.clientGeometry.height === fullScreenArea.height; kwinClient.clientGeometry.height >= fullScreenArea.height;
} }
export function isOnVirtualDesktop(kwinClient: KwinClient, kwinDesktop: KwinDesktop) { export function isOnVirtualDesktop(kwinClient: KwinClient, kwinDesktop: KwinDesktop) {

View File

@@ -7,9 +7,9 @@ class DesktopManager {
constructor( constructor(
private readonly pinManager: PinManager, private readonly pinManager: PinManager,
private readonly config: Desktop.Config, private readonly config: Desktop.Config,
public readonly layoutConfig: LayoutConfig, private readonly layoutConfig: LayoutConfig,
currentActivity: string, private readonly focusPasser: FocusPassing.Passer,
currentDesktop: KwinDesktop, private readonly desktopFilter: DesktopFilter,
) { ) {
this.pinManager = pinManager; this.pinManager = pinManager;
this.config = config; this.config = config;
@@ -18,10 +18,12 @@ class DesktopManager {
this.selectedScreen = Workspace.activeScreen; this.selectedScreen = Workspace.activeScreen;
this.kwinActivities = new Set(Workspace.activities); this.kwinActivities = new Set(Workspace.activities);
this.kwinDesktops = new Set(Workspace.desktops); this.kwinDesktops = new Set(Workspace.desktops);
this.addDesktop(currentActivity, currentDesktop);
} }
public getDesktop(activity: string, kwinDesktop: KwinDesktop) { public getDesktop(activity: string, kwinDesktop: KwinDesktop) {
if (!this.desktopFilter.shouldWorkOnDesktop(kwinDesktop)) {
return undefined;
}
const desktopKey = DesktopManager.getDesktopKey(activity, kwinDesktop); const desktopKey = DesktopManager.getDesktopKey(activity, kwinDesktop);
const desktop = this.desktops.get(desktopKey); const desktop = this.desktops.get(desktopKey);
if (desktop !== undefined) { if (desktop !== undefined) {
@@ -54,6 +56,7 @@ class DesktopManager {
this.config, this.config,
() => this.selectedScreen, () => this.selectedScreen,
this.layoutConfig, this.layoutConfig,
this.focusPasser,
); );
this.desktops.set(desktopKey, desktop); this.desktops.set(desktopKey, desktop);
return desktop; return desktop;

View File

@@ -0,0 +1,51 @@
namespace FocusPassing {
export const enum Type {
None,
Immediate,
OnUnfocus,
}
export class Passer {
private currentRequest: Request | null = null;
public request(target: KwinClient) {
this.currentRequest = new Request(target, Date.now());
}
public clear() {
this.currentRequest = null;
}
public clearIfDifferent(kwinClient: KwinClient) {
if (this.currentRequest !== null && this.currentRequest.target !== kwinClient) {
this.clear();
}
}
public activate() {
if (this.currentRequest === null) {
return;
}
if (this.currentRequest.isExpired()) {
this.clear();
return;
}
Workspace.activeWindow = this.currentRequest.target;
}
}
class Request {
private static readonly validMs = 200;
constructor(
public readonly target: KwinClient,
private readonly time: number,
) {}
public isExpired() {
return Date.now() - this.time > Request.validMs;
}
}
}

View File

@@ -5,12 +5,16 @@ class World {
private readonly workspaceSignalManager: SignalManager; private readonly workspaceSignalManager: SignalManager;
private readonly shortcutActions: ShortcutAction[]; private readonly shortcutActions: ShortcutAction[];
private readonly screenResizedDelayer: Delayer; private readonly screenResizedDelayer: Delayer;
private readonly cursorFollowsFocus: boolean;
constructor(config: Config) { constructor(config: Config) {
this.workspaceSignalManager = initWorkspaceSignalHandlers(this); const focusPasser = new FocusPassing.Passer();
this.workspaceSignalManager = initWorkspaceSignalHandlers(this, focusPasser);
this.cursorFollowsFocus = config.cursorFollowsFocus;
let presetWidths = { let presetWidths = {
next: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth, next: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth,
prev: (currentWidth: number, minWidth: number, maxWidth: number) => currentWidth,
getWidths: (minWidth: number, maxWidth: number): number[] => [], getWidths: (minWidth: number, maxWidth: number): number[] => [],
}; };
try { try {
@@ -39,6 +43,8 @@ class World {
const layoutConfig = { const layoutConfig = {
gapsInnerHorizontal: config.gapsInnerHorizontal, gapsInnerHorizontal: config.gapsInnerHorizontal,
gapsInnerVertical: config.gapsInnerVertical, gapsInnerVertical: config.gapsInnerVertical,
stackOffsetX: config.stackOffsetX,
stackOffsetY: config.stackOffsetY,
offScreenOpacity: config.offScreenOpacity / 100.0, offScreenOpacity: config.offScreenOpacity / 100.0,
stackColumnsByDefault: config.stackColumnsByDefault, stackColumnsByDefault: config.stackColumnsByDefault,
resizeNeighborColumn: config.resizeNeighborColumn, resizeNeighborColumn: config.resizeNeighborColumn,
@@ -58,10 +64,13 @@ class World {
marginRight: config.gapsOuterRight, marginRight: config.gapsOuterRight,
scroller: World.createScroller(config), scroller: World.createScroller(config),
clamper: config.scrollingLazy ? new EdgeClamper() : new CenterClamper(), clamper: config.scrollingLazy ? new EdgeClamper() : new CenterClamper(),
gestureScroll: config.gestureScroll,
gestureScrollInvert: config.gestureScrollInvert,
gestureScrollStep: config.gestureScrollStep,
}, },
layoutConfig, layoutConfig,
Workspace.currentActivity, focusPasser,
Workspace.currentDesktop, new DesktopFilter(config.tiledDesktops),
); );
this.clientManager = new ClientManager(config, this, this.desktopManager, this.pinManager); this.clientManager = new ClientManager(config, this, this.desktopManager, this.pinManager);
this.addExistingClients(); this.addExistingClients();
@@ -82,15 +91,32 @@ class World {
} }
private addExistingClients() { private addExistingClients() {
const kwinClients = Workspace.windows; for (const kwinClient of Workspace.windows) {
for (let i = 0; i < kwinClients.length; i++) {
const kwinClient = kwinClients[i];
this.clientManager.addClient(kwinClient); this.clientManager.addClient(kwinClient);
} }
} }
private update() { private update() {
this.desktopManager.getCurrentDesktop().arrange(); const currentDesktop = this.desktopManager.getCurrentDesktop();
if (currentDesktop !== undefined) {
currentDesktop.arrange();
this.moveCursorToFocus();
}
}
private moveCursorToFocus() {
if (this.cursorFollowsFocus && Workspace.activeWindow !== null) {
// Only move cursor for tiled windows
const tiledWindow = this.clientManager.findTiledWindow(Workspace.activeWindow);
if (tiledWindow === null) {
return;
}
const cursorAlreadyInFocus = rectContainsPoint(Workspace.activeWindow.frameGeometry, Workspace.cursorPos);
if (cursorAlreadyInFocus) {
return;
}
moveCursorToFocus.call();
}
} }
public do(f: (clientManager: ClientManager, desktopManager: DesktopManager) => void) { public do(f: (clientManager: ClientManager, desktopManager: DesktopManager) => void) {
@@ -121,6 +147,24 @@ class World {
this.doIfTiled(Workspace.activeWindow, f); this.doIfTiled(Workspace.activeWindow, f);
} }
public gestureScroll(amount: number) {
this.do((clientManager, desktopManager) => {
const currentDesktop = desktopManager.getCurrentDesktop();
if (currentDesktop !== undefined) {
currentDesktop.gestureScroll(amount);
}
});
}
public gestureScrollFinish() {
this.do((clientManager, desktopManager) => {
const currentDesktop = desktopManager.getCurrentDesktop();
if (currentDesktop !== undefined) {
currentDesktop.gestureScrollFinish();
}
});
}
public destroy() { public destroy() {
this.workspaceSignalManager.destroy(); this.workspaceSignalManager.destroy();
for (const shortcutAction of this.shortcutActions) { for (const shortcutAction of this.shortcutActions) {

View File

@@ -9,7 +9,7 @@ namespace ClientState {
world.onScreenResized(); world.onScreenResized();
} }
public destroy(passFocus: boolean) { public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy(); this.signalManager.destroy();
this.world.onScreenResized(); this.world.onScreenResized();
} }

View File

@@ -16,11 +16,8 @@ namespace ClientState {
this.signalManager = Floating.initSignalManager(world, client.kwinClient); this.signalManager = Floating.initSignalManager(world, client.kwinClient);
} }
public destroy(passFocus: boolean) { public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy(); this.signalManager.destroy();
if (this.config.floatingKeepAbove) {
this.client.kwinClient.keepAbove = false;
}
} }
// TODO: move to `Tiled.restoreClientAfterTiling` // TODO: move to `Tiled.restoreClientAfterTiling`

View File

@@ -6,7 +6,7 @@ namespace ClientState {
this.state = initialState; this.state = initialState;
} }
public setState(constructNewState: () => State, passFocus: boolean) { public setState(constructNewState: () => State, passFocus: FocusPassing.Type) {
this.state.destroy(passFocus); this.state.destroy(passFocus);
this.state = constructNewState(); this.state = constructNewState();
} }
@@ -15,12 +15,12 @@ namespace ClientState {
return this.state; return this.state;
} }
public destroy(passFocus: boolean) { public destroy(passFocus: FocusPassing.Type) {
this.state.destroy(passFocus); this.state.destroy(passFocus);
} }
} }
export type State = { export interface State {
destroy(passFocus: boolean): void; destroy(passFocus: FocusPassing.Type): void;
}; }
} }

View File

@@ -17,11 +17,8 @@ namespace ClientState {
this.signalManager = Pinned.initSignalManager(world, pinManager, kwinClient); this.signalManager = Pinned.initSignalManager(world, pinManager, kwinClient);
} }
public destroy(passFocus: boolean) { public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy(); this.signalManager.destroy();
if (this.config.floatingKeepAbove) {
this.kwinClient.keepAbove = true;
}
this.pinManager.removeClient(this.kwinClient); this.pinManager.removeClient(this.kwinClient);
for (const desktop of this.desktopManager.getDesktopsForClient(this.kwinClient)) { for (const desktop of this.desktopManager.getDesktopsForClient(this.kwinClient)) {
desktop.onPinsChanged(); desktop.onPinsChanged();

View File

@@ -16,7 +16,7 @@ namespace ClientState {
this.signalManager = Tiled.initSignalManager(world, window, grid.config); this.signalManager = Tiled.initSignalManager(world, window, grid.config);
} }
public destroy(passFocus: boolean) { public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy(); this.signalManager.destroy();
const window = this.window; const window = this.window;
@@ -54,7 +54,7 @@ namespace ClientState {
} }
Tiled.moveWindowToGrid(window, desktop.grid); Tiled.moveWindowToGrid(window, desktop.grid);
}); });
}) });
manager.connect(kwinClient.minimizedChanged, () => { manager.connect(kwinClient.minimizedChanged, () => {
console.assert(kwinClient.minimized); console.assert(kwinClient.minimized);
@@ -69,6 +69,7 @@ namespace ClientState {
}); });
}); });
let moving = false;
let resizing = false; let resizing = false;
let resizeStartWidth = 0; let resizeStartWidth = 0;
let resizeNeighbor: { column: Column, startWidth: number } | undefined; let resizeNeighbor: { column: Column, startWidth: number } | undefined;
@@ -78,6 +79,8 @@ namespace ClientState {
world.do((clientManager, desktopManager) => { world.do((clientManager, desktopManager) => {
clientManager.floatClient(client); clientManager.floatClient(client);
}); });
} else {
moving = true;
} }
return; return;
} }
@@ -99,6 +102,10 @@ namespace ClientState {
}); });
manager.connect(kwinClient.interactiveMoveResizeFinished, () => { manager.connect(kwinClient.interactiveMoveResizeFinished, () => {
if (moving) {
moving = false;
world.do(() => window.column.grid.desktop.onLayoutChanged()); // move the dragged window back to its position
}
if (resizing) { if (resizing) {
resizing = false; resizing = false;
resizeNeighbor = undefined; resizeNeighbor = undefined;
@@ -106,7 +113,7 @@ namespace ClientState {
} }
}); });
let externalFrameGeometryChangedRateLimiter = new RateLimiter(4, Tiled.maxExternalFrameGeometryChangedIntervalMs); const externalFrameGeometryChangedRateLimiter = new RateLimiter(4, Tiled.maxExternalFrameGeometryChangedIntervalMs);
manager.connect(kwinClient.frameGeometryChanged, (oldGeometry: QmlRect) => { manager.connect(kwinClient.frameGeometryChanged, (oldGeometry: QmlRect) => {
// on Wayland, this fires after `tileChanged` // on Wayland, this fires after `tileChanged`
if (kwinClient.tile !== null) { if (kwinClient.tile !== null) {
@@ -160,7 +167,15 @@ namespace ClientState {
}); });
manager.connect(kwinClient.fullScreenChanged, () => { manager.connect(kwinClient.fullScreenChanged, () => {
world.do(() => window.onFullScreenChanged(kwinClient.fullScreen)); world.do((clientManager, desktopManager) => {
// some clients only turn out to be untileable after exiting full-screen mode
if (!Clients.canTileEver(kwinClient)) {
clientManager.floatClient(client);
return;
}
window.onFullScreenChanged(kwinClient.fullScreen);
});
}); });
manager.connect(kwinClient.tileChanged, () => { manager.connect(kwinClient.tileChanged, () => {
@@ -194,17 +209,26 @@ namespace ClientState {
} }
const newColumn = new Column(grid, grid.getLastFocusedColumn() ?? grid.getLastColumn()); const newColumn = new Column(grid, grid.getLastFocusedColumn() ?? grid.getLastColumn());
window.moveToColumn(newColumn, true); const passFocus = window.isFocused() ? FocusPassing.Type.OnUnfocus : FocusPassing.Type.None;
window.moveToColumn(newColumn, true, passFocus);
} }
private static prepareClientForTiling(client: ClientWrapper, config: LayoutConfig) { private static prepareClientForTiling(client: ClientWrapper, config: LayoutConfig) {
if (config.skipSwitcher) { if (config.skipSwitcher) {
client.kwinClient.skipSwitcher = true; client.kwinClient.skipSwitcher = true;
} }
if (config.tiledKeepBelow) {
client.kwinClient.keepBelow = true; if (client.kwinClient.fullScreen) {
if (config.maximizedKeepAbove) {
client.kwinClient.keepAbove = true;
}
} else {
if (config.tiledKeepBelow) {
client.kwinClient.keepBelow = true;
}
client.kwinClient.keepAbove = false;
} }
client.setFullScreen(false);
if (client.kwinClient.tile !== null) { if (client.kwinClient.tile !== null) {
client.setMaximize(false, true); // disable quick tile mode client.setMaximize(false, true); // disable quick tile mode
} }
@@ -221,7 +245,6 @@ namespace ClientState {
if (config.offScreenOpacity < 1.0) { if (config.offScreenOpacity < 1.0) {
client.kwinClient.opacity = 1.0; client.kwinClient.opacity = 1.0;
} }
client.setShade(false);
client.setFullScreen(false); client.setFullScreen(false);
if (client.kwinClient.tile === null) { if (client.kwinClient.tile === null) {
client.setMaximize(false, false); client.setMaximize(false, false);
@@ -231,8 +254,8 @@ namespace ClientState {
} }
namespace Tiled { namespace Tiled {
export type WindowState = { export interface WindowState {
skipSwitcher: boolean; skipSwitcher: boolean;
}; }
} }
} }

View File

@@ -6,7 +6,7 @@ namespace ClientState {
this.signalManager = TiledMinimized.initSignalManager(world, client); this.signalManager = TiledMinimized.initSignalManager(world, client);
} }
public destroy(passFocus: boolean) { public destroy(passFocus: FocusPassing.Type) {
this.signalManager.destroy(); this.signalManager.destroy();
} }

View File

@@ -0,0 +1,43 @@
tests.register("Center focused", 1, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const [client0, client1, client2] = workspaceMock.createClientsWithWidths(300, 152, 300);
world.do((clientManager, desktopManager) => {
Assert.assert(clientManager.hasClient(client0));
Assert.assert(clientManager.hasClient(client1));
Assert.assert(clientManager.hasClient(client2));
});
Assert.assert(workspaceMock.activeWindow === client2);
Assert.columnsFillTilingArea([client0, client1, client2]);
// center client2
qtMock.fireShortcut("karousel-grid-scroll-focused");
Assert.centered(config, tilingArea, client2);
Assert.fullyVisible(client1.frameGeometry);
Assert.fullyVisible(client2.frameGeometry);
// undo center client2
qtMock.fireShortcut("karousel-grid-scroll-focused");
Assert.columnsFillTilingArea([client0, client1, client2]);
// center client2
qtMock.fireShortcut("karousel-grid-scroll-focused");
Assert.centered(config, tilingArea, client2);
Assert.fullyVisible(client1.frameGeometry);
Assert.fullyVisible(client2.frameGeometry);
// focus client1 (no scrolling should occur)
qtMock.fireShortcut("karousel-focus-left");
Assert.centered(config, tilingArea, client2, { message: "No scrolling should have occured" });
Assert.fullyVisible(client1.frameGeometry);
Assert.fullyVisible(client2.frameGeometry);
// center client1
qtMock.fireShortcut("karousel-grid-scroll-focused");
Assert.columnsFillTilingArea([client0, client1, client2]);
// undo center client1 (no scrolling should occur, because all clients are already visible and centered)
qtMock.fireShortcut("karousel-grid-scroll-focused");
Assert.columnsFillTilingArea([client0, client1, client2]);
});

View File

@@ -0,0 +1,82 @@
tests.register("Drag tiled window, untile", 10, () => {
const config = getDefaultConfig();
config.cursorFollowsFocus = true;
const { qtMock, workspaceMock, world } = init(config);
const [client1, client2] = workspaceMock.createClients(2);
const initialCursorPos = new MockQmlPoint(380, 20);
Assert.assert(rectContainsPoint(client1.frameGeometry, initialCursorPos), { message: "invalid test setup" });
workspaceMock.cursorPos = initialCursorPos.clone();
runOneOf(
() => { Workspace.activeWindow = client1; },
() => { qtMock.fireShortcut("karousel-focus-1"); },
);
Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
Assert.assert(!rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
Assert.assert(pointEquals(Workspace.cursorPos, initialCursorPos), { message: "Cursor should not have been moved because it was already within the focused client" });
runOneOf(
() => { Workspace.activeWindow = client2; },
() => { qtMock.fireShortcut("karousel-focus-2"); },
);
Assert.assert(!rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
Assert.assert(rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
runOneOf(
() => { Workspace.activeWindow = client1; },
() => { qtMock.fireShortcut("karousel-focus-1"); },
);
Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos));
Assert.assert(!rectContainsPoint(client2.frameGeometry, Workspace.cursorPos));
const lastCursorPos = workspaceMock.cursorPos.clone();
Workspace.activeWindow = null;
Assert.assert(pointEquals(Workspace.cursorPos, lastCursorPos), { message: "Cursor should not have been moved" });
});
tests.register("Cursor follows focus only on matched desktops", 1, () => {
// Test that cursor follow focus only works for windows on matched desktops (tiled windows)
const config = getDefaultConfig();
config.cursorFollowsFocus = true;
config.tiledDesktops = "^Desktop 1$"; // Only work on Desktop 1
const { workspaceMock, world } = init(config);
// Create a client on Desktop 1 (matched desktop) - should be tiled
const client1 = new MockKwinClient();
client1.desktops = [workspaceMock.desktops[0]]; // Desktop 1
workspaceMock.createWindows(client1);
// Create a client on Desktop 2 (non-matched desktop) - should be floating
const client2 = new MockKwinClient();
client2.desktops = [workspaceMock.desktops[1]]; // Desktop 2
workspaceMock.createWindows(client2);
// Set initial cursor position outside both windows
const initialCursorPos = new MockQmlPoint(10, 10);
workspaceMock.cursorPos = initialCursorPos.clone();
// Test 1: Focus client1 on matched desktop (Desktop 1) - cursor should move
workspaceMock.currentDesktop = workspaceMock.desktops[0]; // Switch to Desktop 1
Workspace.activeWindow = client1;
world.do(() => {});
Assert.assert(rectContainsPoint(client1.frameGeometry, Workspace.cursorPos),
{ message: "Cursor should have moved to tiled window on matched desktop" });
// Test 2: Switch to non-matched desktop (Desktop 2) and focus client2 - cursor should NOT move
workspaceMock.cursorPos = initialCursorPos.clone();
workspaceMock.currentDesktop = workspaceMock.desktops[1]; // Switch to Desktop 2
Workspace.activeWindow = client2;
world.do(() => {});
Assert.assert(pointEquals(Workspace.cursorPos, initialCursorPos),
{ message: "Cursor should NOT move on non-matched desktop" });
// Test 3: Even if we focus client1 (tiled) while on Desktop 2, cursor should NOT move
// because the current desktop is not matched
workspaceMock.cursorPos = initialCursorPos.clone();
workspaceMock.currentDesktop = workspaceMock.desktops[1]; // Stay on Desktop 2
Workspace.activeWindow = client1;
world.do(() => {});
Assert.assert(pointEquals(Workspace.cursorPos, initialCursorPos),
{ message: "Cursor should NOT move even for tiled window when current desktop is not matched" });
});

View File

@@ -0,0 +1,73 @@
tests.register("Desktop filtering", 1, () => {
// Test 1: Default config should work on all desktops
const config1 = getDefaultConfig();
const { workspaceMock: wm1, world: world1 } = init(config1);
const client1 = new MockKwinClient();
client1.desktops = [wm1.desktops[0]];
wm1.createWindows(client1);
world1.do((clientManager) => {
Assert.tiledClient(clientManager, client1, { message: "Client should be tiled on desktop1 with default config (*)" });
});
});
tests.register("Desktop filtering - specific desktop", 1, () => {
// Test 2: Specific desktop name - should work only on matching desktop
const config2 = getDefaultConfig();
config2.tiledDesktops = "^Desktop 1$";
const { workspaceMock: wm2, world: world2 } = init(config2);
const client1 = new MockKwinClient();
client1.desktops = [wm2.desktops[0]]; // Desktop 1
wm2.createWindows(client1);
world2.do((clientManager) => {
Assert.tiledClient(clientManager, client1, { message: "Client should be tiled on Desktop 1" });
});
wm2.removeWindow(client1);
const client2 = new MockKwinClient();
client2.desktops = [wm2.desktops[1]]; // Desktop 2
wm2.createWindows(client2);
world2.do((clientManager) => {
Assert.notTiledClient(clientManager, client2, { message: "Client should NOT be tiled on Desktop 2" });
});
});
tests.register("Desktop filtering - multiple desktops", 1, () => {
// Test 3: Multiple desktop names using regex alternation
const config3 = getDefaultConfig();
config3.tiledDesktops = "^Desktop [12]$";
const { workspaceMock: wm3, world: world3 } = init(config3);
const client1 = new MockKwinClient();
client1.desktops = [wm3.desktops[0]]; // Desktop 1
wm3.createWindows(client1);
world3.do((clientManager) => {
Assert.tiledClient(clientManager, client1, { message: "Client should be tiled on Desktop 1" });
});
wm3.removeWindow(client1);
const client2 = new MockKwinClient();
client2.desktops = [wm3.desktops[1]]; // Desktop 2
wm3.createWindows(client2);
world3.do((clientManager) => {
Assert.tiledClient(clientManager, client2, { message: "Client should be tiled on Desktop 2" });
});
});
tests.register("Desktop filtering - windows on multiple desktops", 1, () => {
// Test 4: Windows on multiple desktops should not be tiled (fallback to floating)
const config4 = getDefaultConfig();
config4.tiledDesktops = ".*";
const { workspaceMock: wm4, world: world4 } = init(config4);
const client1 = new MockKwinClient();
client1.desktops = [wm4.desktops[0], wm4.desktops[1]]; // Multiple desktops
wm4.createWindows(client1);
world4.do((clientManager) => {
Assert.notTiledClient(clientManager, client1, { message: "Client on multiple desktops should not be tiled" });
});
});

View File

@@ -0,0 +1,34 @@
tests.register("Drag tiled window, untile", 20, () => {
const config = getDefaultConfig();
config.untileOnDrag = true;
const { qtMock, workspaceMock, world } = init(config);
const clientManager = getClientManager(world);
const [client0, client1] = workspaceMock.createClients(2);
Assert.tiledClient(clientManager, client0);
Assert.tiledClient(clientManager, client1);
Assert.grid(config, tilingArea, 100, [[client0], [client1]], true);
workspaceMock.moveWindow(client0, new MockQmlPoint(10, 10));
Assert.notTiledClient(clientManager, client0);
Assert.tiledClient(clientManager, client1);
Assert.grid(config, tilingArea, 100, [[client1]], true);
});
tests.register("Drag tiled window, keep tiled", 20, () => {
const config = getDefaultConfig();
config.untileOnDrag = false;
const { qtMock, workspaceMock, world } = init(config);
const clientManager = getClientManager(world);
const [client0, client1] = workspaceMock.createClients(2);
Assert.tiledClient(clientManager, client0);
Assert.tiledClient(clientManager, client1);
Assert.grid(config, tilingArea, 100, [[client0], [client1]], true);
const move = new MockQmlPoint(10, 10);
workspaceMock.moveWindow(client0, move, move, move, move, move, move, move, move, move); // many moves in order to trigger externalFrameGeometryChangedRateLimiter
Assert.tiledClient(clientManager, client0);
Assert.tiledClient(clientManager, client1);
Assert.grid(config, tilingArea, 100, [[client0], [client1]], true);
});

View File

@@ -8,7 +8,7 @@ tests.register("External resize", 1, () => {
function getTiledFrame(width: number) { function getTiledFrame(width: number) {
return new MockQmlRect( return new MockQmlRect(
Math.round((screen.width - width) / 2), tilingArea.left + Math.round((tilingArea.width - width) / 2),
tilingArea.top, tilingArea.top,
width, width,
tilingArea.height, tilingArea.height,

View File

@@ -0,0 +1,10 @@
tests.register("Move and follow window to desktop", 20, () => {
// This tests the Kwin shortcuts for moving windows to adjacent desktops.
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const [client0, client1] = workspaceMock.createClients(2);
client1.moveAndFollowToDesktop(workspaceMock.desktops[1], workspaceMock);
Assert.equal(workspaceMock.activeWindow, client1);
});

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ tests.register("Preset Widths default", 1, () => {
function getRect(columnWidth: number) { function getRect(columnWidth: number) {
return new MockQmlRect( return new MockQmlRect(
(screen.width - columnWidth) / 2, tilingArea.left + (tilingArea.width - columnWidth) / 2,
tilingArea.top, tilingArea.top,
columnWidth, columnWidth,
tilingArea.height, tilingArea.height,
@@ -25,6 +25,12 @@ tests.register("Preset Widths default", 1, () => {
qtMock.fireShortcut("karousel-cycle-preset-widths"); qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth)); Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(maxWidth));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
}); });
tests.register("Preset Widths custom", 1, () => { tests.register("Preset Widths custom", 1, () => {
@@ -37,7 +43,7 @@ tests.register("Preset Widths custom", 1, () => {
function getRect(columnWidth: number) { function getRect(columnWidth: number) {
return new MockQmlRect( return new MockQmlRect(
(screen.width - columnWidth) / 2, tilingArea.left + (tilingArea.width - columnWidth) / 2,
tilingArea.top, tilingArea.top,
columnWidth, columnWidth,
tilingArea.height, tilingArea.height,
@@ -61,6 +67,15 @@ tests.register("Preset Widths custom", 1, () => {
qtMock.fireShortcut("karousel-cycle-preset-widths"); qtMock.fireShortcut("karousel-cycle-preset-widths");
Assert.equalRects(kwinClient.frameGeometry, getRect(250)); Assert.equalRects(kwinClient.frameGeometry, getRect(250));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(100));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(500));
qtMock.fireShortcut("karousel-cycle-preset-widths-reverse");
Assert.equalRects(kwinClient.frameGeometry, getRect(halfWidth));
}); });
tests.register("Preset Widths fill screen uniform", 1, () => { tests.register("Preset Widths fill screen uniform", 1, () => {

View File

@@ -0,0 +1,30 @@
tests.register("Stacked", 5, () => {
const config = getDefaultConfig();
const { qtMock, workspaceMock, world } = init(config);
const [leftTop, leftBottom, rightTop, rightBottom] = workspaceMock.createClients(4);
const grid = [[leftTop, leftBottom], [rightTop, rightBottom]];
workspaceMock.activeWindow = rightBottom;
qtMock.fireShortcut("karousel-window-move-left");
workspaceMock.activeWindow = leftBottom;
qtMock.fireShortcut("karousel-window-move-left");
Assert.grid(config, tilingArea, 100, grid, true);
qtMock.fireShortcut("karousel-column-toggle-stacked");
Assert.grid(config, tilingArea, 100, grid, true, [0]);
qtMock.fireShortcut("karousel-focus-up");
Assert.grid(config, tilingArea, 100, grid, true, [0]);
qtMock.fireShortcut("karousel-focus-down");
Assert.grid(config, tilingArea, 100, grid, true, [0]);
qtMock.fireShortcut("karousel-window-move-up");
Assert.grid(config, tilingArea, 100, [[leftBottom, leftTop], [rightTop, rightBottom]], true, [0]);
qtMock.fireShortcut("karousel-window-move-down");
Assert.grid(config, tilingArea, 100, grid, true, [0]);
qtMock.fireShortcut("karousel-column-toggle-stacked");
Assert.grid(config, tilingArea, 100, grid, true);
});

View File

@@ -0,0 +1,41 @@
tests.register("DesktopFilter", 1, () => {
const desktop1 = { __brand: "KwinDesktop" as const, id: "1", name: "Desktop 1" };
const desktop2 = { __brand: "KwinDesktop" as const, id: "2", name: "Work" };
const desktop3 = { __brand: "KwinDesktop" as const, id: "3", name: "Desktop 2" };
// Test 1: Empty config means all desktops
let filter = new DesktopFilter("");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Empty config should work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Empty config should work on desktop2" });
// Test 2: Whitespace only means all desktops
filter = new DesktopFilter(" \n \n ");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Whitespace only should work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Whitespace only should work on desktop2" });
// Test 3: Match all regex pattern
filter = new DesktopFilter(".*");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Regex '.*' should work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Regex '.*' should work on desktop2" });
// Test 4: Partial match without anchors
filter = new DesktopFilter("Work");
Assert.assert(!filter.shouldWorkOnDesktop(desktop1), { message: "Should not work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Should work on desktop2 containing 'Work'" });
// Test 5: Regex alternation for multiple desktops
filter = new DesktopFilter("Desktop 1|Work");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Should work on desktop1" });
Assert.assert(filter.shouldWorkOnDesktop(desktop2), { message: "Should work on desktop2" });
Assert.assert(!filter.shouldWorkOnDesktop(desktop3), { message: "Should not work on desktop3" });
// Test 6: Regex pattern with character class
filter = new DesktopFilter("Desktop [12]");
Assert.assert(filter.shouldWorkOnDesktop(desktop1), { message: "Should work on desktop1" });
Assert.assert(!filter.shouldWorkOnDesktop(desktop2), { message: "Should not work on desktop2" });
Assert.assert(filter.shouldWorkOnDesktop(desktop3), { message: "Should work on desktop3" });
// Test 7: Case-sensitive matching
filter = new DesktopFilter("work");
Assert.assert(!filter.shouldWorkOnDesktop(desktop2), { message: "Should not work on desktop2 (case mismatch)" });
});

View File

@@ -1,4 +1,7 @@
tests.register("WindowRuleEnforcer", 1, () => { tests.register("WindowRuleEnforcer", 1, () => {
screen = new MockQmlRect(0, 0, 800, 600);
Workspace = new MockWorkspace();
const testCases = [ const testCases = [
{ tiledByDefault: true, resourceClass: "unknown", caption: "anything", shouldTile: true }, { tiledByDefault: true, resourceClass: "unknown", caption: "anything", shouldTile: true },
{ tiledByDefault: false, resourceClass: "unknown", caption: "anything", shouldTile: false }, { tiledByDefault: false, resourceClass: "unknown", caption: "anything", shouldTile: false },
@@ -25,6 +28,7 @@ tests.register("WindowRuleEnforcer", 1, () => {
return { return {
normalWindow: normalWindow, normalWindow: normalWindow,
transient: false, transient: false,
clientGeometry: new MockQmlRect(0, 0, 200, 200),
managed: true, managed: true,
pid: 100, pid: 100,
moveable: true, moveable: true,

View File

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

View File

@@ -4,6 +4,7 @@ tests.register("Clients.canTileEver", 1, () => {
{ clientProperties: { resourceClass: "app", caption: "Title", moveable: false }, tileable: false }, { clientProperties: { resourceClass: "app", caption: "Title", moveable: false }, tileable: false },
{ clientProperties: { resourceClass: "app", caption: "Caption", resizeable: 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: "app", caption: "Caption", normalWindow: false, popupWindow: true }, tileable: false },
{ clientProperties: { resourceClass: "app", caption: "Caption", moveable: false, resizeable: false, fullScreen: true }, tileable: true },
{ clientProperties: { resourceClass: "ksmserver-logout-greeter", caption: "Caption" }, tileable: false }, { clientProperties: { resourceClass: "ksmserver-logout-greeter", caption: "Caption" }, tileable: false },
{ clientProperties: { resourceClass: "xwaylandvideobridge", caption: "" }, tileable: false }, { clientProperties: { resourceClass: "xwaylandvideobridge", caption: "" }, tileable: false },
]; ];
@@ -24,6 +25,7 @@ tests.register("Clients.canTileEver", 1, () => {
pid: 100, pid: 100,
moveable: true, moveable: true,
resizeable: true, resizeable: true,
fullScreen: false,
popupWindow: false, popupWindow: false,
minimized: false, minimized: false,
desktops: [1], desktops: [1],

View File

@@ -1,5 +1,5 @@
namespace Assert { namespace Assert {
type Options = { interface Options {
message?: string, message?: string,
skip?: number, skip?: number,
} }
@@ -125,38 +125,111 @@ namespace Assert {
export function grid( export function grid(
config: Config, config: Config,
screen: QmlRect, tilingArea: QmlRect,
columnWidths: number[] | number,
grid: KwinClient[][], grid: KwinClient[][],
centered: boolean,
stackedColumns: number[] = [],
{ message, skip=0 }: Options = {}, { message, skip=0 }: Options = {},
) { ) {
// assumes uniformly sized windows within columns of width 100 const nColumns = grid.length;
function getGridWidth() {
function getColumnsWidth() {
if (columnWidths instanceof Array) {
let columnsWidth = 0;
for (const columnWidth of columnWidths) {
columnsWidth += columnWidth;
}
return columnsWidth;
} else {
return nColumns * columnWidths;
}
}
const gapsWidth = (nColumns-1) * config.gapsInnerHorizontal;
return getColumnsWidth() + gapsWidth;
}
function getColumnWidth(column: number) {
if (columnWidths instanceof Array) {
return columnWidths[column];
} else {
return columnWidths;
}
}
const gridWidth = getGridWidth();
const startX = centered ?
tilingArea.x + (tilingArea.width - gridWidth) / 2 :
grid[0][0].frameGeometry.x;
function getColumnX(column: number) {
if (columnWidths instanceof Array) {
let x = startX;
for (let i = 0; i < column; i++) {
x += columnWidths[i] + config.gapsInnerHorizontal;
}
return x;
} else {
return startX + column * (columnWidths + config.gapsInnerHorizontal);
}
}
// assumes uniformly sized windows within columns of uniform width
function getRectInGrid(column: number, window: number, nColumns: number, nWindows: number) { function getRectInGrid(column: number, window: number, nColumns: number, nWindows: number) {
const columnHeight = screen.height - config.gapsOuterTop - config.gapsOuterBottom; const columnWidth = getColumnWidth(column);
const columnsWidth = nColumns * 100 + (nColumns-1) * config.gapsInnerHorizontal; const windowHeight = (tilingArea.height - config.gapsInnerVertical * (nWindows-1)) / nWindows;
const windowHeight = (columnHeight - config.gapsInnerVertical * (nWindows-1)) / nWindows;
return new MockQmlRect( return new MockQmlRect(
screen.x + column * (100 + config.gapsInnerHorizontal) + (screen.width-columnsWidth) / 2, getColumnX(column),
screen.y + config.gapsOuterTop + (windowHeight + config.gapsInnerVertical) * window, tilingArea.y + (windowHeight + config.gapsInnerVertical) * window,
100, columnWidth,
(columnHeight - config.gapsInnerVertical * (nWindows-1)) / nWindows, (tilingArea.height - config.gapsInnerVertical * (nWindows-1)) / nWindows,
);
}
function getRectInGridStacked(column: number, window: number, nColumns: number, nWindows: number) {
const columnWidth = getColumnWidth(column);
return new MockQmlRect(
getColumnX(column) + window * config.stackOffsetX,
tilingArea.y + window * config.stackOffsetY,
columnWidth - (nWindows-1) * config.stackOffsetX,
tilingArea.height - (nWindows-1) * config.stackOffsetY,
); );
} }
const nColumns = grid.length;
for (let iColumn = 0; iColumn < nColumns; iColumn++) { for (let iColumn = 0; iColumn < nColumns; iColumn++) {
const column = grid[iColumn]; const column = grid[iColumn];
const stacked = stackedColumns.includes(iColumn);
const getRect = stacked ? getRectInGridStacked : getRectInGrid;
const nWindows = column.length; const nWindows = column.length;
for (let iWindow = 0; iWindow < nWindows; iWindow++) { for (let iWindow = 0; iWindow < nWindows; iWindow++) {
const window = column[iWindow]; const window = column[iWindow];
equalRects( equalRects(
window.frameGeometry, window.frameGeometry,
getRectInGrid(iColumn, iWindow, nColumns, nWindows), getRect(iColumn, iWindow, nColumns, nWindows),
{ message: appendMessage(`window ${iWindow}, column ${iColumn}`, message), skip: skip+1 }, { message: appendMessage(`column ${iColumn}, window ${iWindow}`, message), skip: skip+1 },
); );
} }
} }
} }
export function centered(
config: Config,
tilingArea: QmlRect,
client:KwinClient,
{ message, skip=0 }: Options = {},
) {
grid(
config,
tilingArea,
client.frameGeometry.width,
[[client]],
true,
[],
{ message: appendMessage("Window not centered", message), skip: skip+1 },
);
}
export function fullyVisible( export function fullyVisible(
rect: QmlRect, rect: QmlRect,
{ message, skip=0 }: Options = {}, { message, skip=0 }: Options = {},
@@ -197,4 +270,26 @@ namespace Assert {
} }
equal(columns[columns.length-1].frameGeometry.right, tilingArea.right, options); equal(columns[columns.length-1].frameGeometry.right, tilingArea.right, options);
} }
export function tiledClient(
clientManager: ClientManager,
client: KwinClient,
{ message, skip=0 }: Options = {},
) {
assert(
clientManager.findTiledWindow(client) !== null,
{ message: message, skip: skip+1 },
);
}
export function notTiledClient(
clientManager: ClientManager,
client: KwinClient,
{ message, skip=0 }: Options = {},
) {
assert(
clientManager.findTiledWindow(client) === null,
{ message: message, skip: skip+1 },
);
}
} }

View File

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

View File

@@ -2,8 +2,10 @@ let Qt: Qt;
let KWin: KWin; let KWin: KWin;
let Workspace: Workspace; let Workspace: Workspace;
let qmlBase: QmlObject; let qmlBase: QmlObject;
let notificationInvalidTiledDesktops: Notification;
let notificationInvalidWindowRules: Notification; let notificationInvalidWindowRules: Notification;
let notificationInvalidPresetWidths: Notification; let notificationInvalidPresetWidths: Notification;
let moveCursorToFocus: DBusCall;
let screen: MockQmlRect; let screen: MockQmlRect;
let tilingArea: MockQmlRect; let tilingArea: MockQmlRect;
@@ -28,6 +30,15 @@ function init(config: Config) {
Qt = qtMock; Qt = qtMock;
Workspace = workspaceMock; Workspace = workspaceMock;
moveCursorToFocus = {
__brand: "QmlObject",
call: () => {
Assert.assert(Workspace.activeWindow !== null, { message: "moveCursorToFocus should never be called if there's no focused window" });
const frame = Workspace.activeWindow!.frameGeometry;
workspaceMock.cursorPos.x = Math.floor(frame.x + frame.width/2);
workspaceMock.cursorPos.y = Math.floor(frame.y + frame.height/2);
},
};
const world = new World(config); const world = new World(config);
return { qtMock, workspaceMock, world }; return { qtMock, workspaceMock, world };
@@ -35,7 +46,7 @@ function init(config: Config) {
function getGridBounds(clientLeft: KwinClient, clientRight: KwinClient) { function getGridBounds(clientLeft: KwinClient, clientRight: KwinClient) {
const columnsWidth = clientRight.frameGeometry.right - clientLeft.frameGeometry.left; const columnsWidth = clientRight.frameGeometry.right - clientLeft.frameGeometry.left;
const left = Math.floor((screen.width - columnsWidth) / 2); const left = tilingArea.left + Math.floor((tilingArea.width - columnsWidth) / 2);
const right = left + columnsWidth; const right = left + columnsWidth;
return { left, right }; return { left, right };
} }
@@ -44,3 +55,17 @@ function getWindowHeight(windowsInColumn: number) {
const totalGaps = (windowsInColumn-1) * gapV; const totalGaps = (windowsInColumn-1) * gapV;
return Math.round((tilingArea.height - totalGaps) / windowsInColumn); return Math.round((tilingArea.height - totalGaps) / windowsInColumn);
} }
function getClientManager(world: World): ClientManager {
// don't do this outside of tests
let clientManager;
world.do((cm, dm) => clientManager = cm);
return clientManager!;
}
function activateRandomWindowOnDesktop(desktop: KwinDesktop) {
const windows = Workspace.windows.filter(w => w.desktops.includes(desktop));
if (windows.length > 0) {
Workspace.activeWindow = randomItem(windows);
}
}

View File

@@ -3,49 +3,47 @@ class MockKwinClient {
private static readonly borderThickness = 10; private static readonly borderThickness = 10;
public readonly shadeable: boolean = false; public caption = "App";
public readonly caption = "App";
public minSize: Readonly<QmlSize> = new MockQmlSize(0, 0); public minSize: Readonly<QmlSize> = new MockQmlSize(0, 0);
public readonly transient: boolean; public readonly transient: boolean;
public readonly move: boolean = false; public move = false;
public resize: boolean = false; public resize = false;
public readonly moveable: boolean = true;
public readonly resizeable: boolean = true;
public readonly fullScreenable: boolean = true; public readonly fullScreenable: boolean = true;
public readonly maximizable: boolean = true; public readonly maximizable: boolean = true;
public readonly output: Output = { __brand: "Output" }; public readonly output: Output = { __brand: "Output" };
public readonly resourceClass = "app"; public resourceClass = "app";
public readonly dock: boolean = false; public readonly dock: boolean = false;
public readonly normalWindow: boolean = true; public readonly normalWindow: boolean = true;
public readonly managed: boolean = true; public readonly managed: boolean = true;
public readonly popupWindow: boolean = false; public readonly popupWindow: boolean = false;
public readonly pid = 1; public readonly pid = 1;
private _fullScreen: boolean = false; private _maximizedVertically = false;
private _maximizedHorizontally = false;
private _fullScreen = false;
public activities: string[] = []; public activities: string[] = [];
public skipSwitcher: boolean = false; public skipSwitcher = false;
public keepAbove: boolean = false; public keepAbove = false;
public keepBelow: boolean = false; public keepBelow = false;
public shade: boolean = false; private _minimized = false;
public _minimized: boolean = false; private _desktops: KwinDesktop[] = [];
public desktops: KwinDesktop[] = []; private _tile: Tile|null = null;
public _tile: Tile|null = null; public opacity = 1.0;
public opacity: number = 1.0;
public readonly fullScreenChanged = new MockQSignal(); public readonly fullScreenChanged = new MockQSignal<[]>();
public readonly desktopsChanged = new MockQSignal(); public readonly desktopsChanged = new MockQSignal<[]>();
public readonly activitiesChanged = new MockQSignal(); public readonly activitiesChanged = new MockQSignal<[]>();
public readonly minimizedChanged = new MockQSignal(); public readonly minimizedChanged = new MockQSignal<[]>();
public readonly maximizedAboutToChange = new MockQSignal<[MaximizedMode]>(); public readonly maximizedAboutToChange = new MockQSignal<[MaximizedMode]>();
public readonly captionChanged = new MockQSignal(); public readonly captionChanged = new MockQSignal<[]>();
public readonly tileChanged = new MockQSignal(); public readonly tileChanged = new MockQSignal<[]>();
public readonly interactiveMoveResizeStarted = new MockQSignal(); public readonly interactiveMoveResizeStarted = new MockQSignal<[]>();
public readonly interactiveMoveResizeFinished = new MockQSignal(); public readonly interactiveMoveResizeFinished = new MockQSignal<[]>();
public readonly frameGeometryChanged = new MockQSignal<[oldGeometry: QmlRect]>(); public readonly frameGeometryChanged = new MockQSignal<[oldGeometry: QmlRect]>();
private windowedFrameGeometry: MockQmlRect; private windowedFrameGeometry: MockQmlRect;
private windowed: boolean = false; private windowed = true;
private hasBorder: boolean = true; private hasBorder = true;
constructor( constructor(
private _frameGeometry: MockQmlRect = new MockQmlRect(10, 10, 100, 200), private _frameGeometry: MockQmlRect = new MockQmlRect(10, 10, 100, 200),
@@ -53,17 +51,25 @@ class MockKwinClient {
) { ) {
this.windowedFrameGeometry = _frameGeometry.clone(); this.windowedFrameGeometry = _frameGeometry.clone();
this.transient = transientFor !== null; this.transient = transientFor !== null;
this._desktops = [Workspace.currentDesktop];
this.activities = [Workspace.currentActivity];
} }
setMaximize(vertically: boolean, horizontally: boolean) { setMaximize(vertically: boolean, horizontally: boolean) {
this.windowed = !(vertically || horizontally); this.windowed = !(vertically || horizontally);
if (vertically === this._maximizedVertically && horizontally === this._maximizedHorizontally) {
return;
}
this._maximizedVertically = vertically;
this._maximizedHorizontally = horizontally;
this.maximizedAboutToChange.fire( this.maximizedAboutToChange.fire(
vertically ? ( vertically ? (
horizontally ? MaximizedMode.Maximized : MaximizedMode.Vertically horizontally ? MaximizedMode.Maximized : MaximizedMode.Vertically
) : ( ) : (
horizontally ? MaximizedMode.Horizontally : MaximizedMode.Unmaximized horizontally ? MaximizedMode.Horizontally : MaximizedMode.Unmaximized
) ),
); );
this.frameGeometry = new MockQmlRect( this.frameGeometry = new MockQmlRect(
@@ -83,10 +89,26 @@ class MockKwinClient {
this.frameGeometry.height - 2 * MockKwinClient.borderThickness, this.frameGeometry.height - 2 * MockKwinClient.borderThickness,
); );
} else { } else {
return this.frameGeometry; return runOneOf(
() => this.frameGeometry,
() => new MockQmlRect(
this.frameGeometry.x - 20,
this.frameGeometry.y - 20,
this.frameGeometry.width + 40,
this.frameGeometry.height + 40,
), // some full-screen windows that manage their own window decorations can temporarily have a client geometry bigger than the screen
);
} }
} }
public get moveable() {
return !this._fullScreen;
}
public get resizeable() {
return !this._fullScreen;
}
public get fullScreen() { public get fullScreen() {
return this._fullScreen; return this._fullScreen;
} }
@@ -94,6 +116,7 @@ class MockKwinClient {
public set fullScreen(fullScreen: boolean) { public set fullScreen(fullScreen: boolean) {
const oldFullScreen = this._fullScreen; const oldFullScreen = this._fullScreen;
this.hasBorder = !fullScreen; this.hasBorder = !fullScreen;
const targetFrameGeometry = fullScreen ? screen : this.windowedFrameGeometry;
runReorder( runReorder(
() => { () => {
@@ -105,34 +128,34 @@ class MockKwinClient {
() => { () => {
if (oldFullScreen && !fullScreen) { if (oldFullScreen && !fullScreen) {
// when switching from full-screen to windowed, Kwin sometimes first adds the frame before changing the frameGeometry to the final value // when switching from full-screen to windowed, Kwin sometimes first adds the frame before changing the frameGeometry to the final value
if (rectEquals(this.frameGeometry, this.windowedFrameGeometry)) { if (!rectEquals(this.frameGeometry, screen)) {
// already has windowed frame geometry, don't undo that // already has windowed frame geometry, don't undo that
return; return;
} }
runOneOf( runOneOf(
() => this.frameGeometry = new MockQmlRect( () => {
0, this.frameGeometry = new MockQmlRect(
0, 0,
screen.width + 2 * MockKwinClient.borderThickness, 0,
screen.height + 2 * MockKwinClient.borderThickness, screen.width + 2 * MockKwinClient.borderThickness,
), screen.height + 2 * MockKwinClient.borderThickness,
() => this.frameGeometry = new MockQmlRect( );
-MockKwinClient.borderThickness, },
-MockKwinClient.borderThickness, () => {
screen.width + 2 * MockKwinClient.borderThickness, this.frameGeometry = new MockQmlRect(
screen.height + 2 * MockKwinClient.borderThickness, -MockKwinClient.borderThickness,
), -MockKwinClient.borderThickness,
screen.width + 2 * MockKwinClient.borderThickness,
screen.height + 2 * MockKwinClient.borderThickness,
);
},
() => {}, () => {},
); );
} }
}, },
() => { () => {
this.windowed = !fullScreen; this.windowed = !fullScreen;
if (fullScreen) { this.frameGeometry = targetFrameGeometry;
this.frameGeometry = screen;
} else {
this.frameGeometry = this.windowedFrameGeometry;
}
}, },
); );
} }
@@ -167,6 +190,29 @@ class MockKwinClient {
this.minimizedChanged.fire(); this.minimizedChanged.fire();
} }
public get desktops() {
return this._desktops;
}
public set desktops(desktops: KwinDesktop[]) {
this._desktops = desktops;
this.desktopsChanged.fire();
if (Workspace.activeWindow === this && !desktops.includes(Workspace.currentDesktop)) {
Workspace.activeWindow = null;
runMaybe(() => Workspace.activeWindow = null); // fired again for some reason
if (Workspace.activeWindow === null) {
activateRandomWindowOnDesktop(Workspace.currentDesktop);
}
};
}
public moveAndFollowToDesktop(desktop: KwinDesktop, workspaceMock: MockWorkspace) {
Assert.assert(workspaceMock.activeWindow === this);
this._desktops = [desktop];
this.desktopsChanged.fire();
workspaceMock.currentDesktop = desktop;
}
public get tile() { public get tile() {
return this._tile; return this._tile;
} }
@@ -189,4 +235,8 @@ class MockKwinClient {
public getFrameGeometryCopy() { public getFrameGeometryCopy() {
return this._frameGeometry.clone(); return this._frameGeometry.clone();
} }
public toString() {
return `MockKwinClient("${this.caption}")`;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,15 @@ class MockWorkspace {
public activities = ["test-activity"]; public activities = ["test-activity"];
public desktops: KwinDesktop[] = [ public desktops: KwinDesktop[] = [
{ __brand: "KwinDesktop", id: "desktop1" }, { __brand: "KwinDesktop", id: "desktop1", name: "Desktop 1" },
{ __brand: "KwinDesktop", id: "desktop2" } { __brand: "KwinDesktop", id: "desktop2", name: "Desktop 2" },
]; ];
public currentDesktop = this.desktops[0];
public currentActivity = this.activities[0]; public currentActivity = this.activities[0];
public activeScreen: Output = { __brand: "Output" }; public activeScreen: Output = { __brand: "Output" };
public windows = []; public readonly windows: MockKwinClient[] = [];
public cursorPos = new MockQmlPoint(0, 0); public cursorPos = new MockQmlPoint(0, 0);
private _currentDesktop = this.desktops[0];
private _activeWindow: KwinClient|null = null; private _activeWindow: KwinClient|null = null;
public readonly currentDesktopChanged = new MockQSignal<[]>(); public readonly currentDesktopChanged = new MockQSignal<[]>();
@@ -30,6 +30,7 @@ class MockWorkspace {
public createWindows(...kwinClients: MockKwinClient[]) { public createWindows(...kwinClients: MockKwinClient[]) {
for (const kwinClient of kwinClients) { for (const kwinClient of kwinClients) {
this.windows.push(kwinClient);
this.windowAdded.fire(kwinClient); this.windowAdded.fire(kwinClient);
this.activeWindow = kwinClient; this.activeWindow = kwinClient;
} }
@@ -41,6 +42,7 @@ class MockWorkspace {
public createClientsWithFrames(...frames: MockQmlRect[]) { public createClientsWithFrames(...frames: MockQmlRect[]) {
const clients = frames.map(rect => new MockKwinClient(rect)); const clients = frames.map(rect => new MockKwinClient(rect));
clients.forEach((client, index) => client.caption = `Client ${index}`);
this.createWindows(...clients); this.createWindows(...clients);
return clients; return clients;
} }
@@ -49,6 +51,39 @@ class MockWorkspace {
return this.createClientsWithFrames(...widths.map(width => new MockQmlRect(randomInt(100), randomInt(100), width, 100+randomInt(400)))); return this.createClientsWithFrames(...widths.map(width => new MockQmlRect(randomInt(100), randomInt(100), width, 100+randomInt(400))));
} }
public removeWindow(window: MockKwinClient) {
this.activeWindow = null;
runReorder(
() => this.windows.splice(this.windows.indexOf(window), 1),
() => this.windowRemoved.fire(window),
);
if (this.activeWindow === null) {
activateRandomWindowOnDesktop(this.currentDesktop);
};
}
public moveWindow(window: MockKwinClient, ...deltas: QmlPoint[]) {
const frame = window.getFrameGeometryCopy();
window.move = true;
window.interactiveMoveResizeStarted.fire();
for (const delta of deltas) {
if (delta.x !== 0) {
frame.x += delta.x;
}
if (delta.y !== 0) {
frame.y += delta.y;
}
runOneOf(
() => window.frameGeometry.set(frame),
() => window.frameGeometry = frame,
);
}
window.move = false;
window.interactiveMoveResizeFinished.fire();
}
public resizeWindow(window: MockKwinClient, edgeResize: boolean, leftEdge: boolean, topEdge: boolean, ...deltas: QmlSize[]) { public resizeWindow(window: MockKwinClient, edgeResize: boolean, leftEdge: boolean, topEdge: boolean, ...deltas: QmlSize[]) {
const frame = window.getFrameGeometryCopy(); const frame = window.getFrameGeometryCopy();
if (edgeResize) { if (edgeResize) {
@@ -81,13 +116,22 @@ class MockWorkspace {
runOneOf( runOneOf(
() => window.frameGeometry.set(frame), () => window.frameGeometry.set(frame),
() => window.frameGeometry = frame, () => window.frameGeometry = frame,
) );
} }
window.resize = false; window.resize = false;
window.interactiveMoveResizeFinished.fire(); window.interactiveMoveResizeFinished.fire();
} }
public get currentDesktop() {
return this._currentDesktop;
}
public set currentDesktop(currentDesktop: KwinDesktop) {
this._currentDesktop = currentDesktop;
this.currentDesktopChanged.fire();
}
public get activeWindow() { public get activeWindow() {
return this._activeWindow; return this._activeWindow;
} }

View File

@@ -4,10 +4,10 @@ function runMaybe(f: () => void) {
} }
} }
function runOneOf(...fs: (() => void)[]) { function runOneOf<T>(...fs: (() => T)[]) {
const index = randomInt(fs.length); const index = randomInt(fs.length);
runLog.push(`${getStackFrame(1)} - Chose ${index}`); runLog.push(`${getStackFrame(1)} - Chose ${index}`);
fs[index](); return fs[index]();
} }
function runReorder(...fs: (() => void)[]) { function runReorder(...fs: (() => void)[]) {
@@ -22,10 +22,22 @@ function runReorder(...fs: (() => void)[]) {
} }
} }
function runReorderDebug(order: number[], ...fs: (() => void)[]) {
for (const index of order) {
fs[index]();
}
}
function randomInt(n: number) { function randomInt(n: number) {
return Math.floor(Math.random() * n); return Math.floor(Math.random() * n);
} }
function randomItem(items: any[]) {
Assert.assert(items.length > 0);
const index = randomInt(items.length);
return items[index];
}
function shuffle(items: any[]) { function shuffle(items: any[]) {
for (let n = items.length; n > 1; n--) { for (let n = items.length; n > 1; n--) {
const i = n-1; const i = n-1;