├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── bin ├── load-script.sh ├── shortcut.py └── testenv-docker.sh ├── img ├── conf.png └── screenshot.png ├── res ├── config.ui ├── config.xml ├── main.qml ├── metadata.desktop ├── package.json └── popup.qml ├── src ├── common.ts ├── driver │ ├── kwin │ │ ├── kwinconfig.ts │ │ ├── kwindriver.ts │ │ ├── kwinmousepoller.ts │ │ ├── kwinsettimeout.ts │ │ ├── kwinsurface.ts │ │ └── kwinwindow.ts │ └── test │ │ └── testdriver.ts ├── engine │ ├── control.ts │ ├── engine.ts │ ├── enginecontext.ts │ ├── layoutstore.ts │ ├── window.ts │ └── windowstore.ts ├── extern │ ├── global.d.ts │ ├── kwin.d.ts │ ├── plasma.d.ts │ └── qt.d.ts ├── layouts │ ├── cascadelayout.ts │ ├── floatinglayout.ts │ ├── layoutpart.ts │ ├── layoututils.ts │ ├── monoclelayout.ts │ ├── quarterlayout.ts │ ├── spirallayout.ts │ ├── spreadlayout.ts │ ├── stairlayout.ts │ ├── threecolumnlayout.ts │ └── tilelayout.ts └── util │ ├── debug.ts │ ├── func.ts │ ├── kwinutil.ts │ ├── rect.ts │ ├── rectdelta.ts │ └── wrappermap.ts ├── test └── tilelayout.spec.js ├── tsconfig.json └── tslint.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Symptom** 11 | A simple description of the problem you're experiencing. 12 | 13 | **How to Reproduce** 14 | 1. Launch '....' 15 | 2. Press key '....' 16 | 3. See error 17 | 18 | **Expected behavior** 19 | A concise description of what you expected to happen. 20 | 21 | **Environment** 22 | - Distro: [e.g. Ubuntu 18.04, Debian 9.5, Archlinux] 23 | - KWin version: [e.g. 5.14.2] 24 | - Krohnkite version: [e.g. 0.1, git commit] 25 | - List of KWin scripts in use: [e.g. MinimizeAll, VideoWall] 26 | 27 | **Notes** 28 | Anything that you want to tell developers about. 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg/ 2 | /node_modules/ 3 | 4 | /TODO.md 5 | /krohnkite.js 6 | /package-lock.json 7 | /package.json 8 | 9 | /*.qml 10 | *.kwinscript 11 | *.bak -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Eon S. Jeon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_NAME = krohnkite 2 | PROJECT_VER = 0.8.2 3 | PROJECT_REV = $(shell git rev-parse HEAD | cut -b-7) 4 | 5 | KWINPKG_FILE = $(PROJECT_NAME)-$(PROJECT_VER).kwinscript 6 | KWINPKG_DIR = pkg 7 | 8 | KWIN_META = $(KWINPKG_DIR)/metadata.desktop 9 | KWIN_QML = $(KWINPKG_DIR)/contents/ui/main.qml 10 | 11 | NODE_SCRIPT = krohnkite.js 12 | NODE_META = package.json 13 | NODE_FILES = $(NODE_SCRIPT) $(NODE_META) package-lock.json 14 | 15 | SRC = $(shell find src -name "*.ts") 16 | 17 | all: $(KWINPKG_DIR) 18 | 19 | clean: 20 | @rm -rvf $(KWINPKG_DIR) 21 | @rm -vf $(NODE_FILES) 22 | 23 | install: package 24 | plasmapkg2 -t kwinscript -s $(PROJECT_NAME) \ 25 | && plasmapkg2 -u $(KWINPKG_FILE) \ 26 | || plasmapkg2 -i $(KWINPKG_FILE) 27 | 28 | uninstall: 29 | plasmapkg2 -t kwinscript -r $(PROJECT_NAME) 30 | 31 | package: $(KWINPKG_FILE) 32 | 33 | test: $(NODE_SCRIPT) $(NODE_META) 34 | npm test 35 | 36 | run: $(KWINPKG_DIR) 37 | bin/load-script.sh "$(KWIN_QML)" "$(PROJECT_NAME)-test" 38 | @find "$(KWINPKG_DIR)" '(' -name "*.qmlc" -o -name "*.jsc" ')' -delete 39 | 40 | stop: 41 | bin/load-script.sh "unload" "$(PROJECT_NAME)-test" 42 | 43 | $(KWINPKG_FILE): $(KWINPKG_DIR) 44 | @rm -f "$(KWINPKG_FILE)" 45 | @7z a -tzip $(KWINPKG_FILE) ./$(KWINPKG_DIR)/* 46 | 47 | $(KWINPKG_DIR): $(KWIN_META) 48 | $(KWINPKG_DIR): $(KWIN_QML) 49 | $(KWINPKG_DIR): $(KWINPKG_DIR)/contents/ui/config.ui 50 | $(KWINPKG_DIR): $(KWINPKG_DIR)/contents/ui/popup.qml 51 | $(KWINPKG_DIR): $(KWINPKG_DIR)/contents/code/script.js 52 | $(KWINPKG_DIR): $(KWINPKG_DIR)/contents/config/main.xml 53 | @touch $@ 54 | 55 | $(KWIN_META): res/metadata.desktop 56 | @mkdir -vp `dirname $(KWIN_META)` 57 | sed "s/\$$VER/$(PROJECT_VER)/" $< \ 58 | | sed "s/\$$REV/$(PROJECT_REV)/" \ 59 | > $(KWIN_META) 60 | 61 | $(KWIN_QML): res/main.qml 62 | $(KWINPKG_DIR)/contents/ui/config.ui: res/config.ui 63 | $(KWINPKG_DIR)/contents/ui/popup.qml: res/popup.qml 64 | $(KWINPKG_DIR)/contents/code/script.js: $(NODE_SCRIPT) 65 | $(KWINPKG_DIR)/contents/config/main.xml: res/config.xml 66 | $(KWINPKG_DIR)/%: 67 | @mkdir -vp `dirname $@` 68 | @cp -v $< $@ 69 | 70 | $(NODE_SCRIPT): $(SRC) 71 | tsc 72 | 73 | $(NODE_META): res/package.json 74 | sed "s/\$$VER/$(PROJECT_VER).0/" $< > $@ 75 | 76 | .PHONY: all clean install package test run stop 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Kröhnkite 2 | ========= 3 | 4 | [![AUR-git](https://img.shields.io/aur/version/kwin-scripts-krohnkite-git.svg?label=AUR-git)](https://aur.archlinux.org/packages/kwin-scripts-krohnkite-git/) 5 | 6 | A dynamic tiling extension for KWin. 7 | 8 | Kröhnkite is mainly inspired by [dwm][] from suckless folks, and aims to 9 | provide rock solid stability while fully integrating into KWin. 10 | 11 | The name of the script is from mineral [Kröhnkite][wikipedia]; it starts with 12 | K and looks cool. 13 | 14 | [dwm]: https://dwm.suckless.org/ 15 | [wikipedia]: https://en.wikipedia.org/wiki/Kr%C3%B6hnkite 16 | 17 | ![screenshot](img/screenshot.png) 18 | 19 | 20 | Features 21 | -------- 22 | * DWM-like window tiling 23 | - Dynamically tile windows, rather than manually placing each. 24 | - Floating windows 25 | * Fully integrates into KWin features, including: 26 | - **Multi-screen** 27 | - **Activities & Virtual desktop** 28 | - Basic window management (minimize, fullscreen, switching, etc) 29 | * Multiple Layout Support 30 | - Tiling layout 31 | - Monocle layout 32 | - Desktop-friendly layouts (Spread, Stair) 33 | 34 | Development Requirement 35 | ----------------------- 36 | 37 | * Typescript (tested w/ 3.1.x) 38 | * GNU Make 39 | * p7zip (7z) 40 | 41 | 42 | Installation 43 | ------------ 44 | 45 | You can install Kröhnkite in multiple ways. 46 | 47 | ### Using .kwinscript package file ### 48 | 49 | You can download `krohnkite-x.x.kwinscript` file, and install it through 50 | *System Settings*. 51 | 52 | 1. Download the kwinscript file 53 | 2. Open `System Settings` > `Window Management` > `KWin Scripts` 54 | 3. Press `Import KWin script...` on the top-right corner 55 | 4. Select the downloaded file 56 | 57 | Alternatively, through command-line: 58 | 59 | plasmapkg2 -t kwinscript -i krohnkite.kwinscript # installing new script 60 | plasmapkg2 -t kwinscript -u krohnkite.kwinscript # upgrading existing script 61 | 62 | To uninstall the package: 63 | 64 | plasmapkg2 -t kwinscript -r krohnkite 65 | 66 | ### Installing from Git repository ### 67 | 68 | The simplest method would be: 69 | 70 | make install 71 | make uninstall # to uninstall the script 72 | 73 | This will automatically build and install kwinscript package. 74 | 75 | You can also manually build package file using: 76 | 77 | make package 78 | 79 | The generated package file can be imported from "KWin Script" dialog. 80 | 81 | ### Simply Trying Out ### 82 | 83 | Krohnkite can be temporarily loaded without installing the script: 84 | 85 | make run 86 | make stop 87 | 88 | Note that Krohnkite can destroy itself completely once it is disabled, so no 89 | restart is required to deactivated it. 90 | 91 | ### Enabling User-Configuration ### 92 | 93 | [It is reported][kwinconf] that a manual step is required to enable user 94 | configuration of KWin scripts. This is a limitation of KWin scripting. 95 | 96 | To enable configuration, you must perform the following in command-line: 97 | 98 | mkdir -p ~/.local/share/kservices5/ 99 | ln -s ~/.local/share/kwin/scripts/krohnkite/metadata.desktop ~/.local/share/kservices5/krohnkite.desktop 100 | 101 | A configuration button will appear in `KWin Scripts` in `System Settings`. 102 | 103 | ![config button shown](img/conf.png) 104 | 105 | To make changes effective, **the script must be reactivated**: 106 | 1) On `KWin Scripts` dialog, untick Krohnkite 107 | 2) `Apply` 108 | 3) tick Krohnkite 109 | 4) `Apply` 110 | 111 | [kwinconf]: https://github.com/faho/kwin-tiling/issues/79#issuecomment-311465357 112 | 113 | 114 | Default Key Bindings 115 | -------------------- 116 | 117 | | Key | Action | 118 | | ----------------- | ------------------------------ | 119 | | Meta + J | Focus Down/Next | 120 | | Meta + K | Focus Up/Previous | 121 | | Meta + H | Left | 122 | | Meta + L | Right | 123 | | | | 124 | | Meta + Shift + J | Move Down/Next | 125 | | Meta + Shift + K | Move Up/Previous | 126 | | Meta + Shift + H | Move Left | 127 | | Meta + Shift + L | Move Right | 128 | | | | 129 | | Meta + I | Increase | 130 | | Meta + D | Decrease | 131 | | Meta + F | Toggle Floating | 132 | | Meta + \ | Cycle Layout | 133 | | | | 134 | | Meta + Return | Set as Master | 135 | | | | 136 | | Meta + T | Use Tile Layout | 137 | | Meta + M | Use Monocle Layout | 138 | | *unbound* | Use Spread Layout | 139 | | *unbound* | Use Stair Layout | 140 | 141 | 142 | Tips 143 | ---- 144 | 145 | ### Setting Up for Multi-Screen ### 146 | 147 | Krohnkite supports multi-screen setup, but KWin has to be configured to unlock 148 | the full potential of the script. 149 | 150 | 1. Enable `Separate Screen Focus` under `Window Management` > 151 | `Window Behavior` > `Multiscreen Behaviour` 152 | 2. Bind keys for global shortcut `Switch to Next/Previous Screen` 153 | (Recommend: `Meta + ,` / `Meta + .`) 154 | 3. Bind keys for global shortcut `Window to Next/Previous Screen` 155 | (Recommend: `Meta + <` / `Meta + >`) 156 | 157 | Note: `Separate Screen Focus` appears only when multiple monitors are present. 158 | 159 | ### Removing Title Bars ### 160 | 161 | Breeze window decoration can be configured to completely remove title bars from 162 | all windows: 163 | 164 | 1. `System Setting` > `Application Style` > `Window Decorations` 165 | 2. Click `Configure Breeze` inside the decoration preview. 166 | 3. `Window-Specific Overrides` tab > `Add` button 167 | 4. Enter the followings, and press `Ok`: 168 | - `Regular expression to match`: `.*` 169 | - Tick `Hide window title bar` 170 | 171 | ### Changing Border Colors ### 172 | 173 | Changing the border color makes it easier to identify current window. This is 174 | convinient if title bars are removed. 175 | 176 | 1. Open `~/.config/kdeglobals` with your favorite editor 177 | 2. Scroll down and find `[WM]` section 178 | 3. Append the followings to the section: 179 | - `frame=61,174,233`: set the border color of active window to *RGB(61,174,233)* 180 | - `inactiveFrame=239,240,241`: set the border color of inactive window to *RGB(239,240,241)* 181 | 182 | Here's a nice 2-liner that'll do it for you: 183 | 184 | kwriteconfig5 --file ~/.config/kdeglobals --group WM --key frame 61,174,233 185 | kwriteconfig5 --file ~/.config/kdeglobals --group WM --key inactiveFrame 239,240,241 186 | 4. You must **restart** your session to see changes. (i.e. re-login, reboot) 187 | 188 | Note: the RGB values presented here are for the default Breeze theme 189 | 190 | Note: You might also need to set the border size larger than the theme's default: 191 | `System Settings` > `Application Style` > `Window Decorations`: Untick `Use theme's default window border size` and adjust the size (right from the checkbox). 192 | 193 | ### Setting Minimum Geometry Size ### 194 | 195 | Some applications like discord and KDE settings dont tile nicely as they have a minimum size requirement. 196 | This causes the applications to overlap with other applications. To mitigate this we can set minimum size for all windows to be 0. 197 | 198 | 1. `System Setting` > `Window Management` > `Window Rules` 199 | 2. Click on `+ Add New...` 200 | 3. Set `Window class` to be `Unimportant` 201 | 4. Set `Window types` to `Normal Window` 202 | 5. Click `+ Add Properties...` 203 | 6. Add the `Minimum Size` Property 204 | 7. Set the fields to `Force` and `0` x `0` 205 | 8. Apply 206 | 207 | ### Prevent borders and shadows from disappearing. ### 208 | 209 | When a window is marked "maximized" in Breeze theme, its borders are removed to save screen space. 210 | This behavior may not be preferable depending on your setup. This can be mitigated by disabling maximized windows using Window Rules. 211 | 212 | 1. `System Setting` > `Window Management` > `Window Rules` 213 | 2. Click on `+ Add New...` 214 | 3. Set `Window class` to be `Unimportant` 215 | 4. Set `Window types` to `Normal Window` 216 | 5. Click `+ Add Properties...` 217 | 6. Add the `Maximized horizontally` and `Maximized vertically` Properties. 218 | 7. Set the options to `Force` and `No`. 219 | 8. Apply 220 | 221 | Useful Development Resources 222 | ---------------------------- 223 | 224 | * [KWin Scripting Tutorial](https://techbase.kde.org/Development/Tutorials/KWin/Scripting) 225 | * [KWin Scripting API 4.9 Reference](https://techbase.kde.org/Development/Tutorials/KWin/Scripting/API_4.9) 226 | * Adding configuration dialog 227 | - [Development/Tutorials/Plasma/JavaScript/ConfigDialog](https://techbase.kde.org/Development/Tutorials/Plasma/JavaScript/ConfigDialog) 228 | - [Development/Tutorials/Using KConfig XT](https://techbase.kde.org/Development/Tutorials/Using_KConfig_XT) 229 | * `*.ui` files can be edited with [Qt Designer](http://doc.qt.io/qt-5/qtdesigner-manual.html). 230 | It's very straight-forward if you're used to UI programming. 231 | 232 | -------------------------------------------------------------------------------- /bin/load-script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | #set -x 4 | 5 | file_path="$1" 6 | plugin_name="$2" 7 | 8 | # 9 | # Functions 10 | # 11 | 12 | _invoke() { 13 | method="$1" 14 | shift 1 15 | 16 | dbus-send --session --print-reply=literal \ 17 | --dest="org.kde.KWin" \ 18 | "/Scripting" "org.kde.kwin.Scripting.${method}" \ 19 | "$@" 20 | } 21 | 22 | check_loaded() { 23 | _invoke "isScriptLoaded" string:"${plugin_name}" \ 24 | | awk '{ print $2 }' 25 | } 26 | 27 | unload_script() { 28 | _invoke "unloadScript" string:"${plugin_name}" 29 | } 30 | 31 | # 32 | # Main 33 | # 34 | 35 | if [ "${file_path}" = "unload" ]; then 36 | unload_script 37 | [ "$(check_loaded)" = "false" ] && exit 38 | 39 | echo "$(basename $0): Failed to unload script: ${plugin_name}" >&2 40 | exit 1 41 | fi 42 | 43 | file_path="$(realpath "${file_path}")" 44 | if [ ! -f "${file_path}" ]; then 45 | echo "$(basename $0): File does not exist: ${file_path}" >&2 46 | exit 1 47 | fi 48 | 49 | if [ "$(check_loaded)" != "false" ]; then 50 | unload_script 51 | fi 52 | 53 | # randomized file_path 54 | # (KWin doesn't reload files, and keep running old versions.) 55 | file_path_random="${file_path}.$$.qml" 56 | trap "{ rm -vf ${file_path_random}; }" EXIT 57 | cp -v "${file_path}" "${file_path_random}" 58 | 59 | # load script and run 60 | _invoke "loadDeclarativeScript" string:"${file_path_random}" string:"${plugin_name}" 61 | _invoke "start" 62 | 63 | if [ "$(check_loaded)" = "false" ]; then 64 | echo "$(basename $0): Failed to load script: ${file_path}, ${plugin_name}" >&2 65 | exit 1 66 | fi 67 | -------------------------------------------------------------------------------- /bin/shortcut.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # * Requirements: 4 | # - pyside2 5 | # - dbus-python 6 | # 7 | 8 | import argparse 9 | import sys 10 | 11 | from PySide2.QtGui import QKeySequence 12 | import dbus 13 | 14 | # TODO: manually syncing key bindings? sucks! 15 | # split this into a data file, which then can be translated into 16 | # typescript code or included in other places. 17 | KROHNKITE_DEFAULT_BINDINGS = [ 18 | ("Down/Next" , "j" ), 19 | ("Up/Prev" , "k" ), 20 | ("Left" , "h" ), 21 | ("Right" , "l" ), 22 | 23 | ("Move Down/Next", "shift+j"), 24 | ("Move Up/Prev" , "shift+k"), 25 | ("Move Left" , "shift+h"), 26 | ("Move Right" , "shift+l"), 27 | 28 | ("Grow Height" , "ctrl+j" ), 29 | ("Shrink Height" , "ctrl+k" ), 30 | ("Shrink Width" , "ctrl+h" ), 31 | ("Grow Width" , "ctrl+l" ), 32 | 33 | ("Increase" , "i" ), 34 | ("Decrease" , "d" ), 35 | 36 | ("Float" , "f" ), 37 | ("Float All" , "shift+f"), 38 | ("Cycle Layout" , "\\" ), 39 | ("Set master" , "return" ), 40 | 41 | ('Tile Layout' , 't'), 42 | ('Monocle Layout' , 'm'), 43 | ('Spread Layout' , None), 44 | ('Stair Layout' , None), 45 | ('Floating Layout', None), 46 | ] 47 | 48 | NUMBER_SHIFT_MAP = ")!@#$%^&*()" 49 | 50 | VERBOSE = True 51 | 52 | 53 | def parse_arguments() -> argparse.Namespace: 54 | # common arguments 55 | parser = argparse.ArgumentParser( 56 | description='A helper script for managing Krohnkite shortcuts') 57 | parser.add_argument('--quiet', '-q', action='store_true', 58 | help='Suppress output') 59 | 60 | subparsers = parser.add_subparsers(dest="command", 61 | help='Commands') 62 | 63 | # 64 | # register command 65 | # 66 | parser_register = subparsers.add_parser('register', 67 | help='Register Krohnkite-related shortcuts') 68 | parser_register.add_argument('--bind', '-b', action='append', dest='binds', 69 | metavar='ACTION=KEY', type=str, 70 | help='''Use a different key for specified action. The final result is MOD+KEY = ACTION. 71 | This option can be specified multiple times.''') 72 | parser_register.add_argument('--force', '-f', action='store_true', 73 | help='Remove any conflicting shortcuts before registering new ones') 74 | parser_register.add_argument('--modifier', '-m', default='meta', dest='modifier', 75 | type=str, 76 | help='A modifier key to use. Defaults to meta.') 77 | 78 | # 79 | # unregister command 80 | # 81 | parser_unregister = subparsers.add_parser('unregister', 82 | help='''Remove all Krohnkite shortcuts from KWin. 83 | This doesn't reset other key bindings changed by this script.''') 84 | 85 | # 86 | # register-desktops command 87 | # 88 | parser_register_desktops = subparsers.add_parser('register-desktops', 89 | help='''Set virtual desktop shortcuts to MOD+NUMBER, which is a recommended setup.''') 90 | parser_register_desktops.add_argument('--force', '-f', action='store_true', 91 | help='Remove any conflicting shortcuts before registering new ones') 92 | parser_register_desktops.add_argument('--modifier', '-m', default='meta', dest='modifier', type=str, 93 | help='''A modifier key to use. Defaults to meta.''') 94 | 95 | if len(sys.argv) == 1: 96 | parser.print_help(sys.stderr) 97 | sys.exit(1) 98 | 99 | return parser.parse_args() 100 | 101 | def parse_kvpair(s: str): 102 | '''Parses simple "A=B"-style expression''' 103 | return tuple(s.split("=", 2)) 104 | 105 | def get_keycode(keycomb: str): 106 | keyseq = QKeySequence.fromString(keycomb) 107 | return keyseq[0] 108 | 109 | def is_key_valid(keycomb: str) -> bool: 110 | # NOTE: this might be internal detail that should not be accessed. 111 | return get_keycode(keycomb) != 0x1FFFFFF 112 | 113 | def register_shortcut(action_id, keycomb, force=False): 114 | if force is True: 115 | unregister_colliding_shortcut(keycomb) 116 | 117 | keycode = get_keycode(keycomb) 118 | if VERBOSE: 119 | print("register [{2:<14}] to '{1}/{0}'.".format(action_id[1], action_id[2], keycomb)) 120 | kglobalaccel.setForeignShortcut(action_id, [keycode]) 121 | 122 | def register_krohnkite_shortcut(action: str, keycomb_full: str): 123 | action = "Krohnkite: " + action 124 | keycode = get_keycode(keycomb_full) 125 | 126 | if VERBOSE: print("register [{1:<14}] to '{0}'.".format(action, keycomb_full)) 127 | 128 | kglobalaccel.setForeignShortcut(["kwin", action, "KWin", ""], [keycode]) 129 | 130 | def unregister_krohnkite_shortcut(action: str): 131 | action = "Krohnkite: " + action 132 | 133 | if VERBOSE: print("unregister '{}'.".format(action)) 134 | 135 | kglobalaccel.setForeignShortcut(["kwin", action, "KWin", ""], []) 136 | 137 | def is_shortcut_colliding(keycomb_full: str) -> bool: 138 | action_id = kglobalaccel.action(get_keycode(keycomb_full)) 139 | return not not action_id 140 | 141 | def unregister_colliding_shortcut(keycomb_full: str): 142 | action_id = kglobalaccel.action(get_keycode(keycomb_full)) 143 | if len(action_id) > 0: 144 | if VERBOSE: print("unregister [{:<14}] bound to '{}'".format(keycomb_full, action_id[1])) 145 | kglobalaccel.setForeignShortcut(action_id, []) 146 | 147 | def unregister_all_krohnkite_shortcuts(): 148 | names = [ 149 | str(name) for name 150 | in kwin_component.shortcutNames() 151 | if name.startswith('Krohnkite:') 152 | ] 153 | 154 | for name in names: 155 | kglobalaccel.unregister("kwin", name) 156 | 157 | def is_shortcut_already_bound(keycomb_full: str) -> bool: 158 | '''Check if the given key combination is already bound to something. ''' 159 | action_id = kglobalaccel.action(get_keycode(keycomb_full)) 160 | return not not action_id 161 | 162 | def register_desktop_shortcuts(modifier, force): 163 | for i in range(1,10): 164 | action = "Switch to Desktop {}".format(i) 165 | keycomb = "{}+{}".format(modifier, i) 166 | register_shortcut(["kwin", action, "KWin", action], keycomb, force=force) 167 | 168 | action = "Window to Desktop {}".format(i) 169 | keycomb = "{}+{}".format(modifier, NUMBER_SHIFT_MAP[i]) 170 | register_shortcut(["kwin", action, "KWin", action], keycomb, force=force) 171 | 172 | def main(): 173 | config = parse_arguments() 174 | 175 | global VERBOSE 176 | VERBOSE = False if config.quiet else True 177 | 178 | if config.command == 'register': 179 | binds = dict(KROHNKITE_DEFAULT_BINDINGS) 180 | 181 | if config.binds is not None: 182 | # parse ACTION=KEY parameter 183 | custom_binds = (parse_kvpair(b) for b in config.binds) 184 | 185 | # read-through custom binds 186 | for action, keycomb in custom_binds: 187 | if action not in binds: 188 | print("invalid action '{}'",format(action)) 189 | sys.exit(1) 190 | elif keycomb.lower() == "none": 191 | binds[action] = None 192 | elif is_key_valid(config.modifier + '+' + keycomb): 193 | binds[action] = keycomb 194 | else: 195 | print("invalid key '{}' for action '{}'".format(action, keycomb)) 196 | sys.exit(1) 197 | 198 | if config.force is True: 199 | for keycomb in binds.values(): 200 | if keycomb is not None: 201 | unregister_colliding_shortcut(config.modifier + '+' + keycomb) 202 | 203 | # register shortcuts 204 | for action, keycomb in binds.items(): 205 | if keycomb is None: 206 | unregister_krohnkite_shortcut(action) 207 | else: 208 | keycomb_full = config.modifier + '+' + keycomb 209 | if is_shortcut_colliding(keycomb): 210 | print("skipping {} due to shortcut collision...".format(keycomb_full)) 211 | else: 212 | register_krohnkite_shortcut(action, keycomb_full) 213 | 214 | elif config.command == 'unregister': 215 | unregister_all_krohnkite_shortcuts() 216 | elif config.command == 'register-desktops': 217 | register_desktop_shortcuts(config.modifier, config.force) 218 | else: 219 | pass 220 | 221 | 222 | session_bus = dbus.SessionBus() 223 | 224 | kglobalaccel_obj = session_bus.get_object('org.kde.kglobalaccel', '/kglobalaccel') 225 | kglobalaccel = dbus.Interface(kglobalaccel_obj, dbus_interface='org.kde.KGlobalAccel') 226 | 227 | kwin_component_obj = session_bus.get_object('org.kde.kglobalaccel', '/component/kwin') 228 | kwin_component = dbus.Interface(kwin_component_obj, dbus_interface='org.kde.kglobalaccel.Component') 229 | 230 | if __name__ == '__main__': 231 | main() 232 | 233 | -------------------------------------------------------------------------------- /bin/testenv-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -mx 4 | 5 | display=${1:-1} 6 | edition=${2:-user} 7 | case "$edition" in 8 | user|user-lts|dev-stable|dev-unstable);; 9 | *) 10 | echo "invalid edition: $edition" >&2 11 | exit 1 12 | ;; 13 | esac 14 | 15 | projdir=$(realpath "$(dirname "$0")/..") 16 | ctname="krohnkite-$edition" 17 | 18 | Xephyr \ 19 | -dpi 96 \ 20 | -screen 1366x768 \ 21 | :"$display" & 22 | 23 | ctid=$(docker ps -aq --filter "name=$ctname") 24 | if [[ -z "$ctid" ]]; then 25 | docker run \ 26 | --name "$ctname" \ 27 | -e DISPLAY=":$display" \ 28 | -v "$projdir":"/mnt" \ 29 | -v "/tmp/.X11-unix":"/tmp/.X11-unix" \ 30 | "kdeneon/plasma":"$edition" & 31 | 32 | # HACK: stop CPU hoggig bluetooth service invocation. This is a bug. 33 | (sleep 1; docker exec -u root "$ctname" rm '/usr/share/dbus-1/services/org.bluez.obex.service') & 34 | else 35 | docker start "$ctname" 36 | fi 37 | 38 | fg Xephyr 39 | wait 40 | -------------------------------------------------------------------------------- /img/conf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esjeon/krohnkite/bc6fe23afe4eb545a75755206e982b273426e32e/img/conf.png -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esjeon/krohnkite/bc6fe23afe4eb545a75755206e982b273426e32e/img/screenshot.png -------------------------------------------------------------------------------- /res/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | false 12 | 13 | 14 | 15 | 16 | true 17 | 18 | 19 | 20 | 21 | true 22 | 23 | 24 | 25 | 26 | true 27 | 28 | 29 | 30 | 31 | true 32 | 33 | 34 | 35 | 36 | true 37 | 38 | 39 | 40 | 41 | true 42 | 43 | 44 | 45 | 46 | false 47 | 48 | 49 | 50 | 51 | false 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | krunner,yakuake,spectacle,kded5 62 | 63 | 64 | 65 | 66 | quake 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | true 92 | 93 | 94 | 95 | 96 | true 97 | 98 | 99 | 100 | 101 | true 102 | 103 | 104 | 105 | 106 | false 107 | 108 | 109 | 110 | 111 | true 112 | 113 | 114 | 115 | 116 | false 117 | 118 | 119 | 120 | 121 | true 122 | 123 | 124 | 125 | 126 | true 127 | 128 | 129 | 130 | 131 | true 132 | 133 | 134 | 135 | 136 | false 137 | 138 | 139 | 140 | 141 | false 142 | 143 | 144 | 145 | 146 | true 147 | 148 | 149 | 150 | 151 | 0 152 | 153 | 154 | 155 | 156 | 0 157 | 158 | 159 | 160 | 161 | 0 162 | 163 | 164 | 165 | 166 | 0 167 | 168 | 169 | 170 | 171 | 0 172 | 173 | 174 | 175 | 176 | false 177 | 178 | 179 | 180 | 181 | true 182 | 183 | 184 | 185 | 186 | false 187 | 188 | 189 | 190 | 191 | false 192 | 193 | 194 | 195 | 196 | 1.6 197 | 198 | 199 | 200 | 201 | false 202 | 203 | 204 | 205 | -------------------------------------------------------------------------------- /res/main.qml: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | import QtQuick 2.0 22 | import org.kde.plasma.core 2.0 as PlasmaCore; 23 | import org.kde.plasma.components 2.0 as Plasma; 24 | import org.kde.kwin 2.0; 25 | import org.kde.taskmanager 0.1 as TaskManager 26 | import "../code/script.js" as K 27 | 28 | Item { 29 | id: scriptRoot 30 | 31 | TaskManager.ActivityInfo { 32 | id: activityInfo 33 | } 34 | 35 | PlasmaCore.DataSource { 36 | id: mousePoller 37 | engine: 'executable' 38 | } 39 | 40 | Loader { 41 | id: popupDialog 42 | source: "popup.qml" 43 | 44 | function show(text) { 45 | var area = workspace.clientArea(KWin.FullScreenArea, workspace.activeScreen, workspace.currentDesktop); 46 | this.item.show(text, area, 1000); 47 | } 48 | } 49 | 50 | Component.onCompleted: { 51 | console.log("KROHNKITE: starting the script"); 52 | (new K.KWinDriver()).main(); 53 | } 54 | } -------------------------------------------------------------------------------- /res/metadata.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Krohnkite 3 | Comment=A dynamic tiling script for KWin ($REV) 4 | Icon=dialog-tile-clones 5 | 6 | X-Plasma-API=declarativescript 7 | X-Plasma-MainScript=ui/main.qml 8 | 9 | X-KDE-PluginInfo-Author=Eon S. Jeon 10 | X-KDE-PluginInfo-Email=esjeon@hyunmu.am 11 | X-KDE-PluginInfo-Name=krohnkite 12 | X-KDE-PluginInfo-Version=$VER 13 | 14 | X-KDE-PluginInfo-Depends= 15 | X-KDE-PluginInfo-License=MIT 16 | X-KDE-ServiceTypes=KWin/Script,KCModule 17 | 18 | X-KDE-Library=kwin/effects/configs/kcm_kwin4_genericscripted 19 | X-KDE-PluginKeyword=krohnkite 20 | 21 | X-KDE-ParentComponents=krohnkite 22 | Type=Service 23 | 24 | ## Uncomment this line for KWin 5.24.0 only 25 | #X-KDE-ConfigModule=kwin/effects/configs/kcm_kwin4_genericscripted 26 | -------------------------------------------------------------------------------- /res/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "krohnkite", 3 | "version": "$VER", 4 | "description": "A dynamic tiling extension for KWin", 5 | "main": "krohnkite.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "dependencies": {}, 10 | "devDependencies": { 11 | "mocha": "^6.0.0" 12 | }, 13 | "scripts": { 14 | "test": "mocha 'test/*.spec.js'" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/esjeon/krohnkite.git" 19 | }, 20 | "author": "Eon S. Jeon ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/esjeon/krohnkite/issues" 24 | }, 25 | "homepage": "https://github.com/esjeon/krohnkite#readme" 26 | } 27 | -------------------------------------------------------------------------------- /res/popup.qml: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | import QtQuick 2.0 22 | import QtQuick.Controls 2.0 23 | import org.kde.plasma.core 2.0 as PlasmaCore; 24 | 25 | /* 26 | * Component Documentation 27 | * - PlasmaCore global `theme` object: 28 | * https://techbase.kde.org/Development/Tutorials/Plasma2/QML2/API#Plasma_Themes 29 | * - PlasmaCore.Dialog: 30 | * https://techbase.kde.org/Development/Tutorials/Plasma2/QML2/API#Top_Level_windows 31 | */ 32 | 33 | PlasmaCore.Dialog { 34 | id: popupDialog 35 | type: PlasmaCore.Dialog.OnScreenDisplay 36 | flags: Qt.Popup | Qt.WindowStaysOnTopHint 37 | location: PlasmaCore.Types.Floating 38 | outputOnly: true 39 | 40 | visible: false 41 | 42 | mainItem: Item { 43 | width: messageLabel.implicitWidth 44 | height: messageLabel.implicitHeight 45 | 46 | Label { 47 | id: messageLabel 48 | padding: 10 49 | 50 | // TODO: customizable font & size ???? 51 | font.pointSize: Math.round(theme.defaultFont.pointSize * 2) 52 | font.weight: Font.Bold 53 | } 54 | 55 | /* hides the popup window when triggered */ 56 | Timer { 57 | id: hideTimer 58 | repeat: false 59 | 60 | onTriggered: { 61 | popupDialog.visible = false; 62 | } 63 | } 64 | } 65 | 66 | Component.onCompleted: { 67 | /* NOTE: IDK what this is, but this is necessary to keep the window working. */ 68 | KWin.registerWindow(this); 69 | } 70 | 71 | function show(text, area, duration) { 72 | hideTimer.stop(); 73 | 74 | messageLabel.text = text; 75 | 76 | this.x = (area.x + area.width / 2) - this.width / 2; 77 | this.y = (area.y + area.height / 2) - this.height / 2; 78 | this.visible = true; 79 | 80 | hideTimer.interval = duration; 81 | hideTimer.start(); 82 | } 83 | } -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | enum Shortcut { 22 | Left, 23 | Right, 24 | Up, 25 | Down, 26 | 27 | /* Alternate HJKL bindings */ 28 | FocusUp, 29 | FocusDown, 30 | FocusLeft, 31 | FocusRight, 32 | 33 | ShiftLeft, 34 | ShiftRight, 35 | ShiftUp, 36 | ShiftDown, 37 | 38 | SwapUp, 39 | SwapDown, 40 | SwapLeft, 41 | SwapRight, 42 | 43 | GrowWidth, 44 | GrowHeight, 45 | ShrinkWidth, 46 | ShrinkHeight, 47 | 48 | Increase, 49 | Decrease, 50 | ShiftIncrease, 51 | ShiftDecrease, 52 | 53 | ToggleFloat, 54 | ToggleFloatAll, 55 | SetMaster, 56 | NextLayout, 57 | PreviousLayout, 58 | SetLayout, 59 | 60 | Rotate, 61 | RotatePart, 62 | } 63 | 64 | //#region Driver 65 | 66 | interface IConfig { 67 | //#region Layout 68 | layoutOrder: string[]; 69 | layoutFactories: {[key: string]: () => ILayout}; 70 | monocleMaximize: boolean; 71 | maximizeSoleTile: boolean; 72 | //#endregion 73 | 74 | //#region Features 75 | adjustLayout: boolean; 76 | adjustLayoutLive: boolean; 77 | keepFloatAbove: boolean; 78 | noTileBorder: boolean; 79 | limitTileWidthRatio: number; 80 | //#endregion 81 | 82 | //#region Gap 83 | screenGapBottom: number; 84 | screenGapLeft: number; 85 | screenGapRight: number; 86 | screenGapTop: number; 87 | tileLayoutGap: number; 88 | //#endregion 89 | 90 | //#region Behavior 91 | directionalKeyMode: "dwm" | "focus"; 92 | newWindowAsMaster: boolean; 93 | //#endregion 94 | } 95 | 96 | interface IDriverWindow { 97 | readonly fullScreen: boolean; 98 | readonly geometry: Readonly; 99 | readonly id: string; 100 | readonly maximized: boolean; 101 | readonly shouldIgnore: boolean; 102 | readonly shouldFloat: boolean; 103 | 104 | surface: ISurface; 105 | 106 | commit(geometry?: Rect, noBorder?: boolean, keepAbove?: boolean): void; 107 | visible(srf: ISurface): boolean; 108 | } 109 | 110 | interface ISurface { 111 | readonly id: string; 112 | readonly ignore: boolean; 113 | readonly workingArea: Readonly; 114 | 115 | next(): ISurface | null; 116 | } 117 | 118 | interface IDriverContext { 119 | readonly backend: string; 120 | readonly screens: ISurface[]; 121 | readonly cursorPosition: [number, number] | null; 122 | 123 | currentSurface: ISurface; 124 | currentWindow: Window | null; 125 | 126 | setTimeout(func: () => void, timeout: number): void; 127 | showNotification(text: string): void; 128 | } 129 | 130 | //#endregion 131 | 132 | interface ILayoutClass { 133 | readonly id: string; 134 | new(): ILayout; 135 | } 136 | 137 | interface ILayout { 138 | /* read-only */ 139 | readonly capacity?: number; 140 | readonly description: string; 141 | 142 | /* methods */ 143 | adjust?(area: Rect, tiles: Window[], basis: Window, delta: RectDelta): void; 144 | apply(ctx: EngineContext, tileables: Window[], area: Rect): void; 145 | handleShortcut?(ctx: EngineContext, input: Shortcut, data?: any): boolean; 146 | 147 | toString(): string; 148 | } 149 | 150 | let CONFIG: IConfig; 151 | -------------------------------------------------------------------------------- /src/driver/kwin/kwinconfig.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class KWinConfig implements IConfig { 22 | //#region Layout 23 | public layoutOrder: string[]; 24 | public layoutFactories: {[key: string]: () => ILayout}; 25 | public maximizeSoleTile: boolean; 26 | public monocleMaximize: boolean; 27 | public monocleMinimizeRest: boolean; // KWin-specific 28 | //#endregion 29 | 30 | //#region Features 31 | public adjustLayout: boolean; 32 | public adjustLayoutLive: boolean; 33 | public keepFloatAbove: boolean; 34 | public noTileBorder: boolean; 35 | public limitTileWidthRatio: number; 36 | //#endregion 37 | 38 | //#region Gap 39 | public screenGapBottom: number; 40 | public screenGapLeft: number; 41 | public screenGapRight: number; 42 | public screenGapTop: number; 43 | public tileLayoutGap: number; 44 | //#endregion 45 | 46 | //#region Behavior 47 | public directionalKeyMode: "dwm" | "focus"; 48 | public newWindowAsMaster: boolean; 49 | //#endregion 50 | 51 | //#region KWin-specific 52 | public layoutPerActivity: boolean; 53 | public layoutPerDesktop: boolean; 54 | public preventMinimize: boolean; 55 | public preventProtrusion: boolean; 56 | public pollMouseXdotool: boolean; 57 | //#endregion 58 | 59 | //#region KWin-specific Rules 60 | public floatUtility: boolean; 61 | 62 | public floatingClass: string[]; 63 | public floatingTitle: string[]; 64 | public ignoreClass: string[]; 65 | public ignoreTitle: string[]; 66 | public ignoreRole: string[]; 67 | 68 | public ignoreActivity: string[]; 69 | public ignoreScreen: number[]; 70 | //#endregion 71 | 72 | constructor() { 73 | function commaSeparate(str: string): string[] { 74 | if (!str || typeof str !== "string") 75 | return []; 76 | return str.split(",").map((part) => part.trim()); 77 | } 78 | 79 | DEBUG.enabled = DEBUG.enabled || KWin.readConfig("debug", false); 80 | 81 | this.layoutOrder = []; 82 | this.layoutFactories = {}; 83 | ([ 84 | ["enableTileLayout" , true , TileLayout ], 85 | ["enableMonocleLayout" , true , MonocleLayout ], 86 | ["enableThreeColumnLayout", true , ThreeColumnLayout], 87 | ["enableSpreadLayout" , true , SpreadLayout ], 88 | ["enableStairLayout" , true , StairLayout ], 89 | ["enableSpiralLayout" , true , SpiralLayout ], 90 | ["enableQuarterLayout" , false, QuarterLayout ], 91 | ["enableFloatingLayout" , false, FloatingLayout ], 92 | ["enableCascadeLayout" , false, CascadeLayout ], // TODO: add config 93 | ] as Array<[string, boolean, ILayoutClass]>) 94 | .forEach(([configKey, defaultValue, layoutClass]) => { 95 | if (KWin.readConfig(configKey, defaultValue)) 96 | this.layoutOrder.push(layoutClass.id); 97 | this.layoutFactories[layoutClass.id] = (() => new layoutClass()); 98 | }); 99 | 100 | this.maximizeSoleTile = KWin.readConfig("maximizeSoleTile" , false); 101 | this.monocleMaximize = KWin.readConfig("monocleMaximize" , true); 102 | this.monocleMinimizeRest = KWin.readConfig("monocleMinimizeRest" , false); 103 | 104 | this.adjustLayout = KWin.readConfig("adjustLayout" , true); 105 | this.adjustLayoutLive = KWin.readConfig("adjustLayoutLive" , true); 106 | this.keepFloatAbove = KWin.readConfig("keepFloatAbove" , true); 107 | this.noTileBorder = KWin.readConfig("noTileBorder" , false); 108 | 109 | this.limitTileWidthRatio = 0; 110 | if (KWin.readConfig("limitTileWidth" , false)) 111 | this.limitTileWidthRatio = KWin.readConfig("limitTileWidthRatio", 1.6); 112 | 113 | this.screenGapBottom = KWin.readConfig("screenGapBottom" , 0); 114 | this.screenGapLeft = KWin.readConfig("screenGapLeft" , 0); 115 | this.screenGapRight = KWin.readConfig("screenGapRight" , 0); 116 | this.screenGapTop = KWin.readConfig("screenGapTop" , 0); 117 | this.tileLayoutGap = KWin.readConfig("tileLayoutGap" , 0); 118 | 119 | const directionalKeyDwm = KWin.readConfig("directionalKeyDwm" , true); 120 | const directionalKeyFocus = KWin.readConfig("directionalKeyFocus" , false); 121 | this.directionalKeyMode = (directionalKeyDwm) ? "dwm" : "focus"; 122 | this.newWindowAsMaster = KWin.readConfig("newWindowAsMaster" , false); 123 | 124 | this.layoutPerActivity = KWin.readConfig("layoutPerActivity" , true); 125 | this.layoutPerDesktop = KWin.readConfig("layoutPerDesktop" , true); 126 | this.floatUtility = KWin.readConfig("floatUtility" , true); 127 | this.preventMinimize = KWin.readConfig("preventMinimize" , false); 128 | this.preventProtrusion = KWin.readConfig("preventProtrusion" , true); 129 | this.pollMouseXdotool = KWin.readConfig("pollMouseXdotool" , false); 130 | 131 | this.floatingClass = commaSeparate(KWin.readConfig("floatingClass" , "")); 132 | this.floatingTitle = commaSeparate(KWin.readConfig("floatingTitle" , "")); 133 | this.ignoreActivity = commaSeparate(KWin.readConfig("ignoreActivity", "")); 134 | this.ignoreClass = commaSeparate(KWin.readConfig("ignoreClass" , 135 | "krunner,yakuake,spectacle,kded5")); 136 | this.ignoreRole = commaSeparate(KWin.readConfig("ignoreRole" , 137 | "quake")); 138 | 139 | this.ignoreScreen = commaSeparate(KWin.readConfig("ignoreScreen", "")) 140 | .map((str) => parseInt(str, 10)); 141 | this.ignoreTitle = commaSeparate(KWin.readConfig("ignoreTitle" , "")); 142 | 143 | if (this.preventMinimize && this.monocleMinimizeRest) { 144 | debug(() => "preventMinimize is disabled because of monocleMinimizeRest."); 145 | this.preventMinimize = false; 146 | } 147 | } 148 | 149 | public toString(): string { 150 | return "Config(" + JSON.stringify(this, undefined, 2) + ")"; 151 | } 152 | } 153 | 154 | /* HACK: save casting */ 155 | let KWINCONFIG: KWinConfig; 156 | -------------------------------------------------------------------------------- /src/driver/kwin/kwindriver.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | /** 22 | * Abstracts KDE implementation specific details. 23 | * 24 | * Driver is responsible for initializing the tiling logic, connecting 25 | * signals(Qt/KDE term for binding events), and providing specific utility 26 | * functions. 27 | */ 28 | class KWinDriver implements IDriverContext { 29 | public static backendName: string = "kwin"; 30 | 31 | // TODO: split context implementation 32 | //#region implement properties of IDriverContext (except `setTimeout`) 33 | public get backend(): string { 34 | return KWinDriver.backendName; 35 | } 36 | 37 | public get currentSurface(): ISurface { 38 | return new KWinSurface( 39 | (workspace.activeClient) ? workspace.activeClient.screen : 0, 40 | workspace.currentActivity, 41 | workspace.currentDesktop, 42 | ); 43 | } 44 | 45 | public set currentSurface(value: ISurface) { 46 | const ksrf = value as KWinSurface; 47 | 48 | /* NOTE: only supports switching desktops */ 49 | // TODO: fousing window on other screen? 50 | // TODO: find a way to change activity 51 | 52 | if (workspace.currentDesktop !== ksrf.desktop) 53 | workspace.currentDesktop = ksrf.desktop; 54 | } 55 | 56 | public get currentWindow(): Window | null { 57 | const client = workspace.activeClient; 58 | return (client) ? this.windowMap.get(client) : null; 59 | } 60 | 61 | public set currentWindow(window: Window | null) { 62 | if (window !== null) 63 | workspace.activeClient = (window.window as KWinWindow).client; 64 | } 65 | 66 | public get screens(): ISurface[] { 67 | const screens = []; 68 | for (let screen = 0; screen < workspace.numScreens; screen++) 69 | screens.push(new KWinSurface( 70 | screen, workspace.currentActivity, workspace.currentDesktop)); 71 | return screens; 72 | } 73 | 74 | public get cursorPosition(): [number, number] | null { 75 | return this.mousePoller.mousePosition; 76 | } 77 | 78 | //#endregion 79 | 80 | private engine: TilingEngine; 81 | private control: TilingController; 82 | private windowMap: WrapperMap; 83 | private entered: boolean; 84 | private mousePoller: KWinMousePoller; 85 | 86 | constructor() { 87 | this.engine = new TilingEngine(); 88 | this.control = new TilingController(this.engine); 89 | this.windowMap = new WrapperMap( 90 | (client: KWin.Client) => KWinWindow.generateID(client), 91 | (client: KWin.Client) => new Window(new KWinWindow(client)), 92 | ); 93 | this.entered = false; 94 | this.mousePoller = new KWinMousePoller(); 95 | } 96 | 97 | /* 98 | * Main 99 | */ 100 | 101 | public main() { 102 | CONFIG = KWINCONFIG = new KWinConfig(); 103 | debug(() => "Config: " + KWINCONFIG); 104 | 105 | this.bindEvents(); 106 | this.bindShortcut(); 107 | 108 | const clients = workspace.clientList(); 109 | for (let i = 0; i < clients.length; i++) { 110 | const window = this.windowMap.add(clients[i]); 111 | this.engine.manage(window); 112 | if (window.state !== WindowState.Unmanaged) 113 | this.bindWindowEvents(window, clients[i]); 114 | else 115 | this.windowMap.remove(clients[i]); 116 | } 117 | this.engine.arrange(this); 118 | } 119 | 120 | //#region implement methods of IDriverContext` 121 | public setTimeout(func: () => void, timeout: number) { 122 | KWinSetTimeout(() => this.enter(func), timeout); 123 | } 124 | 125 | public showNotification(text: string) { 126 | popupDialog.show(text); 127 | } 128 | //#endregion 129 | 130 | private bindShortcut() { 131 | if (!KWin.registerShortcut) { 132 | debug(() => "KWin.registerShortcut doesn't exist. Omitting shortcut binding."); 133 | return; 134 | } 135 | 136 | const bind = (seq: string, title: string, input: Shortcut) => { 137 | title = "Krohnkite: " + title; 138 | seq = "Meta+" + seq; 139 | KWin.registerShortcut(title, "", seq, () => { 140 | this.enter(() => 141 | this.control.onShortcut(this, input)); 142 | }); 143 | }; 144 | 145 | bind("J", "Down/Next", Shortcut.Down); 146 | bind("K", "Up/Prev" , Shortcut.Up); 147 | bind("H", "Left" , Shortcut.Left); 148 | bind("L", "Right" , Shortcut.Right); 149 | 150 | bind("Shift+J", "Move Down/Next", Shortcut.ShiftDown); 151 | bind("Shift+K", "Move Up/Prev" , Shortcut.ShiftUp); 152 | bind("Shift+H", "Move Left" , Shortcut.ShiftLeft); 153 | bind("Shift+L", "Move Right" , Shortcut.ShiftRight); 154 | 155 | bind("Ctrl+J", "Grow Height" , Shortcut.GrowHeight); 156 | bind("Ctrl+K", "Shrink Height" , Shortcut.ShrinkHeight); 157 | bind("Ctrl+H", "Shrink Width" , Shortcut.ShrinkWidth); 158 | bind("Ctrl+L", "Grow Width" , Shortcut.GrowWidth); 159 | 160 | bind("I", "Increase", Shortcut.Increase); 161 | bind("D", "Decrease", Shortcut.Decrease); 162 | 163 | bind("F", "Float", Shortcut.ToggleFloat); 164 | bind("Shift+F", "Float All", Shortcut.ToggleFloatAll); 165 | bind("", "Cycle Layout", Shortcut.NextLayout); // TODO: remove this shortcut 166 | bind("\\", "Next Layout", Shortcut.NextLayout); 167 | bind("|", "Previous Layout", Shortcut.PreviousLayout); 168 | 169 | bind("R", "Rotate", Shortcut.Rotate); 170 | bind("Shift+R", "Rotate Part", Shortcut.RotatePart); 171 | 172 | bind("Return", "Set master", Shortcut.SetMaster); 173 | 174 | const bindLayout = (seq: string, title: string, layoutClass: ILayoutClass) => { 175 | title = "Krohnkite: " + title + " Layout"; 176 | seq = (seq !== "") ? "Meta+" + seq : ""; 177 | KWin.registerShortcut(title, "", seq, () => { 178 | this.enter(() => 179 | this.control.onShortcut(this, Shortcut.SetLayout, layoutClass.id)); 180 | }); 181 | }; 182 | 183 | bindLayout("T", "Tile", TileLayout); 184 | bindLayout("M", "Monocle", MonocleLayout); 185 | bindLayout("", "Three Column", ThreeColumnLayout); 186 | bindLayout("", "Spread", SpreadLayout); 187 | bindLayout("", "Stair", StairLayout); 188 | bindLayout("", "Floating", FloatingLayout); 189 | bindLayout("", "Quarter", QuarterLayout); 190 | } 191 | 192 | //#region Helper functions 193 | /** 194 | * Binds callback to the signal w/ extra fail-safe measures, like re-entry 195 | * prevention and auto-disconnect on termination. 196 | */ 197 | private connect(signal: QSignal, handler: (..._: any[]) => void): (() => void) { 198 | const wrapper = (...args: any[]) => { 199 | /* HACK: `workspace` become undefined when the script is disabled. */ 200 | if (typeof workspace === "undefined") 201 | signal.disconnect(wrapper); 202 | else 203 | this.enter(() => handler.apply(this, args)); 204 | }; 205 | signal.connect(wrapper); 206 | 207 | return wrapper; 208 | } 209 | 210 | /** 211 | * Run the given function in a protected(?) context to prevent nested event 212 | * handling. 213 | * 214 | * KWin emits signals as soons as window states are changed, even when 215 | * those states are modified by the script. This causes multiple re-entry 216 | * during event handling, resulting in performance degradation and harder 217 | * debugging. 218 | */ 219 | private enter(callback: () => void) { 220 | if (this.entered) 221 | return; 222 | 223 | this.entered = true; 224 | try { 225 | callback(); 226 | } catch (e: any) { 227 | debug(() => "Error raised from line " + e.lineNumber); 228 | debug(() => e); 229 | } finally { 230 | this.entered = false; 231 | } 232 | } 233 | //#endregion 234 | 235 | private bindEvents() { 236 | this.connect(workspace.numberScreensChanged, (count: number) => 237 | this.control.onSurfaceUpdate(this, "screens=" + count)); 238 | 239 | this.connect(workspace.screenResized, (screen: number) => { 240 | const srf = new KWinSurface( 241 | screen, workspace.currentActivity, workspace.currentDesktop); 242 | this.control.onSurfaceUpdate(this, "resized " + srf.toString()); 243 | }); 244 | 245 | this.connect(workspace.currentActivityChanged, (activity: string) => 246 | this.control.onCurrentSurfaceChanged(this)); 247 | 248 | this.connect(workspace.currentDesktopChanged, (desktop: number, client: KWin.Client) => 249 | this.control.onCurrentSurfaceChanged(this)); 250 | 251 | this.connect(workspace.clientAdded, (client: KWin.Client) => { 252 | /* NOTE: windowShown can be fired in various situations. 253 | * We need only the first one - when window is created. */ 254 | let handled = false; 255 | const handler = () => { 256 | if (handled) 257 | return; 258 | handled = true; 259 | 260 | const window = this.windowMap.add(client); 261 | this.control.onWindowAdded(this, window); 262 | if (window.state !== WindowState.Unmanaged) 263 | this.bindWindowEvents(window, client); 264 | else 265 | this.windowMap.remove(client); 266 | 267 | client.windowShown.disconnect(wrapper); 268 | }; 269 | 270 | const wrapper = this.connect(client.windowShown, handler); 271 | this.setTimeout(handler, 50); 272 | }); 273 | 274 | this.connect(workspace.clientRemoved, (client: KWin.Client) => { 275 | const window = this.windowMap.get(client); 276 | if (window) { 277 | this.control.onWindowRemoved(this, window); 278 | this.windowMap.remove(client); 279 | } 280 | }); 281 | 282 | this.connect(workspace.clientMaximizeSet, (client: KWin.Client, h: boolean, v: boolean) => { 283 | const maximized = (h === true && v === true); 284 | const window = this.windowMap.get(client); 285 | if (window) { 286 | (window.window as KWinWindow).maximized = maximized; 287 | this.control.onWindowMaximizeChanged(this, window, maximized); 288 | } 289 | }); 290 | 291 | this.connect(workspace.clientFullScreenSet, (client: KWin.Client, fullScreen: boolean, user: boolean) => 292 | this.control.onWindowChanged(this, this.windowMap.get(client), 293 | "fullscreen=" + fullScreen + " user=" + user)); 294 | 295 | this.connect(workspace.clientMinimized, (client: KWin.Client) => { 296 | if (KWINCONFIG.preventMinimize) { 297 | client.minimized = false; 298 | workspace.activeClient = client; 299 | } else 300 | this.control.onWindowChanged(this, this.windowMap.get(client), "minimized"); 301 | }); 302 | 303 | this.connect(workspace.clientUnminimized, (client: KWin.Client) => 304 | this.control.onWindowChanged(this, this.windowMap.get(client), "unminimized")); 305 | 306 | // TODO: options.configChanged.connect(this.onConfigChanged); 307 | /* NOTE: How disappointing. This doesn't work at all. Even an official kwin script tries this. 308 | * https://github.com/KDE/kwin/blob/master/scripts/minimizeall/contents/code/main.js */ 309 | } 310 | 311 | private bindWindowEvents(window: Window, client: KWin.Client) { 312 | let moving = false; 313 | let resizing = false; 314 | 315 | this.connect(client.moveResizedChanged, () => { 316 | debugObj(() => ["moveResizedChanged", {window, move: client.move, resize: client.resize}]); 317 | if (moving !== client.move) { 318 | moving = client.move; 319 | if (moving) { 320 | this.mousePoller.start(); 321 | this.control.onWindowMoveStart(window); 322 | } else { 323 | this.control.onWindowMoveOver(this, window); 324 | this.mousePoller.stop(); 325 | } 326 | } 327 | if (resizing !== client.resize) { 328 | resizing = client.resize; 329 | if (resizing) 330 | this.control.onWindowResizeStart(window); 331 | else 332 | this.control.onWindowResizeOver(this, window); 333 | } 334 | }); 335 | 336 | this.connect(client.geometryChanged, () => { 337 | if (moving) 338 | this.control.onWindowMove(window); 339 | else if (resizing) 340 | this.control.onWindowResize(this, window); 341 | else { 342 | if (!window.actualGeometry.equals(window.geometry)) 343 | this.control.onWindowGeometryChanged(this, window); 344 | } 345 | }); 346 | 347 | this.connect(client.activeChanged, () => { 348 | if (client.active) 349 | this.control.onWindowFocused(this, window); 350 | }); 351 | 352 | this.connect(client.screenChanged, () => 353 | this.control.onWindowChanged(this, window, "screen=" + client.screen)); 354 | 355 | this.connect(client.activitiesChanged, () => 356 | this.control.onWindowChanged(this, window, "activity=" + client.activities.join(","))); 357 | 358 | this.connect(client.desktopChanged, () => 359 | this.control.onWindowChanged(this, window, "desktop=" + client.desktop)); 360 | } 361 | 362 | // TODO: private onConfigChanged = () => { 363 | // this.loadConfig(); 364 | // this.engine.arrange(); 365 | // } 366 | /* NOTE: check `bindEvents` for details */ 367 | } 368 | -------------------------------------------------------------------------------- /src/driver/kwin/kwinmousepoller.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class KWinMousePoller { 22 | public static readonly COMMAND = "xdotool getmouselocation"; 23 | public static readonly INTERVAL = 50; /* ms */ 24 | 25 | public get started(): boolean { 26 | return this.startCount > 0; 27 | } 28 | 29 | public get mousePosition(): [number, number] | null { 30 | return this.parseResult(); 31 | } 32 | 33 | /** poller activates only when count > 0 */ 34 | private startCount: number; 35 | private cmdResult: string | null; 36 | 37 | constructor() { 38 | this.startCount = 0; 39 | this.cmdResult = null; 40 | 41 | /* we will poll manually, because this interval value will be 42 | * aligned to intervalAlignment, which probably is 1000. */ 43 | mousePoller.interval = 0; 44 | 45 | mousePoller.onNewData.connect((sourceName: string, data: any) => { 46 | // tslint:disable-next-line:no-string-literal 47 | this.cmdResult = (data["exit code"] === 0) ? data["stdout"] : null; 48 | mousePoller.disconnectSource(KWinMousePoller.COMMAND); 49 | 50 | KWinSetTimeout(() => { 51 | if (this.started) 52 | mousePoller.connectSource(KWinMousePoller.COMMAND); 53 | }, KWinMousePoller.INTERVAL); 54 | }); 55 | } 56 | 57 | public start() { 58 | this.startCount += 1; 59 | if (KWINCONFIG.pollMouseXdotool) 60 | mousePoller.connectSource(KWinMousePoller.COMMAND); 61 | } 62 | 63 | public stop() { 64 | this.startCount = Math.max(this.startCount - 1, 0); 65 | } 66 | 67 | private parseResult(): [number, number] | null { 68 | // output example: x:1031 y:515 screen:0 window:90177537 69 | if (!this.cmdResult) 70 | return null; 71 | 72 | let x: number | null = null; 73 | let y: number | null = null; 74 | this.cmdResult 75 | .split(" ") 76 | .slice(0, 2) 77 | .forEach((part) => { 78 | const [key, value, _] = part.split(":"); 79 | if (key === "x") x = parseInt(value, 10); 80 | if (key === "y") y = parseInt(value, 10); 81 | }); 82 | 83 | if (x === null || y === null) 84 | return null; 85 | return [x, y]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/driver/kwin/kwinsettimeout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class KWinTimerPool { 22 | public static readonly instance = new KWinTimerPool(); 23 | 24 | public timers: QQmlTimer[]; 25 | public numTimers: number; 26 | 27 | constructor() { 28 | this.timers = []; 29 | this.numTimers = 0; 30 | } 31 | 32 | public setTimeout(func: () => void, timeout: number) { 33 | if (this.timers.length === 0) { 34 | this.numTimers ++; 35 | debugObj(() => ["setTimeout/newTimer", { numTimers: this.numTimers}]); 36 | } 37 | 38 | const timer: QQmlTimer = this.timers.pop() || 39 | Qt.createQmlObject("import QtQuick 2.0; Timer {}", scriptRoot); 40 | 41 | const callback = () => { 42 | try { timer.triggered.disconnect(callback); } catch (e) { /* ignore */ } 43 | try { func(); } catch (e) { /* ignore */ } 44 | this.timers.push(timer); 45 | }; 46 | 47 | timer.interval = timeout; 48 | timer.repeat = false; 49 | timer.triggered.connect(callback); 50 | timer.start(); 51 | } 52 | } 53 | 54 | function KWinSetTimeout(func: () => void, timeout: number) { 55 | KWinTimerPool.instance.setTimeout(func, timeout); 56 | } 57 | -------------------------------------------------------------------------------- /src/driver/kwin/kwinsurface.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class KWinSurface implements ISurface { 22 | public static generateId(screen: number, activity: string, desktop: number) { 23 | let path = String(screen); 24 | if (KWINCONFIG.layoutPerActivity) 25 | path += "@" + activity; 26 | if (KWINCONFIG.layoutPerDesktop) 27 | path += "#" + desktop; 28 | return path; 29 | } 30 | 31 | public readonly id: string; 32 | public readonly ignore: boolean; 33 | public readonly workingArea: Rect; 34 | 35 | public readonly screen: number; 36 | public readonly activity: string; 37 | public readonly desktop: number; 38 | 39 | constructor(screen: number, activity: string, desktop: number) { 40 | const activityName = activityInfo.activityName(activity); 41 | 42 | this.id = KWinSurface.generateId(screen, activity, desktop); 43 | this.ignore = ( 44 | (KWINCONFIG.ignoreActivity.indexOf(activityName) >= 0) 45 | || (KWINCONFIG.ignoreScreen.indexOf(screen) >= 0) 46 | ); 47 | this.workingArea = toRect( 48 | workspace.clientArea(KWin.PlacementArea, screen, desktop)); 49 | 50 | this.screen = screen; 51 | this.activity = activity; 52 | this.desktop = desktop; 53 | } 54 | 55 | public next(): ISurface | null { 56 | if (this.desktop === workspace.desktops) 57 | /* this is the last virtual desktop */ 58 | /* TODO: option to create additional desktop */ 59 | return null; 60 | 61 | return new KWinSurface(this.screen, this.activity, this.desktop + 1); 62 | } 63 | 64 | public toString(): string { 65 | return "KWinSurface(" + [this.screen, activityInfo.activityName(this.activity), this.desktop].join(", ") + ")"; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/driver/kwin/kwinwindow.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class KWinWindow implements IDriverWindow { 22 | public static generateID(client: KWin.Client) { 23 | return String(client) + "/" + client.windowId; 24 | } 25 | 26 | public readonly client: KWin.Client; 27 | public readonly id: string; 28 | 29 | public get fullScreen(): boolean { 30 | return this.client.fullScreen; 31 | } 32 | 33 | public get geometry(): Rect { 34 | return toRect(this.client.geometry); 35 | } 36 | 37 | public get shouldIgnore(): boolean { 38 | const resourceClass = String(this.client.resourceClass); 39 | const resourceName = String(this.client.resourceName); 40 | const windowRole = String(this.client.windowRole); 41 | return ( 42 | this.client.specialWindow 43 | || resourceClass === "plasmashell" 44 | || (KWINCONFIG.ignoreClass.indexOf(resourceClass) >= 0) 45 | || (KWINCONFIG.ignoreClass.indexOf(resourceName) >= 0) 46 | || (matchWords(this.client.caption, KWINCONFIG.ignoreTitle) >= 0) 47 | || (KWINCONFIG.ignoreRole.indexOf(windowRole) >= 0) 48 | ); 49 | } 50 | 51 | public get shouldFloat(): boolean { 52 | const resourceClass = String(this.client.resourceClass); 53 | const resourceName = String(this.client.resourceName); 54 | return ( 55 | this.client.modal 56 | || (!this.client.resizeable) 57 | || (KWINCONFIG.floatUtility 58 | && (this.client.dialog || this.client.splash || this.client.utility)) 59 | || (KWINCONFIG.floatingClass.indexOf(resourceClass) >= 0) 60 | || (KWINCONFIG.floatingClass.indexOf(resourceName) >= 0) 61 | || (matchWords(this.client.caption, KWINCONFIG.floatingTitle) >= 0) 62 | ); 63 | } 64 | 65 | public maximized: boolean; 66 | 67 | public get surface(): ISurface { 68 | let activity; 69 | if (this.client.activities.length === 0) 70 | activity = workspace.currentActivity; 71 | else if (this.client.activities.indexOf(workspace.currentActivity) >= 0) 72 | activity = workspace.currentActivity; 73 | else 74 | activity = this.client.activities[0]; 75 | 76 | const desktop = (this.client.desktop >= 0) 77 | ? this.client.desktop 78 | : workspace.currentDesktop; 79 | 80 | return new KWinSurface(this.client.screen, activity, desktop); 81 | } 82 | 83 | public set surface(srf: ISurface) { 84 | const ksrf = srf as KWinSurface; 85 | 86 | // TODO: setting activity? 87 | // TODO: setting screen = move to the screen 88 | if (this.client.desktop !== ksrf.desktop) 89 | this.client.desktop = ksrf.desktop; 90 | } 91 | 92 | private noBorderManaged: boolean; 93 | private noBorderOriginal: boolean; 94 | 95 | constructor(client: KWin.Client) { 96 | this.client = client; 97 | this.id = KWinWindow.generateID(client); 98 | this.maximized = false; 99 | this.noBorderManaged = false; 100 | this.noBorderOriginal = client.noBorder; 101 | } 102 | 103 | public commit(geometry?: Rect, noBorder?: boolean, keepAbove?: boolean) { 104 | debugObj(() => ["KWinWindow#commit", { geometry, noBorder, keepAbove }]); 105 | 106 | if (this.client.move || this.client.resize) 107 | return; 108 | 109 | if (noBorder !== undefined) { 110 | if (!this.noBorderManaged && noBorder) 111 | /* Backup border state when transitioning from unmanaged to managed */ 112 | this.noBorderOriginal = this.client.noBorder; 113 | else if (this.noBorderManaged && !this.client.noBorder) 114 | /* If border is enabled while in managed mode, remember it. 115 | * Note that there's no way to know if border is re-disabled in managed mode. */ 116 | this.noBorderOriginal = false; 117 | 118 | if (noBorder) 119 | /* (Re)entering managed mode: remove border. */ 120 | this.client.noBorder = true; 121 | else if (this.noBorderManaged) 122 | /* Exiting managed mode: restore original value. */ 123 | this.client.noBorder = this.noBorderOriginal; 124 | 125 | /* update mode */ 126 | this.noBorderManaged = noBorder; 127 | } 128 | 129 | if (keepAbove !== undefined) 130 | this.client.keepAbove = keepAbove; 131 | 132 | if (geometry !== undefined) { 133 | geometry = this.adjustGeometry(geometry); 134 | if (KWINCONFIG.preventProtrusion) { 135 | const area = toRect( 136 | workspace.clientArea(KWin.PlacementArea, this.client.screen, workspace.currentDesktop)); 137 | if (!area.includes(geometry)) { 138 | /* assume windows will extrude only through right and bottom edges */ 139 | const x = geometry.x + Math.min(area.maxX - geometry.maxX, 0); 140 | const y = geometry.y + Math.min(area.maxY - geometry.maxY, 0); 141 | geometry = new Rect(x, y, geometry.width, geometry.height); 142 | geometry = this.adjustGeometry(geometry); 143 | } 144 | } 145 | this.client.geometry = toQRect(geometry); 146 | } 147 | } 148 | 149 | public toString(): string { 150 | /* using a shorthand name to keep debug message tidy */ 151 | return "KWin(" + this.client.windowId.toString(16) + "." + this.client.resourceClass + ")"; 152 | } 153 | 154 | public visible(srf: ISurface): boolean { 155 | const ksrf = srf as KWinSurface; 156 | return ( 157 | (!this.client.minimized) 158 | && (this.client.desktop === ksrf.desktop 159 | || this.client.desktop === -1 /* on all desktop */) 160 | && (this.client.activities.length === 0 /* on all activities */ 161 | || this.client.activities.indexOf(ksrf.activity) !== -1) 162 | && (this.client.screen === ksrf.screen) 163 | ); 164 | } 165 | 166 | //#region Private Methods 167 | 168 | /** apply various resize hints to the given geometry */ 169 | private adjustGeometry(geometry: Rect): Rect { 170 | let width = geometry.width; 171 | let height = geometry.height; 172 | 173 | /* do not resize fixed-size windows */ 174 | if (!this.client.resizeable) { 175 | width = this.client.geometry.width; 176 | height = this.client.geometry.height; 177 | } else { 178 | /* respect resize increment */ 179 | if (!(this.client.basicUnit.width === 1 && this.client.basicUnit.height === 1)) /* NOT free-size */ 180 | [width, height] = this.applyResizeIncrement(geometry); 181 | 182 | /* respect min/max size limit */ 183 | width = clip(width , this.client.minSize.width , this.client.maxSize.width ); 184 | height = clip(height, this.client.minSize.height, this.client.maxSize.height); 185 | } 186 | 187 | return new Rect(geometry.x, geometry.y, width, height); 188 | } 189 | 190 | private applyResizeIncrement(geom: Rect): [number, number] { 191 | const unit = this.client.basicUnit; 192 | const base = this.client.minSize; 193 | 194 | const padWidth = this.client.geometry.width - this.client.clientSize.width; 195 | const padHeight = this.client.geometry.height - this.client.clientSize.height; 196 | 197 | const quotWidth = Math.floor((geom.width - base.width - padWidth ) / unit.width); 198 | const quotHeight = Math.floor((geom.height - base.height - padHeight) / unit.height); 199 | 200 | const newWidth = base.width + unit.width * quotWidth + padWidth ; 201 | const newHeight = base.height + unit.height * quotHeight + padHeight; 202 | 203 | // debugObj(() => ["applyResizeIncrement", { 204 | // // tslint:disable-next-line:object-literal-sort-keys 205 | // unit, base, geom, 206 | // pad: [padWidth, padHeight].join("x"), 207 | // size: [newWidth, newHeight].join("x"), 208 | // }]); 209 | 210 | return [newWidth, newHeight]; 211 | } 212 | 213 | //#endregion 214 | } 215 | -------------------------------------------------------------------------------- /src/driver/test/testdriver.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class TestDriver { 22 | public currentScreen: number; 23 | public currentWindow: number; 24 | public numScreen: number; 25 | public screenSize: Rect; 26 | public windows: Window[]; 27 | 28 | constructor() { 29 | this.currentScreen = 0; 30 | this.currentWindow = 0; 31 | this.numScreen = 1; 32 | this.screenSize = new Rect(0, 0, 10000, 10000); 33 | this.windows = []; 34 | } 35 | 36 | public forEachScreen(func: (srf: ISurface) => void) { 37 | for (let screen = 0; screen < this.numScreen; screen ++) 38 | func(new TestSurface(this, screen)); 39 | } 40 | 41 | public getCurrentContext(): ISurface { 42 | const window = this.getCurrentWindow(); 43 | if (window) 44 | return window.surface; 45 | return new TestSurface(this, 0); 46 | } 47 | 48 | public getCurrentWindow(): Window | null { 49 | return (this.windows.length !== 0) 50 | ? this.windows[this.currentWindow] 51 | : null; 52 | } 53 | 54 | public getWorkingArea(srf: ISurface): Rect { 55 | return this.screenSize; 56 | } 57 | 58 | public setCurrentWindow(window: Window) { 59 | const idx = this.windows.indexOf(window); 60 | if (idx !== -1) 61 | this.currentWindow = idx; 62 | } 63 | 64 | public setTimeout(func: () => void, timeout: number) { 65 | setTimeout(func, timeout); 66 | } 67 | } 68 | 69 | class TestSurface implements ISurface { 70 | public readonly screen: number; 71 | 72 | public get id(): string { 73 | return String(this.screen); 74 | } 75 | 76 | public get ignore(): boolean { 77 | // TODO: optionally ignore some surface to test LayoutStore 78 | return false; 79 | } 80 | 81 | public get workingArea(): Rect { 82 | return this.driver.screenSize; 83 | } 84 | 85 | constructor(private driver: TestDriver, screen: number) { 86 | this.screen = screen; 87 | } 88 | 89 | public next(): ISurface { 90 | return new TestSurface(this.driver, this.screen + 1); 91 | } 92 | } 93 | 94 | class TestWindow implements IDriverWindow { 95 | private static windowCount: number = 0; 96 | 97 | public readonly id: string; 98 | public readonly shouldFloat: boolean; 99 | public readonly shouldIgnore: boolean; 100 | 101 | public surface: TestSurface; 102 | public fullScreen: boolean; 103 | public geometry: Rect; 104 | public keepAbove: boolean; 105 | public maximized: boolean; 106 | public noBorder: boolean; 107 | 108 | constructor(srf: TestSurface, geometry?: Rect, ignore?: boolean, float?: boolean) { 109 | this.id = String(TestWindow.windowCount); 110 | TestWindow.windowCount += 1; 111 | 112 | this.shouldFloat = (float !== undefined) ? float : false; 113 | this.shouldIgnore = (ignore !== undefined) ? ignore : false; 114 | 115 | this.surface = srf; 116 | this.fullScreen = false; 117 | this.geometry = geometry || new Rect(0, 0, 100, 100); 118 | this.keepAbove = false; 119 | this.maximized = false; 120 | this.noBorder = false; 121 | } 122 | 123 | public commit(geometry?: Rect, noBorder?: boolean, keepAbove?: boolean) { 124 | if (geometry) 125 | this.geometry = geometry; 126 | if (noBorder !== undefined) 127 | this.noBorder = noBorder; 128 | if (keepAbove !== undefined) 129 | this.keepAbove = keepAbove; 130 | } 131 | 132 | public focus() { 133 | // TODO: track focus 134 | } 135 | 136 | public visible(srf: ISurface): boolean { 137 | const tctx = srf as TestSurface; 138 | return this.surface.screen === tctx.screen; 139 | } 140 | } 141 | 142 | function setTestConfig(name: string, value: any) { 143 | if (!CONFIG) 144 | CONFIG = {} as any; 145 | (CONFIG as any)[name] = value; 146 | } 147 | -------------------------------------------------------------------------------- /src/engine/control.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | /** 22 | * TilingController translates events to actions, implementing high-level 23 | * window management logic. 24 | * 25 | * In short, this class is just a bunch of event handling methods. 26 | */ 27 | class TilingController { 28 | private engine: TilingEngine; 29 | 30 | public constructor(engine: TilingEngine) { 31 | this.engine = engine; 32 | } 33 | 34 | public onSurfaceUpdate(ctx: IDriverContext, comment: string): void { 35 | debugObj(() => ["onSurfaceUpdate", {comment}]); 36 | this.engine.arrange(ctx); 37 | } 38 | 39 | public onCurrentSurfaceChanged(ctx: IDriverContext): void { 40 | debugObj(() => ["onCurrentSurfaceChanged", {srf: ctx.currentSurface}]); 41 | this.engine.arrange(ctx); 42 | } 43 | 44 | public onWindowAdded(ctx: IDriverContext, window: Window): void { 45 | debugObj(() => ["onWindowAdded", {window}]); 46 | this.engine.manage(window); 47 | 48 | /* move window to next surface if the current surface is "full" */ 49 | if (window.tileable) { 50 | const srf = ctx.currentSurface; 51 | const tiles = this.engine.windows.getVisibleTiles(srf); 52 | const layoutCapcity = this.engine.layouts.getCurrentLayout(srf).capacity; 53 | if (layoutCapcity !== undefined && tiles.length > layoutCapcity) { 54 | const nsrf = ctx.currentSurface.next(); 55 | if (nsrf) { 56 | // (window.window as KWinWindow).client.desktop = (nsrf as KWinSurface).desktop; 57 | window.surface = nsrf; 58 | ctx.currentSurface = nsrf; 59 | } 60 | } 61 | } 62 | 63 | this.engine.arrange(ctx); 64 | } 65 | 66 | public onWindowRemoved(ctx: IDriverContext, window: Window): void { 67 | debugObj(() => ["onWindowRemoved", {window}]); 68 | this.engine.unmanage(window); 69 | this.engine.arrange(ctx); 70 | } 71 | 72 | public onWindowMoveStart(window: Window): void { 73 | /* do nothing */ 74 | } 75 | 76 | public onWindowMove(window: Window): void { 77 | /* do nothing */ 78 | } 79 | 80 | public onWindowMoveOver(ctx: IDriverContext, window: Window): void { 81 | debugObj(() => ["onWindowMoveOver", {window}]); 82 | 83 | /* swap window by dragging */ 84 | if (window.state === WindowState.Tiled) { 85 | const tiles = this.engine.windows.getVisibleTiles(ctx.currentSurface); 86 | const cursorPos = ctx.cursorPosition || window.actualGeometry.center; 87 | 88 | const targets = tiles.filter((tile) => 89 | tile !== window && tile.actualGeometry.includesPoint(cursorPos)); 90 | 91 | if (targets.length === 1) { 92 | this.engine.windows.swap(window, targets[0]); 93 | this.engine.arrange(ctx); 94 | return; 95 | } 96 | } 97 | 98 | /* ... or float window by dragging */ 99 | if (window.state === WindowState.Tiled) { 100 | const diff = window.actualGeometry.subtract(window.geometry); 101 | const distance = Math.sqrt(diff.x ** 2 + diff.y ** 2); 102 | // TODO: arbitrary constant 103 | if (distance > 30) { 104 | window.floatGeometry = window.actualGeometry; 105 | window.state = WindowState.Floating; 106 | this.engine.arrange(ctx); 107 | return; 108 | } 109 | } 110 | 111 | /* ... or return to the previous position */ 112 | window.commit(); 113 | } 114 | 115 | public onWindowResizeStart(window: Window): void { 116 | /* do nothing */ 117 | } 118 | 119 | public onWindowResize(ctx: IDriverContext, window: Window): void { 120 | debugObj(() => ["onWindowResize", {window}]); 121 | if (CONFIG.adjustLayout && CONFIG.adjustLayoutLive) { 122 | if (window.state === WindowState.Tiled) { 123 | this.engine.adjustLayout(window); 124 | this.engine.arrange(ctx); 125 | } 126 | } 127 | } 128 | 129 | public onWindowResizeOver(ctx: IDriverContext, window: Window): void { 130 | debugObj(() => ["onWindowResizeOver", {window}]); 131 | if (CONFIG.adjustLayout && window.tiled) { 132 | this.engine.adjustLayout(window); 133 | this.engine.arrange(ctx); 134 | } else if (!CONFIG.adjustLayout) 135 | this.engine.enforceSize(ctx, window); 136 | } 137 | 138 | public onWindowMaximizeChanged(ctx: IDriverContext, window: Window, maximized: boolean): void { 139 | this.engine.arrange(ctx); 140 | } 141 | 142 | public onWindowGeometryChanged(ctx: IDriverContext, window: Window): void { 143 | debugObj(() => ["onWindowGeometryChanged", {window}]); 144 | this.engine.enforceSize(ctx, window); 145 | } 146 | 147 | // NOTE: accepts `null` to simplify caller. This event is a catch-all hack 148 | // by itself anyway. 149 | public onWindowChanged(ctx: IDriverContext, window: Window | null, comment?: string): void { 150 | if (window) { 151 | debugObj(() => ["onWindowChanged", {window, comment}]); 152 | 153 | if (comment === "unminimized") 154 | ctx.currentWindow = window; 155 | 156 | this.engine.arrange(ctx); 157 | } 158 | } 159 | 160 | public onWindowFocused(ctx: IDriverContext, window: Window) { 161 | window.timestamp = new Date().getTime(); 162 | } 163 | 164 | public onShortcut(ctx: IDriverContext, input: Shortcut, data?: any) { 165 | if (CONFIG.directionalKeyMode === "focus") { 166 | switch (input) { 167 | case Shortcut.Up : input = Shortcut.FocusUp; break; 168 | case Shortcut.Down : input = Shortcut.FocusDown; break; 169 | case Shortcut.Left : input = Shortcut.FocusLeft; break; 170 | case Shortcut.Right: input = Shortcut.FocusRight; break; 171 | 172 | case Shortcut.ShiftUp : input = Shortcut.SwapUp; break; 173 | case Shortcut.ShiftDown : input = Shortcut.SwapDown; break; 174 | case Shortcut.ShiftLeft : input = Shortcut.SwapLeft; break; 175 | case Shortcut.ShiftRight: input = Shortcut.SwapRight; break; 176 | } 177 | } 178 | 179 | if (this.engine.handleLayoutShortcut(ctx, input, data)) { 180 | this.engine.arrange(ctx); 181 | return; 182 | } 183 | 184 | const window = ctx.currentWindow; 185 | switch (input) { 186 | case Shortcut.Up : this.engine.focusOrder(ctx, -1); break; 187 | case Shortcut.Down: this.engine.focusOrder(ctx, +1); break; 188 | 189 | case Shortcut.FocusUp : this.engine.focusDir(ctx, "up" ); break; 190 | case Shortcut.FocusDown : this.engine.focusDir(ctx, "down" ); break; 191 | case Shortcut.FocusLeft : this.engine.focusDir(ctx, "left" ); break; 192 | case Shortcut.FocusRight: this.engine.focusDir(ctx, "right"); break; 193 | 194 | case Shortcut.GrowWidth : if (window) this.engine.resizeWindow(window, "east" , 1); break; 195 | case Shortcut.ShrinkWidth : if (window) this.engine.resizeWindow(window, "east" , -1); break; 196 | case Shortcut.GrowHeight : if (window) this.engine.resizeWindow(window, "south", 1); break; 197 | case Shortcut.ShrinkHeight: if (window) this.engine.resizeWindow(window, "south", -1); break; 198 | 199 | case Shortcut.ShiftUp : if (window) this.engine.swapOrder(window, -1); break; 200 | case Shortcut.ShiftDown: if (window) this.engine.swapOrder(window, +1); break; 201 | 202 | case Shortcut.SwapUp : this.engine.swapDirOrMoveFloat(ctx, "up"); break; 203 | case Shortcut.SwapDown : this.engine.swapDirOrMoveFloat(ctx, "down"); break; 204 | case Shortcut.SwapLeft : this.engine.swapDirOrMoveFloat(ctx, "left"); break; 205 | case Shortcut.SwapRight: this.engine.swapDirOrMoveFloat(ctx, "right"); break; 206 | 207 | case Shortcut.SetMaster : if (window) this.engine.setMaster(window); break; 208 | case Shortcut.ToggleFloat: if (window) this.engine.toggleFloat(window); break; 209 | case Shortcut.ToggleFloatAll: this.engine.floatAll(ctx, ctx.currentSurface); break; 210 | 211 | case Shortcut.NextLayout: this.engine.cycleLayout(ctx, 1); break; 212 | case Shortcut.PreviousLayout: this.engine.cycleLayout(ctx, -1); break; 213 | case Shortcut.SetLayout: if (typeof data === "string") this.engine.setLayout(ctx, data); break; 214 | } 215 | 216 | this.engine.arrange(ctx); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/engine/engine.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | type Direction = "up" | "down" | "left" | "right"; 22 | 23 | /** 24 | * Maintains tiling context and performs various tiling actions. 25 | */ 26 | class TilingEngine { 27 | public layouts: LayoutStore; 28 | public windows: WindowStore; 29 | 30 | constructor() { 31 | this.layouts = new LayoutStore(); 32 | this.windows = new WindowStore(); 33 | } 34 | 35 | /** 36 | * Adjust layout based on the change in size of a tile. 37 | * 38 | * This operation is completely layout-dependent, and no general implementation is 39 | * provided. 40 | * 41 | * Used when tile is resized using mouse. 42 | */ 43 | public adjustLayout(basis: Window) { 44 | const srf = basis.surface; 45 | const layout = this.layouts.getCurrentLayout(srf); 46 | if (layout.adjust) { 47 | const area = srf.workingArea.gap(CONFIG.screenGapLeft, CONFIG.screenGapRight, 48 | CONFIG.screenGapTop, CONFIG.screenGapBottom); 49 | const tiles = this.windows.getVisibleTiles(srf); 50 | layout.adjust(area, tiles, basis, basis.geometryDelta); 51 | } 52 | } 53 | 54 | /** 55 | * Resize the current floating window. 56 | * 57 | * @param window a floating window 58 | */ 59 | public resizeFloat(window: Window, dir: "east" | "west" | "south" | "north", step: -1 | 1) { 60 | const srf = window.surface; 61 | 62 | // TODO: configurable step size? 63 | const hStepSize = srf.workingArea.width * 0.05; 64 | const vStepSize = srf.workingArea.height * 0.05; 65 | 66 | let hStep, vStep; 67 | switch (dir) { 68 | case "east" : hStep = step, vStep = 0; break; 69 | case "west" : hStep = -step, vStep = 0; break; 70 | case "south": hStep = 0, vStep = step; break; 71 | case "north": hStep = 0, vStep = -step; break; 72 | } 73 | 74 | const geometry = window.actualGeometry; 75 | const width = geometry.width + hStepSize * hStep; 76 | const height = geometry.height + vStepSize * vStep; 77 | 78 | window.forceSetGeometry(new Rect(geometry.x, geometry.y, width, height)); 79 | } 80 | 81 | /** 82 | * Resize the current tile by adjusting the layout. 83 | * 84 | * Used by grow/shrink shortcuts. 85 | */ 86 | public resizeTile(basis: Window, dir: "east" | "west" | "south" | "north", step: -1 | 1) { 87 | const srf = basis.surface; 88 | 89 | if (dir === "east") { 90 | const maxX = basis.geometry.maxX; 91 | const easternNeighbor = this.windows.getVisibleTiles(srf) 92 | .filter((tile) => tile.geometry.x >= maxX); 93 | if (easternNeighbor.length === 0) { 94 | dir = "west"; 95 | step *= -1; 96 | } 97 | } else if (dir === "south") { 98 | const maxY = basis.geometry.maxY; 99 | const southernNeighbor = this.windows.getVisibleTiles(srf) 100 | .filter((tile) => tile.geometry.y >= maxY); 101 | if (southernNeighbor.length === 0) { 102 | dir = "north"; 103 | step *= -1; 104 | } 105 | } 106 | 107 | // TODO: configurable step size? 108 | const hStepSize = srf.workingArea.width * 0.03; 109 | const vStepSize = srf.workingArea.height * 0.03; 110 | let delta: RectDelta; 111 | switch (dir) { 112 | case "east" : delta = new RectDelta(hStepSize * step, 0, 0, 0); break; 113 | case "west" : delta = new RectDelta(0, hStepSize * step, 0, 0); break; 114 | case "south": delta = new RectDelta(0, 0, vStepSize * step, 0); break; 115 | case "north": /* passthru */ 116 | default : delta = new RectDelta(0, 0, 0, vStepSize * step); break; 117 | } 118 | 119 | const layout = this.layouts.getCurrentLayout(srf); 120 | if (layout.adjust) { 121 | const area = srf.workingArea.gap(CONFIG.screenGapLeft, CONFIG.screenGapRight, 122 | CONFIG.screenGapTop, CONFIG.screenGapBottom); 123 | layout.adjust(area, this.windows.getVisibleTileables(srf), basis, delta); 124 | } 125 | } 126 | 127 | /** 128 | * Resize the given window, by moving border inward or outward. 129 | * 130 | * The actual behavior depends on the state of the given window. 131 | * 132 | * @param dir which border 133 | * @param step which direction. 1 means outward, -1 means inward. 134 | */ 135 | public resizeWindow(window: Window, dir: "east" | "west" | "south" | "north", step: -1 | 1) { 136 | const state = window.state; 137 | if (Window.isFloatingState(state)) 138 | this.resizeFloat(window, dir, step); 139 | else if (Window.isTiledState(state)) 140 | this.resizeTile(window, dir, step); 141 | } 142 | 143 | /** 144 | * Arrange tiles on all screens. 145 | */ 146 | public arrange(ctx: IDriverContext) { 147 | debug(() => "arrange"); 148 | ctx.screens.forEach((srf: ISurface) => { 149 | this.arrangeScreen(ctx, srf); 150 | }); 151 | } 152 | 153 | /** 154 | * Arrange tiles on a screen. 155 | */ 156 | public arrangeScreen(ctx: IDriverContext, srf: ISurface) { 157 | const layout = this.layouts.getCurrentLayout(srf); 158 | 159 | const workingArea = srf.workingArea; 160 | 161 | let tilingArea: Rect; 162 | if (CONFIG.monocleMaximize && layout instanceof MonocleLayout) 163 | tilingArea = workingArea; 164 | else 165 | tilingArea = workingArea.gap(CONFIG.screenGapLeft, CONFIG.screenGapRight, 166 | CONFIG.screenGapTop, CONFIG.screenGapBottom); 167 | 168 | const visibles = this.windows.getVisibleWindows(srf); 169 | debugObj(() => ["arrangeScreen", { 170 | layout, srf, 171 | visibles: visibles.length, 172 | }]); 173 | 174 | visibles.forEach((window) => { 175 | if (window.state === WindowState.Undecided) 176 | window.state = (window.shouldFloat) ? WindowState.Floating : WindowState.Tiled; 177 | }); 178 | 179 | const tileables = this.windows.getVisibleTileables(srf); 180 | if (CONFIG.maximizeSoleTile && tileables.length === 1) { 181 | tileables[0].state = WindowState.Maximized; 182 | tileables[0].geometry = workingArea; 183 | } else if (tileables.length > 0) 184 | layout.apply(new EngineContext(ctx, this), tileables, tilingArea); 185 | 186 | if (CONFIG.limitTileWidthRatio > 0 && !(layout instanceof MonocleLayout)) { 187 | const maxWidth = Math.floor(workingArea.height * CONFIG.limitTileWidthRatio); 188 | tileables.filter((tile) => tile.tiled && tile.geometry.width > maxWidth) 189 | .forEach((tile) => { 190 | const g = tile.geometry; 191 | tile.geometry = new Rect( 192 | g.x + Math.floor((g.width - maxWidth) / 2), 193 | g.y, maxWidth, g.height, 194 | ); 195 | }); 196 | } 197 | 198 | visibles.forEach((window) => window.commit()); 199 | debugObj(() => ["arrangeScreen/finished", { srf }]); 200 | } 201 | 202 | /** 203 | * Re-apply window geometry, computed by layout algorithm. 204 | * 205 | * Sometimes applications move or resize windows without user intervention, 206 | * which is straigh against the purpose of tiling WM. This operation 207 | * move/resize such windows back to where/how they should be. 208 | */ 209 | public enforceSize(ctx: IDriverContext, window: Window) { 210 | if (window.tiled && !window.actualGeometry.equals(window.geometry)) 211 | ctx.setTimeout(() => { 212 | if (window.tiled) 213 | window.commit(); 214 | }, 10); 215 | } 216 | 217 | /** 218 | * Register the given window to WM. 219 | */ 220 | public manage(window: Window) { 221 | if (!window.shouldIgnore) { 222 | /* engine#arrange will update the state when required. */ 223 | window.state = WindowState.Undecided; 224 | if (CONFIG.newWindowAsMaster) 225 | this.windows.unshift(window); 226 | else 227 | this.windows.push(window); 228 | } 229 | } 230 | 231 | /** 232 | * Unregister the given window from WM. 233 | */ 234 | public unmanage(window: Window) { 235 | this.windows.remove(window); 236 | } 237 | 238 | /** 239 | * Focus the next or previous window. 240 | */ 241 | public focusOrder(ctx: IDriverContext, step: -1 | 1) { 242 | const window = ctx.currentWindow; 243 | 244 | /* if no current window, select the first tile. */ 245 | if (window === null) { 246 | const tiles = this.windows.getVisibleTiles(ctx.currentSurface); 247 | if (tiles.length > 1) 248 | ctx.currentWindow = tiles[0]; 249 | return; 250 | } 251 | 252 | const visibles = this.windows.getVisibleWindows(ctx.currentSurface); 253 | if (visibles.length === 0) /* nothing to focus */ 254 | return; 255 | 256 | const idx = visibles.indexOf(window); 257 | if (!window || idx < 0) { /* unmanaged window -> focus master */ 258 | ctx.currentWindow = visibles[0]; 259 | return; 260 | } 261 | 262 | const num = visibles.length; 263 | const newIndex = (idx + (step % num) + num) % num; 264 | 265 | ctx.currentWindow = visibles[newIndex]; 266 | } 267 | 268 | /** 269 | * Focus a neighbor at the given direction. 270 | */ 271 | public focusDir(ctx: IDriverContext, dir: Direction) { 272 | const window = ctx.currentWindow; 273 | 274 | /* if no current window, select the first tile. */ 275 | if (window === null) { 276 | const tiles = this.windows.getVisibleTiles(ctx.currentSurface); 277 | if (tiles.length > 1) 278 | ctx.currentWindow = tiles[0]; 279 | return; 280 | } 281 | 282 | const neighbor = this.getNeighborByDirection(ctx, window, dir); 283 | if (neighbor) 284 | ctx.currentWindow = neighbor; 285 | } 286 | 287 | /** 288 | * Swap the position of the current window with the next or previous window. 289 | */ 290 | public swapOrder(window: Window, step: -1 | 1) { 291 | const srf = window.surface; 292 | const visibles = this.windows.getVisibleWindows(srf); 293 | if (visibles.length < 2) 294 | return; 295 | 296 | const vsrc = visibles.indexOf(window); 297 | const vdst = wrapIndex(vsrc + step, visibles.length); 298 | const dstWin = visibles[vdst]; 299 | 300 | this.windows.move(window, dstWin); 301 | } 302 | 303 | /** 304 | * Swap the position of the current window with a neighbor at the given direction. 305 | */ 306 | public swapDirection(ctx: IDriverContext, dir: Direction) { 307 | const window = ctx.currentWindow; 308 | if (window === null) { 309 | /* if no current window, select the first tile. */ 310 | const tiles = this.windows.getVisibleTiles(ctx.currentSurface); 311 | if (tiles.length > 1) 312 | ctx.currentWindow = tiles[0]; 313 | return; 314 | } 315 | 316 | const neighbor = this.getNeighborByDirection(ctx, window, dir); 317 | if (neighbor) 318 | this.windows.swap(window, neighbor); 319 | } 320 | 321 | /** 322 | * Move the given window towards the given direction by one step. 323 | * @param window a floating window 324 | * @param dir which direction 325 | */ 326 | public moveFloat(window: Window, dir: Direction) { 327 | const srf = window.surface; 328 | 329 | // TODO: configurable step size? 330 | const hStepSize = srf.workingArea.width * 0.05; 331 | const vStepSize = srf.workingArea.height * 0.05; 332 | 333 | let hStep, vStep; 334 | switch (dir) { 335 | case "up" : hStep = 0, vStep = -1; break; 336 | case "down" : hStep = 0, vStep = 1; break; 337 | case "left" : hStep = -1, vStep = 0; break; 338 | case "right": hStep = 1, vStep = 0; break; 339 | } 340 | 341 | const geometry = window.actualGeometry; 342 | const x = geometry.x + hStepSize * hStep; 343 | const y = geometry.y + vStepSize * vStep; 344 | 345 | window.forceSetGeometry(new Rect(x, y, geometry.width, geometry.height)); 346 | } 347 | 348 | public swapDirOrMoveFloat(ctx: IDriverContext, dir: Direction) { 349 | const window = ctx.currentWindow; 350 | if (!window) return; 351 | 352 | const state = window.state; 353 | if (Window.isFloatingState(state)) 354 | this.moveFloat(window, dir); 355 | else if (Window.isTiledState(state)) 356 | this.swapDirection(ctx, dir); 357 | } 358 | 359 | /** 360 | * Toggle float mode of window. 361 | */ 362 | public toggleFloat(window: Window) { 363 | window.state = (!window.tileable) 364 | ? WindowState.Tiled 365 | : WindowState.Floating; 366 | } 367 | 368 | /** 369 | * Toggle float on all windows on the given surface. 370 | * 371 | * The behaviours of this operation depends on the number of floating 372 | * windows: windows will be tiled if more than half are floating, and will 373 | * be floated otherwise. 374 | */ 375 | public floatAll(ctx: IDriverContext, srf: ISurface) { 376 | const windows = this.windows.getVisibleWindows(srf); 377 | const numFloats = windows.reduce((count, window) => { 378 | return (window.state === WindowState.Floating) ? count + 1 : count; 379 | }, 0); 380 | 381 | if (numFloats < windows.length / 2) { 382 | windows.forEach((window) => { 383 | /* TODO: do not use arbitrary constants */ 384 | window.floatGeometry = window.actualGeometry.gap(4, 4, 4, 4); 385 | window.state = WindowState.Floating; 386 | }); 387 | ctx.showNotification("Float All"); 388 | } else { 389 | windows.forEach((window) => { 390 | window.state = WindowState.Tiled; 391 | }); 392 | ctx.showNotification("Tile All"); 393 | } 394 | } 395 | 396 | /** 397 | * Set the current window as the "master". 398 | * 399 | * The "master" window is simply the first window in the window list. 400 | * Some layouts depend on this assumption, and will make such windows more 401 | * visible than others. 402 | */ 403 | public setMaster(window: Window) { 404 | this.windows.setMaster(window); 405 | } 406 | 407 | /** 408 | * Change the layout of the current surface to the next. 409 | */ 410 | public cycleLayout(ctx: IDriverContext, step: 1 | -1) { 411 | const layout = this.layouts.cycleLayout(ctx.currentSurface, step); 412 | if (layout) 413 | ctx.showNotification(layout.description); 414 | } 415 | 416 | /** 417 | * Set the layout of the current surface to the specified layout. 418 | */ 419 | public setLayout(ctx: IDriverContext, layoutClassID: string) { 420 | const layout = this.layouts.setLayout(ctx.currentSurface, layoutClassID); 421 | if (layout) 422 | ctx.showNotification(layout.description); 423 | } 424 | 425 | /** 426 | * Let the current layout override shortcut. 427 | * 428 | * @returns True if the layout overrides the shortcut. False, otherwise. 429 | */ 430 | public handleLayoutShortcut(ctx: IDriverContext, input: Shortcut, data?: any): boolean { 431 | const layout = this.layouts.getCurrentLayout(ctx.currentSurface); 432 | if (layout.handleShortcut) 433 | return layout.handleShortcut(new EngineContext(ctx, this), input, data); 434 | return false; 435 | } 436 | 437 | private getNeighborByDirection(ctx: IDriverContext, basis: Window, dir: Direction): Window | null{ 438 | let vertical: boolean; 439 | let sign: -1 | 1; 440 | switch (dir) { 441 | case "up" : vertical = true ; sign = -1; break; 442 | case "down" : vertical = true ; sign = 1; break; 443 | case "left" : vertical = false; sign = -1; break; 444 | case "right": vertical = false; sign = 1; break; 445 | default: return null; 446 | } 447 | 448 | const candidates = this.windows.getVisibleTiles(ctx.currentSurface) 449 | .filter((vertical) 450 | ? ((tile) => tile.geometry.y * sign > basis.geometry.y * sign) 451 | : ((tile) => tile.geometry.x * sign > basis.geometry.x * sign)) 452 | .filter((vertical) 453 | ? ((tile) => overlap(basis.geometry.x, basis.geometry.maxX, tile.geometry.x, tile.geometry.maxX)) 454 | : ((tile) => overlap(basis.geometry.y, basis.geometry.maxY, tile.geometry.y, tile.geometry.maxY))); 455 | if (candidates.length === 0) 456 | return null; 457 | 458 | const min = sign * candidates.reduce( 459 | (vertical) 460 | ? ((prevMin, tile): number => Math.min(tile.geometry.y * sign, prevMin)) 461 | : ((prevMin, tile): number => Math.min(tile.geometry.x * sign, prevMin)), 462 | Infinity); 463 | 464 | const closest = candidates.filter( 465 | (vertical) 466 | ? (tile) => tile.geometry.y === min 467 | : (tile) => tile.geometry.x === min); 468 | 469 | return closest.sort((a, b) => b.timestamp - a.timestamp)[0]; 470 | } 471 | } 472 | -------------------------------------------------------------------------------- /src/engine/enginecontext.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | /** 22 | * Provides contextual information and operations to Layout layer. 23 | * 24 | * Its purpose is to limit the visibility of information and operation. It's 25 | * not really a find-grained control mechanism, but is simple and concise. 26 | */ 27 | class EngineContext { 28 | public get backend(): string { 29 | return this.drvctx.backend; 30 | } 31 | 32 | public get currentWindow(): Window | null { 33 | return this.drvctx.currentWindow; 34 | } 35 | 36 | public set currentWindow(window: Window | null) { 37 | this.drvctx.currentWindow = window; 38 | } 39 | 40 | constructor(private drvctx: IDriverContext, private engine: TilingEngine) { 41 | } 42 | 43 | public setTimeout(func: () => void, timeout: number): void { 44 | this.drvctx.setTimeout(func, timeout); 45 | } 46 | 47 | public cycleFocus(step: -1 | 1) { 48 | this.engine.focusOrder(this.drvctx, step); 49 | } 50 | 51 | public moveWindow(window: Window, target: Window, after?: boolean) { 52 | this.engine.windows.move(window, target, after); 53 | } 54 | 55 | public showNotification(text: string) { 56 | this.drvctx.showNotification(text); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/engine/layoutstore.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class LayoutStoreEntry { 22 | public get currentLayout(): ILayout { 23 | return this.loadLayout(this.currentID); 24 | } 25 | 26 | private currentIndex: number | null; 27 | private currentID: string; 28 | private layouts: {[key: string]: ILayout}; 29 | private previousID: string; 30 | 31 | constructor() { 32 | this.currentIndex = 0; 33 | this.currentID = CONFIG.layoutOrder[0]; 34 | this.layouts = {}; 35 | this.previousID = this.currentID; 36 | 37 | this.loadLayout(this.currentID); 38 | } 39 | 40 | public cycleLayout(step: -1 | 1): ILayout { 41 | this.previousID = this.currentID; 42 | this.currentIndex = (this.currentIndex !== null) 43 | ? wrapIndex(this.currentIndex + step, CONFIG.layoutOrder.length) 44 | : 0 45 | ; 46 | this.currentID = CONFIG.layoutOrder[this.currentIndex]; 47 | return this.loadLayout(this.currentID); 48 | } 49 | 50 | public setLayout(targetID: string): ILayout { 51 | const targetLayout = this.loadLayout(targetID); 52 | if (targetLayout instanceof MonocleLayout 53 | && this.currentLayout instanceof MonocleLayout) { 54 | /* toggle Monocle "OFF" */ 55 | this.currentID = this.previousID; 56 | this.previousID = targetID; 57 | } else if (this.currentID !== targetID) { 58 | this.previousID = this.currentID; 59 | this.currentID = targetID; 60 | } 61 | 62 | this.updateCurrentIndex(); 63 | return targetLayout; 64 | } 65 | 66 | private updateCurrentIndex(): void { 67 | const idx = CONFIG.layoutOrder.indexOf(this.currentID); 68 | this.currentIndex = (idx === -1) ? null : idx; 69 | } 70 | 71 | private loadLayout(ID: string): ILayout { 72 | let layout = this.layouts[ID]; 73 | if (!layout) 74 | layout = this.layouts[ID] = CONFIG.layoutFactories[ID](); 75 | return layout 76 | } 77 | } 78 | 79 | class LayoutStore { 80 | private store: { [key: string]: LayoutStoreEntry }; 81 | 82 | constructor() { 83 | this.store = {}; 84 | } 85 | 86 | public getCurrentLayout(srf: ISurface): ILayout { 87 | return (srf.ignore) 88 | ? FloatingLayout.instance 89 | : this.getEntry(srf.id).currentLayout; 90 | } 91 | 92 | public cycleLayout(srf: ISurface, step: 1 | -1): ILayout | null { 93 | if (srf.ignore) 94 | return null; 95 | return this.getEntry(srf.id).cycleLayout(step); 96 | } 97 | 98 | public setLayout(srf: ISurface, layoutClassID: string): ILayout | null { 99 | if (srf.ignore) 100 | return null; 101 | return this.getEntry(srf.id).setLayout(layoutClassID); 102 | } 103 | 104 | private getEntry(key: string): LayoutStoreEntry { 105 | if (!this.store[key]) 106 | this.store[key] = new LayoutStoreEntry(); 107 | return this.store[key]; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/engine/window.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | enum WindowState { 22 | /* initial value */ 23 | Unmanaged, 24 | 25 | /* script-external state - overrides internal state */ 26 | NativeFullscreen, 27 | NativeMaximized, 28 | 29 | /* script-internal state */ 30 | Floating, 31 | Maximized, 32 | Tiled, 33 | TiledAfloat, 34 | Undecided, 35 | } 36 | 37 | class Window { 38 | public static isTileableState(state: WindowState): boolean { 39 | return ( 40 | (state === WindowState.Tiled) 41 | || (state === WindowState.Maximized) 42 | || (state === WindowState.TiledAfloat) 43 | ); 44 | } 45 | 46 | public static isTiledState(state: WindowState): boolean { 47 | return ( 48 | (state === WindowState.Tiled) 49 | || (state === WindowState.Maximized) 50 | ); 51 | } 52 | 53 | public static isFloatingState(state: WindowState): boolean { 54 | return ( 55 | (state === WindowState.Floating) 56 | || (state === WindowState.TiledAfloat) 57 | ); 58 | } 59 | 60 | public readonly id: string; 61 | public readonly window: IDriverWindow; 62 | 63 | public get actualGeometry(): Readonly { return this.window.geometry; } 64 | public get shouldFloat(): boolean { return this.window.shouldFloat; } 65 | public get shouldIgnore(): boolean { return this.window.shouldIgnore; } 66 | 67 | /** If this window ***can be*** tiled by layout. */ 68 | public get tileable(): boolean { return Window.isTileableState(this.state); } 69 | /** If this window is ***already*** tiled, thus a part of the current layout. */ 70 | public get tiled(): boolean { return Window.isTiledState(this.state); } 71 | /** If this window is floating, thus its geometry is not tightly managed. */ 72 | public get floating(): boolean { return Window.isFloatingState(this.state); } 73 | 74 | public get geometryDelta(): RectDelta { 75 | return RectDelta.fromRects(this.geometry, this.actualGeometry); 76 | } 77 | 78 | public floatGeometry: Rect; 79 | public geometry: Rect; 80 | public timestamp: number; 81 | 82 | /** 83 | * The current state of the window. 84 | * 85 | * This value affects what and how properties gets commited to the backend. 86 | * 87 | * Avoid comparing this value directly, and use `tileable`, `tiled`, 88 | * `floating` as much as possible. 89 | */ 90 | public get state(): WindowState { 91 | /* external states override the internal state. */ 92 | if (this.window.fullScreen) 93 | return WindowState.NativeFullscreen; 94 | if (this.window.maximized) 95 | return WindowState.NativeMaximized; 96 | 97 | return this.internalState; 98 | } 99 | 100 | public set state(value: WindowState) { 101 | const state = this.state; 102 | 103 | /* cannot transit to the current state */ 104 | if (state === value) 105 | return; 106 | 107 | if ((state === WindowState.Unmanaged || Window.isTileableState(state)) && Window.isFloatingState(value)) 108 | this.shouldCommitFloat = true; 109 | else if (Window.isFloatingState(state) && Window.isTileableState(value)) 110 | /* save the current geometry before leaving floating state */ 111 | this.floatGeometry = this.actualGeometry; 112 | 113 | this.internalState = value; 114 | } 115 | 116 | public get surface(): ISurface { 117 | return this.window.surface; 118 | } 119 | 120 | public set surface(srf: ISurface) { 121 | this.window.surface = srf; 122 | } 123 | 124 | public get weight(): number { 125 | const srfID = this.window.surface.id; 126 | const weight: number | undefined = this.weightMap[srfID]; 127 | if (weight === undefined) { 128 | this.weightMap[srfID] = 1.0; 129 | return 1.0; 130 | } 131 | return weight; 132 | } 133 | 134 | public set weight(value: number) { 135 | const srfID = this.window.surface.id; 136 | this.weightMap[srfID] = value; 137 | } 138 | 139 | private internalState: WindowState; 140 | private shouldCommitFloat: boolean; 141 | private weightMap: {[key: string]: number}; 142 | 143 | constructor(window: IDriverWindow) { 144 | this.id = window.id; 145 | this.window = window; 146 | 147 | this.floatGeometry = window.geometry; 148 | this.geometry = window.geometry; 149 | this.timestamp = 0; 150 | 151 | this.internalState = WindowState.Unmanaged; 152 | this.shouldCommitFloat = this.shouldFloat; 153 | this.weightMap = {}; 154 | } 155 | 156 | public commit() { 157 | const state = this.state; 158 | debugObj(() => ["Window#commit", {state: WindowState[state]}]); 159 | switch (state) { 160 | case WindowState.NativeMaximized: 161 | this.window.commit(undefined, undefined, false); 162 | break; 163 | 164 | case WindowState.NativeFullscreen: 165 | this.window.commit(undefined, undefined, undefined); 166 | break; 167 | 168 | case WindowState.Floating: 169 | if (!this.shouldCommitFloat) break; 170 | this.window.commit(this.floatGeometry, false, CONFIG.keepFloatAbove); 171 | this.shouldCommitFloat = false; 172 | break; 173 | 174 | case WindowState.Maximized: 175 | this.window.commit(this.geometry, true, false); 176 | break; 177 | 178 | case WindowState.Tiled: 179 | this.window.commit(this.geometry, CONFIG.noTileBorder, false); 180 | break; 181 | 182 | case WindowState.TiledAfloat: 183 | if (!this.shouldCommitFloat) break; 184 | this.window.commit(this.floatGeometry, false, CONFIG.keepFloatAbove); 185 | this.shouldCommitFloat = false; 186 | break; 187 | } 188 | } 189 | 190 | /** 191 | * Force apply the geometry *immediately*. 192 | * 193 | * This method is a quick hack created for engine#resizeFloat, thus should 194 | * not be used in other places. 195 | */ 196 | public forceSetGeometry(geometry: Rect) { 197 | this.window.commit(geometry); 198 | } 199 | 200 | public visible(srf: ISurface): boolean { 201 | return this.window.visible(srf); 202 | } 203 | 204 | public toString(): string { 205 | return "Window(" + String(this.window) + ")"; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/engine/windowstore.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class WindowStore { 22 | public list: Window[]; 23 | 24 | constructor(windows?: Window[]) { 25 | this.list = windows || []; 26 | } 27 | 28 | public move(srcWin: Window, destWin: Window, after?: boolean) { 29 | const srcIdx = this.list.indexOf(srcWin); 30 | const destIdx = this.list.indexOf(destWin); 31 | if (srcIdx === -1 || destIdx === -1) 32 | return; 33 | 34 | this.list.splice(srcIdx, 1); 35 | this.list.splice((after) ? (destIdx + 1) : destIdx, 0, srcWin); 36 | } 37 | 38 | public setMaster(window: Window) { 39 | const idx = this.list.indexOf(window); 40 | if (idx === -1) return; 41 | this.list.splice(idx, 1); 42 | this.list.splice(0, 0, window); 43 | } 44 | 45 | public swap(alpha: Window, beta: Window) { 46 | const alphaIndex = this.list.indexOf(alpha); 47 | const betaIndex = this.list.indexOf(beta); 48 | if (alphaIndex < 0 || betaIndex < 0) 49 | return; 50 | 51 | this.list[alphaIndex] = beta; 52 | this.list[betaIndex] = alpha; 53 | } 54 | 55 | //#region Storage Operation 56 | 57 | public get length(): number { 58 | return this.list.length; 59 | } 60 | 61 | public at(idx: number) { 62 | return this.list[idx]; 63 | } 64 | 65 | public indexOf(window: Window) { 66 | return this.list.indexOf(window); 67 | } 68 | 69 | public push(window: Window) { 70 | this.list.push(window); 71 | } 72 | 73 | public remove(window: Window) { 74 | const idx = this.list.indexOf(window); 75 | if (idx >= 0) 76 | this.list.splice(idx, 1); 77 | } 78 | 79 | public unshift(window: Window) { 80 | this.list.unshift(window); 81 | } 82 | //#endregion 83 | 84 | //#region Querying Windows 85 | 86 | /** Returns all visible windows on the given surface. */ 87 | public getVisibleWindows(srf: ISurface): Window[] { 88 | return this.list.filter((win) => win.visible(srf)); 89 | } 90 | 91 | /** Return all visible "Tile" windows on the given surface. */ 92 | public getVisibleTiles(srf: ISurface): Window[] { 93 | return this.list.filter((win) => 94 | win.tiled && win.visible(srf)); 95 | } 96 | 97 | /** 98 | * Return all visible "tileable" windows on the given surface 99 | * @see Window#tileable 100 | */ 101 | public getVisibleTileables(srf: ISurface): Window[] { 102 | return this.list.filter((win) => win.tileable && win.visible(srf)); 103 | } 104 | 105 | //#endregion 106 | } 107 | -------------------------------------------------------------------------------- /src/extern/global.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | /* KWin global objects */ 22 | declare var workspace: KWin.WorkspaceWrapper; 23 | declare var options: KWin.Options; 24 | 25 | /* QML objects */ 26 | declare var activityInfo: Plasma.TaskManager.ActivityInfo; 27 | declare var mousePoller: Plasma.PlasmaCore.DataSource; 28 | declare var scriptRoot: object; 29 | 30 | interface PopupDialog { 31 | show(text: string): void; 32 | } 33 | declare var popupDialog: PopupDialog; 34 | 35 | /* Common Javascript globals */ 36 | declare let console: any; 37 | declare let setTimeout: any; 38 | -------------------------------------------------------------------------------- /src/extern/kwin.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | // API Reference: 22 | // https://techbase.kde.org/Development/Tutorials/KWin/Scripting/API_4.9 23 | 24 | declare namespace KWin { 25 | /* enum ClientAreaOption */ 26 | var PlacementArea: number; 27 | 28 | function readConfig(key: string, defaultValue?: any): any; 29 | 30 | function registerShortcut( 31 | title: string, 32 | text: string, 33 | keySequence: string, 34 | callback: any 35 | ): boolean; 36 | 37 | interface WorkspaceWrapper { 38 | /* read-only */ 39 | readonly activeScreen: number; 40 | readonly currentActivity: string; 41 | readonly numScreens: number; 42 | 43 | /* read-write */ 44 | activeClient: KWin.Client; 45 | currentDesktop: number; 46 | desktops: number; 47 | 48 | /* signals */ 49 | activitiesChanged: QSignal; 50 | activityAdded: QSignal; 51 | activityRemoved: QSignal; 52 | clientAdded: QSignal; 53 | clientFullScreenSet: QSignal; 54 | clientMaximizeSet: QSignal; 55 | clientMinimized: QSignal; 56 | clientRemoved: QSignal; 57 | clientUnminimized: QSignal; 58 | currentActivityChanged: QSignal; 59 | currentDesktopChanged: QSignal; 60 | numberDesktopsChanged: QSignal; 61 | numberScreensChanged: QSignal; 62 | screenResized: QSignal; 63 | 64 | /* functions */ 65 | clientList(): Client[]; 66 | clientArea(option: number, screen: number, desktop: number): QRect; 67 | } 68 | 69 | interface Options { 70 | /* signal */ 71 | configChanged: QSignal; 72 | } 73 | 74 | interface Toplevel { 75 | /* read-only */ 76 | readonly activities: string[]; /* Not exactly `Array` */ 77 | readonly dialog: boolean; 78 | readonly resourceClass: QByteArray; 79 | readonly resourceName: QByteArray; 80 | readonly screen: number; 81 | readonly splash: boolean; 82 | readonly utility: boolean; 83 | readonly windowId: number; 84 | readonly windowRole: QByteArray; 85 | 86 | readonly clientPos: QPoint; 87 | readonly clientSize: QSize; 88 | 89 | /* signal */ 90 | activitiesChanged: QSignal; 91 | geometryChanged: QSignal; 92 | screenChanged: QSignal; 93 | windowShown: QSignal; 94 | } 95 | 96 | interface Client extends Toplevel { 97 | /* read-only */ 98 | readonly active: boolean; 99 | readonly caption: string; 100 | readonly maxSize: QSize; 101 | readonly minSize: QSize; 102 | readonly modal: boolean; 103 | readonly move: boolean; 104 | readonly resize: boolean; 105 | readonly resizeable: boolean; 106 | readonly specialWindow: boolean; 107 | 108 | /* read-write */ 109 | desktop: number; 110 | fullScreen: boolean; 111 | geometry: QRect; 112 | keepAbove: boolean; 113 | keepBelow: boolean; 114 | minimized: boolean; 115 | noBorder: boolean; 116 | onAllDesktops: boolean; 117 | basicUnit: QSize; 118 | 119 | /* signals */ 120 | activeChanged: QSignal; 121 | desktopChanged: QSignal; 122 | moveResizedChanged: QSignal; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/extern/plasma.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | declare namespace Plasma { 22 | namespace TaskManager { 23 | /* reference: https://github.com/KDE/plasma-workspace/blob/master/libtaskmanager/activityinfo.h */ 24 | interface ActivityInfo { 25 | /* read-only */ 26 | readonly currentActivity: string; 27 | readonly numberOfRunningActivities: number; 28 | 29 | /* methods */ 30 | runningActivities(): string[]; 31 | activityName(id: string): string; 32 | 33 | /* signals */ 34 | currentActivityChanged: QSignal; 35 | numberOfRunningActivitiesChanged: QSignal; 36 | namesOfRunningActivitiesChanged: QSignal; 37 | } 38 | } 39 | 40 | namespace PlasmaCore { 41 | /* reference: https://techbase.kde.org/Development/Tutorials/Plasma4/QML/API#DataSource */ 42 | interface DataSource { 43 | readonly sources: string[]; 44 | readonly valid: boolean; 45 | readonly data: {[key: string]: object}; /* variant map */ 46 | 47 | interval: number; 48 | engine: string; 49 | connectedSources: string[]; 50 | 51 | /** (sourceName: string, data: object) */ 52 | onNewData: QSignal; 53 | /** (source: string) */ 54 | onSourceAdded: QSignal; 55 | /** (source: string) */ 56 | onSourceRemoved: QSignal; 57 | /** (source: string) */ 58 | onSourceConnected: QSignal; 59 | /** (source: string) */ 60 | onSourceDisconnected: QSignal; 61 | onIntervalChanged: QSignal; 62 | onEngineChanged: QSignal; 63 | onDataChanged: QSignal; 64 | onConnectedSourcesChanged: QSignal; 65 | onSourcesChanged: QSignal; 66 | 67 | keysForSource(source: string): string[]; 68 | serviceForSource(source: string): object; // TODO: returns Service 69 | connectSource(source: string): void; 70 | disconnectSource(source: string): void; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/extern/qt.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | 22 | interface QByteArray { 23 | /* keep it empty for now */ 24 | } 25 | 26 | interface QRect { 27 | height: number; 28 | width: number; 29 | x: number; 30 | y: number; 31 | } 32 | 33 | interface QPoint { 34 | x: number; 35 | y: number; 36 | } 37 | 38 | interface QSize { 39 | width: number; 40 | height: number; 41 | } 42 | 43 | interface QSignal { 44 | connect(callback: any): void; 45 | disconnect(callback: any): void; 46 | } 47 | 48 | /* Reference: http://doc.qt.io/qt-5/qml-qtqml-timer.html */ 49 | interface QQmlTimer { 50 | interval: number; 51 | repeat: boolean; 52 | running: boolean; 53 | triggeredOnStart: boolean; 54 | 55 | triggered: QSignal; 56 | 57 | restart(): void; 58 | start(): void; 59 | stop(): void; 60 | } 61 | 62 | declare namespace Qt { 63 | function createQmlObject(qml: string, parent: object, filepath?: string): any; 64 | 65 | function rect(x: number, y: number, width: number, height: number): QRect; 66 | } 67 | -------------------------------------------------------------------------------- /src/layouts/cascadelayout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | enum CascadeDirection { 22 | NorthWest = 0, 23 | North = 1, 24 | NorthEast = 2, 25 | East = 3, 26 | SouthEast = 4, 27 | South = 5, 28 | SouthWest = 6, 29 | West = 7, 30 | } 31 | 32 | class CascadeLayout implements ILayout { 33 | public static readonly id = "CascadeLayout"; 34 | 35 | /** Decompose direction into vertical and horizontal steps */ 36 | public static decomposeDirection(dir: CascadeDirection): [-1|0|1, -1|0|1] { 37 | switch (dir) { 38 | case CascadeDirection.NorthWest: return [ -1, -1 ]; 39 | case CascadeDirection.North : return [ -1, 0 ]; 40 | case CascadeDirection.NorthEast: return [ -1, 1 ]; 41 | case CascadeDirection.East : return [ 0, 1 ]; 42 | case CascadeDirection.SouthEast: return [ 1, 1 ]; 43 | case CascadeDirection.South : return [ 1, 0 ]; 44 | case CascadeDirection.SouthWest: return [ 1, -1 ]; 45 | case CascadeDirection.West : return [ 0, -1 ]; 46 | } 47 | } 48 | 49 | public readonly classID = CascadeLayout.id; 50 | 51 | public get description() { 52 | return "Cascade [" + CascadeDirection[this.dir] + "]"; 53 | } 54 | 55 | constructor(private dir: CascadeDirection = CascadeDirection.SouthEast) { 56 | /* nothing */ 57 | } 58 | 59 | public apply(ctx: EngineContext, tileables: Window[], area: Rect): void { 60 | const [vertStep, horzStep] = CascadeLayout.decomposeDirection(this.dir); 61 | 62 | // TODO: adjustable step size 63 | const stepSize = 25; 64 | 65 | const windowWidth = (horzStep !== 0) 66 | ? area.width - (stepSize * (tileables.length - 1)) 67 | : area.width; 68 | const windowHeight = (vertStep !== 0) 69 | ? area.height - (stepSize * (tileables.length - 1)) 70 | : area.height; 71 | 72 | const baseX = (horzStep >= 0) ? area.x : area.maxX - windowWidth; 73 | const baseY = (vertStep >= 0) ? area.y : area.maxY - windowHeight; 74 | 75 | let x = baseX, y = baseY; 76 | tileables.forEach((tile) => { 77 | tile.state = WindowState.Tiled; 78 | tile.geometry = new Rect(x, y, windowWidth, windowHeight); 79 | 80 | x += horzStep * stepSize; 81 | y += vertStep * stepSize; 82 | }); 83 | } 84 | 85 | public clone(): CascadeLayout { 86 | return new CascadeLayout(this.dir); 87 | } 88 | 89 | public handleShortcut(ctx: EngineContext, input: Shortcut, data?: any): boolean { 90 | switch (input) { 91 | case Shortcut.Increase: 92 | this.dir = (this.dir + 1 + 8) % 8; 93 | ctx.showNotification(this.description); 94 | break; 95 | case Shortcut.Decrease: 96 | this.dir = (this.dir - 1 + 8) % 8; 97 | ctx.showNotification(this.description); 98 | break; 99 | default: 100 | return false; 101 | } 102 | return true; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/layouts/floatinglayout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class FloatingLayout implements ILayout { 22 | public static readonly id = "FloatingLayout "; 23 | public static instance = new FloatingLayout(); 24 | 25 | public readonly classID = FloatingLayout.id; 26 | public readonly description: string = "Floating"; 27 | 28 | public apply(ctx: EngineContext, tileables: Window[], area: Rect): void { 29 | tileables.forEach((tileable: Window) => 30 | tileable.state = WindowState.TiledAfloat); 31 | } 32 | 33 | public clone(): this { 34 | /* fake clone */ 35 | return this; 36 | } 37 | 38 | public toString(): string { 39 | return "FloatingLayout()"; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/layouts/layoutpart.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | 22 | interface ILayoutPart { 23 | adjust(area: Rect, tiles: Window[], basis: Window, delta: RectDelta): RectDelta; 24 | apply(area: Rect, tiles: Window[]): Rect[]; 25 | } 26 | 27 | class FillLayoutPart implements ILayoutPart { 28 | public adjust(area: Rect, tiles: Window[], basis: Window, delta: RectDelta): RectDelta { 29 | /* do nothing */ 30 | return delta; 31 | } 32 | 33 | public apply(area: Rect, tiles: Window[]): Rect[] { 34 | return tiles.map((tile) => { 35 | return area; 36 | }); 37 | } 38 | } 39 | 40 | class HalfSplitLayoutPart implements ILayoutPart { 41 | /** the rotation angle for this part. 42 | * 43 | * | angle | direction | primary | 44 | * | ----- | ---------- | ------- | 45 | * | 0 | horizontal | left | 46 | * | 90 | vertical | top | 47 | * | 180 | horizontal | right | 48 | * | 270 | vertical | bottom | 49 | */ 50 | public angle: 0 | 90 | 180 | 270; 51 | 52 | public gap: number; 53 | public primarySize: number; 54 | public ratio: number; 55 | 56 | private get horizontal(): boolean { 57 | return this.angle === 0 || this.angle === 180; 58 | } 59 | 60 | private get reversed(): boolean { 61 | return this.angle === 180 || this.angle === 270; 62 | } 63 | 64 | constructor( 65 | public primary: L, 66 | public secondary: R, 67 | ) { 68 | this.angle = 0; 69 | this.gap = 0; 70 | this.primarySize = 1; 71 | this.ratio = 0.5; 72 | } 73 | 74 | public adjust(area: Rect, tiles: Window[], basis: Window, delta: RectDelta): RectDelta { 75 | const basisIndex = tiles.indexOf(basis); 76 | if (basisIndex < 0) 77 | return delta; 78 | 79 | if (tiles.length <= this.primarySize) { 80 | /* primary only */ 81 | return this.primary.adjust(area, tiles, basis, delta); 82 | } else if (this.primarySize === 0) { 83 | /* secondary only */ 84 | return this.secondary.adjust(area, tiles, basis, delta); 85 | } else { 86 | /* both parts */ 87 | 88 | /** which part to adjust. 0 = primary, 1 = secondary */ 89 | const targetIndex = (basisIndex < this.primarySize) ? 0 : 1; 90 | 91 | if (targetIndex === /* primary */ 0) { 92 | delta = this.primary.adjust(area, tiles.slice(0, this.primarySize), basis, delta); 93 | } else { 94 | delta = this.secondary.adjust(area, tiles.slice(this.primarySize), basis, delta); 95 | } 96 | 97 | this.ratio = LayoutUtils.adjustAreaHalfWeights( 98 | area, 99 | (this.reversed) ? 1 - this.ratio : this.ratio, 100 | this.gap, 101 | (this.reversed) ? 1 - targetIndex : targetIndex, 102 | delta, 103 | this.horizontal, 104 | ); 105 | if (this.reversed) 106 | this.ratio = 1 - this.ratio; 107 | 108 | switch((this.angle * 10) + targetIndex + 1) { 109 | case 1: /* 0, Primary */ 110 | case 1802: /* 180, Secondary */ 111 | return new RectDelta(0, delta.west, delta.south, delta.north); 112 | case 2: 113 | case 1801: 114 | return new RectDelta(delta.east, 0, delta.south, delta.north); 115 | case 901: 116 | case 2702: 117 | return new RectDelta(delta.east, delta.west, 0, delta.north); 118 | case 902: 119 | case 2701: 120 | return new RectDelta(delta.east, delta.west, delta.south, 0); 121 | } 122 | return delta; 123 | } 124 | } 125 | 126 | public apply(area: Rect, tiles: Window[]): Rect[] { 127 | if (tiles.length <= this.primarySize) { 128 | /* primary only */ 129 | return this.primary.apply(area, tiles); 130 | } else if (this.primarySize === 0) { 131 | /* secondary only */ 132 | return this.secondary.apply(area, tiles); 133 | } else { 134 | /* both parts */ 135 | const reversed = this.reversed; 136 | const ratio = (reversed) ? 1 - this.ratio: this.ratio; 137 | const [area1, area2] = LayoutUtils.splitAreaHalfWeighted(area, ratio, this.gap, this.horizontal); 138 | const result1 = this.primary.apply((reversed) ? area2 : area1, tiles.slice(0, this.primarySize)); 139 | const result2 = this.secondary.apply((reversed) ? area1 : area2, tiles.slice(this.primarySize)); 140 | return result1.concat(result2); 141 | } 142 | } 143 | } 144 | 145 | class StackLayoutPart implements ILayoutPart { 146 | public gap: number; 147 | 148 | constructor() { 149 | this.gap = 0; 150 | } 151 | 152 | public adjust(area: Rect, tiles: Window[], basis: Window, delta: RectDelta): RectDelta { 153 | const weights = LayoutUtils.adjustAreaWeights( 154 | area, 155 | tiles.map((tile) => tile.weight), 156 | CONFIG.tileLayoutGap, 157 | tiles.indexOf(basis), 158 | delta, 159 | false 160 | ); 161 | 162 | weights.forEach((weight, i) => { 163 | tiles[i].weight = weight * tiles.length; 164 | }); 165 | 166 | const idx = tiles.indexOf(basis); 167 | return new RectDelta( 168 | delta.east, 169 | delta.west, 170 | (idx === tiles.length - 1) ? delta.south : 0, 171 | (idx === 0) ? delta.north : 0 172 | ); 173 | } 174 | 175 | public apply(area: Rect, tiles: Window[]): Rect[] { 176 | const weights = tiles.map((tile) => tile.weight); 177 | return LayoutUtils.splitAreaWeighted(area, weights, this.gap); 178 | } 179 | } 180 | 181 | class RotateLayoutPart implements ILayoutPart { 182 | constructor( 183 | public inner: T, 184 | public angle: 0 | 90 | 180 | 270 = 0, 185 | ) { 186 | } 187 | 188 | public adjust(area: Rect, tiles: Window[], basis: Window, delta: RectDelta): RectDelta { 189 | // let area = area, delta = delta; 190 | switch (this.angle) { 191 | case 0 : break; 192 | case 90 : 193 | area = new Rect(area.y, area.x, area.height, area.width); 194 | delta = new RectDelta(delta.south, delta.north, delta.east, delta.west); break; 195 | case 180: 196 | delta = new RectDelta(delta.west, delta.east, delta.south, delta.north); break; 197 | case 270: 198 | area = new Rect(area.y, area.x, area.height, area.width); 199 | delta = new RectDelta(delta.north, delta.south, delta.east, delta.west); break; 200 | } 201 | 202 | delta = this.inner.adjust(area, tiles, basis, delta); 203 | 204 | switch (this.angle) { 205 | case 0 : delta = delta; break; 206 | case 90 : delta = new RectDelta(delta.south, delta.north, delta.east, delta.west); break; 207 | case 180: delta = new RectDelta(delta.west, delta.east, delta.south, delta.north); break; 208 | case 270: delta = new RectDelta(delta.north, delta.south, delta.east, delta.west); break; 209 | } 210 | return delta; 211 | } 212 | 213 | public apply(area: Rect, tiles: Window[]): Rect[] { 214 | switch (this.angle) { 215 | case 0 : break; 216 | case 90 : area = new Rect(area.y, area.x, area.height, area.width); break; 217 | case 180: break; 218 | case 270: area = new Rect(area.y, area.x, area.height, area.width); break; 219 | } 220 | 221 | const innerResult = this.inner.apply(area, tiles); 222 | 223 | switch (this.angle) { 224 | case 0: 225 | return innerResult; 226 | case 90: 227 | return innerResult.map((g) => 228 | new Rect(g.y, g.x, g.height, g.width)); 229 | case 180: 230 | return innerResult.map((g) => { 231 | const rx = g.x - area.x; 232 | const newX = area.x + area.width - (rx + g.width); 233 | return new Rect(newX, g.y, g.width, g.height) 234 | }); 235 | case 270: 236 | return innerResult.map((g) => { 237 | const rx = g.x - area.x; 238 | const newY = area.x + area.width - (rx + g.width); 239 | return new Rect(g.y, newY, g.height, g.width) 240 | }); 241 | } 242 | } 243 | 244 | public rotate(amount: -90 | 90): void { 245 | // -90 | 0 | 90 | 180 | 270 | 360 246 | let angle = this.angle + amount; 247 | if (angle < 0) 248 | angle = 270; 249 | else if (angle >= 360) 250 | angle = 0; 251 | 252 | this.angle = angle as (0 | 90 | 180 | 270); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/layouts/layoututils.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class LayoutUtils { 22 | /** 23 | * Split a (virtual) line into weighted lines w/ gaps. 24 | * @param length The length of the line to be splitted 25 | * @param weights The weight of each part 26 | * @param gap The size of gap b/w parts 27 | * @returns An array of parts: [begin, length] 28 | */ 29 | public static splitWeighted( 30 | [begin, length]: [number, number], 31 | weights: number[], 32 | gap: number, 33 | ): Array<[number, number]> { 34 | gap = (gap !== undefined) ? gap : 0; 35 | 36 | const n = weights.length; 37 | const actualLength = length - (n - 1) * gap; 38 | const weightSum = weights.reduce((sum, weight) => sum + weight, 0); 39 | 40 | let weightAcc = 0; 41 | return weights.map((weight, i) => { 42 | const partBegin = actualLength * weightAcc / weightSum + (i * gap); 43 | const partLength = actualLength * weight / weightSum; 44 | weightAcc += weight; 45 | return [begin + Math.floor(partBegin), Math.floor(partLength)]; 46 | }); 47 | } 48 | 49 | /** 50 | * Split an area into multiple parts based on weight. 51 | * @param area The area to be splitted 52 | * @param weights The weight of each part 53 | * @param gap The size of gaps b/w parts 54 | * @param horizontal If true, split horizontally. Otherwise, vertically. 55 | */ 56 | public static splitAreaWeighted(area: Rect, weights: number[], gap?: number, horizontal?: boolean): Rect[] { 57 | gap = (gap !== undefined) ? gap : 0; 58 | horizontal = (horizontal !== undefined) ? horizontal : false; 59 | 60 | const line: [number, number] = (horizontal) ? [area.x, area.width] : [area.y, area.height]; 61 | const parts = LayoutUtils.splitWeighted(line, weights, gap); 62 | 63 | return parts.map(([begin, length]) => 64 | (horizontal) 65 | ? new Rect(begin, area.y, length, area.height) 66 | : new Rect(area.x, begin, area.width, length), 67 | ); 68 | } 69 | 70 | /** 71 | * Split an area into two based on weight. 72 | * @param area The area to be splitted 73 | * @param weight The weight of the left/upper part. 74 | * @param gap The size of gaps b/w parts 75 | * @param horizontal If true, split horizontally. Otherwise, vertically. 76 | */ 77 | public static splitAreaHalfWeighted(area: Rect, weight: number, gap?: number, horizontal?: boolean): Rect[] { 78 | return LayoutUtils.splitAreaWeighted(area, [weight, 1 - weight], gap, horizontal); 79 | } 80 | 81 | /** 82 | * Recalculate the weights of subareas of the line, based on size change. 83 | * @param line The line being aplitted 84 | * @param weights The weight of each part 85 | * @param gap The gap size b/w parts 86 | * @param target The index of the part being changed. 87 | * @param deltaFw The amount of growth towards the origin. 88 | * @param deltaBw The amount of growth towards the infinity. 89 | */ 90 | public static adjustWeights( 91 | [begin, length]: [number, number], 92 | weights: number[], 93 | gap: number, 94 | target: number, 95 | deltaFw: number, 96 | deltaBw: number, 97 | ): number[] { 98 | // TODO: configurable min length? 99 | const minLength = 1; 100 | 101 | const parts = this.splitWeighted([begin, length], weights, gap); 102 | const [targetBase, targetLength] = parts[target]; 103 | 104 | /* apply backward delta */ 105 | if (target > 0 && deltaBw !== 0) { 106 | const neighbor = target - 1; 107 | const [neighborBase, neighborLength] = parts[neighbor]; 108 | 109 | /* limit delta to prevent squeezing windows */ 110 | const delta = clip(deltaBw, 111 | minLength - targetLength, 112 | neighborLength - minLength, 113 | ); 114 | 115 | parts[target] = [(targetBase - delta), (targetLength + delta)]; 116 | parts[neighbor] = [neighborBase, (neighborLength - delta)]; 117 | } 118 | 119 | /* apply forward delta */ 120 | if (target < parts.length - 1 && deltaFw !== 0) { 121 | const neighbor = target + 1; 122 | const [neighborBase, neighborLength] = parts[neighbor]; 123 | 124 | /* limit delta to prevent squeezing windows */ 125 | const delta = clip(deltaFw, 126 | minLength - targetLength, 127 | neighborLength - minLength, 128 | ); 129 | 130 | parts[target] = [targetBase, targetLength + delta]; 131 | parts[neighbor] = [neighborBase + delta, neighborLength - delta]; 132 | } 133 | 134 | return LayoutUtils.calculateWeights(parts); 135 | } 136 | 137 | /** 138 | * Recalculate weights of subareas splitting the given area, based on size change. 139 | * @param area The area being splitted 140 | * @param weights The weight of each part 141 | * @param gap The gap size b/w parts 142 | * @param target The index of the part being changed. 143 | * @param delta The changes in dimension of the target 144 | * @param horizontal If true, calculate horizontal weights, instead of vertical. 145 | */ 146 | public static adjustAreaWeights( 147 | area: Rect, 148 | weights: number[], 149 | gap: number, 150 | target: number, 151 | delta: RectDelta, 152 | horizontal?: boolean, 153 | ): number[] { 154 | const line: [number, number] = (horizontal) ? [area.x, area.width] : [area.y, area.height]; 155 | const [deltaFw, deltaBw] = (horizontal) 156 | ? [delta.east, delta.west] 157 | : [delta.south, delta.north] 158 | ; 159 | return LayoutUtils.adjustWeights(line, weights, gap, target, deltaFw, deltaBw); 160 | } 161 | 162 | /** 163 | * Recalculate weights of two areas splitting the given area, based on size change. 164 | * @param area The area being splitted 165 | * @param weight The weight of the left/upper part 166 | * @param gap The gap size b/w parts 167 | * @param target The index of the part being changed. 168 | * @param delta The changes in dimension of the target 169 | * @param horizontal If true, calculate horizontal weights, instead of vertical. 170 | */ 171 | public static adjustAreaHalfWeights( 172 | area: Rect, 173 | weight: number, 174 | gap: number, 175 | target: number, 176 | delta: RectDelta, 177 | horizontal?: boolean, 178 | ): number { 179 | const weights = [weight, 1 - weight]; 180 | const newWeights = LayoutUtils.adjustAreaWeights(area, weights, gap, target, delta, horizontal); 181 | return newWeights[0]; 182 | } 183 | 184 | /** 185 | * Calculates the weights of all parts, splitting a line. 186 | */ 187 | public static calculateWeights(parts: Array<[number, number]>): number[] { 188 | const totalLength = parts.reduce((acc, [base, length]) => acc + length, 0); 189 | return parts.map(([base, length]) => length / totalLength); 190 | } 191 | 192 | /** 193 | * Calculates the weights of all parts, splitting an area. 194 | */ 195 | public static calculateAreaWeights(area: Rect, geometries: Rect[], gap?: number, horizontal?: boolean): number[] { 196 | gap = (gap !== undefined) ? gap : 0; 197 | horizontal = (horizontal !== undefined) ? horizontal : false; 198 | 199 | const line = (horizontal) ? area.width : area.height; 200 | const parts: Array<[number, number]> = (horizontal) 201 | ? geometries.map((geometry) => [geometry.x, geometry.width]) 202 | : geometries.map((geometry) => [geometry.y, geometry.height]) 203 | ; 204 | return LayoutUtils.calculateWeights(parts); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/layouts/monoclelayout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class MonocleLayout implements ILayout { 22 | public static readonly id = "MonocleLayout"; 23 | public readonly description: string = "Monocle"; 24 | 25 | public readonly classID = MonocleLayout.id; 26 | 27 | public apply(ctx: EngineContext, tileables: Window[], area: Rect): void { 28 | /* Tile all tileables */ 29 | tileables.forEach((tile) => { 30 | tile.state = (CONFIG.monocleMaximize) 31 | ? WindowState.Maximized 32 | : WindowState.Tiled; 33 | tile.geometry = area; 34 | }); 35 | 36 | /* KWin-specific `monocleMinimizeRest` option */ 37 | if (ctx.backend === KWinDriver.backendName && KWINCONFIG.monocleMinimizeRest) { 38 | const tiles = [...tileables]; 39 | ctx.setTimeout(() => { 40 | const current = ctx.currentWindow; 41 | if (current && current.tiled) { 42 | tiles.forEach((window) => { 43 | if (window !== current) 44 | (window.window as KWinWindow).client.minimized = true; 45 | }); 46 | } 47 | }, 50); 48 | } 49 | } 50 | 51 | public clone(): this { 52 | /* fake clone */ 53 | return this; 54 | } 55 | 56 | public handleShortcut(ctx: EngineContext, input: Shortcut, data?: any): boolean { 57 | switch (input) { 58 | case Shortcut.Up: 59 | case Shortcut.FocusUp: 60 | case Shortcut.Left: 61 | case Shortcut.FocusLeft: 62 | ctx.cycleFocus(-1); 63 | return true; 64 | case Shortcut.Down: 65 | case Shortcut.FocusDown: 66 | case Shortcut.Right: 67 | case Shortcut.FocusRight: 68 | ctx.cycleFocus(1); 69 | return true; 70 | default: 71 | return false; 72 | } 73 | } 74 | 75 | public toString(): string { 76 | return "MonocleLayout()"; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/layouts/quarterlayout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class QuarterLayout implements ILayout { 22 | public static readonly MAX_PROPORTION = 0.8; 23 | public static readonly id = "QuarterLayout"; 24 | 25 | public readonly classID = QuarterLayout.id; 26 | public readonly description = "Quarter"; 27 | 28 | public get capacity(): number { 29 | return 4; 30 | } 31 | 32 | private lhsplit: number; 33 | private rhsplit: number; 34 | private vsplit: number; 35 | 36 | public constructor() { 37 | this.lhsplit = 0.5; 38 | this.rhsplit = 0.5; 39 | this.vsplit = 0.5; 40 | } 41 | 42 | public adjust(area: Rect, tiles: Window[], basis: Window, delta: RectDelta) { 43 | if (tiles.length <= 1 || tiles.length > 4) 44 | return; 45 | 46 | const idx = tiles.indexOf(basis); 47 | if (idx < 0) 48 | return; 49 | 50 | /* vertical split */ 51 | if ((idx === 0 || idx === 3) && delta.east !== 0) 52 | this.vsplit = (Math.floor(area.width * this.vsplit) + delta.east) / area.width; 53 | else if ((idx === 1 || idx === 2) && delta.west !== 0) 54 | this.vsplit = (Math.floor(area.width * this.vsplit) - delta.west) / area.width; 55 | 56 | /* left-side horizontal split */ 57 | if (tiles.length === 4) { 58 | if (idx === 0 && delta.south !== 0) 59 | this.lhsplit = (Math.floor(area.height * this.lhsplit) + delta.south) / area.height; 60 | if (idx === 3 && delta.north !== 0) 61 | this.lhsplit = (Math.floor(area.height * this.lhsplit) - delta.north) / area.height; 62 | } 63 | 64 | /* right-side horizontal split */ 65 | if (tiles.length >= 3) { 66 | if (idx === 1 && delta.south !== 0) 67 | this.rhsplit = (Math.floor(area.height * this.rhsplit) + delta.south) / area.height; 68 | if (idx === 2 && delta.north !== 0) 69 | this.rhsplit = (Math.floor(area.height * this.rhsplit) - delta.north) / area.height; 70 | } 71 | 72 | /* clipping */ 73 | this.vsplit = clip(this.vsplit, 1 - QuarterLayout.MAX_PROPORTION, QuarterLayout.MAX_PROPORTION); 74 | this.lhsplit = clip(this.lhsplit, 1 - QuarterLayout.MAX_PROPORTION, QuarterLayout.MAX_PROPORTION); 75 | this.rhsplit = clip(this.rhsplit, 1 - QuarterLayout.MAX_PROPORTION, QuarterLayout.MAX_PROPORTION); 76 | } 77 | 78 | public clone(): ILayout { 79 | const other = new QuarterLayout(); 80 | other.lhsplit = this.lhsplit; 81 | other.rhsplit = this.rhsplit; 82 | other.vsplit = this.vsplit; 83 | return other; 84 | } 85 | 86 | public apply(ctx: EngineContext, tileables: Window[], area: Rect): void { 87 | for (let i = 0; i < 4 && i < tileables.length; i++) 88 | tileables[i].state = WindowState.Tiled; 89 | 90 | if (tileables.length > 4) 91 | tileables.slice(4).forEach((tile) => tile.state = WindowState.TiledAfloat); 92 | 93 | if (tileables.length === 1) { 94 | tileables[0].geometry = area; 95 | return; 96 | } 97 | 98 | const gap1 = Math.floor(CONFIG.tileLayoutGap / 2); 99 | const gap2 = CONFIG.tileLayoutGap - gap1; 100 | 101 | const leftWidth = Math.floor(area.width * this.vsplit); 102 | const rightWidth = area.width - leftWidth; 103 | const rightX = area.x + leftWidth; 104 | if (tileables.length === 2) { 105 | tileables[0].geometry = new Rect(area.x, area.y, leftWidth , area.height).gap(0, gap1, 0, 0); 106 | tileables[1].geometry = new Rect(rightX, area.y, rightWidth, area.height).gap(gap2, 0, 0, 0); 107 | return; 108 | } 109 | 110 | const rightTopHeight = Math.floor(area.height * this.rhsplit); 111 | const rightBottomHeight = area.height - rightTopHeight; 112 | const rightBottomY = area.y + rightTopHeight; 113 | if (tileables.length === 3) { 114 | tileables[0].geometry = new Rect(area.x, area.y , leftWidth , area.height ).gap(0, gap1, 0, 0); 115 | tileables[1].geometry = new Rect(rightX, area.y , rightWidth, rightTopHeight ).gap(gap2, 0, 0, gap1); 116 | tileables[2].geometry = new Rect(rightX, rightBottomY, rightWidth, rightBottomHeight).gap(gap2, 0, gap2, 0); 117 | return; 118 | } 119 | 120 | const leftTopHeight = Math.floor(area.height * this.lhsplit); 121 | const leftBottomHeight = area.height - leftTopHeight; 122 | const leftBottomY = area.y + leftTopHeight; 123 | if (tileables.length >= 4) { 124 | tileables[0].geometry = new Rect(area.x, area.y , leftWidth , leftTopHeight ).gap(0, gap1, 0, gap1); 125 | tileables[1].geometry = new Rect(rightX, area.y , rightWidth, rightTopHeight ).gap(gap2, 0, 0, gap1); 126 | tileables[2].geometry = new Rect(rightX, rightBottomY, rightWidth, rightBottomHeight).gap(gap2, 0, gap2, 0); 127 | tileables[3].geometry = new Rect(area.x, leftBottomY , leftWidth , leftBottomHeight ).gap(0, gap2, gap2, 0); 128 | } 129 | } 130 | 131 | public toString(): string { 132 | return "QuarterLayout()"; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/layouts/spirallayout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | 22 | type SpiralLayoutPart = HalfSplitLayoutPart; 23 | 24 | class SpiralLayout implements ILayout { 25 | public readonly description = "Spiral"; 26 | 27 | private depth: number; 28 | private parts: SpiralLayoutPart; 29 | 30 | constructor() { 31 | this.depth = 1; 32 | this.parts = new HalfSplitLayoutPart(new FillLayoutPart(), new FillLayoutPart()); 33 | this.parts.angle = 0; 34 | this.parts.gap = CONFIG.tileLayoutGap; 35 | } 36 | 37 | public adjust(area: Rect, tiles: Window[], basis: Window, delta: RectDelta): void { 38 | this.parts.adjust(area, tiles, basis, delta); 39 | } 40 | 41 | public apply(ctx: EngineContext, tileables: Window[], area: Rect): void { 42 | tileables.forEach((tileable) => 43 | tileable.state = WindowState.Tiled); 44 | 45 | this.bore(tileables.length); 46 | 47 | this.parts.apply(area, tileables) 48 | .forEach((geometry, i) => { 49 | tileables[i].geometry = geometry; 50 | }); 51 | } 52 | 53 | //handleShortcut?(ctx: EngineContext, input: Shortcut, data?: any): boolean; 54 | 55 | public toString(): string { 56 | return "Spiral()"; 57 | } 58 | 59 | private bore(depth: number): void { 60 | if (this.depth >= depth) 61 | return; 62 | 63 | let hpart = this.parts; 64 | let i; 65 | for(i = 0; i < this.depth - 1; i ++) { 66 | hpart = hpart.secondary as SpiralLayoutPart; 67 | } 68 | 69 | const lastFillPart = hpart.secondary as FillLayoutPart; 70 | let npart: SpiralLayoutPart; 71 | while (i < depth - 1) { 72 | npart = new HalfSplitLayoutPart(new FillLayoutPart(), lastFillPart); 73 | npart.gap = CONFIG.tileLayoutGap; 74 | switch((i + 1) % 4) { 75 | case 0: npart.angle = 0; break; 76 | case 1: npart.angle = 90; break; 77 | case 2: npart.angle = 180; break; 78 | case 3: npart.angle = 270; break; 79 | } 80 | 81 | hpart.secondary = npart; 82 | hpart = npart; 83 | i ++; 84 | } 85 | this.depth = depth; 86 | } 87 | } -------------------------------------------------------------------------------- /src/layouts/spreadlayout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class SpreadLayout implements ILayout { 22 | public static readonly id = "SpreadLayout"; 23 | 24 | public readonly classID = SpreadLayout.id; 25 | public readonly description = "Spread"; 26 | 27 | private space: number; /* in ratio */ 28 | 29 | constructor() { 30 | this.space = 0.07; 31 | } 32 | 33 | public apply(ctx: EngineContext, tileables: Window[], area: Rect): void { 34 | /* Tile all tileables */ 35 | tileables.forEach((tileable) => tileable.state = WindowState.Tiled); 36 | const tiles = tileables; 37 | 38 | let numTiles = tiles.length; 39 | const spaceWidth = Math.floor(area.width * this.space); 40 | let cardWidth = area.width - (spaceWidth * (numTiles - 1)); 41 | 42 | // TODO: define arbitrary constants 43 | const miniumCardWidth = area.width * 0.40; 44 | while (cardWidth < miniumCardWidth) { 45 | cardWidth += spaceWidth; 46 | numTiles -= 1; 47 | } 48 | 49 | for (let i = 0; i < tiles.length; i++) 50 | tiles[i].geometry = new Rect( 51 | area.x + ((i < numTiles) ? spaceWidth * (numTiles - i - 1) : 0), 52 | area.y, 53 | cardWidth, 54 | area.height, 55 | ); 56 | } 57 | 58 | public clone(): ILayout { 59 | const other = new SpreadLayout(); 60 | other.space = this.space; 61 | return other; 62 | } 63 | 64 | public handleShortcut(ctx: EngineContext, input: Shortcut) { 65 | switch (input) { 66 | case Shortcut.Decrease: 67 | // TODO: define arbitrary constants 68 | this.space = Math.max(0.04, this.space - 0.01); 69 | break; 70 | case Shortcut.Increase: 71 | // TODO: define arbitrary constants 72 | this.space = Math.min(0.10, this.space + 0.01); 73 | break; 74 | default: 75 | return false; 76 | } 77 | return true; 78 | } 79 | 80 | public toString(): string { 81 | return "SpreadLayout(" + this.space + ")"; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/layouts/stairlayout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class StairLayout implements ILayout { 22 | public static readonly id = "StairLayout"; 23 | 24 | public readonly classID = StairLayout.id; 25 | public readonly description = "Stair"; 26 | 27 | private space: number; /* in PIXELS */ 28 | 29 | constructor() { 30 | this.space = 24; 31 | } 32 | 33 | public apply(ctx: EngineContext, tileables: Window[], area: Rect): void { 34 | /* Tile all tileables */ 35 | tileables.forEach((tileable) => tileable.state = WindowState.Tiled); 36 | const tiles = tileables; 37 | 38 | const len = tiles.length; 39 | const space = this.space; 40 | 41 | // TODO: limit the maximum number of staired windows. 42 | 43 | for (let i = 0; i < len; i++) { 44 | const dx = space * (len - i - 1); 45 | const dy = space * i; 46 | tiles[i].geometry = new Rect( 47 | area.x + dx, 48 | area.y + dy, 49 | area.width - dx, 50 | area.height - dy, 51 | ); 52 | } 53 | } 54 | 55 | public clone(): ILayout { 56 | const other = new StairLayout(); 57 | other.space = this.space; 58 | return other; 59 | } 60 | 61 | public handleShortcut(ctx: EngineContext, input: Shortcut) { 62 | switch (input) { 63 | case Shortcut.Decrease: 64 | // TODO: define arbitrary constants 65 | this.space = Math.max(16, this.space - 8); 66 | break; 67 | case Shortcut.Increase: 68 | // TODO: define arbitrary constants 69 | this.space = Math.min(160, this.space + 8); 70 | break; 71 | default: 72 | return false; 73 | } 74 | return true; 75 | } 76 | 77 | public toString(): string { 78 | return "StairLayout(" + this.space + ")"; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/layouts/threecolumnlayout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class ThreeColumnLayout implements ILayout { 22 | public static readonly MIN_MASTER_RATIO = 0.2; 23 | public static readonly MAX_MASTER_RATIO = 0.75; 24 | public static readonly id = "ThreeColumnLayout"; 25 | 26 | public readonly classID = ThreeColumnLayout.id; 27 | 28 | public get description(): string { 29 | return "Three-Column [" + (this.masterSize) + "]"; 30 | } 31 | 32 | private masterRatio: number; 33 | private masterSize: number; 34 | 35 | constructor() { 36 | this.masterRatio = 0.6; 37 | this.masterSize = 1; 38 | } 39 | 40 | public adjust(area: Rect, tiles: Window[], basis: Window, delta: RectDelta): void { 41 | const basisIndex = tiles.indexOf(basis); 42 | if (basisIndex < 0) 43 | return; 44 | 45 | if (tiles.length === 0) 46 | /* no tiles */ 47 | return; 48 | else if (tiles.length <= this.masterSize) { 49 | /* one column */ 50 | LayoutUtils.adjustAreaWeights( 51 | area, 52 | tiles.map((tile) => tile.weight), 53 | CONFIG.tileLayoutGap, 54 | tiles.indexOf(basis), 55 | delta, 56 | ).forEach((newWeight, i) => 57 | tiles[i].weight = newWeight * tiles.length); 58 | } else if (tiles.length === this.masterSize + 1) { 59 | /* two columns */ 60 | 61 | /* adjust master-stack ratio */ 62 | this.masterRatio = LayoutUtils.adjustAreaHalfWeights( 63 | area, 64 | this.masterRatio, 65 | CONFIG.tileLayoutGap, 66 | (basisIndex < this.masterSize) ? 0 : 1, 67 | delta, 68 | true); 69 | 70 | /* adjust master tile weights */ 71 | if (basisIndex < this.masterSize) { 72 | const masterTiles = tiles.slice(0, -1); 73 | LayoutUtils.adjustAreaWeights( 74 | area, 75 | masterTiles.map((tile) => tile.weight), 76 | CONFIG.tileLayoutGap, 77 | basisIndex, 78 | delta, 79 | ).forEach((newWeight, i) => 80 | masterTiles[i].weight = newWeight * masterTiles.length); 81 | } 82 | } else if (tiles.length > this.masterSize + 1) { 83 | /* three columns */ 84 | let basisGroup; 85 | if (basisIndex < this.masterSize) 86 | basisGroup = 1; /* master */ 87 | else if (basisIndex < Math.floor((this.masterSize + tiles.length) / 2)) 88 | basisGroup = 2; /* R-stack */ 89 | else 90 | basisGroup = 0; /* L-stack */ 91 | 92 | /* adjust master-stack ratio */ 93 | const stackRatio = 1 - this.masterRatio; 94 | const newRatios = LayoutUtils.adjustAreaWeights( 95 | area, 96 | [stackRatio, this.masterRatio, stackRatio], 97 | CONFIG.tileLayoutGap, 98 | basisGroup, 99 | delta, 100 | true); 101 | const newMasterRatio = newRatios[1]; 102 | const newStackRatio = (basisGroup === 0) ? newRatios[0] : newRatios[2]; 103 | this.masterRatio = newMasterRatio / (newMasterRatio + newStackRatio); 104 | 105 | /* adjust tile weight */ 106 | const rstackNumTile = Math.floor((tiles.length - this.masterSize) / 2); 107 | const [masterTiles, rstackTiles, lstackTiles] = 108 | partitionArrayBySizes(tiles, [this.masterSize, rstackNumTile]); 109 | const groupTiles = [lstackTiles, masterTiles, rstackTiles][basisGroup]; 110 | LayoutUtils.adjustAreaWeights( 111 | area, /* we only need height */ 112 | groupTiles.map((tile) => tile.weight), 113 | CONFIG.tileLayoutGap, 114 | groupTiles.indexOf(basis), 115 | delta) 116 | .forEach((newWeight, i) => 117 | groupTiles[i].weight = newWeight * groupTiles.length); 118 | } 119 | } 120 | 121 | public apply(ctx: EngineContext, tileables: Window[], area: Rect): void { 122 | /* Tile all tileables */ 123 | tileables.forEach((tileable) => tileable.state = WindowState.Tiled); 124 | const tiles = tileables; 125 | 126 | if (tiles.length <= this.masterSize) { 127 | /* only master */ 128 | LayoutUtils.splitAreaWeighted( 129 | area, 130 | tiles.map((tile) => tile.weight), 131 | CONFIG.tileLayoutGap) 132 | .forEach((tileArea, i) => 133 | tiles[i].geometry = tileArea); 134 | } else if (tiles.length === this.masterSize + 1) { 135 | /* master & R-stack (only 1 window in stack) */ 136 | const [masterArea, stackArea] = LayoutUtils.splitAreaHalfWeighted( 137 | area, this.masterRatio, CONFIG.tileLayoutGap, true); 138 | 139 | const masterTiles = tiles.slice(0, this.masterSize); 140 | LayoutUtils.splitAreaWeighted( 141 | masterArea, 142 | masterTiles.map((tile) => tile.weight), 143 | CONFIG.tileLayoutGap) 144 | .forEach((tileArea, i) => 145 | masterTiles[i].geometry = tileArea); 146 | 147 | tiles[tiles.length - 1].geometry = stackArea; 148 | } else if (tiles.length > this.masterSize + 1) { 149 | /* L-stack & master & R-stack */ 150 | const stackRatio = 1 - this.masterRatio; 151 | 152 | /** Areas allocated to L-stack, master, and R-stack */ 153 | const groupAreas = LayoutUtils.splitAreaWeighted( 154 | area, 155 | [stackRatio, this.masterRatio, stackRatio], 156 | CONFIG.tileLayoutGap, 157 | true); 158 | 159 | const rstackSize = Math.floor((tiles.length - this.masterSize) / 2); 160 | const [masterTiles, rstackTiles, lstackTiles] = 161 | partitionArrayBySizes(tiles, [this.masterSize, rstackSize]); 162 | [lstackTiles, masterTiles, rstackTiles].forEach((groupTiles, group) => { 163 | LayoutUtils.splitAreaWeighted( 164 | groupAreas[group], 165 | groupTiles.map((tile) => tile.weight), 166 | CONFIG.tileLayoutGap) 167 | .forEach((tileArea, i) => 168 | groupTiles[i].geometry = tileArea); 169 | }); 170 | } 171 | } 172 | 173 | public clone(): ILayout { 174 | const other = new ThreeColumnLayout(); 175 | other.masterRatio = this.masterRatio; 176 | other.masterSize = this.masterSize; 177 | return other; 178 | } 179 | 180 | public handleShortcut(ctx: EngineContext, input: Shortcut, data?: any): boolean { 181 | switch (input) { 182 | case Shortcut.Increase: this.resizeMaster(ctx, +1); return true; 183 | case Shortcut.Decrease: this.resizeMaster(ctx, -1); return true; 184 | case Shortcut.Left: 185 | this.masterRatio = clip( 186 | slide(this.masterRatio, -0.05), 187 | ThreeColumnLayout.MIN_MASTER_RATIO, 188 | ThreeColumnLayout.MAX_MASTER_RATIO); 189 | return true; 190 | case Shortcut.Right: 191 | this.masterRatio = clip( 192 | slide(this.masterRatio, +0.05), 193 | ThreeColumnLayout.MIN_MASTER_RATIO, 194 | ThreeColumnLayout.MAX_MASTER_RATIO); 195 | return true; 196 | default: 197 | return false; 198 | } 199 | } 200 | 201 | public toString(): string { 202 | return "ThreeColumnLayout(nmaster=" + this.masterSize + ")"; 203 | } 204 | 205 | private resizeMaster(ctx: EngineContext, step: -1 | 1): void { 206 | this.masterSize = clip(this.masterSize + step, 207 | 1, 10); 208 | ctx.showNotification(this.description); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/layouts/tilelayout.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class TileLayout implements ILayout { 22 | public static readonly MIN_MASTER_RATIO = 0.2; 23 | public static readonly MAX_MASTER_RATIO = 0.8; 24 | public static readonly id = "TileLayout"; 25 | 26 | public readonly classID = TileLayout.id; 27 | 28 | public get description(): string { 29 | return "Tile [" + this.numMaster + "]"; 30 | } 31 | 32 | private parts: ( 33 | RotateLayoutPart< 34 | HalfSplitLayoutPart< 35 | RotateLayoutPart, 36 | StackLayoutPart 37 | >> 38 | ); 39 | 40 | private get numMaster(): number { 41 | return this.parts.inner.primarySize; 42 | } 43 | 44 | private set numMaster(value: number) { 45 | this.parts.inner.primarySize = value; 46 | } 47 | 48 | private get masterRatio(): number { 49 | return this.parts.inner.ratio; 50 | } 51 | 52 | private set masterRatio(value: number) { 53 | this.parts.inner.ratio = value; 54 | } 55 | 56 | constructor() { 57 | this.parts = new RotateLayoutPart(new HalfSplitLayoutPart( 58 | new RotateLayoutPart(new StackLayoutPart()), 59 | new StackLayoutPart(), 60 | )); 61 | 62 | const masterPart = this.parts.inner; 63 | masterPart.gap = 64 | masterPart.primary.inner.gap = 65 | masterPart.secondary.gap = CONFIG.tileLayoutGap; 66 | } 67 | 68 | public adjust(area: Rect, tiles: Window[], basis: Window, delta: RectDelta) { 69 | this.parts.adjust(area, tiles, basis, delta); 70 | } 71 | 72 | public apply(ctx: EngineContext, tileables: Window[], area: Rect): void { 73 | tileables.forEach((tileable) => 74 | tileable.state = WindowState.Tiled); 75 | 76 | this.parts.apply(area, tileables) 77 | .forEach((geometry, i) => { 78 | tileables[i].geometry = geometry; 79 | }); 80 | } 81 | 82 | public clone(): ILayout { 83 | const other = new TileLayout(); 84 | other.masterRatio = this.masterRatio; 85 | other.numMaster = this.numMaster; 86 | return other; 87 | } 88 | 89 | public handleShortcut(ctx: EngineContext, input: Shortcut) { 90 | switch (input) { 91 | case Shortcut.Left: 92 | this.masterRatio = clip( 93 | slide(this.masterRatio, -0.05), 94 | TileLayout.MIN_MASTER_RATIO, 95 | TileLayout.MAX_MASTER_RATIO); 96 | break; 97 | case Shortcut.Right: 98 | this.masterRatio = clip( 99 | slide(this.masterRatio, +0.05), 100 | TileLayout.MIN_MASTER_RATIO, 101 | TileLayout.MAX_MASTER_RATIO); 102 | break; 103 | case Shortcut.Increase: 104 | // TODO: define arbitrary constant 105 | if (this.numMaster < 10) 106 | this.numMaster += 1; 107 | ctx.showNotification(this.description); 108 | break; 109 | case Shortcut.Decrease: 110 | if (this.numMaster > 0) 111 | this.numMaster -= 1; 112 | ctx.showNotification(this.description); 113 | break; 114 | case Shortcut.Rotate: 115 | this.parts.rotate(90); 116 | break; 117 | case Shortcut.RotatePart: 118 | this.parts.inner.primary.rotate(90); 119 | break; 120 | default: 121 | return false; 122 | } 123 | return true; 124 | } 125 | 126 | public toString(): string { 127 | return "TileLayout(nmaster=" + this.numMaster + ", ratio=" + this.masterRatio + ")"; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/util/debug.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | const DEBUG = { 22 | enabled: false, 23 | started: new Date().getTime(), 24 | }; 25 | 26 | function debug(f: () => any) { 27 | if (DEBUG.enabled) { 28 | const timestamp = (new Date().getTime() - DEBUG.started) / 1000; 29 | console.log("[" + timestamp + "]", f()); // tslint:disable-line:no-console 30 | } 31 | } 32 | 33 | function debugObj(f: () => [string, any]) { 34 | if (DEBUG.enabled) { 35 | const timestamp = (new Date().getTime() - DEBUG.started) / 1000; 36 | const [name, obj] = f(); 37 | const buf = []; 38 | for (const i in obj) 39 | buf.push(i + "=" + obj[i]); 40 | 41 | console.log("[" + timestamp + "]", name + ": " + buf.join(" ")); // tslint:disable-line:no-console 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/util/func.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | function clip(value: number, min: number, max: number): number { 22 | if (value < min) 23 | return min; 24 | if (value > max) 25 | return max; 26 | return value; 27 | } 28 | 29 | function slide(value: number, step: number): number { 30 | if (step === 0) 31 | return value; 32 | return Math.floor(value / step + 1.000001) * step; 33 | } 34 | 35 | function matchWords(str: string, words: string[]): number { 36 | for (let i = 0; i < words.length; i++) { 37 | if (str.indexOf(words[i]) >= 0) 38 | return i; 39 | } 40 | return -1; 41 | } 42 | 43 | function wrapIndex(index: number, length: number): number { 44 | if (index < 0) 45 | return index + length; 46 | if (index >= length) 47 | return index - length; 48 | return index; 49 | } 50 | 51 | /** 52 | * Partition the given array into two parts, based on the value of the predicate 53 | * 54 | * @param array 55 | * @param predicate A function which accepts an item and returns a boolean value. 56 | * @return A tuple containing an array of true(matched) items, and an array of false(unmatched) items. 57 | */ 58 | function partitionArray(array: T[], predicate: (item: T, index: number) => boolean): [T[], T[]] { 59 | return array.reduce((parts: [T[], T[]], item: T, index: number) => { 60 | parts[predicate(item, index) ? 0 : 1].push(item); 61 | return parts; 62 | }, [[], []]); 63 | } 64 | 65 | /** 66 | * Partition the array into chunks of designated sizes. 67 | * 68 | * This function splits the given array into N+1 chunks, where N chunks are 69 | * specified by `sizes`, and the additional chunk is for remaining items. When 70 | * the array runs out of items first, any remaining chunks will be empty. 71 | * @param array 72 | * @param sizes A list of chunk sizes 73 | * @returns An array of (N+1) chunks, where the last chunk contains remaining 74 | * items. 75 | */ 76 | function partitionArrayBySizes(array: T[], sizes: number[]): T[][] { 77 | let base = 0; 78 | const chunks = sizes.map((size): T[] => { 79 | const chunk = array.slice(base, base + size); 80 | base += size; 81 | return chunk; 82 | }); 83 | chunks.push(array.slice(base)); 84 | 85 | return chunks; 86 | } 87 | 88 | /** 89 | * Tests if two ranges are overlapping 90 | * @param min1 Range 1, begin 91 | * @param max1 Range 1, end 92 | * @param min2 Range 2, begin 93 | * @param max2 Range 2, end 94 | */ 95 | function overlap(min1: number, max1: number, min2: number, max2: number): boolean { 96 | const min = Math.min; 97 | const max = Math.max; 98 | const dx = max(0, min(max1, max2) - max(min1, min2)); 99 | return (dx > 0); 100 | } 101 | -------------------------------------------------------------------------------- /src/util/kwinutil.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | function toQRect(rect: Rect) { 22 | return Qt.rect(rect.x, rect.y, rect.width, rect.height); 23 | } 24 | 25 | function toRect(qrect: QRect) { 26 | return new Rect(qrect.x, qrect.y, qrect.width, qrect.height); 27 | } 28 | -------------------------------------------------------------------------------- /src/util/rect.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class Rect { 22 | constructor( 23 | public x: number, 24 | public y: number, 25 | public width: number, 26 | public height: number, 27 | ) { 28 | } 29 | 30 | public get maxX(): number { return this.x + this.width; } 31 | public get maxY(): number { return this.y + this.height; } 32 | 33 | public get center(): [number, number] { 34 | return [ 35 | this.x + Math.floor(this.width / 2), 36 | this.y + Math.floor(this.height / 2), 37 | ]; 38 | } 39 | 40 | public clone(): Rect { 41 | return new Rect(this.x, this.y, this.width, this.height); 42 | } 43 | 44 | public equals(other: Rect): boolean { 45 | return ( 46 | this.x === other.x && 47 | this.y === other.y && 48 | this.width === other.width && 49 | this.height === other.height 50 | ); 51 | } 52 | 53 | public gap(left: number, right: number, top: number, bottom: number): Rect { 54 | return new Rect( 55 | this.x + left, 56 | this.y + top, 57 | this.width - (left + right), 58 | this.height - (top + bottom), 59 | ); 60 | } 61 | 62 | public gap_mut(left: number, right: number, top: number, bottom: number): this { 63 | this.x += left; 64 | this.y += top; 65 | this.width -= (left + right); 66 | this.height -= (top + bottom); 67 | return this; 68 | } 69 | 70 | public includes(other: Rect): boolean { 71 | return ( 72 | this.x <= other.x && 73 | this.y <= other.y && 74 | other.maxX < this.maxX && 75 | other.maxY < this.maxY 76 | ); 77 | } 78 | 79 | public includesPoint([x, y]: [number, number]): boolean { 80 | return ( 81 | (this.x <= x && x <= this.maxX) 82 | && (this.y <= y && y <= this.maxY) 83 | ); 84 | } 85 | 86 | public subtract(other: Rect): Rect { 87 | return new Rect( 88 | this.x - other.x, 89 | this.y - other.y, 90 | this.width - other.width, 91 | this.height - other.height, 92 | ); 93 | } 94 | 95 | public toString(): string { 96 | return "Rect(" + [this.x, this.y, this.width, this.height].join(", ") + ")"; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/util/rectdelta.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | /** 22 | * Describes geometric changes of a rectangle, in terms of changes per edge. 23 | * Outward changes are in positive, and inward changes are in negative. 24 | */ 25 | class RectDelta { 26 | /** Generate a delta that transforms basis to target. */ 27 | public static fromRects(basis: Rect, target: Rect): RectDelta { 28 | const diff = target.subtract(basis); 29 | return new RectDelta( 30 | diff.width + diff.x, 31 | -diff.x, 32 | diff.height + diff.y, 33 | -diff.y, 34 | ); 35 | } 36 | 37 | constructor( 38 | public readonly east: number, 39 | public readonly west: number, 40 | public readonly south: number, 41 | public readonly north: number, 42 | ) { 43 | } 44 | 45 | public toString(): string { 46 | return "WindowResizeDelta(" + [ 47 | "east=" + this.east, 48 | "west=" + this.west, 49 | "north=" + this.north, 50 | "south=" + this.south, 51 | ].join(" ") + ")"; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/util/wrappermap.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | class WrapperMap { 22 | private items: { [key: string]: T }; 23 | 24 | constructor( 25 | public readonly hasher: (item: F) => string, 26 | public readonly wrapper: (item: F) => T, 27 | ) { 28 | this.items = {}; 29 | } 30 | 31 | public add(item: F): T { 32 | const key = this.hasher(item); 33 | if (this.items[key] !== undefined) 34 | throw "WrapperMap: the key [" + key + "] already exists!"; 35 | const wrapped = this.wrapper(item); 36 | this.items[key] = wrapped; 37 | return wrapped; 38 | } 39 | 40 | public get(item: F): T | null { 41 | const key = this.hasher(item); 42 | return this.items[key] || null; 43 | } 44 | 45 | public getByKey(key: string): T | null { 46 | return this.items[key] || null; 47 | } 48 | 49 | public remove(item: F): boolean { 50 | const key = this.hasher(item); 51 | return (delete this.items[key]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/tilelayout.spec.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2019 Eon S. Jeon 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a 4 | // copy of this software and associated documentation files (the "Software"), 5 | // to deal in the Software without restriction, including without limitation 6 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | // and/or sell copies of the Software, and to permit persons to whom the 8 | // Software is furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | // DEALINGS IN THE SOFTWARE. 20 | 21 | let assert = require('assert'); 22 | let K = require('../krohnkite'); 23 | 24 | describe('TileLayout', function() { 25 | describe('#apply', function() { 26 | let layout = new K.TileLayout(); 27 | let area = new K.Rect(0, 0, 1000, 1000); 28 | let srf = new K.TestContext(0); 29 | let gap; 30 | 31 | K.setTestConfig('tileLayoutGap', gap = 0); 32 | 33 | it('tiles sole master to full screen', function() { 34 | let win = new K.Window(new K.TestWindow(srf)); 35 | layout.apply([win], area); 36 | assert(win.geometry.equals(area)); 37 | }); 38 | 39 | it('corretly applies master ratio', function() { 40 | let master = new K.Window(new K.TestWindow(srf)); 41 | let stack = new K.Window(new K.TestWindow(srf)); 42 | 43 | let ratio = layout.masterRatio; 44 | let masterWidth = Math.floor(area.width * ratio); 45 | 46 | layout.apply([master, stack], area); 47 | assert.equal(master.geometry.width, masterWidth); 48 | assert.equal(stack.geometry.width, area.width - masterWidth); 49 | }); 50 | 51 | it('supports non-origin screen', function() { 52 | const base = 30; 53 | const size = 1000; 54 | const area = new K.Rect(base, base, size, size); 55 | 56 | let tiles = []; 57 | for (let i = 0; i < 5; i++) { 58 | tiles.push(new K.Window(new K.TestWindow(srf))); 59 | layout.apply(tiles, area); 60 | 61 | for (let j = 0; j <= i; j++) { 62 | assert(tiles[i].geometry.x >= base); 63 | assert(tiles[i].geometry.y >= base); 64 | assert(tiles[i].geometry.x + tiles[i].geometry.width <= base + size); 65 | assert(tiles[i].geometry.y + tiles[i].geometry.height <= base + size); 66 | } 67 | } 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "target": "es5", 5 | "outFile": "krohnkite.js", 6 | "noEmitOnError": false, 7 | "removeComments": true, 8 | "lib": ["es5"], 9 | "alwaysStrict": true, 10 | "strict": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "curly": false, 9 | "max-classes-per-file": false, 10 | "no-conditional-assignment": false, 11 | "one-variable-per-declaration": false, 12 | "prefer-for-of": false, 13 | "variable-name": [true, "allow-leading-underscore"] 14 | }, 15 | "rulesDirectory": [] 16 | } --------------------------------------------------------------------------------