├── .dir-locals.el ├── .gitignore ├── KeybindingsComboRow.ui ├── KeybindingsPane.ui ├── KeybindingsRow.ui ├── LICENSE ├── README.md ├── Settings.ui ├── app.js ├── convenience.js ├── debug ├── examples ├── keybindings.js ├── layouts.js ├── user.js └── winprops.js ├── extension.js ├── gestures.js ├── grab.js ├── install.sh ├── keybindings.js ├── kludges.js ├── liveAltTab.js ├── metadata.json ├── minimap.js ├── navigator.js ├── notes.org ├── prefs.js ├── prefsKeybinding.js ├── resources ├── logo.png └── prefs.css ├── schemas ├── Makefile ├── gschemas.compiled └── org.gnome.shell.extensions.org-scrollwm.gschema.xml ├── scratch.js ├── set-recommended-gnome-shell-settings.sh ├── settings.js ├── shell.nix ├── shell.sh ├── stackoverlay.js ├── stylesheet.css ├── tiling.js ├── topbar.js ├── uninstall.sh ├── utils.js └── virtTiling.js /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ;;; Directory Local Variables 2 | ;;; For more information see (info "(emacs) Directory Variables") 3 | 4 | ((org-mode 5 | (org-indent-mode . t)) 6 | (js-mode 7 | (mode . gnome-shell)) 8 | (js2-mode 9 | (js2-basic-offset . 4)) 10 | ) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .config 2 | TAGS 3 | -------------------------------------------------------------------------------- /KeybindingsComboRow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 80 | 81 | 82 | 83 | 84 | False 85 | vertical 86 | 12 87 | 12 88 | 12 89 | 12 90 | 8 91 | 92 | 93 | 94 | Conflicts: 95 | 98 | 99 | 100 | 101 | 102 | 103 | none 104 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /KeybindingsPane.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 44 | 45 | -------------------------------------------------------------------------------- /KeybindingsRow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 124 | 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PaperWM # 2 | 3 | [![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://paperwm.zulipchat.com) 4 | 5 | PaperWM is an experimental [Gnome Shell](https://wiki.gnome.org/Projects/GnomeShell) extension providing scrollable tiling of windows and per monitor workspaces. It's inspired by paper notebooks and tiling window managers. 6 | 7 | Supports Gnome Shell from 3.28 to 3.38 on X11 and wayland. 8 | 9 | While technically an [extension](https://wiki.gnome.org/Projects/GnomeShell/Extensions) it's to a large extent built on top of the Gnome desktop rather than merely extending it. 10 | 11 | We hang out on [zulip](https://paperwm.zulipchat.com). 12 | 13 | ## Installation 14 | 15 | Clone the repo and run the 16 | [`install.sh`](https://github.com/paperwm/PaperWM/blob/master/install.sh) script 17 | from the repository. The installer will create a link to the repo in 18 | `$XDG_DATA_HOME/gnome-shell/extensions/`. It will then ask if you want to apply 19 | the recommended settings (see [Recommended 20 | Settings](#recommended-gnome-shell-settings)) and lastly it will ask to enable PaperWM. 21 | ```bash 22 | ./install.sh # install, load and enable paperwm 23 | ``` 24 | 25 | To uninstall simply run `./uninstall.sh`. 26 | 27 | You'll by default follow the 28 | [develop](https://github.com/paperwm/PaperWM/tree/develop) branch. If you want a 29 | possibly more stable experience you can follow the releases by checking out the 30 | [master](https://github.com/paperwm/PaperWM/tree/master) branch. 31 | 32 | Cloning the repo directly into `$XDG_DATA_HOME` also works (you can then run 33 | `install.sh` to enable PaperWM): 34 | ```bash 35 | git clone 'https://github.com/paperwm/PaperWM.git' \ 36 | "${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions/paperwm@hedning:matrix.org" 37 | ``` 38 | 39 | Running the extension will automatically install a user config file as described in [Development & user configuration](#development--user-configuration). 40 | 41 | ### Note for Ubuntu users ### 42 | 43 | There's three different gnome-desktop variants in Ubuntu: 44 | - [`ubuntu-desktop`](https://packages.ubuntu.com/focal/ubuntu-desktop): the default 45 | - [`ubuntu-gnome-desktop`](https://packages.ubuntu.com/focal/ubuntu-gnome-desktop): 46 | adds plain gnome sessions to the default 47 | - [`vanilla-gnome-desktop`](https://packages.ubuntu.com/focal/vanilla-gnome-desktop): 48 | a «plain» variant 49 | 50 | The default `ubuntu-desktop` ships with `desktop-icons` which doesn't work 51 | correctly with PaperWM ([#145](https://github.com/paperwm/PaperWM/issues/145), 52 | [#218](https://github.com/paperwm/PaperWM/issues/218)). Turning the extension 53 | off in gnome-tweaks [should work in 54 | 19.10](https://github.com/paperwm/PaperWM/issues/218#issuecomment-572250654), 55 | but there's [reports of this not 56 | working](https://github.com/paperwm/PaperWM/issues/145#issuecomment-508620154) 57 | in 19.04, so your milage my vary. 58 | 59 | For the easiest out of the box experience we reccommend `ubuntu-gnome-desktop`. 60 | `vanilla-gnome-desktop` adds some keybindings which plays badly with PaperWM 61 | ([#233](https://github.com/paperwm/PaperWM/issues/233)), making it unsuitable at 62 | the moment. 63 | 64 | ## Usage ## 65 | 66 | Most functionality is available using a mouse, eg. activating a window at the edge of the monitor by clicking on it. In wayland its possible to navigate with 3-finger swipes on the trackpad. But the primary focus is making an environment which works well with a keyboard. 67 | 68 | All keybindings start with the Super modifier. On most keyboards it's the Windows key, on mac keyboards it's the Command key. It's possible to modify the keyboard layout so that Super is switched with Alt making all the keybindings easier to reach. This can be done through Gnome Tweaks under `Keybard & Mouse` ⟶ `Additional Layout Options` ⟶ `Alt/Win key behavior` ⟶ `Left Alt is swapped with Left Win`. 69 | 70 | Most keybindings will grab the keyboard while Super is held down, only switching focus when Super is released. Escape will abort the navigation taking you back to the previously active window. 71 | 72 | Adding Ctrl to a keybinding will take the current window with you when navigating. 73 | 74 | Window management and navigation is based around the three following concepts. 75 | 76 | ### Scrollable window tiling ### 77 | 78 | ![The window tiling with the minimap shown](https://github.com/paperwm/media/blob/master/tiling.png) 79 | 80 | New windows are automatically tiled to the right of the active window, taking up as much height as possible. SuperReturn will open a new window of the same type as the active window. 81 | 82 | Activating a window will ensure it's fully visible, scrolling the tiling if necessary. Pressing Super. activates the window to the right. Super, activates the window to the left. On a US keyboard these keys are intuitively marked by < and >, they are also ordered the same way on almost all keyboard layouts. A minimap will be shown when Super is continually being pressed, as can be seen in the above screenshot. 83 | 84 | Pressing SuperI will move the window to the right below the active window, tiling them vertically in a column. SuperO will do the opposite, pushing the bottom window out of the current column. 85 | 86 | Swiping the trackpad horizontally with three fingers will scroll the tiling (only available in Wayland). 87 | 88 | AltTab is of course also available. 89 | 90 | PaperWM doesn't handle attached modal dialogs very well, so it's best to turn it off in Gnome Tweaks (under Windows). 91 | 92 | | Keybindings | | 93 | | ------ | ------- | 94 | | Super, or Super. | Activate the next or previous window | 95 | | SuperLeft or SuperRight | Activate the window to the left or right | 96 | | SuperUp or SuperDown | Activate the window above or below | 97 | | SuperHome or SuperEnd | Activate the first or last window | 98 | | SuperCtrl, or SuperCtrl. | Move the current window to the left or right | 99 | | SuperCtrlLeft or SuperCtrlRight | Move the current window to the left or right | 100 | | SuperCtrlUp or SuperCtrlDown | Move the current window up or down | 101 | | Supert | Take the window, placing it when finished navigating | 102 | | SuperTab or AltTab | Cycle through the most recently used windows | 103 | | SuperShiftTab or AltShiftTab | Cycle backwards through the most recently used windows | 104 | | SuperC | Center the active window horizontally | 105 | | SuperR | Resize the window (cycles through useful widths) | 106 | | SuperShiftR | Resize the window (cycles through useful heights) | 107 | | SuperF | Maximize the width of a window | 108 | | SuperShiftF | Toggle fullscreen | 109 | | SuperReturn or SuperN | Create a new window from the active application | 110 | | SuperBackspace | Close the active window | 111 | | SuperI | Absorb the window to the right into the active column | 112 | | SuperO | Expel the bottom window out to the right | 113 | 114 | 115 | ### The workspace stack & monitors ### 116 | 117 | Pressing SuperAbove_Tab will slide the active workspace down revealing the stack as shown in the above screenshot. You can then flip through the most recently used workspaces with repeated Above_Tab presses while holding Super down. Above_Tab is the key above Tab (\` in a US qwerty layout). Like alt-tab Shift is added to move in reverse order: 118 | 119 | ![The most recently used workspace stack](https://github.com/paperwm/media/blob/master/stack.png) 120 | 121 | Pressing SuperPage_Down and SuperPage_Up will slide between workspaces sequentially: 122 | 123 | ![Sequential workspace navigation](https://github.com/paperwm/media/blob/master/sequence.png) 124 | 125 | The workspace name is shown in the top left corner replacing the `Activities` button adding a few enhancements. Scrolling on the name will let you browse the workspace stack just like SuperAbove_Tab. Right clicking the name lets you access and change the workspace name and the background color: 126 | 127 | ![The workspace menu](https://github.com/paperwm/media/blob/master/menu.png) 128 | 129 | Swiping the trackpad vertically with three fingers lets you navigate the workspace stack (only available in Wayland). 130 | 131 | There's a single scrollable tiling per workspace. Adding another monitor simply makes it possible to have another workspace visible. The workspace stack is shared among all the monitors, windows being resized vertically as necessary when workspace is displayed on another monitor. 132 | 133 | PaperWM currently works best using the workspaces span monitors preference, this can be turned on with Gnome Tweaks under Workspaces. If you want to use workspaces only on primary you need to place the secondary monitor either below or above the primary (with the best result having it below). 134 | 135 | | Workspace Keybindings | | 136 | | ------ | ------- | 137 | | SuperAbove_Tab | Cycle through the most recently used workspaces | 138 | | SuperShiftAbove_Tab | Cycle backwards through the most recently used workspaces | 139 | | SuperCtrlAbove_Tab | Cycle through the most recently used, taking the active window with you | 140 | | SuperCtrlShiftAbove_Tab | Cycle backwards through the most recently used, taking the active window with you | 141 | | SuperPage_Down/Page_Up | Cycle sequentially through workspaces | 142 | | SuperCtrlPage_Down/Page_Up | Cycle sequentially through workspaces, taking the active window with you | 143 | 144 | 145 | | Monitor Keybindings | | 146 | | ------ | ------- | 147 | | SuperShiftArrow_key | Select neighbouring monitor | 148 | | SuperShiftCtrlArrow_key | Move active window to neighbouring monitor | 149 | 150 | ### Scratch layer ### 151 | 152 | ![The floating scratch layer, with the alt tab menu open](https://github.com/paperwm/media/blob/master/scratch.png) 153 | 154 | The scratch layer is an escape hatch to a familiar floating layout. This layer is intended to store windows that are globally useful like chat applications and in general serve as the kitchen sink. 155 | When the scratch layer is active it will float above the tiled windows, when hidden the windows will be minimized. 156 | 157 | Opening a window when the scratch layer is active will make it float automatically. 158 | 159 | Pressing SuperEscape toggles between showing and hiding the windows in the scratch layer. 160 | Activating windows in the scratch layer is done using SuperTab, the floating windows having priority in the list while active. 161 | When the tiling is active SuperShiftTab selects the most recently used scratch window. 162 | 163 | SuperCtrlEscape will move a tiled window into the scratch layer or alternatively tile an already floating window. This functionality can also be accessed through the window context menu (AltSpace). 164 | 165 | | Keybindings | | 166 | | ------ | ------- | 167 | | SuperEscape | Toggle between showing and hiding the most recent scratch window | 168 | | SuperShiftEscape | Toggle between showing and hiding the scratch windows | 169 | | SuperCtrlEscape | Toggle between floating and tiling the current window | 170 | | SuperTab | Cycle through the most recently used scratch windows | 171 | | SuperH | Minimize the current window | 172 | 173 | ## Development & user configuration ## 174 | 175 | A default user configuration, `user.js`, is created in `~/.config/paperwm/` with three functions `init`, `enable` and `disable`. `init` will run only once on startup, `enable` and `disable` will be run whenever extensions are being told to disable and enable themselves. Eg. when locking the screen with SuperL. 176 | 177 | We also made an emacs package, [gnome-shell-mode](https://github.com/paperwm/gnome-shell-mode), to make hacking on the config and writing extensions a more pleasant experience. To support this out of the box we also install a `metadata.json` so gnome-shell-mode will pick up the correct file context, giving you completion and interactive evaluation ala. looking glass straight in emacs. 178 | 179 | Pressing SuperInsert will assign the active window to a global variable `metaWindow`, its [window actor](https://developer.gnome.org/meta/stable/MetaWindowActor.html) to `actor`, its [workspace](https://developer.gnome.org/meta/stable/MetaWorkspace.html) to `workspace` and its PaperWM style workspace to `space`. This makes it easy to inspect state and test things out. 180 | 181 | #### Using dconf-editor to modify settings 182 | 183 | ```shell 184 | GSETTINGS_SCHEMA_DIR=$HOME/.local/share/gnome-shell/extensions/paperwm@hedning:matrix.org/schemas dconf-editor /org/gnome/shell/extensions/paperwm/ 185 | ``` 186 | 187 | ### Winprops 188 | 189 | It's possible to create simple rules for placing new windows. Currently most useful when a window should be placed in the scratch layer automatically. An example, best placed in the `init` part of `user.js`: 190 | 191 | ```javascript 192 | Tiling.defwinprop({ 193 | wm_class: "Spotify", 194 | scratch_layer: true, 195 | }); 196 | ``` 197 | 198 | The `wm_class` of a window can be found by using looking glass: AltF2 `lg` Return Go to the "Windows" section at the top right and find the window. X11 users can also use the `xprop` command line tool. 199 | 200 | ### New Window Handlers 201 | 202 | If opening a new application window with SuperReturn isn't doing exactly what you want you can create custom functions to fit your needs. Say you want new emacs windows to open the current buffer by default, or have new terminals inherit the current directory: 203 | 204 | ```javascript 205 | let App = Extension.imports.app; 206 | App.customHandlers['emacs.desktop'] = 207 | () => imports.misc.util.spawn(['emacsclient', '--eval', '(make-frame)']); 208 | App.customHandlers['org.gnome.Terminal.desktop'] = 209 | (metaWindow, app) => app.action_group.activate_action( 210 | "win.new-terminal", 211 | new imports.gi.GLib.Variant("(ss)", ["window", "current"])); 212 | ``` 213 | 214 | The app id of a window can be looked up like this: 215 | 216 | ```javascript 217 | var Shell = imports.gi.Shell; 218 | var Tracker = Shell.WindowTracker.get_default(); 219 | var app = Tracker.get_window_app(metaWindow); 220 | app.get_id(); 221 | ``` 222 | 223 | Available application actions can be listed like this: 224 | ```javascript 225 | app.action_group.list_actions(); 226 | ``` 227 | 228 | ### Keybindings 229 | 230 | Due to limitations in the mutter keybinding API we need to steal some built in Gnome Shell actions by default. Eg. the builtin action `switch-group` with the default SuperAbove_Tab keybinding is overridden to cycle through recently used workspaces. If an overridden action has several keybindings they will unfortunately all activate the override, so for instance because AltAbove_Tab is also bound to `switch-group` it will be overridden by default. If you want to avoid this, eg. you want AltTab and AltAbove_Tab to use the builtin behavior simply remove the conflicts (ie. SuperTab and SuperAbove_Tab and their Shift variants) from `/org/gnome/desktop/wm/keybindings/switch-group` (no restarts required). 231 | 232 | #### User defined keybindings 233 | 234 | `Extension.imports.keybindings.bindkey(keystr, name, handler, options)` 235 | 236 | Option | Values | Meaning 237 | --------------------|---------------------|------------------------------------ 238 | `activeInNavigator` | `true`, **`false`** | The keybinding is active when the minimap/navigator is open 239 | `opensMinimap` | `true`, **`false`** | The minimap will open when the keybinding is invoked 240 | 241 | ```javascript 242 | let Keybindings = Extension.imports.keybindings; 243 | Keybindings.bindkey("j", "my-favorite-width", 244 | (metaWindow) => { 245 | let f = metaWindow.get_frame_rect(); 246 | metaWindow.move_resize_frame(true, f.x, f.y, 500, f.h); 247 | }, 248 | { activeInNavigator: true }); 249 | ``` 250 | 251 | See `examples/keybindings.js` for more examples. 252 | 253 | ## Fixed Window Size ## 254 | 255 | Currently it is not possible to have a default fixed window size. 256 | Please check the following issues for progress / info: 257 | 258 | * https://github.com/paperwm/PaperWM/issues/304 259 | * https://github.com/paperwm/PaperWM/pull/189 260 | * https://github.com/paperwm/PaperWM/issues/311 261 | 262 | ## Recommended Gnome Shell Settings ## 263 | 264 | There's a few Gnome Shell settings which works poorly with PaperWM. Namely 265 | - `workspaces-only-on-primary`: Multi monitor support require workspaces 266 | spanning all monitors 267 | - `edge-tiling`: We don't support the native half tiled windows 268 | - `attach-modal-dialogs`: Attached modal dialogs can cause visual glitching 269 | 270 | To use the recommended settings run 271 | [`set-recommended-gnome-shell-settings.sh`](https://github.com/paperwm/PaperWM/blob/master/set-recommended-gnome-shell-settings.sh). A "restore previous settings" script is generated so the original settings is not lost. 272 | 273 | 274 | ## Recommended extensions ## 275 | 276 | These extensions are good complements to PaperWM: 277 | 278 | - [Switcher](https://github.com/daniellandau/switcher) - combined window switcher and launcher 279 | - [Dash to Dock](https://micheleg.github.io/dash-to-dock/) - a great dock 280 | 281 | ## Prior work ## 282 | 283 | A similar idea was apparently tried out a while back: [10/GUI](https://web.archive.org/web/20201123162403/http://10gui.com/) 284 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /* 2 | Application functionality, like global new window actions etc. 3 | */ 4 | 5 | var Extension; 6 | if (imports.misc.extensionUtils.extensions) { 7 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 8 | } else { 9 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 10 | } 11 | 12 | var GLib = imports.gi.GLib 13 | var Gio = imports.gi.Gio; 14 | var Tiling = Extension.imports.tiling 15 | var Kludges = Extension.imports.kludges; 16 | 17 | var Shell = imports.gi.Shell; 18 | var Tracker = Shell.WindowTracker.get_default(); 19 | 20 | var CouldNotLaunch = Symbol(); 21 | 22 | // Lookup table for custom handlers, keys being the app id 23 | var customHandlers, customSpawnHandlers; 24 | function init() { 25 | customHandlers = { 'org.gnome.Terminal.desktop': newGnomeTerminal }; 26 | customSpawnHandlers = { 27 | 'com.gexperts.Tilix.desktop': mkCommandLineSpawner('tilix --working-directory %d') 28 | }; 29 | 30 | function spawnWithFallback(fallback, ...args) { 31 | try { 32 | return trySpawnWindow(...args); 33 | } catch(e) { 34 | return fallback(); 35 | } 36 | } 37 | 38 | let overrideWithFallback = Kludges.overrideWithFallback; 39 | 40 | overrideWithFallback( 41 | Shell.App, "open_new_window", 42 | (fallback, app, workspaceId) => { 43 | return spawnWithFallback(fallback, app, global.workspace_manager.get_workspace_by_index(workspaceId)); 44 | } 45 | ); 46 | 47 | overrideWithFallback( 48 | Shell.App, "launch_action", 49 | (fallback, app, name, ...args) => { 50 | log(`ShellApp.launch_action ${name}`); 51 | if (name === 'new-window') 52 | return spawnWithFallback(fallback, app); 53 | else { 54 | return fallback(); 55 | } 56 | 57 | } 58 | ); 59 | overrideWithFallback( 60 | Gio.DesktopAppInfo, "launch", 61 | (fallback, appInfo) => { 62 | log(`DesktopAppInfo.launch`); 63 | return spawnWithFallback(fallback, appInfo.get_id()); 64 | } 65 | ); 66 | 67 | overrideWithFallback( 68 | Gio.DesktopAppInfo, "launch_action", 69 | (fallback, appInfo, name, ...args) => { 70 | log(`DesktopAppInfo.launch_action ${name}`); 71 | if (name === 'new-window') 72 | return spawnWithFallback(fallback, appInfo.get_id()); 73 | else { 74 | return fallback(); 75 | } 76 | 77 | } 78 | ); 79 | } 80 | 81 | function launchFromWorkspaceDir(app, workspace=null) { 82 | if (typeof(app) === 'string') { 83 | app = new Shell.App({ app_info: Gio.DesktopAppInfo.new(app) }); 84 | } 85 | let dir = getWorkspaceDirectory(workspace); 86 | let cmd = app.app_info.get_commandline(); 87 | if (!cmd || dir == '') { 88 | throw CouldNotLaunch; 89 | } 90 | 91 | /* Note: One would think working directory could be specified in the AppLaunchContext 92 | The dbus spec https://specifications.freedesktop.org/desktop-entry-spec/1.1/ar01s07.html 93 | indicates otherwise (for dbus activated actions). Can affect arbitrary environment 94 | variables of exec activated actions, but no environment variable determine working 95 | directory of new processes. */ 96 | // TODO: substitute correct values according to https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables 97 | cmd = cmd.replace(/%./g, ""); 98 | let [success, cmdArgs] = GLib.shell_parse_argv(cmd); 99 | if (!success) { 100 | print("launchFromWorkspaceDir:", "Could not parse command line", cmd); 101 | throw CouldNotLaunch; 102 | } 103 | GLib.spawn_async(dir, cmdArgs, GLib.get_environ(), GLib.SpawnFlags.SEARCH_PATH, null); 104 | } 105 | 106 | function newGnomeTerminal(metaWindow, app) { 107 | /* Note: this action activation is _not_ bound to the window - instead it 108 | relies on the window being active when called. 109 | 110 | If the new window doesn't start in the same directory it's probably 111 | because 'vte.sh' haven't been sourced by the shell in this terminal */ 112 | app.action_group.activate_action( 113 | "win.new-terminal", new imports.gi.GLib.Variant("(ss)", ["window", "current"])); 114 | } 115 | 116 | function duplicateWindow(metaWindow) { 117 | metaWindow = metaWindow || global.display.focus_window; 118 | let app = Tracker.get_window_app(metaWindow); 119 | 120 | let handler = customHandlers[app.id]; 121 | if (handler) { 122 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 123 | return handler(metaWindow, app, space); 124 | } 125 | 126 | let workspaceId = metaWindow.get_workspace().workspace_index; 127 | 128 | let original = Kludges.getSavedProp(Shell.App.prototype, "open_new_window"); 129 | original.call(app, workspaceId); 130 | return true; 131 | } 132 | 133 | function trySpawnWindow(app, workspace) { 134 | if (typeof(app) === 'string') { 135 | app = new Shell.App({ app_info: Gio.DesktopAppInfo.new(app) }); 136 | } 137 | let handler = customSpawnHandlers[app.id]; 138 | if (handler) { 139 | let space = Tiling.spaces.selectedSpace; 140 | return handler(app, space); 141 | } else { 142 | launchFromWorkspaceDir(app, workspace); 143 | } 144 | } 145 | 146 | function spawnWindow(app, workspace) { 147 | if (typeof(app) === 'string') { 148 | app = new Shell.App({ app_info: Gio.DesktopAppInfo.new(app) }); 149 | } 150 | try { 151 | return trySpawnWindow(app, workspace); 152 | } catch(e) { 153 | // Let the overide take care any fallback 154 | return app.open_new_window(-1); 155 | } 156 | } 157 | 158 | function getWorkspaceDirectory(workspace=null) { 159 | let space = workspace ? Tiling.spaces.get(workspace) : Tiling.spaces.selectedSpace; 160 | 161 | let dir = space.settings.get_string("directory"); 162 | if (dir[0] === "~") { 163 | dir = GLib.getenv("HOME") + dir.slice(1); 164 | } 165 | return dir; 166 | } 167 | 168 | function expandCommandline(commandline, workspace) { 169 | let dir = getWorkspaceDirectory(workspace) 170 | 171 | commandline = commandline.replace(/%d/g, () => GLib.shell_quote(dir)); 172 | 173 | return commandline 174 | } 175 | 176 | function mkCommandLineSpawner(commandlineTemplate, spawnInWorkspaceDir=false) { 177 | return (app, space) => { 178 | let workspace = space.workspace; 179 | let commandline = expandCommandline(commandlineTemplate, workspace); 180 | print("Launching", commandline); 181 | let workingDir = spawnInWorkspaceDir ? getWorkspaceDirectory(workspace) : null; 182 | let [success, cmdArgs] = GLib.shell_parse_argv(commandline); 183 | if (success) { 184 | success = GLib.spawn_async(workingDir, cmdArgs, GLib.get_environ(), GLib.SpawnFlags.SEARCH_PATH, null); 185 | } 186 | if (!success) { 187 | Extension.imports.extension.notify( 188 | `Failed to run custom spawn handler for ${app.id}`, 189 | `Attempted to run '${commandline}'`); 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /convenience.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2011-2012, Giovanni Campagna 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the GNOME nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | */ 26 | 27 | const Gettext = imports.gettext; 28 | const Gio = imports.gi.Gio; 29 | 30 | const Config = imports.misc.config; 31 | const ExtensionUtils = imports.misc.extensionUtils; 32 | 33 | // Cache schema objects - if a user updates the extension without restarting 34 | // gnome-shell we risk re-reading a updated schema file without using the 35 | // updated code 36 | var cache = {}; 37 | 38 | 39 | /** 40 | * initTranslations: 41 | * @domain: (optional): the gettext domain to use 42 | * 43 | * Initialize Gettext to load translations from extensionsdir/locale. 44 | * If @domain is not provided, it will be taken from metadata['gettext-domain'] 45 | */ 46 | function initTranslations(domain) { 47 | let extension = ExtensionUtils.getCurrentExtension(); 48 | 49 | domain = domain || extension.metadata['gettext-domain']; 50 | 51 | // check if this extension was built with "make zip-file", and thus 52 | // has the locale files in a subfolder 53 | // otherwise assume that extension has been installed in the 54 | // same prefix as gnome-shell 55 | let localeDir = extension.dir.get_child('locale'); 56 | if (localeDir.query_exists(null)) 57 | Gettext.bindtextdomain(domain, localeDir.get_path()); 58 | else 59 | Gettext.bindtextdomain(domain, Config.LOCALEDIR); 60 | } 61 | 62 | /** 63 | * getSettings: 64 | * @schema: (optional): the GSettings schema id 65 | * 66 | * Builds and return a GSettings schema for @schema, using schema files 67 | * in extensionsdir/schemas. If @schema is not provided, it is taken from 68 | * metadata['settings-schema']. 69 | */ 70 | function getSettings(schema) { 71 | let extension = ExtensionUtils.getCurrentExtension(); 72 | 73 | schema = schema || extension.metadata['settings-schema']; 74 | 75 | let settings = cache[schema]; 76 | if (settings) { 77 | return settings; 78 | } 79 | 80 | const GioSSS = Gio.SettingsSchemaSource; 81 | 82 | // check if this extension was built with "make zip-file", and thus 83 | // has the schema files in a subfolder 84 | // otherwise assume that extension has been installed in the 85 | // same prefix as gnome-shell (and therefore schemas are available 86 | // in the standard folders) 87 | let schemaDir = extension.dir.get_child('schemas'); 88 | let schemaSource; 89 | if (schemaDir.query_exists(null)) 90 | schemaSource = GioSSS.new_from_directory(schemaDir.get_path(), 91 | GioSSS.get_default(), 92 | false); 93 | else 94 | schemaSource = GioSSS.get_default(); 95 | 96 | let schemaObj = schemaSource.lookup(schema, true); 97 | if (!schemaObj) 98 | throw new Error('Schema ' + schema + ' could not be found for extension ' 99 | + extension.metadata.uuid + '. Please check your installation.'); 100 | 101 | settings = new Gio.Settings({ settings_schema: schemaObj }); 102 | cache[schema] = settings; 103 | return settings; 104 | } 105 | -------------------------------------------------------------------------------- /debug: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | indent=" " 4 | 5 | # Ref: https://gitlab.gnome.org/GNOME/gnome-shell/issues/1 6 | function skip-crap { 7 | local datep="[0-9][0-9]:[0-9][0-9]:[0-9][0-9]: " 8 | local crap_start="^${datep}Object [^ ]+ \(.*\), has been already finalized. Impossible to \w* any property \w* it.*" 9 | 10 | local crap_continue=( 11 | "^${datep}== Stack trace for context.*" 12 | "^${datep}#[0-9]+\s*0x.*" 13 | ) 14 | 15 | local skip=0 16 | local skipped=0 17 | local begin_skip_date 18 | 19 | # Could probably be done more elegantly with awk/sed ? 20 | while IFS=$'\n' read -r line; do 21 | if [[ $line =~ $crap_start ]]; then 22 | # echo setting skip 23 | skip=1 24 | begin_skip=$line 25 | ((skipped += 1)) 26 | continue 27 | fi 28 | 29 | if [[ $skip == 1 ]]; then 30 | if [[ ($line =~ $crap_continue[1]) || 31 | ($line =~ $crap_continue[2]) ]]; then 32 | ((skipped += 1)) 33 | continue 34 | else 35 | # echo reset skip 36 | echo -E "$begin_skip" 37 | printf "${indent}... skipped \"already finalized\" crap ($skipped lines)\n" 38 | skip=0 39 | skipped=0 40 | fi 41 | fi 42 | 43 | echo -E "$line" 44 | done 45 | } 46 | 47 | 48 | # We use non-breaking space to encode newlines in multiline messages 49 | function decode-multiline-message { 50 | stdbuf -oL sed -e 's| |\n |g' 51 | } 52 | 53 | function gnome-shell-exe-path { 54 | if systemctl --user status gnome-shell-x11.service > /dev/null; then 55 | echo --user-unit=gnome-shell-x11.service 56 | elif systemctl --user status gnome-shell-wayland.service > /dev/null; then 57 | echo --user-unit=gnome-shell-wayland.service 58 | elif uname -a | grep --silent "NixOS"; then 59 | echo $(dirname =gnome-shell(:A))/.gnome-shell-wrapped 60 | else 61 | echo =gnome-shell 62 | fi 63 | } 64 | 65 | function procees { 66 | jq --unbuffered --raw-output ' 67 | {ts: .__REALTIME_TIMESTAMP, message: .MESSAGE} 68 | | @sh "TS=\(.ts); MESSAGE=\(.message)\u0000" 69 | ' | while read -r -d $'\0' DATA; do 70 | eval $DATA 71 | 72 | TS=$((TS/1000000)) 73 | 74 | PP_TS=$(date -d @${TS} +'%T') 75 | 76 | if [[ $MESSAGE == *$'\n'* ]]; then 77 | echo $PP_TS: 78 | echo -E $MESSAGE | sed 's/^/ /' 79 | else 80 | echo -E "$PP_TS: $MESSAGE" 81 | fi 82 | done 83 | 84 | } 85 | 86 | journalctl --follow --lines 400 -o json --output-fields MESSAGE \ 87 | $@ $(gnome-shell-exe-path) \ 88 | | procees \ 89 | | skip-crap \ 90 | | decode-multiline-message 91 | 92 | 93 | -------------------------------------------------------------------------------- /examples/keybindings.js: -------------------------------------------------------------------------------- 1 | var Extension = imports.misc.extensionUtils.getCurrentExtension(); 2 | var Keybindings = Extension.imports.keybindings; 3 | var Main = imports.ui.main; 4 | var Tiling = Extension.imports.tiling; 5 | var Scratch = Extension.imports.scratch; 6 | 7 | /** 8 | To use an example as-is ("gotoByIndex" for instance) add the following to the 9 | `init` function in "user.js": 10 | 11 | Extension.imports.examples.keybindings.gotoByIndex(); 12 | */ 13 | 14 | function gotoByIndex() { 15 | function goto(k) { 16 | return () => { 17 | let space = Tiling.spaces.get(global.workspace_manager.get_active_workspace()); 18 | let metaWindow = space.getWindow(k, 0) 19 | if (!metaWindow) 20 | return; 21 | 22 | if (metaWindow.has_focus()) { 23 | // Can happen when navigator is open 24 | Tiling.ensureViewport(metaWindow); 25 | } else { 26 | Main.activateWindow(metaWindow); 27 | } 28 | } 29 | } 30 | for(let k = 1; k <= 9; k++) { 31 | Keybindings.bindkey(`${k}`, `goto-coloumn-${k}`, 32 | goto(k-1), {activeInNavigator: true}) 33 | } 34 | } 35 | 36 | function windowMarks() { 37 | const Meta = imports.gi.Meta; 38 | var marks = {} 39 | 40 | function setMark(k) { 41 | return (mw) => marks[k] = mw 42 | } 43 | 44 | function gotoMark(k) { 45 | return (metaWindow, space, options) => { 46 | let mark = marks[k]; 47 | if (!mark) 48 | return; 49 | 50 | if (mark.has_focus()) { 51 | // Can happen when navigator is open 52 | Tiling.ensureViewport(mark); 53 | if (!options.navigator) { 54 | let mru = global.display.get_tab_list( 55 | Meta.TabList.NORMAL_ALL, null); 56 | let nextWindow = mru[1]; 57 | if (!nextWindow) 58 | return; 59 | Main.activateWindow(nextWindow); 60 | if (Scratch.isScratchWindow(mark) && 61 | !Scratch.isScratchWindow(nextWindow)) { 62 | Scratch.hide(); 63 | } 64 | } 65 | } else { 66 | Main.activateWindow(mark); 67 | } 68 | } 69 | } 70 | 71 | for(let k = 0; k <= 9; k++) { 72 | Keybindings.bindkey(`${k}`, `goto-mark-${k}`, 73 | gotoMark(k), {activeInNavigator: true}) 74 | Keybindings.bindkey(`${k}`, `set-mark-${k}`, 75 | setMark(k), {activeInNavigator: true}) 76 | } 77 | } 78 | 79 | function swapNeighbours(binding = "y") { 80 | var Tiling = Extension.imports.tiling; 81 | var Meta = imports.gi.Meta; 82 | 83 | Keybindings.bindkey(binding, "swap-neighbours", (mw) => { 84 | let space = Tiling.spaces.spaceOfWindow(mw) 85 | let i = space.indexOf(mw); 86 | if (space[i+1]) { 87 | space.swap(Meta.MotionDirection.RIGHT, space[i+1][0]); 88 | space[i+1].map(mw => mw.clone.raise_top()); 89 | } 90 | }, {activeInNavigator: true}); 91 | } 92 | 93 | /** 94 | Before: |[ A ][ *B* ]|[ C ] 95 | After: |[ A ][ *C* ]|[ B ] 96 | */ 97 | function swapWithRight(binding = "d") { 98 | var Tiling = Extension.imports.tiling; 99 | var Utils = Extension.imports.utils; 100 | 101 | Keybindings.bindkey(binding, "swap-with-right", mw => { 102 | let space = Tiling.spaces.spaceOfWindow(mw); 103 | let i = space.indexOf(mw); 104 | if (i === space.length - 1) 105 | return; 106 | 107 | Utils.swap(space, i, i+1); 108 | space.layout(false); 109 | space.emit("full-layout"); 110 | Main.activateWindow(space[i][0]); 111 | }, { opensMinimap: true }); 112 | } 113 | 114 | function cycleMonitor(binding = "d") { 115 | var Tiling = Extension.imports.tiling; 116 | var Main = imports.ui.main; 117 | 118 | Keybindings.bindkey(binding, "cycle-monitor", () => { 119 | let curMonitor = Tiling.spaces.selectedSpace.monitor 120 | let monitors = Main.layoutManager.monitors; 121 | let nextMonitorI = (curMonitor.index + 1) % monitors.length; 122 | let nextMonitor = monitors[nextMonitorI]; 123 | let nextSpace = Tiling.spaces.monitors.get(nextMonitor); 124 | if (nextSpace) { 125 | nextSpace.workspace.activate(global.get_current_time()); 126 | } 127 | }); 128 | } 129 | 130 | /** 131 | Cycle the workspace settings bound to the current workspace. 132 | (among the unused settings) 133 | NB: Only relevant when using dynamic workspaces. 134 | */ 135 | function cycleWorkspaceSettings(binding = "q") { 136 | var Tiling = Extension.imports.tiling; 137 | var Settings = Extension.imports.settings; 138 | var Utils = Extension.imports.utils; 139 | 140 | Keybindings.bindkey( 141 | binding, "next-space-setting", 142 | mw => Tiling.cycleWorkspaceSettings(-1), { activeInNavigator: true } 143 | ); 144 | Keybindings.bindkey( 145 | ""+binding, "prev-space-setting", 146 | mw => Tiling.cycleWorkspaceSettings(1), { activeInNavigator: true } 147 | ); 148 | } 149 | 150 | 151 | function showNavigator(binding = "j") { 152 | Keybindings.bindkey(binding, "show-minimap", () => null, { opensMinimap: true }) 153 | } 154 | 155 | 156 | // listFreeBindings("").join("\n") 157 | function listFreeBindings(modifierString) { 158 | let free = []; 159 | const chars = "abcdefghijklmnopqrstuvxyz1234567890".split("") 160 | const symbols = ["minus", "comma", "period", "plus"] 161 | return [].concat(chars, symbols).filter( 162 | key => Keybindings.getBoundActionId(modifierString+key) === 0 163 | ).map(key => modifierString+key) 164 | } 165 | 166 | function moveSpaceToMonitor(basebinding = '') { 167 | let Meta = imports.gi.Meta; 168 | let display = global.display; 169 | 170 | function moveTo(direction) { 171 | let Navigator = Extension.imports.navigator; 172 | let spaces = Tiling.spaces; 173 | 174 | let currentSpace = spaces.selectedSpace; 175 | let monitor = currentSpace.monitor; 176 | let i = display.get_monitor_neighbor_index(monitor.index, direction); 177 | let opposite; 178 | switch (direction) { 179 | case Meta.DisplayDirection.RIGHT: 180 | opposite = Meta.DisplayDirection.LEFT; break; 181 | case Meta.DisplayDirection.LEFT: 182 | opposite = Meta.DisplayDirection.RIGHT; break; 183 | case Meta.DisplayDirection.UP: 184 | opposite = Meta.DisplayDirection.DOWN; break; 185 | case Meta.DisplayDirection.DOWN: 186 | opposite = Meta.DisplayDirection.UP; break; 187 | } 188 | let n = i; 189 | if (i === -1) { 190 | let i = monitor.index; 191 | while (i !== -1) { 192 | n = i; 193 | i = display.get_monitor_neighbor_index(n, opposite); 194 | } 195 | } 196 | let next = spaces.monitors.get(Main.layoutManager.monitors[n]); 197 | 198 | currentSpace.setMonitor(next.monitor); 199 | spaces.monitors.set(next.monitor, currentSpace); 200 | 201 | next.setMonitor(monitor); 202 | spaces.monitors.set(monitor, next); 203 | 204 | // This is pretty hacky 205 | spaces.switchWorkspace(null, currentSpace.workspace.index(), currentSpace.workspace.index()); 206 | } 207 | 208 | for (let arrow of ['Down', 'Left', 'Up', 'Right']) { 209 | Keybindings.bindkey(`${basebinding}${arrow}`, `move-space-monitor-${arrow}`, 210 | () => { 211 | moveTo(Meta.DisplayDirection[arrow.toUpperCase()]); 212 | }); 213 | } 214 | } 215 | 216 | /** 217 | "KP_Add" and "KP_Subtract" to use the numpad keys 218 | */ 219 | function adjustWidth(incBinding="plus", decBinding="minus", increment=50) { 220 | function adjuster(delta) { 221 | return mw => { 222 | if (!mw) return; 223 | const f = mw.get_frame_rect(); 224 | mw.move_resize_frame(true, f.x, f.y, f.width + delta, f.height); 225 | } 226 | } 227 | 228 | Keybindings.bindkey(incBinding, "inc-width", adjuster(increment)); 229 | Keybindings.bindkey(decBinding, "dec-width", adjuster(-increment)); 230 | } 231 | 232 | function tileInto(leftBinding="less", rightBinding="less") { 233 | Extension.imports.examples.layouts.bindTileInto(leftBinding, rightBinding); 234 | } 235 | 236 | function stackUnstack(basebinding = '') { 237 | // less: '<' 238 | let Tiling = Extension.imports.tiling; 239 | 240 | const stackUnstackDirection = (dir=-1) => (metaWindow) => { 241 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 242 | let column_idx = space.indexOf(metaWindow); 243 | if (column_idx < 0) 244 | return; 245 | let column = space[column_idx]; 246 | 247 | if (column.length >= 2) { 248 | // this is a stacked window 249 | // move it into a new column 250 | let row_idx = column.indexOf(metaWindow); 251 | if (row_idx < 0) 252 | return; 253 | 254 | let removed = column.splice(row_idx, 1)[0]; 255 | let new_column_idx = column_idx; 256 | if (dir === 1) 257 | new_column_idx += 1; 258 | 259 | space.splice(new_column_idx, 0, [removed]); 260 | } 261 | else { 262 | // this is an unstacked window 263 | // move it into a stack 264 | 265 | // can't stack into a column that doesn't exist 266 | if (column_idx == 0 && dir == -1) 267 | return; 268 | if (column_idx + 1 >= space.length && dir == 1) 269 | return; 270 | 271 | let windowToMove = column[0]; 272 | space[column_idx + dir].push(windowToMove); 273 | 274 | // is it necessary to remove the window from the column before removing the column? 275 | column.splice(0, 1); 276 | 277 | space.splice(column_idx, 1); 278 | } 279 | 280 | space.layout(true, { 281 | customAllocators: { [space.indexOf(metaWindow)]: Tiling.allocateEqualHeight } 282 | }); 283 | space.emit("full-layout"); 284 | } 285 | 286 | let options = { activeInNavigator: true }; 287 | Keybindings.bindkey(`${basebinding}Left`, "stack-unstack-left", stackUnstackDirection(-1), options); 288 | Keybindings.bindkey(`${basebinding}Right`, "stack-unstack-right", stackUnstackDirection(1), options); 289 | } 290 | 291 | function cycleEdgeSnap(binding = "u") { 292 | var Tiling = Extension.imports.tiling; 293 | var Meta = imports.gi.Meta; 294 | 295 | Keybindings.bindkey(binding, "cycle-edge-snap", (mw) => { 296 | // Snaps window to the left/right monitor edge 297 | // Note: mostly the same as quickly switching left+right / right+left 298 | 299 | // Note: We work in monitor relative coordinates here 300 | let margin = Tiling.prefs.horizontal_margin; 301 | let space = Tiling.spaces.spaceOfWindow(mw); 302 | let workarea = Main.layoutManager.getWorkAreaForMonitor(space.monitor.index); 303 | let clone = mw.clone; 304 | 305 | let x = clone.targetX + space.targetX; 306 | let width = clone.width; 307 | let wax = workarea.x - space.monitor.x; 308 | 309 | let leftSnapPos = wax + margin; 310 | let rightSnapPos = wax + workarea.width - width - margin; 311 | 312 | let targetX; 313 | if (x == leftSnapPos) { 314 | targetX = rightSnapPos; 315 | } else if (x == rightSnapPos) { 316 | targetX = leftSnapPos; 317 | } else { 318 | targetX = leftSnapPos; 319 | } 320 | 321 | Tiling.move_to(space, mw, {x: targetX}); 322 | }, {activeInNavigator: true}); 323 | } 324 | 325 | function reorderWorkspace(bindingUp = "Page_Up", bindingDown = "Page_Down") { 326 | if (!global.workspace_manager.reorder_workspace) { 327 | print("Reorder workspaces not supported by this gnome-shell version"); 328 | return; 329 | } 330 | function moveWorkspace(dir, metaWindow, space) { 331 | if (!space) 332 | return; 333 | 334 | let nextI = Math.min(Tiling.spaces.size-1 , Math.max(0, space.workspace.index() + dir)); 335 | global.workspace_manager.reorder_workspace(space.workspace, nextI); 336 | } 337 | 338 | Keybindings.bindkey( 339 | bindingUp, "reorder-workspace-up", 340 | moveWorkspace.bind(null, -1), 341 | { activeInNavigator: true } 342 | ); 343 | 344 | Keybindings.bindkey( 345 | bindingDown, "reorder-workspace-down", 346 | moveWorkspace.bind(null, 1), 347 | { activeInNavigator: true } 348 | ); 349 | } 350 | -------------------------------------------------------------------------------- /examples/layouts.js: -------------------------------------------------------------------------------- 1 | var Extension; 2 | if (imports.misc.extensionUtils.extensions) { 3 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 4 | } else { 5 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 6 | } 7 | var Keybindings = Extension.imports.keybindings; 8 | var Main = imports.ui.main; 9 | var Tiling = Extension.imports.tiling; 10 | var Scratch = Extension.imports.scratch; 11 | var Virt = Extension.imports.virtTiling; 12 | var Tweener = Extension.imports.utils.tweener; 13 | var Utils = Extension.imports.utils; 14 | var prefs = Tiling.prefs; 15 | 16 | 17 | /** Adapts an action handler to operate on the neighbour in the given direction */ 18 | function useNeigbour(dir, action) { 19 | return (metaWindow) => { 20 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 21 | let i = space.indexOf(metaWindow); 22 | if (!space[i+dir]) 23 | return action(undefined); 24 | 25 | return action(space[i+dir][0]); 26 | } 27 | } 28 | 29 | /** Find the index of the first not fully visible column in the given direction */ 30 | function findNonVisibleIndex(space, metaWindow, dir=1, margin=1) { 31 | let k = space.indexOf(metaWindow) + dir; 32 | while (0 <= k && k < space.length && space.isFullyVisible(space[k][0], margin)) { 33 | k += dir; 34 | } 35 | return k 36 | } 37 | 38 | function moveTo(space, metaWindow, target) { 39 | space.startAnimate(); 40 | space.targetX = target; 41 | Tweener.addTween(space.cloneContainer, 42 | { x: space.targetX, 43 | time: prefs.animation_time, 44 | onComplete: space.moveDone.bind(space) 45 | }); 46 | 47 | space.fixOverlays(); 48 | } 49 | 50 | function getLeftSnapPosition(space) { 51 | let margin = Tiling.prefs.horizontal_margin; 52 | let workarea = space.workArea(); 53 | let wax = workarea.x - space.monitor.x; 54 | 55 | return wax + margin; 56 | } 57 | 58 | function getSnapPositions(space, windowWidth) { 59 | let margin = Tiling.prefs.horizontal_margin; 60 | let workarea = space.workArea(); 61 | let wax = workarea.x - space.monitor.x; 62 | 63 | let leftSnapPos = wax + margin; 64 | let rightSnapPos = wax + workarea.width - windowWidth - margin; 65 | return [leftSnapPos, rightSnapPos] 66 | } 67 | 68 | function mkVirtTiling(space) { 69 | return Virt.layout(Virt.fromSpace(space), space.workArea(), prefs); 70 | } 71 | 72 | function moveToViewport(space, tiling, i, vx) { 73 | moveTo(space, null, vx - tiling[i][0].x); 74 | } 75 | 76 | function resize(tiling, i, width) { 77 | for (let w of tiling[i]) { 78 | w.width = width; 79 | } 80 | } 81 | 82 | 83 | ////// Actions 84 | 85 | 86 | /** 87 | Expands or shrinks the window to fit the available viewport space. 88 | Available space is space not occupied by fully visible windows 89 | Will move the tiling as necessary. 90 | */ 91 | function fitAvailable(metaWindow) { 92 | // TERMINOLOGY: mold-into ? 93 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 94 | 95 | let a = findNonVisibleIndex(space, metaWindow, -1); 96 | let b = findNonVisibleIndex(space, metaWindow, 1); 97 | 98 | let leftMost = space[a+1][0]; 99 | let availableLeft = space.targetX + leftMost.clone.targetX; 100 | 101 | let rightMost = space[b-1][0]; 102 | let rightEdge = space.targetX + rightMost.clone.targetX + rightMost.clone.width; 103 | let availableRight = space.width - rightEdge; 104 | 105 | let f = metaWindow.get_frame_rect(); 106 | let available = f.width + availableRight + availableLeft - Tiling.prefs.horizontal_margin*2; 107 | 108 | if (a+1 === b-1) { 109 | // We're the only window 110 | Tiling.toggleMaximizeHorizontally(metaWindow); 111 | } else { 112 | metaWindow.move_resize_frame(true, f.x, f.y, available, f.height); 113 | Tiling.move_to(space, space[a+1][0], { x: Tiling.prefs.horizontal_margin }); 114 | } 115 | } 116 | 117 | 118 | 119 | function cycleLayoutDirection(dir) { 120 | 121 | const splits = [ 122 | [0.5, 0.5], 123 | [0.7, 0.3], 124 | [0.3, 0.7] 125 | ]; 126 | 127 | return (metaWindow, space, {navigator}={}) => { 128 | let k = space.indexOf(metaWindow); 129 | let j = k+dir; 130 | let neighbourCol = space[j]; 131 | if (!neighbourCol) 132 | return; 133 | 134 | let neighbour = neighbourCol[0]; 135 | 136 | let tiling = mkVirtTiling(space) 137 | 138 | let available = space.width - Tiling.prefs.horizontal_margin*2 - Tiling.prefs.window_gap; 139 | 140 | let f1 = metaWindow.get_frame_rect(); 141 | let f2 = neighbour.get_frame_rect(); 142 | 143 | let s1 = f1.width / available; 144 | let s2 = f2.width / available; 145 | 146 | let state; 147 | if (!navigator["cycle-layouts"]) { 148 | navigator["cycle-layouts"] = {i: Utils.eq(s1, splits[0][0]) ? 1 : 0 }; 149 | } 150 | state = navigator["cycle-layouts"]; 151 | 152 | let [a, b] = splits[state.i % splits.length]; 153 | state.i++; 154 | 155 | let metaWindowWidth = Math.round(available * a);; 156 | metaWindow.move_resize_frame(true, f1.x, f1.y, metaWindowWidth, f1.height); 157 | resize(tiling, k, metaWindowWidth); 158 | 159 | let neighbourWidth = Math.round(available * b); 160 | neighbour.move_resize_frame(true, f2.x, f2.y, neighbourWidth, f2.height); 161 | resize(tiling, j, neighbourWidth); 162 | 163 | Virt.layout(tiling, space.workArea(), prefs); 164 | 165 | let snapLeft = getLeftSnapPosition(space); 166 | 167 | if (dir === 1) 168 | moveToViewport(space, tiling, k, snapLeft); 169 | else 170 | moveToViewport(space, tiling, j, snapLeft); 171 | } 172 | } 173 | 174 | function cycleLayouts(binding = "d") { 175 | function action(metaWindow, space, {navigator}={}) { 176 | const m = 50; 177 | space = Tiling.spaces.spaceOfWindow(metaWindow); 178 | 179 | let k = space.indexOf(metaWindow); 180 | let next = space.length > k+1 && space.isVisible(space[k+1][0], m) && space[k+1][0]; 181 | let prev = k > 0 && space.isVisible(space[k-1][0], m) && space[k-1][0]; 182 | 183 | let neighbour = next || prev; 184 | 185 | if (neighbour === next) { 186 | return cycleLayoutDirection(1)(metaWindow, space, {navigator}); 187 | } else { 188 | return cycleLayoutDirection(-1)(metaWindow, space, {navigator}); 189 | } 190 | } 191 | 192 | Keybindings.bindkey(binding, "cycle-layouts", action, { opensNavigator: true }); 193 | } 194 | 195 | 196 | function tileInto(dir=-1) { 197 | return (metaWindow, space) => { 198 | space = space || Tiling.spaces.spaceOfWindow(metaWindow); 199 | let jFrom = space.indexOf(metaWindow); 200 | if (space[jFrom].length > 1) { 201 | return tileOut(dir)(metaWindow, space); 202 | } 203 | let jTo = jFrom + dir; 204 | if (jTo < 0 || jTo >= space.length) 205 | return; 206 | 207 | space[jFrom].splice(space.rowOf(metaWindow), 1); 208 | space[jTo].push(metaWindow); 209 | 210 | if (space[jFrom].length === 0) { 211 | space.splice(jFrom, 1); 212 | } 213 | space.layout(true, { 214 | customAllocators: { [space.indexOf(metaWindow)]: Tiling.allocateEqualHeight } 215 | }); 216 | space.emit("full-layout"); 217 | } 218 | } 219 | 220 | function tileOut(dir) { 221 | return (metaWindow, space) => { 222 | space = space || Tiling.spaces.spaceOfWindow(metaWindow); 223 | let [j, i] = space.positionOf(metaWindow); 224 | if (space[j].length === 0) 225 | return; 226 | 227 | space[j].splice(i, 1); 228 | space.splice(j + (dir === 1 ? 1 : 0), 0, [metaWindow]); 229 | space.layout(); 230 | space.emit("full-layout"); 231 | space.fixOverlays(); 232 | } 233 | } 234 | 235 | 236 | ////// Bindings 237 | 238 | function bindTileInto(leftBinding="Left", rightBinding="Right") { 239 | let options = { activeInNavigator: true }; 240 | if (leftBinding) 241 | Keybindings.bindkey(leftBinding, "tile-into-left-column", tileInto(-1), options); 242 | if (rightBinding) 243 | Keybindings.bindkey(rightBinding, "tile-into-right-column", tileInto(1), options); 244 | } 245 | 246 | function bindTileOut(left="k", right="l") { 247 | Keybindings.bindkey(left, "tile-out-left", tileOut(-1), {activeInNavigator: true}); 248 | Keybindings.bindkey(right, "tile-out-right", tileOut(1), {activeInNavigator: true}); 249 | } 250 | 251 | 252 | function bindFitAvailable(left="j", focus = "k", right="l") { 253 | left && Keybindings.bindkey(left, "fit-available-width-left", useNeigbour(-1, fitAvailable), {activeInNavigator: true}); 254 | focus && Keybindings.bindkey(focus, "fit-available-width", fitAvailable, {activeInNavigator: true}); 255 | right && Keybindings.bindkey(right, "fit-available-width-right", useNeigbour(1, fitAvailable), {activeInNavigator: true}); 256 | } 257 | 258 | function bindCycleLayoutDirection(left="d", right="d") { 259 | Keybindings.bindkey(left, "cycle-layout-left", cycleLayoutDirection(-1), { opensNavigator: true }); 260 | Keybindings.bindkey(right, "cycle-layout-right", cycleLayoutDirection(1), { opensNavigator: true }); 261 | } 262 | -------------------------------------------------------------------------------- /examples/user.js: -------------------------------------------------------------------------------- 1 | // -*- mode: gnome-shell -*- 2 | 3 | var Meta = imports.gi.Meta; 4 | var Clutter = imports.gi.Clutter; 5 | var St = imports.gi.St; 6 | var Main = imports.ui.main; 7 | var Shell = imports.gi.Shell; 8 | 9 | // Extension local imports 10 | var Extension, Me, Tiling, Utils, App, Keybindings, Examples; 11 | 12 | function init() { 13 | // Runs _only_ once on startup 14 | 15 | // Initialize extension imports here to make gnome-shell-reload work 16 | Extension = imports.misc.extensionUtils.getCurrentExtension(); 17 | Me = Extension.imports.user; 18 | Tiling = Extension.imports.tiling; 19 | Utils = Extension.imports.utils; 20 | Keybindings = Extension.imports.keybindings; 21 | Examples = Extension.imports.examples; 22 | App = Extension.imports.app; 23 | } 24 | 25 | function enable() { 26 | // Runs on extension reloads, eg. when unlocking the session 27 | } 28 | 29 | function disable() { 30 | // Runs on extension reloads eg. when locking the session (`L). 31 | } 32 | 33 | -------------------------------------------------------------------------------- /examples/winprops.js: -------------------------------------------------------------------------------- 1 | const Extension = imports.misc.extensionUtils.getCurrentExtension(); 2 | const defwinprop = Extension.imports.tiling.defwinprop 3 | 4 | defwinprop({ 5 | wm_class: "copyq", 6 | scratch_layer: true 7 | }); 8 | 9 | defwinprop({ 10 | wm_class: "Riot", 11 | oneshot: true, // Allow reattaching 12 | scratch_layer: true 13 | }); 14 | 15 | // Fix rofi in normal window mode (eg. in Wayland) 16 | defwinprop({ 17 | wm_class: "Rofi", 18 | focus: true 19 | }); 20 | -------------------------------------------------------------------------------- /extension.js: -------------------------------------------------------------------------------- 1 | // polyfill workspace_manager that was introduced in 3.30 (must happen before modules are imported) 2 | if (!global.workspace_manager) { 3 | global.workspace_manager = global.screen; 4 | } 5 | 6 | /** 7 | The currently used modules 8 | - tiling is the main module, responsible for tiling and workspaces 9 | 10 | - navigator is used to initiate a discrete navigation. 11 | Focus is only switched when the navigation is done. 12 | 13 | - keybindings is a utility wrapper around mutters keybinding facilities. 14 | 15 | - scratch is used to manage floating windows, or scratch windows. 16 | 17 | - liveAltTab is a simple altTab implementiation with live previews. 18 | 19 | - stackoverlay is somewhat kludgy. It makes clicking on the left or right 20 | edge of the screen always activate the partially (or sometimes wholly) 21 | concealed window at the edges. 22 | 23 | - app creates new windows based on the current application. It's possible 24 | to create custom new window handlers. 25 | 26 | - kludges is used for monkey patching gnome shell behavior which simply 27 | doesn't fit paperwm. 28 | 29 | - topbar adds the workspace name to the topbar and styles it. 30 | 31 | - gestures is responsible for 3-finger swiping (only works in wayland). 32 | */ 33 | var modules = [ 34 | 'tiling', 'navigator', 'keybindings', 'scratch', 'liveAltTab', 'utils', 35 | 'stackoverlay', 'app', 'kludges', 'topbar', 'settings','gestures' 36 | ]; 37 | 38 | /** 39 | Tell the modules to run init, enable or disable 40 | */ 41 | function run(method) { 42 | for (let name of modules) { 43 | // Bail if there's an error in our own modules 44 | if (!safeCall(name, method)) 45 | return false; 46 | } 47 | 48 | if (hasUserConfigFile()) { 49 | safeCall('user', method); 50 | } 51 | 52 | return true; 53 | } 54 | 55 | function safeCall(name, method) { 56 | try { 57 | print("#paperwm", `${method} ${name}`); 58 | let module = Extension.imports[name]; 59 | module && module[method] && module[method].call(module, errorNotification); 60 | return true; 61 | } catch(e) { 62 | print("#paperwm", `${name} failed ${method}`); 63 | print(`JS ERROR: ${e}\n${e.stack}`); 64 | errorNotification( 65 | "PaperWM", 66 | `Error occured in ${name} @${method}:\n\n${e.message}`, 67 | e.stack); 68 | return false; 69 | } 70 | } 71 | 72 | var SESSIONID = ""+(new Date().getTime()); 73 | 74 | /** 75 | * The extension sometimes go through multiple init -> enable -> disable 76 | * cycles. So we need to keep track of whether we're initialized.. 77 | */ 78 | var initRun; 79 | var enabled = false; 80 | 81 | var Extension, convenience; 82 | function init() { 83 | SESSIONID += "#"; 84 | log(`#paperwm init: ${SESSIONID}`); 85 | 86 | // var Gio = imports.gi.Gio; 87 | // let extfile = Gio.file_new_for_path( Extension.imports.extension.__file__); 88 | Extension = imports.misc.extensionUtils.getCurrentExtension(); 89 | convenience = Extension.imports.convenience; 90 | 91 | if(initRun) { 92 | log(`#startup Reinitialized against our will! Skip adding bindings again to not cause trouble.`); 93 | return; 94 | } 95 | 96 | initUserConfig(); 97 | 98 | if (run('init')) 99 | initRun = true; 100 | } 101 | 102 | function enable() { 103 | log(`#paperwm enable ${SESSIONID}`); 104 | if (enabled) { 105 | log('enable called without calling disable'); 106 | return; 107 | } 108 | 109 | if (run('enable')) 110 | enabled = true; 111 | } 112 | 113 | function disable() { 114 | log(`#paperwm disable ${SESSIONID}`); 115 | if (!enabled) { 116 | log('disable called without calling enable'); 117 | return; 118 | } 119 | 120 | if (run('disable')) 121 | enabled = false; 122 | } 123 | 124 | 125 | var Gio = imports.gi.Gio; 126 | var GLib = imports.gi.GLib; 127 | var Main = imports.ui.main; 128 | 129 | function getConfigDir() { 130 | return Gio.file_new_for_path(GLib.get_user_config_dir() + '/paperwm'); 131 | } 132 | 133 | function hasUserConfigFile() { 134 | return getConfigDir().get_child("user.js").query_exists(null); 135 | } 136 | 137 | function installConfig() { 138 | print("#rc", "Installing config"); 139 | const configDir = getConfigDir(); 140 | configDir.make_directory_with_parents(null); 141 | 142 | // We copy metadata.json to the config directory so gnome-shell-mode 143 | // know which extension the files belong to (ideally we'd symlink, but 144 | // that trips up the importer: Extension.imports. in 145 | // gnome-shell-mode crashes gnome-shell..) 146 | const metadata = Extension.dir.get_child("metadata.json"); 147 | metadata.copy(configDir.get_child("metadata.json"), Gio.FileCopyFlags.NONE, null, null); 148 | 149 | // Copy the user.js template to the config directory 150 | const user = Extension.dir.get_child("examples/user.js"); 151 | user.copy(configDir.get_child("user.js"), Gio.FileCopyFlags.NONE, null, null); 152 | 153 | const settings = convenience.getSettings(); 154 | settings.set_boolean("has-installed-config-template", true); 155 | } 156 | 157 | function initUserConfig() { 158 | const paperSettings = convenience.getSettings(); 159 | 160 | if (!paperSettings.get_boolean("has-installed-config-template") 161 | && !hasUserConfigFile()) 162 | { 163 | try { 164 | installConfig(); 165 | 166 | const configDir = getConfigDir().get_path(); 167 | const notification = notify("PaperWM", `Installed user configuration in ${configDir}`); 168 | notification.connect('activated', () => { 169 | imports.misc.util.spawn(["nautilus", configDir]); 170 | notification.destroy(); 171 | }); 172 | } catch(e) { 173 | errorNotification("PaperWM", 174 | `Failed to install user config: ${e.message}`, e.stack); 175 | print("#rc", "Install failed", e.message); 176 | } 177 | 178 | } 179 | 180 | if (hasUserConfigFile()) { 181 | Extension.imports.searchPath.push(getConfigDir().get_path()); 182 | } 183 | } 184 | 185 | /** 186 | * Our own version of imports.ui.main.notify allowing more control over the 187 | * notification 188 | */ 189 | function notify(msg, details, params) { 190 | const MessageTray = imports.ui.messageTray; 191 | let source = new MessageTray.SystemNotificationSource(); 192 | // note-to-self: the source is automatically destroyed when all its 193 | // notifications are removed. 194 | Main.messageTray.add(source); 195 | let notification = new MessageTray.Notification(source, msg, details, params); 196 | notification.setResident(true); // Usually more annoying that the notification disappear than not 197 | source.showNotification(notification); 198 | return notification; 199 | } 200 | 201 | function spawnPager(content) { 202 | const quoted = GLib.shell_quote(content); 203 | imports.misc.util.spawn(["sh", "-c", `echo -En ${quoted} | gedit --new-window -`]); 204 | } 205 | 206 | /** 207 | * Show an notification opening a the full message in dedicated window upon 208 | * activation 209 | */ 210 | function errorNotification(title, message, fullMessage) { 211 | const notification = notify(title, message); 212 | notification.connect('activated', () => { 213 | spawnPager([title, message, "", fullMessage].join("\n")); 214 | notification.destroy(); 215 | }); 216 | } 217 | -------------------------------------------------------------------------------- /gestures.js: -------------------------------------------------------------------------------- 1 | var Extension; 2 | if (imports.misc.extensionUtils.extensions) { 3 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 4 | } else { 5 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 6 | } 7 | 8 | var gliding = false; 9 | 10 | var Meta = imports.gi.Meta; 11 | var St = imports.gi.St; 12 | var Gio = imports.gi.Gio; 13 | var PanelMenu = imports.ui.panelMenu; 14 | var PopupMenu = imports.ui.popupMenu; 15 | var Clutter = imports.gi.Clutter; 16 | var Main = imports.ui.main; 17 | var Shell = imports.gi.Shell; 18 | var Tweener = Extension.imports.utils.tweener; 19 | 20 | var Utils = Extension.imports.utils; 21 | var Tiling = Extension.imports.tiling; 22 | var Navigator = Extension.imports.navigator; 23 | var prefs = Extension.imports.settings.prefs; 24 | 25 | const stage = global.stage; 26 | 27 | var signals; 28 | function init() { 29 | signals = new Utils.Signals(); 30 | } 31 | 32 | const DIRECTIONS = { 33 | Horizontal: true, 34 | Vertical: false, 35 | } 36 | 37 | var vy; 38 | var time; 39 | var vState; 40 | var navigator; 41 | var direction = undefined; 42 | // 1 is natural scrolling, -1 is unnatural 43 | var natural = 1; 44 | function enable() { 45 | // Touchpad swipes only works in Wayland 46 | if (!Meta.is_wayland_compositor()) 47 | return; 48 | 49 | var touchpadSettings = new Gio.Settings({ 50 | schema_id: 'org.gnome.desktop.peripherals.touchpad' 51 | }); 52 | 53 | signals.destroy(); 54 | /** 55 | In order for the space.background actors to get any input we need to hide 56 | all the window actors from the stage. 57 | 58 | The stage takes care of scrolling vertically through the workspace mru. 59 | Delegating the horizontal scrolling to each space. This way vertical 60 | scrolling works anywhere, while horizontal scrolling is done on the space 61 | under the mouse cursor. 62 | */ 63 | signals.connect(stage, 'captured-event', (actor, event) => { 64 | if (event.type() !== Clutter.EventType.TOUCHPAD_SWIPE || 65 | event.get_touchpad_gesture_finger_count() < 3 || 66 | (Main.actionMode & Shell.ActionMode.OVERVIEW) > 0) { 67 | return Clutter.EVENT_PROPAGATE; 68 | } 69 | const phase = event.get_gesture_phase(); 70 | switch (phase) { 71 | case Clutter.TouchpadGesturePhase.UPDATE: 72 | if (direction === DIRECTIONS.Horizontal) { 73 | return Clutter.EVENT_PROPAGATE; 74 | } 75 | let [dx, dy] = event.get_gesture_motion_delta(); 76 | if (direction === undefined) { 77 | if (Math.abs(dx) < Math.abs(dy)) { 78 | vy = 0; 79 | vState = phase; 80 | direction = DIRECTIONS.Vertical; 81 | } 82 | } 83 | if (direction === DIRECTIONS.Vertical) { 84 | updateVertical(-dy*natural*prefs.swipe_sensitivity[1], event.get_time()); 85 | return Clutter.EVENT_STOP; 86 | } 87 | return Clutter.EVENT_PROPAGATE; 88 | case Clutter.TouchpadGesturePhase.BEGIN: 89 | time = event.get_time(); 90 | natural = touchpadSettings.get_boolean("natural-scroll") ? 1 : -1; 91 | direction = undefined; 92 | navigator = Navigator.getNavigator(); 93 | navigator.connect('destroy', () => { 94 | vState = -1; 95 | }); 96 | return Clutter.EVENT_STOP; 97 | case Clutter.TouchpadGesturePhase.CANCEL: 98 | case Clutter.TouchpadGesturePhase.END: 99 | if (direction === DIRECTIONS.Vertical) { 100 | vState = phase; 101 | endVertical(); 102 | return Clutter.EVENT_STOP; 103 | } 104 | }; 105 | return Clutter.EVENT_PROPAGATE; 106 | }); 107 | } 108 | 109 | function disable() { 110 | signals.destroy(); 111 | } 112 | 113 | /** 114 | Handle scrolling horizontally in a space. The handler is meant to be 115 | connected from each space.background and bound to the space. 116 | */ 117 | let start, dxs = [], dts = []; 118 | function horizontalScroll(actor, event) { 119 | if (event.type() !== Clutter.EventType.TOUCHPAD_SWIPE || 120 | event.get_touchpad_gesture_finger_count() < 3) { 121 | return Clutter.EVENT_PROPAGATE; 122 | } 123 | const phase = event.get_gesture_phase(); 124 | switch (phase) { 125 | case Clutter.TouchpadGesturePhase.UPDATE: 126 | let [dx, dy] = event.get_gesture_motion_delta(); 127 | if (direction === undefined) { 128 | this.vx = 0; 129 | dxs = []; 130 | dts = []; 131 | this.hState = phase; 132 | start = this.targetX; 133 | Tweener.removeTweens(this.cloneContainer); 134 | direction = DIRECTIONS.Horizontal; 135 | } 136 | return update(this, -dx*natural*prefs.swipe_sensitivity[0], event.get_time()); 137 | case Clutter.TouchpadGesturePhase.CANCEL: 138 | case Clutter.TouchpadGesturePhase.END: 139 | this.hState = phase; 140 | done(this, event); 141 | dxs = []; 142 | dts = []; 143 | return Clutter.EVENT_STOP; 144 | } 145 | } 146 | 147 | function update(space, dx, t) { 148 | 149 | dxs.push(dx); 150 | dts.push(t); 151 | 152 | space.cloneContainer.x -= dx; 153 | space.targetX = space.cloneContainer.x; 154 | 155 | // Check which target windew will be selected if we releas the swipe at this 156 | // moment 157 | dx = Utils.sum(dxs.slice(-3)); 158 | let v = dx/(t - dts.slice(-3)[0]); 159 | if (Number.isFinite(v)) { 160 | space.vx = v; 161 | } 162 | 163 | let accel = prefs.swipe_friction[0]/16; // px/ms^2 164 | accel = space.vx > 0 ? -accel : accel; 165 | let duration = -space.vx/accel; 166 | let d = space.vx*duration + .5*accel*duration**2; 167 | let target = Math.round(space.targetX - d); 168 | 169 | space.targetX = target; 170 | let selected = findTargetWindow(space, direction, start - space.targetX > 0); 171 | space.targetX = space.cloneContainer.x; 172 | Tiling.updateSelection(space, selected); 173 | space.selectedWindow = selected; 174 | space.emit('select'); 175 | 176 | return Clutter.EVENT_STOP; 177 | } 178 | 179 | function done(space) { 180 | if (!Number.isFinite(space.vx) || space.length === 0) { 181 | navigator.finish(); 182 | space.hState = -1; 183 | return Clutter.EVENT_STOP; 184 | } 185 | 186 | let startGlide = space.targetX; 187 | 188 | // timetravel 189 | let accel = prefs.swipe_friction[0]/16; // px/ms^2 190 | accel = space.vx > 0 ? -accel : accel; 191 | let t = -space.vx/accel; 192 | let d = space.vx*t + .5*accel*t**2; 193 | let target = Math.round(space.targetX - d); 194 | 195 | let mode = Clutter.AnimationMode.EASE_OUT_QUAD; 196 | let first; 197 | let last; 198 | 199 | let full = space.cloneContainer.width > space.width; 200 | // Only snap to the edges if we started gliding when the viewport is fully covered 201 | let snap = !(0 <= space.targetX || 202 | space.targetX + space.cloneContainer.width <= space.width); 203 | if ((snap && target > 0) 204 | || (full && target > space.width*2)) { 205 | // Snap to left edge 206 | first = space[0][0]; 207 | target = 0; 208 | mode = Clutter.AnimationMode.EASE_OUT_BACK; 209 | } else if ((snap && target + space.cloneContainer.width < space.width) 210 | || (full && target + space.cloneContainer.width < -space.width)) { 211 | // Snap to right edge 212 | last = space[space.length-1][0]; 213 | target = space.width - space.cloneContainer.width; 214 | mode = Clutter.AnimationMode.EASE_OUT_BACK; 215 | } 216 | 217 | // Adjust for target window 218 | let selected; 219 | space.targetX = Math.round(target); 220 | selected = last || first || findTargetWindow(space, start - target > 0 ); 221 | delete selected.lastFrame; // Invalidate frame information 222 | let x = Tiling.ensuredX(selected, space); 223 | target = x - selected.clone.targetX; 224 | 225 | // Scale down travel time if we've cut down the discance to travel 226 | let newD = Math.abs(startGlide - target); 227 | if (newD < Math.abs(d)) 228 | t = t*Math.abs(newD/d); 229 | 230 | // Use a minimum duration if we've adjusted travel 231 | if (target !== space.targetX || mode === Clutter.AnimationMode.EASE_OUT_BACK) { 232 | t = Math.max(t, 200); 233 | } 234 | space.targetX = target; 235 | 236 | Tiling.updateSelection(space, selected); 237 | space.selectedWindow = selected; 238 | space.emit('select'); 239 | gliding = true; 240 | Tweener.addTween(space.cloneContainer, { 241 | x: space.targetX, 242 | duration: t, 243 | mode, 244 | onStopped: () => { 245 | gliding = false; 246 | }, 247 | onComplete: () => { 248 | if (!Tiling.inPreview) 249 | Navigator.getNavigator().finish(); 250 | } 251 | }); 252 | } 253 | 254 | 255 | function findTargetWindow(space, direction) { 256 | let selected = space.selectedWindow.clone; 257 | if (selected.x + space.targetX >= 0 && 258 | selected.x + selected.width + space.targetX <= space.width) { 259 | return selected.meta_window; 260 | } 261 | selected = selected && space.selectedWindow; 262 | let workArea = space.workArea(); 263 | let min = workArea.x; 264 | 265 | let windows = space.getWindows().filter(w => { 266 | let clone = w.clone; 267 | let x = clone.targetX + space.targetX; 268 | return !(x + clone.width < min 269 | || x > min + workArea.width); 270 | }); 271 | if (!direction) // scroll left 272 | windows.reverse(); 273 | let visible = windows.filter(w => { 274 | let clone = w.clone; 275 | let x = clone.targetX + space.targetX; 276 | return x >= 0 && 277 | x + clone.width <= min + workArea.width; 278 | }); 279 | if (visible.length > 0) { 280 | return visible[0]; 281 | } 282 | 283 | if (windows.length === 0) { 284 | let first = space.getWindow(0, 0); 285 | let last = space.getWindow(space.length - 1, 0); 286 | if (direction) { 287 | return last; 288 | } else { 289 | return first; 290 | } 291 | } 292 | 293 | if (windows.length === 1) 294 | return windows[0]; 295 | 296 | let closest = windows[0].clone; 297 | let next = windows[1].clone; 298 | let r1, r2; 299 | if (direction) { // -> 300 | r1 = Math.abs(closest.targetX + closest.width + space.targetX)/closest.width; 301 | r2 = Math.abs(next.targetX + space.targetX - space.width)/next.width; 302 | } else { 303 | r1 = Math.abs(closest.targetX + space.targetX - space.width)/closest.width; 304 | r2 = Math.abs(next.targetX + next.width + space.targetX)/next.width; 305 | } 306 | // Choose the window the most visible width (as a ratio) 307 | if (r1 > r2) 308 | return closest.meta_window; 309 | else 310 | return next.meta_window; 311 | } 312 | 313 | var transition = 'easeOutQuad'; 314 | function updateVertical(dy, t) { 315 | if (!Tiling.inPreview) { 316 | Tiling.spaces._initWorkspaceStack(); 317 | } 318 | let selected = Tiling.spaces.selectedSpace; 319 | let monitor = navigator.monitor; 320 | let v = dy/(t - time); 321 | time = t; 322 | const StackPositions = Tiling.StackPositions; 323 | if (dy > 0 324 | && selected !== navigator.from 325 | && (selected.actor.y - dy < StackPositions.up*monitor.height) 326 | ) { 327 | dy = 0; 328 | vy = 1; 329 | selected.actor.y = StackPositions.up*selected.height; 330 | Tiling.spaces.selectStackSpace(Meta.MotionDirection.UP, false, transition); 331 | selected = Tiling.spaces.selectedSpace; 332 | Tweener.removeTweens(selected.actor); 333 | Tweener.addTween(selected.actor, {scale_x: 0.9, scale_y: 0.9, time: 334 | prefs.animation_time, transition}); 335 | } else if (dy < 0 336 | && (selected.actor.y - dy > StackPositions.down*monitor.height)) { 337 | dy = 0; 338 | vy = -1; 339 | selected.actor.y = StackPositions.down*selected.height; 340 | Tiling.spaces.selectStackSpace(Meta.MotionDirection.DOWN, false, transition); 341 | selected = Tiling.spaces.selectedSpace; 342 | Tweener.removeTweens(selected.actor); 343 | Tweener.addTween(selected.actor, {scale_x: 0.9, scale_y: 0.9, time: 344 | prefs.animation_time, transition}); 345 | } else if (Number.isFinite(v)) { 346 | vy = v; 347 | } 348 | 349 | selected.actor.y -= dy; 350 | if (selected === navigator.from) { 351 | let scale = 0.90; 352 | let s = 1 - (1 - scale)*(selected.actor.y/(0.1*monitor.height)); 353 | s = Math.max(s, scale); 354 | Tweener.removeTweens(selected.actor); 355 | selected.actor.set_scale(s, s); 356 | } 357 | } 358 | 359 | function endVertical() { 360 | let test = vy > 0 ? 361 | () => vy < 0 : 362 | () => vy > 0; 363 | 364 | let glide = () => { 365 | if (vState < Clutter.TouchpadGesturePhase.END) 366 | return false; 367 | 368 | if (!Number.isFinite(vy)) { 369 | return false; 370 | } 371 | 372 | let selected = Tiling.spaces.selectedSpace; 373 | let y = selected.actor.y; 374 | if (selected === navigator.from && y <= 0.1*selected.height) { 375 | navigator.finish(); 376 | return false; 377 | } 378 | 379 | if (test()) { 380 | return false; 381 | } 382 | 383 | let dy = vy*16; 384 | let v = vy; 385 | let accel = prefs.swipe_friction[1]; 386 | accel = v > 0 ? -accel : accel; 387 | updateVertical(dy, time + 16); 388 | vy = vy + accel; 389 | return true; // repeat 390 | }; 391 | 392 | imports.mainloop.timeout_add(16, glide, 0); 393 | } 394 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | REPO="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 4 | UUID=paperwm@hedning:matrix.org 5 | EXT_DIR=${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions 6 | mkdir -p "$EXT_DIR" 7 | ln -sn "$REPO" "$EXT_DIR"/"$UUID" 8 | 9 | cat < /dev/null; then 75 | gnome-extensions enable "$UUID" 76 | else 77 | gnome-shell-extension-tool --enable="$UUID" 78 | fi 79 | else 80 | echo something went wrong: 81 | echo $RET | sed -e "s/(true, '\"//" | sed -e "s/\\\\n/\n/g" 82 | 83 | echo Success 84 | fi 85 | -------------------------------------------------------------------------------- /liveAltTab.js: -------------------------------------------------------------------------------- 1 | var Extension; 2 | if (imports.misc.extensionUtils.extensions) { 3 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 4 | } else { 5 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 6 | } 7 | 8 | var Clutter = imports.gi.Clutter; 9 | var Meta = imports.gi.Meta; 10 | var AltTab = imports.ui.altTab; 11 | var Main = imports.ui.main; 12 | var Tweener = Extension.imports.utils.tweener; 13 | var Gio = imports.gi.Gio; 14 | 15 | var Scratch = Extension.imports.scratch; 16 | var Tiling = Extension.imports.tiling; 17 | var Keybindings = Extension.imports.keybindings; 18 | var utils = Extension.imports.utils; 19 | var debug = utils.debug; 20 | 21 | var prefs = Extension.imports.settings.prefs; 22 | 23 | var switcherSettings = new Gio.Settings({ 24 | schema_id: 'org.gnome.shell.window-switcher' 25 | }) 26 | 27 | var LiveAltTab = utils.registerClass( 28 | class LiveAltTab extends AltTab.WindowSwitcherPopup { 29 | 30 | _init(reverse) { 31 | this.reverse = reverse; 32 | super._init(); 33 | } 34 | 35 | _getWindowList(reverse) { 36 | let tabList = global.display.get_tab_list( 37 | Meta.TabList.NORMAL_ALL, 38 | switcherSettings.get_boolean('current-workspace-only') ? 39 | global.workspace_manager.get_active_workspace() : null) 40 | .filter(w => !Scratch.isScratchWindow(w)); 41 | 42 | let scratch = Scratch.getScratchWindows(); 43 | 44 | if (Scratch.isScratchWindow(global.display.focus_window)) { 45 | // Access scratch windows in mru order with shift-super-tab 46 | return scratch.concat(this.reverse ? tabList.reverse() : tabList); 47 | } else { 48 | return tabList.concat(this.reverse ? scratch.reverse() : scratch); 49 | } 50 | } 51 | 52 | _initialSelection(backward, actionName) { 53 | this.space = Tiling.spaces.selectedSpace; 54 | this.space.startAnimate(); 55 | 56 | let monitor = Tiling.spaces.selectedSpace.monitor; 57 | let workArea = Main.layoutManager.getWorkAreaForMonitor(monitor.index); 58 | let fog = new Clutter.Actor({x: workArea.x, y: workArea.y, 59 | width: workArea.width, height: workArea.height, 60 | opacity: 0, background_color: Clutter.color_from_string("black")[1] 61 | }); 62 | 63 | // this.blur = new Clutter.BlurEffect(); 64 | // this.space.cloneContainer.add_effect(this.blur); 65 | this.space.setSelectionInactive(); 66 | 67 | Main.uiGroup.insert_child_above(fog, global.window_group); 68 | Tweener.addTween(fog, { 69 | time: prefs.animation_time, 70 | opacity: 100, 71 | }); 72 | this.fog = fog; 73 | 74 | super._initialSelection(backward, actionName); 75 | } 76 | 77 | _keyPressHandler(keysym, mutterActionId) { 78 | if (keysym === Clutter.KEY_Escape) 79 | return Clutter.EVENT_PROPAGATE; 80 | // After the first super-tab the mutterActionId we get is apparently 81 | // SWITCH_APPLICATIONS so we need to case on those too. 82 | switch(mutterActionId) { 83 | case Meta.KeyBindingAction.SWITCH_APPLICATIONS: 84 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS; 85 | break; 86 | case Meta.KeyBindingAction.SWITCH_APPLICATIONS_BACKWARD: 87 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS_BACKWARD; 88 | break; 89 | case Keybindings.idOf('live-alt-tab'): 90 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS; 91 | break; 92 | ;; 93 | case Keybindings.idOf('live-alt-tab-backward'): 94 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS_BACKWARD; 95 | break; 96 | ;; 97 | } 98 | // let action = Keybindings.byId(mutterActionId); 99 | // if (action && action.options.activeInNavigator) { 100 | // let space = Tiling.spaces.selectedSpace; 101 | // let metaWindow = space.selectedWindow; 102 | // action.handler(metaWindow, space); 103 | // return true; 104 | // } 105 | return super._keyPressHandler(keysym, mutterActionId); 106 | } 107 | 108 | _select(num) { 109 | 110 | let from = this._switcherList.windows[this._selectedIndex]; 111 | let to = this._switcherList.windows[num]; 112 | 113 | this.clone && this.clone.destroy(); 114 | this.clone = null; 115 | 116 | let actor = to.get_compositor_private(); 117 | actor.remove_clip(); 118 | let frame = to.get_frame_rect(); 119 | let clone = new Clutter.Clone({source: actor}); 120 | clone.position = actor.position; 121 | 122 | let space = Tiling.spaces.spaceOfWindow(to); 123 | if (space.indexOf(to) !== -1) { 124 | clone.x = Tiling.ensuredX(to, space) + space.monitor.x; 125 | clone.x -= frame.x - actor.x; 126 | } 127 | 128 | this.clone = clone; 129 | Main.uiGroup.insert_child_above(clone, this.fog); 130 | 131 | // Tiling.ensureViewport(to, space); 132 | this._selectedIndex = num; 133 | this._switcherList.highlight(num); 134 | } 135 | 136 | _finish() { 137 | this.was_accepted = true; 138 | super._finish(); 139 | } 140 | 141 | _itemEnteredHandler() { 142 | // The item-enter (mouse hover) event is triggered even after a item is 143 | // accepted. This can cause _select to run on the item below the pointer 144 | // ensuring the wrong window. 145 | if(!this.was_accepted) { 146 | super._itemEnteredHandler.apply(this, arguments); 147 | } 148 | } 149 | 150 | _onDestroy() { 151 | super._onDestroy(); 152 | debug('#preview', 'onDestroy', this.was_accepted); 153 | Tweener.addTween(this.fog, { 154 | time: prefs.animation_time, 155 | opacity: 0, 156 | onStopped: () => { 157 | this.fog.destroy(); 158 | this.fog = null; 159 | // this.space.cloneContainer.remove_effect(this.blur); 160 | this.clone && this.clone.destroy(); 161 | this.clone = null; 162 | this.space.moveDone(); 163 | } 164 | }); 165 | let index = this.was_accepted ? this._selectedIndex : 0 166 | let to = this._switcherList.windows[index]; 167 | Tiling.focus_handler(to); 168 | let actor = to.get_compositor_private(); 169 | if (this.was_accepted) { 170 | actor.x = this.clone.x; 171 | actor.y = this.clone.y; 172 | } 173 | actor.set_scale(1, 1); 174 | } 175 | }); 176 | 177 | function liveAltTab(meta_window, space, {display, screen, binding}) { 178 | let tabPopup = new LiveAltTab(binding.is_reversed()); 179 | tabPopup.show(binding.is_reversed(), binding.get_name(), binding.get_mask()); 180 | } 181 | -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "paperwm@hedning:matrix.org", 3 | "name": "PaperWM", 4 | "description": "Tiling window manager with a twist", 5 | "url": "https://github.com/paperwm/PaperWM", 6 | "settings-schema": "org.gnome.Shell.Extensions.PaperWM", 7 | "shell-version": [ "40", "41", "42" ], 8 | "version": "42.0" 9 | } 10 | -------------------------------------------------------------------------------- /minimap.js: -------------------------------------------------------------------------------- 1 | var Extension; 2 | if (imports.misc.extensionUtils.extensions) { 3 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 4 | } else { 5 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 6 | } 7 | 8 | var Clutter = imports.gi.Clutter; 9 | var Tweener = Extension.imports.utils.tweener; 10 | var Main = imports.ui.main; 11 | var Lang = imports.lang; 12 | var St = imports.gi.St; 13 | var Pango = imports.gi.Pango; 14 | 15 | var Tiling = Extension.imports.tiling; 16 | var utils = Extension.imports.utils; 17 | var debug = utils.debug; 18 | 19 | var prefs = Extension.imports.settings.prefs; 20 | 21 | var MINIMAP_SCALE = 0.15; 22 | 23 | function calcOffset(metaWindow) { 24 | let buffer = metaWindow.get_buffer_rect(); 25 | let frame = metaWindow.get_frame_rect(); 26 | let x_offset = frame.x - buffer.x; 27 | let y_offset = frame.y - buffer.y; 28 | return [x_offset, y_offset]; 29 | } 30 | 31 | var Minimap = class Minimap extends Array { 32 | constructor(space, monitor) { 33 | super(); 34 | this.space = space; 35 | this.monitor = monitor; 36 | let actor = new St.Widget({name: 'minimap-background', 37 | style_class: 'switcher-list'}); 38 | this.actor = actor; 39 | actor.height = space.height*0.20; 40 | 41 | let highlight = new St.Widget({name: 'minimap-highlight', 42 | style_class: 'item-box'}); 43 | highlight.add_style_pseudo_class('selected'); 44 | this.highlight = highlight; 45 | let label = new St.Label(); 46 | label.clutter_text.ellipsize = Pango.EllipsizeMode.END; 47 | this.label = label;; 48 | 49 | let clip = new St.Widget({name: 'container-clip'}); 50 | this.clip = clip; 51 | let container = new St.Widget({name: 'minimap-container'}); 52 | this.container = container; 53 | container.height = Math.round(space.height*MINIMAP_SCALE) - prefs.window_gap; 54 | 55 | actor.add_actor(highlight); 56 | actor.add_actor(label); 57 | actor.add_actor(clip); 58 | clip.add_actor(container); 59 | clip.set_position(12 + prefs.window_gap, 12 + Math.round(1.5*prefs.window_gap)); 60 | highlight.y = clip.y - 10; 61 | Main.uiGroup.add_actor(this.actor); 62 | this.actor.opacity = 0; 63 | this.createClones(); 64 | 65 | this.signals = new utils.Signals(); 66 | this.signals.connect(space, 'select', this.select.bind(this)); 67 | this.signals.connect(space, 'window-added', this.addWindow.bind(this)); 68 | this.signals.connect(space, 'window-removed', this.removeWindow.bind(this)); 69 | this.signals.connect(space, 'layout', this.layout.bind(this)); 70 | this.signals.connect(space, 'swapped', this.swapped.bind(this)); 71 | this.signals.connect(space, 'full-layout', this.reset.bind(this)); 72 | 73 | this.layout(); 74 | } 75 | 76 | static get [Symbol.species]() { return Array; } 77 | 78 | reset() { 79 | this.splice(0,this.length).forEach(c => c.forEach(x => x.destroy())) 80 | this.createClones() 81 | this.layout(); 82 | } 83 | 84 | addWindow(space, metaWindow, index, row) { 85 | let clone = this.createClone(metaWindow); 86 | if (row !== undefined && this[index]) { 87 | let column = this[index]; 88 | column.splice(row, 0, clone); 89 | } else { 90 | row = row || 0; 91 | this.splice(index, 0, [clone]); 92 | } 93 | this.layout(); 94 | } 95 | 96 | removeWindow(space, metaWindow, index, row) { 97 | let clone = this[index][row]; 98 | let column = this[index]; 99 | column.splice(row, 1); 100 | if (column.length === 0) 101 | this.splice(index, 1); 102 | this.container.remove_child(clone); 103 | this.layout(); 104 | } 105 | 106 | swapped(space, index, targetIndex, row, targetRow) { 107 | let column = this[index]; 108 | utils.swap(this, index, targetIndex); 109 | utils.swap(column, row, targetRow); 110 | this.layout(); 111 | } 112 | 113 | show(animate) { 114 | if (this.destroyed) 115 | return; 116 | let time = animate ? 0.25 : 0; 117 | this.actor.show(); 118 | Tweener.addTween(this.actor, 119 | {opacity: 255, time, mode: Clutter.AnimationMode.EASE_OUT_EXPO}); 120 | } 121 | 122 | hide(animate) { 123 | if (this.destroyed) 124 | return; 125 | let time = animate ? 0.25 : 0; 126 | Tweener.addTween(this.actor, 127 | {opacity: 0, time, mode: Clutter.AnimationMode.EASE_OUT_EXPO, 128 | onComplete: () => this.actor.hide() }); 129 | } 130 | 131 | createClones() { 132 | for (let column of this.space) { 133 | this.push(column.map(this.createClone.bind(this))); 134 | } 135 | } 136 | 137 | createClone(mw) { 138 | let windowActor = mw.get_compositor_private(); 139 | let clone = new Clutter.Clone({ source: windowActor }); 140 | let container = new Clutter.Actor({ 141 | // layout_manager: new WindowCloneLayout(this), 142 | name: "window-clone-container" 143 | }); 144 | clone.meta_window = mw; 145 | container.clone = clone; 146 | container.meta_window = mw; 147 | container.add_actor(clone); 148 | this.container.add_actor(container); 149 | this._allocateClone(container); 150 | return container; 151 | } 152 | 153 | _allocateClone(container) { 154 | let clone = container.clone; 155 | let meta_window = clone.meta_window; 156 | let buffer = meta_window.get_buffer_rect(); 157 | let frame = meta_window.get_frame_rect(); 158 | clone.set_size(buffer.width*MINIMAP_SCALE, buffer.height*MINIMAP_SCALE - prefs.window_gap); 159 | clone.set_position(((buffer.x - frame.x)*MINIMAP_SCALE), 160 | (buffer.y - frame.y)*MINIMAP_SCALE); 161 | container.set_size(frame.width*MINIMAP_SCALE, frame.height*MINIMAP_SCALE); 162 | } 163 | 164 | layout() { 165 | if (this.destroyed) 166 | return; 167 | let gap = prefs.window_gap; 168 | let x = 0; 169 | for (let column of this) { 170 | let y = 0, w = 0; 171 | for (let c of column) { 172 | c.set_position(x, y); 173 | this._allocateClone(c); 174 | w = Math.max(w, c.width); 175 | y += c.height; 176 | } 177 | x += w + gap; 178 | } 179 | 180 | this.clip.width = Math.min(this.container.width, 181 | this.monitor.width - this.clip.x*2 - 24); 182 | this.actor.width = this.clip.width + this.clip.x*2; 183 | this.clip.set_clip(0, 0, this.clip.width, this.clip.height); 184 | this.label.set_style(`max-width: ${this.clip.width}px;`); 185 | this.actor.set_position( 186 | this.monitor.x + Math.floor((this.monitor.width - this.actor.width)/2), 187 | this.monitor.y + Math.floor((this.monitor.height - this.actor.height)/2)); 188 | this.select(); 189 | } 190 | 191 | select() { 192 | let position = this.space.positionOf(); 193 | let highlight = this.highlight; 194 | if (!position) { 195 | this.highlight.hide(); 196 | return; 197 | } 198 | let [index, row] = position; 199 | if (!(index in this && row in this[index])) 200 | return; 201 | highlight.show(); 202 | let clip = this.clip; 203 | let container = this.container; 204 | let label = this.label; 205 | let selected = this[index][row]; 206 | if (!selected) 207 | return; 208 | 209 | label.text = selected.meta_window.title; 210 | 211 | if (selected.x + selected.width + container.x > clip.width) { 212 | // Align right edge of selected with the clip 213 | container.x = clip.width - (selected.x + selected.width) 214 | container.x -= 500; // margin 215 | } 216 | if (selected.x + container.x < 0) { 217 | // Align left edge of selected with the clip 218 | container.x = -selected.x 219 | container.x += 500; // margin 220 | } 221 | 222 | if (container.x + container.width < clip.width) 223 | container.x = clip.width - container.width; 224 | 225 | if (container.x > 0) 226 | container.x = 0; 227 | 228 | let gap = prefs.window_gap; 229 | highlight.x = Math.round( 230 | clip.x + container.x + selected.x - gap/2); 231 | highlight.y = Math.round( 232 | clip.y + selected.y - prefs.window_gap); 233 | highlight.set_size(Math.round(selected.width + gap), 234 | Math.round(Math.min(selected.height, this.clip.height + gap) + gap)); 235 | 236 | let x = highlight.x 237 | + (highlight.width - label.width)/2; 238 | if (x + label.width > clip.x + clip.width) 239 | x = clip.x + clip.width - label.width + 5; 240 | if (x < 0) 241 | x = clip.x - 5; 242 | 243 | label.set_position( 244 | Math.round(x), 245 | clip.y + Math.round(clip.height + 20)); 246 | 247 | this.actor.height = this.label.y + this.label.height + 12; 248 | } 249 | 250 | destroy() { 251 | if (this.destroyed) 252 | return; 253 | this.destroyed = true; 254 | this.signals.destroy(); 255 | this.splice(0,this.length); 256 | this.actor.destroy(); 257 | this.actor = null; 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /navigator.js: -------------------------------------------------------------------------------- 1 | /** 2 | Navigation and previewing functionality. 3 | 4 | This is a somewhat messy tangle of functionality relying on 5 | `SwitcherPopup.SwitcherPopup` when we really should just take full control. 6 | */ 7 | 8 | var Extension; 9 | if (imports.misc.extensionUtils.extensions) { 10 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 11 | } else { 12 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 13 | } 14 | 15 | var SwitcherPopup = imports.ui.switcherPopup; 16 | var Meta = imports.gi.Meta; 17 | var Main = imports.ui.main; 18 | var Mainloop = imports.mainloop; 19 | var GLib = imports.gi.GLib; 20 | var Clutter = imports.gi.Clutter; 21 | var Tweener = Extension.imports.utils.tweener; 22 | var Signals = imports.signals; 23 | 24 | var TopBar = Extension.imports.topbar; 25 | var Scratch = Extension.imports.scratch; 26 | var Minimap = Extension.imports.minimap; 27 | var Tiling = Extension.imports.tiling; 28 | var Keybindings = Extension.imports.keybindings; 29 | var utils = Extension.imports.utils; 30 | var debug = utils.debug; 31 | 32 | var prefs = Extension.imports.settings.prefs; 33 | 34 | var workspaceManager = global.workspace_manager; 35 | var display = global.display; 36 | 37 | var scale = 0.9; 38 | var navigating = false; 39 | var grab = null; 40 | 41 | /** 42 | Handle catching keyevents and dispatching actions 43 | 44 | Adapted from SwitcherPopup, without any visual handling. 45 | */ 46 | var ActionDispatcher = class { 47 | constructor() { 48 | this.actor = new Clutter.Actor(); 49 | this.actor.set_flags(Clutter.ActorFlags.REACTIVE); 50 | Main.uiGroup.add_actor(this.actor); 51 | 52 | let grabHandle = Main.pushModal(this.actor); 53 | // We expect at least a keyboard grab here 54 | if ((grabHandle.get_seat_state() & Clutter.GrabState.KEYBOARD) === 0) { 55 | Main.popModal(grabHandle); 56 | log("Failed to grab modal"); 57 | throw new Error('Could not grab modal') 58 | } 59 | grab = grabHandle; 60 | 61 | this.actor.connect('key-press-event', this._keyPressEvent.bind(this)); 62 | this.actor.connect('key-release-event', this._keyReleaseEvent.bind(this)); 63 | 64 | this._noModsTimeoutId = 0; 65 | } 66 | 67 | show(backward, binding, mask) { 68 | this._modifierMask = SwitcherPopup.primaryModifier(mask); 69 | this.navigator = getNavigator(); 70 | TopBar.fixTopBar(); 71 | let actionId = Keybindings.idOf(binding); 72 | if(actionId === Meta.KeyBindingAction.NONE) { 73 | try { 74 | // Check for built-in actions 75 | actionId = Meta.prefs_get_keybinding_action(binding); 76 | } catch(e) { 77 | debug("Couldn't resolve action name"); 78 | return false; 79 | } 80 | } 81 | 82 | this._doAction(actionId); 83 | 84 | // There's a race condition; if the user released Alt before 85 | // we got the grab, then we won't be notified. (See 86 | // https://bugzilla.gnome.org/show_bug.cgi?id=596695 for 87 | // details.) So we check now. (straight from SwitcherPopup) 88 | if (this._modifierMask) { 89 | let [x, y, mods] = global.get_pointer(); 90 | if (!(mods & this._modifierMask)) { 91 | this._finish(global.get_current_time()); 92 | return false; 93 | } 94 | } else { 95 | this._resetNoModsTimeout(); 96 | } 97 | 98 | return true; 99 | } 100 | 101 | _resetNoModsTimeout() { 102 | if (this._noModsTimeoutId != 0) 103 | Mainloop.source_remove(this._noModsTimeoutId); 104 | 105 | this._noModsTimeoutId = Mainloop.timeout_add(0, 106 | () => { 107 | this._finish(global.get_current_time()); 108 | this._noModsTimeoutId = 0; 109 | return GLib.SOURCE_REMOVE; 110 | }); 111 | } 112 | 113 | _keyPressEvent(actor, event) { 114 | let keysym = event.get_key_symbol(); 115 | let action = global.display.get_keybinding_action(event.get_key_code(), event.get_state()); 116 | 117 | // Popping the modal on keypress doesn't work properly, as the release 118 | // event will leak to the active window. To work around this we initate 119 | // visual destruction on key-press and signal to the release handler 120 | // that we should destroy the dispactcher too 121 | // https://github.com/paperwm/PaperWM/issues/70 122 | if (keysym == Clutter.KEY_Escape) { 123 | this.navigator.destroy(); 124 | this._destroy = true; 125 | return Clutter.EVENT_STOP; 126 | } 127 | 128 | this._doAction(action); 129 | 130 | return Clutter.EVENT_STOP; 131 | } 132 | 133 | _keyReleaseEvent(actor, event) { 134 | let keysym = event.get_key_symbol(); 135 | if (this._destroy) { 136 | this.destroy(); 137 | } 138 | 139 | if (this._modifierMask) { 140 | let [x, y, mods] = global.get_pointer(); 141 | let state = mods & this._modifierMask; 142 | 143 | if (state == 0) 144 | this._finish(event.get_time()); 145 | } else { 146 | this._resetNoModsTimeout(); 147 | } 148 | 149 | return Clutter.EVENT_STOP; 150 | } 151 | 152 | _doAction(mutterActionId) { 153 | 154 | let action = Keybindings.byId(mutterActionId); 155 | let space = Tiling.spaces.selectedSpace; 156 | let metaWindow = space.selectedWindow; 157 | 158 | if (action && action.options.activeInNavigator) { 159 | if (!metaWindow && (action.options.mutterFlags & Meta.KeyBindingFlags.PER_WINDOW)) { 160 | return; 161 | } 162 | 163 | if (!Tiling.inGrab && action.options.opensMinimap) { 164 | this.navigator._showMinimap(space); 165 | } 166 | action.handler(metaWindow, space, {navigator: this.navigator}); 167 | if (space !== Tiling.spaces.selectedSpace) { 168 | this.navigator.minimaps.forEach(m => typeof(m) === 'number' ? 169 | Mainloop.source_remove(m) : m.hide()); 170 | } 171 | if (Tiling.inGrab && !Tiling.inGrab.dnd && Tiling.inGrab.window) { 172 | Tiling.inGrab.beginDnD(); 173 | } 174 | 175 | return true; 176 | } else if (mutterActionId == Meta.KeyBindingAction.MINIMIZE) { 177 | metaWindow.minimize(); 178 | } 179 | 180 | return false; 181 | } 182 | 183 | _finish(timestamp) { 184 | debug('#preview', 'finish'); 185 | this.navigator.accept(); 186 | this.destroy(); 187 | } 188 | 189 | destroy() { 190 | if (this._noModsTimeoutId != 0) 191 | Mainloop.source_remove(this._noModsTimeoutId); 192 | 193 | Main.popModal(grab); 194 | grab = null; 195 | this.actor.destroy(); 196 | this.actor = null; 197 | 198 | // We have already destroyed the navigator 199 | !this._destroy && this.navigator.destroy(); 200 | } 201 | } 202 | 203 | var navigator; 204 | var Navigator = class Navigator { 205 | constructor() { 206 | navigating = true; 207 | this._block = Main.wm._blockAnimations; 208 | Main.wm._blockAnimations = true; 209 | // Meta.disable_unredirect_for_screen(screen); 210 | this.space = Tiling.spaces.spaceOf(workspaceManager.get_active_workspace()); 211 | 212 | this._startWindow = this.space.selectedWindow; 213 | this.from = this.space; 214 | this.monitor = this.space.monitor; 215 | this.monitor.clickOverlay.hide(); 216 | this.minimaps = new Map(); 217 | 218 | TopBar.fixTopBar(); 219 | 220 | Scratch.animateWindows(); 221 | this.space.startAnimate(); 222 | } 223 | 224 | _showMinimap(space) { 225 | let minimap = this.minimaps.get(space); 226 | if (!minimap) { 227 | let minimapId = Mainloop.timeout_add(200, () => { 228 | minimap = new Minimap.Minimap(space, this.monitor); 229 | space.startAnimate(); 230 | minimap.show(false); 231 | this.minimaps.set(space, minimap); 232 | }); 233 | this.minimaps.set(space, minimapId); 234 | } else { 235 | typeof(minimap) !== 'number' && minimap.show(); 236 | } 237 | } 238 | 239 | accept() { 240 | this.was_accepted = true; 241 | } 242 | 243 | finish(space, focus) { 244 | if (grab) 245 | return; 246 | this.accept(); 247 | this.destroy(space, focus); 248 | } 249 | 250 | destroy(space, focus) { 251 | this.minimaps.forEach(m => { 252 | if (typeof(m) === 'number') 253 | Mainloop.source_remove(m); 254 | else 255 | m.destroy(); 256 | }); 257 | 258 | if (Tiling.inGrab && !Tiling.inGrab.dnd) { 259 | Tiling.inGrab.beginDnD() 260 | } 261 | 262 | if (Main.panel.statusArea.appMenu) 263 | Main.panel.statusArea.appMenu.container.show(); 264 | 265 | let force = Tiling.inPreview; 266 | navigating = false; 267 | 268 | if (force) { 269 | this.space.monitor.clickOverlay.hide(); 270 | } 271 | 272 | this.space = space || Tiling.spaces.selectedSpace; 273 | 274 | let from = this.from; 275 | let selected = this.space.selectedWindow; 276 | if(!this.was_accepted) { 277 | // Abort the navigation 278 | this.space = from; 279 | if (this.startWindow && this._startWindow.get_compositor_private()) 280 | selected = this._startWindow; 281 | else 282 | selected = display.focus_window; 283 | } 284 | 285 | let visible = []; 286 | for (let monitor of Main.layoutManager.monitors) { 287 | visible.push( Tiling.spaces.monitors.get(monitor)); 288 | if (monitor === this.monitor) 289 | continue; 290 | monitor.clickOverlay.activate(); 291 | } 292 | 293 | if (!visible.includes(space) && this.monitor !== this.space.monitor) { 294 | this.space.setMonitor(this.monitor, true); 295 | } 296 | 297 | if (this.space === from) { 298 | // Animate the selected space into full view - normally this 299 | // happens on workspace switch, but activating the same workspace 300 | // again doesn't trigger a switch signal 301 | if (force) { 302 | const workspaceId = this.space.workspace.index(); 303 | Tiling.spaces.switchWorkspace(null, workspaceId, workspaceId); 304 | } 305 | } else { 306 | if (Tiling.inGrab && Tiling.inGrab.window) { 307 | this.space.workspace.activate_with_focus(Tiling.inGrab.window, global.get_current_time()); 308 | } else { 309 | this.space.workspace.activate(global.get_current_time()); 310 | } 311 | } 312 | 313 | selected = this.space.indexOf(selected) !== -1 ? selected : 314 | this.space.selectedWindow; 315 | 316 | let curFocus = display.focus_window; 317 | if (force && curFocus && curFocus.is_on_all_workspaces()) 318 | selected = curFocus; 319 | 320 | if (focus) 321 | selected = focus; 322 | 323 | if (selected && !Tiling.inGrab) { 324 | let hasFocus = selected && selected.has_focus(); 325 | selected.foreach_transient(mw => hasFocus = mw.has_focus() || hasFocus); 326 | if (hasFocus) { 327 | Tiling.focus_handler(selected) 328 | } else { 329 | Main.activateWindow(selected); 330 | } 331 | } 332 | if (selected && Tiling.inGrab && !this.was_accepted) { 333 | Tiling.focus_handler(selected) 334 | } 335 | 336 | if (!Tiling.inGrab) 337 | Scratch.showWindows(); 338 | 339 | TopBar.fixTopBar(); 340 | 341 | Main.wm._blockAnimations = this._block; 342 | this.space.moveDone(); 343 | 344 | this.emit('destroy', this.was_accepted); 345 | navigator = false; 346 | } 347 | } 348 | Signals.addSignalMethods(Navigator.prototype); 349 | 350 | function getNavigator() { 351 | if (navigator) 352 | return navigator; 353 | 354 | navigator = new Navigator(); 355 | return navigator; 356 | } 357 | 358 | function preview_navigate(meta_window, space, {display, screen, binding}) { 359 | let tabPopup = new ActionDispatcher(); 360 | tabPopup.show(binding.is_reversed(), binding.get_name(), binding.get_mask()); 361 | } 362 | -------------------------------------------------------------------------------- /notes.org: -------------------------------------------------------------------------------- 1 | * Mutter state change signal summary (WIP) 2 | stuck -> unstuck: 3 | stuck property change 4 | remove from all workspaces 5 | add on target workspace 6 | 7 | unstuck -> stuck: 8 | stuck property change [not-verified] 9 | remove from current workspace 10 | add on all workspaces 11 | 12 | window moved from workspace A -> workspace B: 13 | remove from A 14 | add on B 15 | 16 | window A close: 17 | next window receive focus 18 | remove A from workspace 19 | window-left-monitor (actor is null) 20 | 21 | window is created: 22 | window-added (actor is null) 23 | window-entered-monitor (actor is null) 24 | window-created 25 | window-focus 26 | 27 | window monitor changed: (ws-only-primary) 28 | [order not-verified] 29 | window-left-monitor [not-verified] 30 | window-entered-monitor 31 | unstuck -> stuck 32 | 33 | window monitor changed: (ws-spans-monitor) 34 | window-left-monitor [not-verified] 35 | window-entered-monitor 36 | 37 | Monitor changes (ws-only-primary) 38 | primary -> secondary <=> unstuck -> stuck 39 | seconday -> primary <=> stuck -> unstuck 40 | 41 | window_added always follows a window_removed except when a window is closed. 42 | 43 | window monitor membership determined by majority area (ish) 44 | 45 | * Mutter signal order 46 | ** When window A is closed 47 | 1. The next window, B, receives 'focus' (but the actor of A seems to be gone?) 48 | 2. Workspace receives 'window-removed'. ('A' seems to have been stripped of signal handlers) 49 | 3. on screen 'window-left-monitor', actor isn't available 50 | ** When window A is created 51 | 1. on workspace "window-added" is run, actor isn't available 52 | 2. on screen "window-entered-monitor", actor isn't available 53 | 3. on display "window-created" is run, actor is available 54 | 4. focus is run if the new window should be focused 55 | ** Toggle "Always on visible workspace" (scratch windows) 56 | - window-removed on workspace of window 57 | - window-added on all workspaces 58 | * Keybinding system 59 | `Main.wm.addKeybinding` is used to register a named keybindable /action/ and it's handler. An numeric id is returned. (this is a thin wrapper around `[[https://developer.gnome.org/meta/stable/MetaDisplay.html#meta-display-add-keybinding][MetaDisplay.add_keybinding]]`) 60 | 61 | The action should have an entry in the schema underlying the `GSettings` object supplied to `addKeybinding`. This is where the actual keybinding is specified. Multiple bindings can be specified. 62 | 63 | #+BEGIN_SRC xml 64 | 65 | e']]]> 66 | Toggles the floating scratch layer 67 | 68 | #+END_SRC 69 | 70 | To change a keybinding simply change this value in the gsetting: (mutter will pick up the change automatically. 71 | 72 | #+BEGIN_SRC javascript 73 | mySettings.set_strv("toggle-scratch-layer", ["s"]); 74 | #+END_SRC 75 | 76 | Action names are global. (note that the mutter documentation mostly refers to actions as keybindings) 77 | 78 | `Meta.keybindings_set_custom_handler` is used to change a action handler. Despite what the documentation suggests this works for non-builtin actions too. 79 | 80 | If the action is a mutter built-in (one of `Meta.KeyBindingAction.*`, setting the custom handler to `null` restores the default handler. 81 | 82 | Action handlers fire on key-down. 83 | 84 | Mutter itself does not support key-release sensitive bindings, but it's possible to create a Clutter actor in response to a key-down binding, which temporarily take over the keyboard. Clutter can listen for key-up/key-release events. 85 | 86 | `[[https://developer.gnome.org/meta/stable/MetaDisplay.html#meta-display-get-keybinding-action][MetaDisplay.get_keybinding_action]]` looks up the action id bound to a specific modifer+keycode. This is mostly useful when handling key events within clutter. 87 | 88 | The id -> action-name mapping is not(?) exposed. For builtin actions `Meta.prefs_get_keybinding_action(actionName)` will give the id of actionName. 89 | 90 | It's not possible to look up the handler of a action...(?) 91 | 92 | A slightly annoying detail about how all this works is that you normally give the handler before you know the action-id. So if the handler need to know the action-id (eg. if it use clutter to implement a mini-mode and want to respond to the same key that triggered the mode) you either have to store a name->id map, or re-assign the handler afterward. 93 | 94 | The Keybinding object which is supplied to keyhandler doesn't seem to expose the key used to trigger the action either? 95 | 96 | ** Modifier-only bindings 97 | Simply use the keysym name as if the modifier was a regular key. Don't use angle brackets - those are used for **modifiers**. 98 | : settings.set_strv("my-action", ["Super_L"]) 99 | ** Bind keys without using actions from a schema 100 | From: https://stackoverflow.com/a/42466781/1517969 101 | 102 | #+BEGIN_SRC javascript 103 | Meta = imports.gi.Meta; 104 | Main = imports.ui.main; 105 | Shell = imports.gi.Shell; 106 | 107 | let action = global.display.grab_accelerator("u"); 108 | let name = Meta.external_binding_name_for_action(action); 109 | Main.wm.allowKeybinding(name, Shell.ActionMode.ALL); 110 | global.display.connect( 111 | 'accelerator-activated', 112 | function(display, action, deviceId, timestamp){ 113 | print('Accelerator Activated: [display={}, action={}, deviceId={}, timestamp={}]', 114 | display, action, deviceId, timestamp) 115 | }) 116 | #+END_SRC 117 | ** Lookup an keybinding action by a accelerator string 118 | ~global.display.get_keybinding_action(keycode, mask)~ is simple to use in clutter event handlers since the keycode and mask is readily available. Outside of clutter is harder: 119 | 120 | #+BEGIN_SRC javascript 121 | function devirtualizeMask(gdkVirtualMask) { 122 | const keymap = Gdk.Keymap.get_default(); 123 | let [success, rawMask] = keymap.map_virtual_modifiers(gdkVirtualMask); 124 | if (!success) 125 | throw new Error("Couldn't devirtualize mask " + gdkVirtualMask); 126 | return rawMask; 127 | } 128 | 129 | function getBoundActionId(keystr) { 130 | let [dontcare, keycodes, mask] = 131 | Gtk.accelerator_parse_with_keycode(keystr); 132 | if(keycodes.length > 1) { 133 | throw new Error("Multiple keycodes " + keycodes + " " + keystr); 134 | } 135 | const rawMask = devirtualizeMask(mask); 136 | return global.display.get_keybinding_action(keycodes[0], rawMask); 137 | } 138 | #+END_SRC 139 | * GJS 140 | ** import system / module system 141 | `imports.NAME` reflects the directories and javascript files present in `imports.searchPath`. 142 | To add a path, simply do `imports.searchPath.push(PATH)` 143 | 144 | Environment variable `GJS_PATH` initializes `imports.searchPath`. 145 | 146 | The special property `imports.gi` expose gobject-introspectable libraries. 147 | Another search path controls which libraries are available: 148 | `imports.gi.GIRepository.Repository.get_search_path()` initialized by environment variable `GI_TYPELIB_PATH` (`Repository` is the global instance of [[https://developer.gnome.org/gi/stable/GIRepository.html][GIRepository]]) 149 | 150 | *** Reloading modules 151 | Modules **can't** be reloaded, but writing to `imports.myModule.myVariable` works. Eg. 152 | #+BEGIN_SRC javascript 153 | // myModule 154 | var foo = 1; 155 | function printFoo() { 156 | print(foo); 157 | } 158 | #+END_SRC 159 | 160 | After `imports.myModule.foo = 2`, `printFoo` will print 2. All users of the module share the same module object so they will also see the updated variable. 161 | 162 | *** Refering to the current module 163 | Refering to the module being loaded works: 164 | #+BEGIN_SRC javascript 165 | // myModule.js 166 | var currentModule = imports.myModule; 167 | var foo = 1; 168 | currentModule.foo = 2; 169 | print(foo); // prints 2 170 | #+END_SRC 171 | I don't know if it's possible without knowing the module name. 172 | *** Creating a standalone importer 173 | This trick is due to gnome-shell 174 | #+BEGIN_SRC javascript 175 | function createImporter (directoryPath) { 176 | const Gio = imports.gi.Gio; 177 | let oldSearchPath = imports.searchPath.slice(); // make a copy 178 | let directory = Gio.file_new_for_path(directoryPath); 179 | try { 180 | imports.searchPath = [ directory.get_parent().get_path() ]; 181 | // importing a "subdir" creates a new importer object that doesn't 182 | // affect the global one 183 | return imports[directory.get_basename()]; 184 | } finally { 185 | imports.searchPath = oldSearchPath; 186 | } 187 | } 188 | #+END_SRC 189 | ** Debugging 190 | *** Get a stacktrace 191 | `(new Error()).stack` 192 | * GObject 193 | The `notify` signal is emited on changes to all GObject properties. Listen to `notify::propery-name` to only receive for changes to ` property-name`. ([[https://developer.gnome.org/gobject/stable/gobject-The-Base-Object-Type.html#GObject-notify][Reference]]) 194 | * Gnome-shell scene graph and GUI system 195 | NB: some details might differ with the wayland backend. 196 | 197 | Gnome shell use [[https://developer.gnome.org/clutter/stable/][Clutter]] to mange all visible components including the window textures. Basic GUI components are provided by the [[https://developer.gnome.org/st/stable/][St]] (built on top of clutter). 198 | 199 | Low level window management and input handling happens through [[https://developer.gnome.org/meta/stable/][mutter/meta]]. Gnome-shell is technically a mutter plugin. 200 | 201 | ** Input handling 202 | 203 | (Also see [[Keybinding system]]) 204 | 205 | Input is normally fully handled by X11. This means that even though gnome-shell use clutter (which have input mechanisms) inputs does not normally go through clutter. 206 | 207 | Ie. making an actor `reactive` is not enough to capture input reliable. 208 | 209 | Input handling can be directed through clutter by using: 210 | 211 | : Main.layoutManager._trackActor(actor) 212 | 213 | This informs mutter[1] that mouse input in the actor's region should be sent through clutter. 214 | 215 | Some higher-level interfaces: 216 | 217 | : Main.pushModal(actor) 218 | 219 | The clutter actor will receives all input until `Main.popModal` is called. 220 | 221 | : Main.layoutManager.trackChrome(actor) 222 | 223 | NB: It does not seem to be possible to propagate input captured by a tracked actor to a window actor below. 224 | 225 | NB! When a "tracked" actor is stacked below a _window actor_ it will still prevent the window actor from receiving input! 226 | 227 | [1] By using `meta_set_stage_input_region` through `global.set_stage_input_region` 228 | 229 | ** `MetaWindow` and `MetaWindowActor` 230 | WIP: display_rect vs frame_rect vs actor.width. Gotchas when placing MetaWindowActors in containers, etc. 231 | 232 | Warning: This is a somewhat confusing part of gnome-shell/mutter. 233 | 234 | A window is represented by two objects: a `MetaWindow` representing the underlying windowing system object (eg. a X11 window) and a `MetaWindowActor` which basically is the window texture/visible part. 235 | 236 | Both of these objects have a /geometry/ (size and position). The meta window geometry determines the input region, while the actor geometry determines the texture. Normally these geometries are kept in sync so the visible and input regions corresponds. It is however possible for these to drift: The thumb of rule is that changes to the meta window geometry is propagated to the actor, but not the other way. 237 | 238 | The coordinate system used is thankfully shared :) 239 | 240 | The size of the window actor is slightly bigger than the meta window since the actor includes border decorations and window-resize region. The size difference varies with the toolkit used to create the window. 241 | 242 | *** Basic operations 243 | To get the window actor of a meta window: `metaWindow.get_compositor_private()` 244 | 245 | To get the meta window of a window actor: `windowActor.meta_window` 246 | 247 | The window actor geometry: `windowActor.size, windowActor.position` or `metaWindow.get_buffer_rect` 248 | 249 | The meta window geometry: `[[https://developer.gnome.org/meta/stable/MetaWindow.html#meta-window-get-frame-rect][metaWindow.get_frame_rect()]]` 250 | 251 | Changing the geometry of a window: `[[https://developer.gnome.org/meta/stable/MetaWindow.html#meta-window-move-frame][metaWindow.move_frame]]` or `[[https://developer.gnome.org/meta/stable/MetaWindow.html#meta-window-move-resize-frame][metaWindow.move_resize_frame]]` 252 | 253 | ** Stacking/"z-index" 254 | The "z-index" in clutter is controlled by the actors position in the scene graph. Ie. the actors are drawn in a depth first manner. So the last child of a parent will be drawn on top of all the other children, and so on. 255 | 256 | To my knowledge there is no way to make a actor "break out" of its parent. If sibling A is drawn below another actor X, sibling B will also be drawn below X. 257 | 258 | NB: `ClutterActor.z-position` **don't** control the z-index. It is used to control the perspective of the actors (most relevant for rotated actors). 259 | 260 | A complication when using non-window actors inside `global.window_group` is that mutter keep restacking the window actors in a way that destroys the non-window actors z-index. Listening on the `restacked` signal of `global.screen` (`MetaScreen`) and restack the non-window actors in the handler is a workaround that seems to work. 261 | 262 | ** Gotchas 263 | Building `StWidget` detached from the stage are prone to result in the following warning: 264 | 265 | : st_widget_get_theme_node called on the widget [0x... St...] which is not in the stage. 266 | 267 | This is because a lot of actor properties depend on the style of the actor and that can depend on the ancestors of the actor. (`.parent .child { border: 2px; }`) 268 | 269 | So any code that try to access eg. height/width (unless these have been explicitly set beforehand) requires that the full style info is present. 270 | * Extension system 271 | All extension objects are available using 272 | `imports.misc.extensionUtils.extensions[extensionUiid];` 273 | where the key is the uuid from the metadata.json file. 274 | 275 | The /current/ extension object is usually found like this: 276 | #+BEGIN_SRC javascript 277 | const ExtensionUtils = imports.misc.extensionUtils; 278 | const Me = ExtensionUtils.getCurrentExtension(); 279 | #+END_SRC 280 | 281 | The absolute path of the an extension: `Extension.dir.get_path()` 282 | * Misc HowTo 283 | ** Defer an execution of a function 284 | [[https://developer.gnome.org/meta/stable/meta-Utility-functions.html#meta-later-add][~Meta.later_add~]] (assoc: ~imports.mainloop.timeout_add~) 285 | ** Increase mutter log verbosity 286 | ~Meta.add_verbose_topic(Meta.DebugTopic.FOCUS)~ 287 | ~Meta.remove_verbose_topic(Meta.DebugTopic.FOCUS)~ 288 | ** Profiling 289 | *** Show clutter FPS 290 | Clutter prints the FPS at regular intervals if ~CLUTTER_SHOW_FPS~ is set when gnome-shell starts. Where the output ends up depends on how gnome-shell was started. On my system it ends up in the system journal (journalctl) 291 | 292 | To turn on off without disrupting flow too much use ~GLib.setenv("CLUTTER_SHOW_FPS", "1", true)~ and restart gnome-shell. 293 | * Invariants 294 | ** Focus and active workspace 295 | It's not possible the have a focused window which doesn't belong to the active workspace 296 | ~global.display.focus_window.workspace === workspaceManger.get_active_workspace()~ 297 | * Clutter animation 298 | ~time: 0~ does not result in an instant animation. A default duration seems to be selected instead. 299 | -------------------------------------------------------------------------------- /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PaperWM-community/PaperWM/d0da1301a0c0ec55a70a850ecdd20b608f04c5c9/resources/logo.png -------------------------------------------------------------------------------- /resources/prefs.css: -------------------------------------------------------------------------------- 1 | list.keybindings > row { 2 | padding: 0 0; 3 | background-color: transparent; 4 | } 5 | 6 | list.keybindings > row:hover { 7 | background-color: transparent; 8 | } 9 | 10 | list.keybindings > row.expanded { 11 | background-color: alpha(darker(@theme_base_color), 0.33); 12 | } 13 | 14 | list.keybindings > row.expanded:backdrop:not(:hover):not(:active):not(:selected) { 15 | background-color: alpha(darker(@theme_unfocused_base_color), 0.33); 16 | } 17 | 18 | list.keybindings > row .header, 19 | list.combos > row { 20 | padding: 8px 12px; 21 | min-height: 32px; 22 | } 23 | 24 | list.keybindings > row .header:hover { 25 | background-color: alpha(@theme_fg_color, 0.10); 26 | } 27 | 28 | list.keybindings > row .header:hover:backdrop { 29 | background-color: alpha(@theme_unfocused_fg_color, 0.10); 30 | } 31 | 32 | list.keybindings > row.expanded label.description { 33 | font-weight: bold; 34 | } 35 | 36 | list.combos { 37 | background-color: transparent; 38 | } 39 | 40 | list.combos > .editing { 41 | background-color: @theme_selected_bg_color; 42 | color: @theme_selected_fg_color; 43 | } 44 | -------------------------------------------------------------------------------- /schemas/Makefile: -------------------------------------------------------------------------------- 1 | gschemas.compiled: phony 2 | glib-compile-schemas . 3 | 4 | .PHONY: phony 5 | -------------------------------------------------------------------------------- /schemas/gschemas.compiled: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PaperWM-community/PaperWM/d0da1301a0c0ec55a70a850ecdd20b608f04c5c9/schemas/gschemas.compiled -------------------------------------------------------------------------------- /schemas/org.gnome.shell.extensions.org-scrollwm.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | Return', 'n']]]> 10 | Open new window 11 | 12 | 13 | 14 | Tab']]]> 15 | Switch to previously active window 16 | 17 | 18 | Tab']]]> 19 | Switch to previously active window, backward order 20 | 21 | 22 | 23 | Above_Tab']]]> 24 | Switch to previously active workspace 25 | 26 | 27 | Above_Tab']]]> 28 | Switch to the previously active workspace, backward order 29 | 30 | 31 | 32 | Above_Tab']]]> 33 | Move the active window to the previously active workspace 34 | 35 | 36 | Above_Tab']]]> 37 | Move the active window to the previously active workspace, backward order 38 | 39 | 40 | 41 | Page_Down']]]> 42 | Switch to workspace below 43 | 44 | 45 | 46 | Page_Up']]]> 47 | Switch to workspace above 48 | 49 | 50 | 51 | Page_Down']]]> 52 | Move window one workspace down 53 | 54 | 55 | Page_Up']]]> 56 | Move window one workspace up 57 | 58 | 59 | 60 | 61 | Right']]]> 62 | Move the active window to the right monitor 63 | 64 | 65 | Left']]]> 66 | Move the active window to the left monitor 67 | 68 | 69 | Up']]]> 70 | Move the active window to the above monitor 71 | 72 | 73 | Down']]]> 74 | Move the active window to the below monitor 75 | 76 | 77 | 78 | Right']]]> 79 | Switch to the right monitor 80 | 81 | 82 | Left']]]> 83 | Switch to the left monitor 84 | 85 | 86 | Up']]]> 87 | Switch to the above monitor 88 | 89 | 90 | Down']]]> 91 | Switch to the below monitor 92 | 93 | 94 | 95 | Escape']]]> 96 | Toggle the most recent scratch window 97 | 98 | 99 | 100 | Escape']]]> 101 | Toggles the floating scratch layer 102 | 103 | 104 | 105 | Escape']]]> 106 | Attach/detach the active window into the scratch layer 107 | 108 | 109 | 110 | t']]]> 111 | Take the window, dropping it when finished navigating 112 | 113 | 114 | 115 | period']]]> 116 | Switch to the next window 117 | 118 | 119 | comma']]]> 120 | Switch to the previous window 121 | 122 | 123 | 124 | Right']]]> 125 | Switch to the right window 126 | 127 | 128 | Left']]]> 129 | Switch to the left window 130 | 131 | 132 | Up']]]> 133 | Switch to the above window 134 | 135 | 136 | Down']]]> 137 | Switch to the below window 138 | 139 | 140 | 141 | Home']]]> 142 | Switch to the first window 143 | 144 | 145 | End']]]> 146 | Switch to the last window 147 | 148 | 149 | 150 | period', 'period', 'Right']]]> 151 | Move the active window to the right 152 | 153 | 154 | comma', 'comma', 'Left']]]> 155 | Move the active window to the left 156 | 157 | 158 | Up']]]> 159 | Move the active window up 160 | 161 | 162 | Down']]]> 163 | Move the active window down 164 | 165 | 166 | 167 | i']]]> 168 | Consume the window to right into the active column 169 | 170 | 171 | o']]]> 172 | Expel the bottom window into its own column 173 | 174 | 175 | 176 | plus']]]> 177 | Increment window height 178 | 179 | 180 | 181 | minus']]]> 182 | Decrement window height 183 | 184 | 185 | 186 | plus']]]> 187 | Increment window width 188 | 189 | 190 | 191 | minus']]]> 192 | Decrement window width 193 | 194 | 195 | 196 | r']]]> 197 | Cycle through useful window widths 198 | 199 | 200 | 201 | r']]]> 202 | Cycle through useful window heights 203 | 204 | 205 | 206 | c']]]> 207 | Center window horizontally 208 | 209 | 210 | 211 | f']]]> 212 | Maximize the width of the active window 213 | 214 | 215 | 216 | f']]]> 217 | Toggle fullscreen 218 | 219 | 220 | 221 | Insert']]]> 222 | 223 | Develop: set various global js variables. Eg. `metaWindow` to the active window 224 | 225 | 226 | 227 | 228 | BackSpace']]]> 229 | Close the active window 230 | 231 | 232 | 233 | 234 | 235 | -1 236 | The workspace index 237 | 238 | 239 | '' 240 | The name of the workspace 241 | 242 | 243 | '' 244 | The background image 245 | 246 | 247 | true 248 | Wether to hide the top bar or not 249 | 250 | 251 | '' 252 | The background color 253 | 254 | 255 | '' 256 | Affect various integrations. An empty string means use PWD. (usually ~) 257 | 258 | 259 | 260 | 262 | 263 | [] 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | false 272 | Have we installed the user.js template in ~/.config/paperwm ? 273 | 274 | 275 | 276 | 277 | 20 278 | Minimum margin from windows left and right edge 279 | 280 | 281 | 282 | 5 283 | Minimum margin from windows top edge 284 | 285 | 286 | 287 | 0 288 | Minimum margin from windows bottom edge 289 | 290 | 291 | 292 | 20 293 | Minimum gap between windows 294 | 295 | 296 | 297 | true 298 | Disable the upper left hot corner 299 | 300 | 301 | 302 | false 303 | Limit window previews in the overview to scratch windows 304 | 305 | 306 | 307 | false 308 | Don't show scratch windows in the overview 309 | 310 | 311 | 312 | true 313 | Replace the Activities text with the current workspace name 314 | 315 | 316 | 317 | false 318 | Enable pressure barriers at the monitor edges 319 | 320 | 321 | 322 | 323 | Swipe sensitivity in [x, y] coordinates 324 | 325 | 326 | 327 | 328 | Swipe friction in [x, y] coordinates 329 | 330 | 331 | 332 | 333 | cycle-width cycles through these widths. value < 1 is interpreted as a ratio of the monitor width. Mixed ratios and pixel values not allowed. 334 | 335 | 336 | 337 | 338 | cycle-height cycles through these heights. value < 1 is interpreted as a ratio of the monitor height. Mixed ratios and pixel values not allowed. 339 | 340 | 341 | 342 | 346 | Workspace colors 347 | 348 | 349 | 350 | '' 351 | Default background image 352 | 353 | 354 | 355 | false 356 | Use the default GNOME Shell background 357 | 358 | 359 | 360 | true 361 | Show the top bar on workspaces by default 362 | 363 | 364 | 365 | true 366 | Make the top bar follow the focused monitor 367 | 368 | 369 | 370 | 0.25 371 | Duration of animations in seconds 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | -------------------------------------------------------------------------------- /scratch.js: -------------------------------------------------------------------------------- 1 | var Extension; 2 | if (imports.misc.extensionUtils.extensions) { 3 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 4 | } else { 5 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 6 | } 7 | 8 | var Meta = imports.gi.Meta; 9 | var Main = imports.ui.main; 10 | 11 | var TopBar = Extension.imports.topbar; 12 | var Tiling = Extension.imports.tiling; 13 | var utils = Extension.imports.utils; 14 | var debug = utils.debug; 15 | var float, scratchFrame; // symbols used for expando properties on metawindow 16 | 17 | 18 | function focusMonitor() { 19 | if (global.display.focus_window) { 20 | return Main.layoutManager.monitors[global.display.focus_window.get_monitor()] 21 | } else { 22 | let [pointerX, pointerY, mask] = global.get_pointer(); 23 | let monitor = utils.monitorOfPoint(pointerX, pointerY); 24 | return monitor || Main.layoutManager.primaryMonitor; 25 | } 26 | } 27 | 28 | /** 29 | Tween window to "frame-coordinate" (targetX, targetY). 30 | The frame is moved once the tween is done. 31 | 32 | The actual window actor (not clone) is tweened to ensure it's on top of the 33 | other windows/clones (clones if the space animates) 34 | */ 35 | function tweenScratch(metaWindow, targetX, targetY, tweenParams={}) { 36 | let Tweener = Extension.imports.utils.tweener; 37 | let Settings = Extension.imports.settings; 38 | let f = metaWindow.get_frame_rect(); 39 | let b = metaWindow.get_buffer_rect(); 40 | let dx = f.x - b.x; 41 | let dy = f.y - b.y; 42 | 43 | Tweener.addTween(metaWindow.get_compositor_private(), Object.assign( 44 | { 45 | time: Settings.prefs.animation_time, 46 | x: targetX - dx, 47 | y: targetY - dy, 48 | }, 49 | tweenParams, 50 | { 51 | onComplete: function(...args) { 52 | metaWindow.move_frame(true, targetX , targetY); 53 | tweenParams.onComplete && tweenParams.onComplete.apply(this, args); 54 | } 55 | })); 56 | } 57 | 58 | function makeScratch(metaWindow) { 59 | let fromNonScratch = !metaWindow[float]; 60 | let fromTiling = false; 61 | // Relevant when called while navigating. Use the position the user actually sees. 62 | let windowPositionSeen; 63 | 64 | if (fromNonScratch) { 65 | // Figure out some stuff before the window is removed from the tiling 66 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 67 | fromTiling = space.indexOf(metaWindow) > -1; 68 | windowPositionSeen = metaWindow.clone.get_transformed_position().map(Math.round); 69 | } 70 | 71 | metaWindow[float] = true; 72 | metaWindow.make_above(); 73 | metaWindow.stick(); // NB! Removes the window from the tiling (synchronously) 74 | 75 | if (!metaWindow.minimized) 76 | Tiling.showWindow(metaWindow); 77 | 78 | if (fromTiling) { 79 | let f = metaWindow.get_frame_rect(); 80 | let targetFrame = null; 81 | 82 | if (metaWindow[scratchFrame]) { 83 | let sf = metaWindow[scratchFrame]; 84 | if (utils.monitorOfPoint(sf.x, sf.y) === focusMonitor()) { 85 | targetFrame = sf; 86 | } 87 | } 88 | 89 | if (!targetFrame) { 90 | // Default to moving the window slightly down and reducing the height 91 | let vDisplacement = 30; 92 | let [x, y] = windowPositionSeen; // The window could be non-placable so can't use frame 93 | 94 | targetFrame = new Meta.Rectangle({ 95 | x: x, y: y + vDisplacement, 96 | width: f.width, 97 | height: Math.min(f.height - vDisplacement, Math.floor(f.height * 0.9)) 98 | }) 99 | } 100 | 101 | if (!metaWindow.minimized) { 102 | metaWindow.move_resize_frame(true, f.x, f.y, 103 | targetFrame.width, targetFrame.height); 104 | tweenScratch(metaWindow, targetFrame.x, targetFrame.y, 105 | {onComplete: () => delete metaWindow[scratchFrame]}); 106 | } else { 107 | // Can't restore the scratch geometry immediately since it distort the minimize animation 108 | // ASSUMPTION: minimize animation is not disabled and not already done 109 | let actor = metaWindow.get_compositor_private(); 110 | let signal = actor.connect('effects-completed', () => { 111 | metaWindow.move_resize_frame(true, targetFrame.x, targetFrame.y, 112 | targetFrame.width, targetFrame.height); 113 | actor.disconnect(signal) 114 | }) 115 | } 116 | } 117 | 118 | let monitor = focusMonitor(); 119 | if (monitor.clickOverlay) 120 | monitor.clickOverlay.hide(); 121 | } 122 | 123 | function unmakeScratch(metaWindow) { 124 | if (!metaWindow[scratchFrame]) 125 | metaWindow[scratchFrame] = metaWindow.get_frame_rect(); 126 | metaWindow[float] = false; 127 | metaWindow.unmake_above(); 128 | metaWindow.unstick(); 129 | } 130 | 131 | function toggle(metaWindow) { 132 | if (isScratchWindow(metaWindow)) { 133 | unmakeScratch(metaWindow); 134 | hide(); 135 | } else { 136 | makeScratch(metaWindow); 137 | 138 | if (metaWindow.has_focus) { 139 | let space = Tiling.spaces.get(global.workspace_manager.get_active_workspace()); 140 | space.setSelectionInactive(); 141 | } 142 | } 143 | } 144 | 145 | function isScratchWindow(metaWindow) { 146 | return metaWindow && metaWindow[float]; 147 | } 148 | 149 | /** Return scratch windows in MRU order */ 150 | function getScratchWindows() { 151 | return global.display.get_tab_list(Meta.TabList.NORMAL, null) 152 | .filter(isScratchWindow); 153 | } 154 | 155 | function isScratchActive() { 156 | return getScratchWindows().some(metaWindow => !metaWindow.minimized); 157 | } 158 | 159 | function toggleScratch() { 160 | if (isScratchActive()) 161 | hide(); 162 | else 163 | show(); 164 | } 165 | 166 | function toggleScratchWindow() { 167 | let focus = global.display.focus_window; 168 | if (isScratchWindow(focus)) 169 | hide(); 170 | else 171 | show(true); 172 | } 173 | 174 | function show(top) { 175 | let windows = getScratchWindows(); 176 | if (windows.length === 0) { 177 | return; 178 | } 179 | if (top) 180 | windows = windows.slice(0,1); 181 | 182 | TopBar.fixTopBar(); 183 | 184 | windows.slice().reverse() 185 | .map(function(meta_window) { 186 | meta_window.unminimize(); 187 | meta_window.make_above(); 188 | meta_window.get_compositor_private().show(); 189 | }); 190 | windows[0].activate(global.get_current_time()); 191 | 192 | let monitor = focusMonitor(); 193 | if (monitor.clickOverlay) 194 | monitor.clickOverlay.hide(); 195 | } 196 | 197 | function hide() { 198 | let windows = getScratchWindows(); 199 | windows.map(function(meta_window) { 200 | meta_window.minimize(); 201 | }); 202 | } 203 | 204 | function animateWindows() { 205 | let ws = getScratchWindows().filter(w => !w.minimized); 206 | ws = global.display.sort_windows_by_stacking(ws); 207 | for (let w of ws) { 208 | let parent = w.clone.get_parent() 209 | parent && parent.remove_child(w.clone); 210 | Main.uiGroup.insert_child_below(w.clone, Main.layoutManager.panelBox) 211 | let f = w.get_frame_rect(); 212 | w.clone.set_position(f.x, f.y); 213 | Tiling.animateWindow(w); 214 | } 215 | } 216 | 217 | function showWindows() { 218 | let ws = getScratchWindows().filter(w => !w.minimized); 219 | ws.forEach(Tiling.showWindow) 220 | } 221 | 222 | // Monkey patch the alt-space menu 223 | var PopupMenu = imports.ui.popupMenu; 224 | var WindowMenu = imports.ui.windowMenu; 225 | var originalBuildMenu = WindowMenu.WindowMenu.prototype._buildMenu; 226 | 227 | function init() { 228 | float = Symbol(); 229 | scratchFrame = Symbol(); 230 | } 231 | 232 | function enable() { 233 | WindowMenu.WindowMenu.prototype._buildMenu = 234 | function (window) { 235 | let item; 236 | item = this.addAction(_('Scratch'), () => { 237 | toggle(window); 238 | }); 239 | if (isScratchWindow(window)) 240 | item.setOrnament(PopupMenu.Ornament.CHECK); 241 | 242 | originalBuildMenu.call(this, window); 243 | }; 244 | } 245 | 246 | function disable() { 247 | WindowMenu.WindowMenu.prototype._buildMenu = originalBuildMenu; 248 | } 249 | -------------------------------------------------------------------------------- /set-recommended-gnome-shell-settings.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | RESTORE_SETTINGS_SCRIPT="restore-gnome-shell-settings-$(date +%F).sh" 4 | 5 | if [[ -e $RESTORE_SETTINGS_SCRIPT ]]; then 6 | echo "$RESTORE_SETTINGS_SCRIPT exists" 7 | exit 1 8 | fi 9 | 10 | GNOME_SHELL_VERSION=$(gnome-shell --version) 11 | 12 | if [[ $GNOME_SHELL_VERSION > "GNOME Shell 3.3" ]]; then 13 | USE_OVERRIDE_SCHEMA=true 14 | fi 15 | 16 | echo -e "#!/usr/bin/env bash\n\n" > $RESTORE_SETTINGS_SCRIPT 17 | chmod +x $RESTORE_SETTINGS_SCRIPT 18 | 19 | function set-with-backup { 20 | SCHEMA=$1 21 | KEY=$2 22 | TARGET_VAL=$3 23 | 24 | if [[ $USE_OVERRIDE_SCHEMA == true ]]; then 25 | # Gnome 3.3x doesn't use the override path 26 | # https://gitlab.gnome.org/GNOME/gnome-shell/commit/393d7246cc176cbe8200a62bd661830597ca2fb6 27 | SCHEMA=$(echo $SCHEMA | 28 | sed "s|^org\.gnome\.shell\.overrides|org.gnome.mutter|g") 29 | fi 30 | 31 | CURRENT_VAL=$(gsettings get $SCHEMA $KEY) 32 | if [[ "$CURRENT_VAL" == "$TARGET_VAL" ]]; then 33 | return 34 | fi 35 | 36 | echo "gsettings set $SCHEMA $KEY $CURRENT_VAL" >> $RESTORE_SETTINGS_SCRIPT 37 | 38 | gsettings set $SCHEMA $KEY $TARGET_VAL 39 | echo "Changed $SCHEMA $KEY from '$CURRENT_VAL' to '$TARGET_VAL'" 40 | } 41 | 42 | 43 | ##### Recommended settings 44 | 45 | # Multi-monitor support is much more complete with workspaces spanning monitors 46 | set-with-backup org.gnome.shell.overrides workspaces-only-on-primary false 47 | 48 | # We make no attempt at handing edge-tiling 49 | set-with-backup org.gnome.shell.overrides edge-tiling false 50 | 51 | # Attached modal dialogs isn't handled very well 52 | set-with-backup org.gnome.shell.overrides attach-modal-dialogs false 53 | 54 | 55 | 56 | echo 57 | echo "Run $RESTORE_SETTINGS_SCRIPT to revert changes" 58 | -------------------------------------------------------------------------------- /settings.js: -------------------------------------------------------------------------------- 1 | /** 2 | 3 | Settings utility shared between the running extension and the preference UI. 4 | 5 | */ 6 | var Extension; 7 | if (imports.misc.extensionUtils.extensions) { 8 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 9 | } else { 10 | // Cannot relaiably test for imports.ui in the preference ui 11 | try { 12 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 13 | } catch(e) { 14 | Extension = imports.misc.extensionUtils.getCurrentExtension(); 15 | } 16 | } 17 | 18 | var Gio = imports.gi.Gio; 19 | var GLib = imports.gi.GLib; 20 | var Gtk = imports.gi.Gtk; 21 | 22 | var Convenience = Extension.imports.convenience; 23 | var settings = Convenience.getSettings(); 24 | var workspaceSettingsCache = {}; 25 | 26 | var WORKSPACE_KEY = 'org.gnome.Shell.Extensions.PaperWM.Workspace'; 27 | var WORKSPACE_LIST_KEY = 'org.gnome.Shell.Extensions.PaperWM.WorkspaceList'; 28 | var KEYBINDINGS_KEY = 'org.gnome.Shell.Extensions.PaperWM.Keybindings'; 29 | 30 | // This is the value mutter uses for the keyvalue of above_tab 31 | var META_KEY_ABOVE_TAB = 0x2f7259c9; 32 | 33 | var prefs = {}; 34 | ['window-gap', 'vertical-margin', 'vertical-margin-bottom', 'horizontal-margin', 35 | 'workspace-colors', 'default-background', 'animation-time', 'use-workspace-name', 36 | 'pressure-barrier', 'default-show-top-bar', 'swipe-sensitivity', 'swipe-friction', 37 | 'cycle-width-steps', 'cycle-height-steps', 'topbar-follow-focus'] 38 | .forEach((k) => setState(null, k)); 39 | 40 | prefs.__defineGetter__("minimum_margin", function() { return Math.min(15, this.horizontal_margin) }); 41 | 42 | 43 | function setVerticalMargin() { 44 | let vMargin = settings.get_int('vertical-margin'); 45 | let gap = settings.get_int('window-gap'); 46 | prefs.vertical_margin = Math.max(Math.round(gap/2), vMargin); 47 | } 48 | let timerId; 49 | function onWindowGapChanged() { 50 | setVerticalMargin(); 51 | if (timerId) 52 | imports.mainloop.source_remove(timerId); 53 | timerId = imports.mainloop.timeout_add(500, () => { 54 | Extension.imports.tiling.spaces.mru().forEach(space => { 55 | space.layout(); 56 | }); 57 | timerId = null; 58 | }); 59 | } 60 | 61 | function setState($, key) { 62 | let value = settings.get_value(key); 63 | let name = key.replace(/-/g, '_'); 64 | prefs[name] = value.deep_unpack(); 65 | } 66 | 67 | var schemaSource, workspaceList, conflictSettings; 68 | function setSchemas() { 69 | // Schemas that may contain conflicting keybindings 70 | // It's possible to inject or remove settings here on `user.init`. 71 | conflictSettings = [ 72 | new Gio.Settings({schema_id: 'org.gnome.mutter.keybindings'}), 73 | new Gio.Settings({schema_id: 'org.gnome.mutter.wayland.keybindings'}), 74 | new Gio.Settings({schema_id: "org.gnome.desktop.wm.keybindings"}), 75 | new Gio.Settings({schema_id: "org.gnome.shell.keybindings"}) 76 | ]; 77 | schemaSource = Gio.SettingsSchemaSource.new_from_directory( 78 | GLib.build_filenamev([Extension.path, "schemas"]), 79 | Gio.SettingsSchemaSource.get_default(), 80 | false 81 | ); 82 | 83 | workspaceList = new Gio.Settings({ 84 | settings_schema: schemaSource.lookup(WORKSPACE_LIST_KEY, true) 85 | }); 86 | } 87 | setSchemas(); // Initialize imediately so prefs.js can import properly 88 | function init() { 89 | settings.connect('changed', setState); 90 | settings.connect('changed::vertical-margin', onWindowGapChanged); 91 | settings.connect('changed::vertical-margin-bottom', onWindowGapChanged); 92 | settings.connect('changed::window-gap', onWindowGapChanged); 93 | setVerticalMargin(); 94 | 95 | // A intermediate window is created before the prefs dialog is created. 96 | // Prevent it from being inserted into the tiling causing flickering and general disorder 97 | defwinprop({ 98 | wm_class: "Gnome-shell-extension-prefs", 99 | scratch_layer: true, 100 | focus: true, 101 | }); 102 | defwinprop({ 103 | wm_class: /gnome-screenshot/i, 104 | scratch_layer: true, 105 | focus: true, 106 | }); 107 | } 108 | 109 | var id; 110 | function enable() { 111 | setSchemas(); 112 | } 113 | 114 | function disable() { 115 | workspaceSettingsCache = {}; 116 | } 117 | 118 | /// Workspaces 119 | 120 | 121 | function getWorkspaceSettings(index) { 122 | let list = workspaceList.get_strv('list'); 123 | for (let uuid of list) { 124 | let settings = getWorkspaceSettingsByUUID(uuid); 125 | if (settings.get_int('index') === index) { 126 | return [uuid, settings]; 127 | } 128 | } 129 | return getNewWorkspaceSettings(index); 130 | } 131 | 132 | function getNewWorkspaceSettings(index) { 133 | let uuid = GLib.uuid_string_random(); 134 | let settings = getWorkspaceSettingsByUUID(uuid); 135 | let list = workspaceList.get_strv('list'); 136 | list.push(uuid); 137 | workspaceList.set_strv('list', list); 138 | settings.set_int('index', index); 139 | return [uuid, settings]; 140 | } 141 | 142 | function getWorkspaceSettingsByUUID(uuid) { 143 | if (!workspaceSettingsCache[uuid]) { 144 | let settings = new Gio.Settings({ 145 | settings_schema: schemaSource.lookup(WORKSPACE_KEY, true), 146 | path: `/org/gnome/shell/extensions/paperwm/workspaces/${uuid}/` 147 | }); 148 | workspaceSettingsCache[uuid] = settings; 149 | } 150 | return workspaceSettingsCache[uuid]; 151 | } 152 | 153 | /** Returns [[uuid, settings, name], ...] (Only used for debugging/development atm.) */ 154 | function findWorkspaceSettingsByName(regex) { 155 | let list = workspaceList.get_strv('list'); 156 | let settingss = list.map(getWorkspaceSettingsByUUID); 157 | return Extension.imports.utils.zip(list, settingss, settingss.map(s => s.get_string('name'))) 158 | .filter(([uuid, s, name]) => name.match(regex)); 159 | } 160 | 161 | /** Only used for debugging/development atm. */ 162 | function deleteWorkspaceSettingsByName(regex, dryrun=true) { 163 | let out = "" 164 | function rprint(...args) { print(...args); out += args.join(" ") + "\n"; } 165 | let n = global.workspace_manager.get_n_workspaces(); 166 | for (let [uuid, s, name] of findWorkspaceSettingsByName(regex)) { 167 | let index = s.get_int('index'); 168 | if (index < n) { 169 | rprint("Skipping in-use settings", name, index); 170 | continue; 171 | } 172 | rprint(dryrun ? "[dry]" : "", `Delete settings for '${name}' (${uuid})`); 173 | if (!dryrun) { 174 | deleteWorkspaceSettings(uuid); 175 | } 176 | } 177 | return out; 178 | } 179 | 180 | /** Only used for debugging/development atm. */ 181 | function deleteWorkspaceSettings(uuid) { 182 | // NB! Does not check if the settings is currently in use. Does not reindex subsequent settings. 183 | let list = workspaceList.get_strv('list'); 184 | let i = list.indexOf(uuid); 185 | let settings = getWorkspaceSettingsByUUID(list[i]); 186 | for (let key of settings.list_keys()) { 187 | // Hopefully resetting all keys will delete the relocatable settings from dconf? 188 | settings.reset(key); 189 | } 190 | 191 | list.splice(i, 1); 192 | workspaceList.set_strv('list', list); 193 | } 194 | 195 | // Useful for debugging 196 | function printWorkspaceSettings() { 197 | let list = workspaceList.get_strv('list'); 198 | let settings = list.map(getWorkspaceSettingsByUUID); 199 | let zipped = Extension.imports.utils.zip(list, settings); 200 | const key = s => s[1].get_int('index'); 201 | zipped.sort((a,b) => key(a) - key(b)); 202 | for (let [uuid, s] of zipped) { 203 | print('index:', s.get_int('index'), s.get_string('name'), s.get_string('color'), uuid); 204 | } 205 | } 206 | 207 | /// Keybindings 208 | 209 | /** 210 | * Two keystrings can represent the same key combination 211 | */ 212 | function keystrToKeycombo(keystr) { 213 | // Above_Tab is a fake keysymbol provided by mutter 214 | let aboveTab = false; 215 | if (keystr.match(/Above_Tab/)) { 216 | // Gtk bails out if provided with an unknown keysymbol 217 | keystr = keystr.replace('Above_Tab', 'A'); 218 | aboveTab = true; 219 | } 220 | let [key, mask] = Gtk.accelerator_parse(keystr); 221 | 222 | if (aboveTab) 223 | key = META_KEY_ABOVE_TAB; 224 | return `${key}|${mask}`; // Since js doesn't have a mapable tuple type 225 | } 226 | 227 | function keycomboToKeystr(combo) { 228 | let [mutterKey, mods] = combo.split('|').map(s => Number.parseInt(s)); 229 | let key = mutterKey; 230 | if (mutterKey === META_KEY_ABOVE_TAB) 231 | key = 97; // a 232 | let keystr = Gtk.accelerator_name(key, mods); 233 | if (mutterKey === META_KEY_ABOVE_TAB) 234 | keystr = keystr.replace(/a$/, 'Above_Tab'); 235 | return keystr; 236 | } 237 | 238 | function keycomboToKeylab(combo) { 239 | let [mutterKey, mods] = combo.split('|').map(s => Number.parseInt(s)); 240 | let key = mutterKey; 241 | if (mutterKey === META_KEY_ABOVE_TAB) 242 | key = 97; // a 243 | let keylab = Gtk.accelerator_get_label(key, mods); 244 | if (mutterKey === META_KEY_ABOVE_TAB) 245 | keylab = keylab.replace(/a$/, 'Above_Tab'); 246 | return keylab; 247 | } 248 | 249 | function generateKeycomboMap(settings) { 250 | let map = {}; 251 | for (let name of settings.list_keys()) { 252 | let value = settings.get_value(name); 253 | if (value.get_type_string() !== 'as') 254 | continue; 255 | 256 | for (let combo of value.deep_unpack().map(keystrToKeycombo)) { 257 | if (combo === '0|0') 258 | continue; 259 | if (map[combo]) { 260 | map[combo].push(name); 261 | } else { 262 | map[combo] = [name]; 263 | } 264 | } 265 | } 266 | return map; 267 | } 268 | 269 | function findConflicts(schemas) { 270 | schemas = schemas || conflictSettings; 271 | let conflicts = []; 272 | const paperMap = 273 | generateKeycomboMap(Convenience.getSettings(KEYBINDINGS_KEY)); 274 | 275 | for (let settings of schemas) { 276 | const against = generateKeycomboMap(settings); 277 | for (let combo in paperMap) { 278 | if (against[combo]) { 279 | conflicts.push({ 280 | name: paperMap[combo][0], 281 | conflicts: against[combo], 282 | settings, combo 283 | }); 284 | } 285 | } 286 | } 287 | return conflicts; 288 | } 289 | 290 | 291 | /// Winprops 292 | 293 | /** 294 | Modelled after notion/ion3's system 295 | 296 | Examples: 297 | 298 | defwinprop({ 299 | wm_class: "Riot", 300 | scratch_layer: true 301 | }) 302 | */ 303 | var winprops = []; 304 | 305 | function winprop_match_p(meta_window, prop) { 306 | let wm_class = meta_window.wm_class || ""; 307 | let title = meta_window.title; 308 | if (prop.wm_class.constructor === RegExp) { 309 | if (!wm_class.match(prop.wm_class)) 310 | return false; 311 | } else if (prop.wm_class !== wm_class) { 312 | return false; 313 | } 314 | if (prop.title) { 315 | if (prop.title.constructor === RegExp) { 316 | if (!title.match(prop.title)) 317 | return false; 318 | } else { 319 | if (prop.title !== title) 320 | return false; 321 | } 322 | } 323 | 324 | return true; 325 | } 326 | 327 | function find_winprop(meta_window) { 328 | let props = winprops.filter( 329 | winprop_match_p.bind(null, meta_window)); 330 | 331 | return props[0]; 332 | } 333 | 334 | function defwinprop(spec) { 335 | winprops.push(spec); 336 | } 337 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | runCommand "shell" { 4 | buildInputs = [ glib ]; 5 | } "" 6 | 7 | -------------------------------------------------------------------------------- /shell.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Simple helper script to start nested wayland/x11 gnome sessions 4 | 5 | # The new dbus address is copied into the clipboard so you're able to run 6 | # `M-x # gnome-shell-set-dbus-address` and paste the address. 7 | 8 | old_display=$DISPLAY 9 | 10 | d=0 11 | while [ -e /tmp/.X11-unix/X${d} ]; do 12 | d=$((d + 1)) 13 | done 14 | 15 | NEW_DISPLAY=:$d 16 | 17 | export XDG_CONFIG_HOME=$HOME/paperwm/.config 18 | 19 | args=() 20 | 21 | DISPLAY=$NEW_DISPLAY 22 | eval $(dbus-launch --exit-with-session --sh-syntax) 23 | echo $DBUS_SESSION_BUS_ADDRESS 24 | 25 | echo -n $DBUS_SESSION_BUS_ADDRESS \ 26 | | DISPLAY=$old_display xclip -i -selection clipboard 27 | 28 | DISPLAY=$old_display 29 | case $1 in 30 | w*|-w*|--w*) 31 | echo "Running Wayland Gnome Shell" 32 | args=(--nested --wayland) 33 | ;; 34 | *) 35 | echo "Running X11 Gnome Shell" 36 | Xephyr $NEW_DISPLAY & 37 | DISPLAY=$NEW_DISPLAY 38 | args=--x11 39 | ;; 40 | esac 41 | 42 | 43 | dconf reset -f / # Reset settings 44 | dconf write /org/gnome/shell/enabled-extensions "['paperwm@hedning:matrix.org']" 45 | 46 | gnome-shell $args 47 | 48 | -------------------------------------------------------------------------------- /stackoverlay.js: -------------------------------------------------------------------------------- 1 | var Extension; 2 | if (imports.misc.extensionUtils.extensions) { 3 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 4 | } else { 5 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 6 | } 7 | 8 | var Tiling = Extension.imports.tiling; 9 | var Clutter = imports.gi.Clutter; 10 | var Tweener = Extension.imports.utils.tweener; 11 | var Main = imports.ui.main; 12 | var Mainloop = imports.mainloop; 13 | var Shell = imports.gi.Shell; 14 | var Meta = imports.gi.Meta; 15 | var utils = Extension.imports.utils; 16 | var debug = utils.debug; 17 | var Minimap = Extension.imports.minimap; 18 | 19 | var Settings = Extension.imports.settings; 20 | var prefs = Settings.prefs; 21 | 22 | /* 23 | The stack overlay decorates the top stacked window with its icon and 24 | captures mouse input such that a mouse click only _activates_ the 25 | window. A very limited portion of the window is visible and due to 26 | the animation the button-up event will be triggered at an 27 | unpredictable position 28 | 29 | See #10 30 | */ 31 | 32 | /* 33 | Parent of the overlay? 34 | 35 | Most natural parent is the window actor, but then the overlay 36 | becomes visible in the clones too. 37 | 38 | Since the stacked windows doesn't really move it's not a big problem 39 | that the overlay doesn't track the window. The main challenge with 40 | using a different parent becomes controlling the "z-index". 41 | 42 | If I understand clutter correctly that can only be done by managing 43 | the order of the scene graph nodes. Descendants of node A will thus 44 | always be drawn in the same plane compared to a non-descendants. 45 | 46 | The overlay thus have to be parented to `global.window_group`. One 47 | would think that was ok, but unfortunately mutter keeps syncing the 48 | window_group with the window stacking and in the process destroy the 49 | stacking of any non-window actors. 50 | 51 | Adding a "clutter restack" to the `MetaScreen` `restacked` signal 52 | seems keep the stacking in sync (without entering into infinite 53 | restack loops) 54 | */ 55 | 56 | function createAppIcon(metaWindow, size) { 57 | let tracker = Shell.WindowTracker.get_default(); 58 | let app = tracker.get_window_app(metaWindow); 59 | let appIcon = app ? app.create_icon_texture(size) 60 | : new St.Icon({ icon_name: 'icon-missing', 61 | icon_size: size }); 62 | appIcon.x_expand = appIcon.y_expand = true; 63 | appIcon.x_align = appIcon.y_align = Clutter.ActorAlign.END; 64 | 65 | return appIcon; 66 | } 67 | 68 | /** 69 | */ 70 | var ClickOverlay = class ClickOverlay { 71 | constructor(monitor, onlyOnPrimary) { 72 | this.monitor = monitor; 73 | this.onlyOnPrimary = onlyOnPrimary; 74 | this.left = new StackOverlay(Meta.MotionDirection.LEFT, monitor); 75 | this.right = new StackOverlay(Meta.MotionDirection.RIGHT, monitor); 76 | 77 | let enterMonitor = new Clutter.Actor({reactive: true}); 78 | this.enterMonitor = enterMonitor; 79 | enterMonitor.set_position(monitor.x, monitor.y); 80 | 81 | Main.uiGroup.add_actor(enterMonitor); 82 | Main.layoutManager.trackChrome(enterMonitor); 83 | 84 | this.signals = new utils.Signals(); 85 | 86 | this._lastPointer = []; 87 | this.signals.connect( 88 | enterMonitor, 'motion-event', 89 | (actor, event) => { 90 | // Changing monitors while in workspace preview doesn't work 91 | if (Tiling.inPreview) 92 | return; 93 | let [x, y, z] = global.get_pointer(); 94 | let [lX, lY] = this._lastPointer; 95 | this._lastPointer = [x, y]; 96 | Mainloop.timeout_add(500, () => { 97 | this._lastPointer = []; 98 | }); 99 | if (lX === undefined || 100 | Math.sqrt((lX - x)**2 + (lY - y)**2) < 10) 101 | return; 102 | this.select(); 103 | return Clutter.EVENT_STOP; 104 | } 105 | ); 106 | 107 | this.signals.connect( 108 | enterMonitor, 'button-press-event', () => { 109 | if (Tiling.inPreview) 110 | return; 111 | this.select(); 112 | return Clutter.EVENT_STOP; 113 | } 114 | ); 115 | 116 | this.signals.connect(Main.overview, 'showing', () => { 117 | this.deactivate(); 118 | this.hide(); 119 | }); 120 | this.signals.connect(Main.overview, 'hidden', () => { 121 | this.activate(); 122 | this.show(); 123 | }); 124 | } 125 | 126 | select() { 127 | this.deactivate(); 128 | let space = Tiling.spaces.monitors.get(this.monitor); 129 | let display = global.display; 130 | let mi = space.monitor.index; 131 | let mru = display.get_tab_list(Meta.TabList.NORMAL, 132 | space.workspace) 133 | .filter(w => !w.minimized && w.get_monitor() === mi); 134 | 135 | let stack = display.sort_windows_by_stacking(mru); 136 | // Select the highest stacked window on the monitor 137 | let select = stack[stack.length - 1]; 138 | 139 | // But don't change focus if a stuck window is active 140 | if (display.focus_window && 141 | display.focus_window.is_on_all_workspaces()) 142 | select = display.focus_window; 143 | 144 | if (select) { 145 | space.workspace.activate_with_focus( 146 | select, global.get_current_time()); 147 | } else { 148 | space.workspace.activate(global.get_current_time()); 149 | } 150 | } 151 | 152 | activate() { 153 | if (this.onlyOnPrimary || Main.overview.visible) 154 | return; 155 | 156 | let spaces = Tiling.spaces; 157 | let active = global.workspace_manager.get_active_workspace(); 158 | let monitor = this.monitor; 159 | // Never activate the clickoverlay of the active monitor 160 | if (spaces && spaces.monitors.get(monitor) === spaces.get(active)) 161 | return; 162 | 163 | this.enterMonitor.set_position(monitor.x, monitor.y); 164 | this.enterMonitor.set_size(monitor.width, monitor.height); 165 | } 166 | 167 | deactivate() { 168 | this.enterMonitor.set_size(0, 0); 169 | } 170 | 171 | reset() { 172 | this.left.setTarget(null); 173 | this.right.setTarget(null); 174 | } 175 | 176 | hide() { 177 | this.left.overlay.hide(); 178 | this.right.overlay.hide(); 179 | } 180 | 181 | show() { 182 | if (Main.overview.visible) 183 | return; 184 | this.left.overlay.show(); 185 | this.right.overlay.show(); 186 | } 187 | 188 | destroy() { 189 | this.signals.destroy(); 190 | for (let overlay of [this.left, this.right]) { 191 | let actor = overlay.overlay; 192 | overlay.signals.destroy(); 193 | if (overlay.clone) { 194 | overlay.clone.destroy(); 195 | overlay.clone = null; 196 | } 197 | actor.destroy(); 198 | overlay.removeBarrier(); 199 | } 200 | this.enterMonitor.destroy(); 201 | } 202 | } 203 | 204 | var StackOverlay = class StackOverlay { 205 | constructor(direction, monitor) { 206 | 207 | this._direction = direction; 208 | 209 | let overlay = new Clutter.Actor({ reactive: true 210 | , name: "stack-overlay" }); 211 | 212 | // Uncomment to debug the overlays 213 | // overlay.background_color = Clutter.color_from_string('green')[1]; 214 | // overlay.opacity = 100; 215 | 216 | this.monitor = monitor; 217 | 218 | let panelBox = Main.layoutManager.panelBox; 219 | 220 | overlay.y = monitor.y + panelBox.height + prefs.vertical_margin; 221 | overlay.height = this.monitor.height - panelBox.height - prefs.vertical_margin; 222 | overlay.width = Tiling.stack_margin; 223 | 224 | this.signals = new utils.Signals(); 225 | this.signals.connect(overlay, 'button-press-event', () => { 226 | Main.activateWindow(this.target); 227 | if (this.clone) { 228 | this.clone.destroy(); 229 | this.clone = null; 230 | } 231 | return true; 232 | }); 233 | 234 | this.signals.connect(overlay, 'enter-event', this.triggerPreview.bind(this)); 235 | this.signals.connect(overlay,'leave-event', this.removePreview.bind(this)); 236 | this.signals.connect(Settings.settings, 'changed::pressure-barrier', 237 | this.updateBarrier.bind(this, true)); 238 | 239 | this.updateBarrier(); 240 | 241 | global.window_group.add_child(overlay); 242 | Main.layoutManager.trackChrome(overlay); 243 | 244 | this.overlay = overlay; 245 | this.setTarget(null); 246 | } 247 | 248 | triggerPreview() { 249 | if ("_previewId" in this) 250 | return; 251 | this._previewId = Mainloop.timeout_add(100, () => { 252 | delete this._previewId; 253 | if (this.clone) { 254 | this.clone.destroy(); 255 | this.clone = null; 256 | } 257 | 258 | let [x, y, mask] = global.get_pointer(); 259 | let actor = this.target.get_compositor_private(); 260 | let clone = new Clutter.Clone({source: actor}); 261 | // Remove any window clips, and show the metaWindow.clone's 262 | actor.remove_clip(); 263 | Tiling.animateWindow(this.target); 264 | 265 | this.clone = clone; 266 | clone.set_scale(0.15, 0.15); 267 | Main.uiGroup.add_actor(clone); 268 | 269 | let monitor = this.monitor; 270 | if (this._direction === Meta.MotionDirection.RIGHT) 271 | x = monitor.x + monitor.width - clone.get_transformed_size()[0]; 272 | else 273 | x = monitor.x; 274 | clone.set_position(x, y); 275 | }); 276 | 277 | this._removeId = Mainloop.timeout_add_seconds(2, this.removePreview.bind(this)); 278 | } 279 | 280 | removePreview() { 281 | if ("_previewId" in this) { 282 | Mainloop.source_remove(this._previewId); 283 | delete this._previewId; 284 | } 285 | if ("_removeId" in this) { 286 | Mainloop.source_remove(this._removeId); 287 | delete this._removeId; 288 | } 289 | 290 | if (!this.clone) 291 | return; 292 | 293 | this.clone.destroy(); 294 | this.clone = null; 295 | let space = Tiling.spaces.spaceOfWindow(this.target); 296 | // Show the WindowActors again and re-apply clipping 297 | space.moveDone(); 298 | } 299 | 300 | removeBarrier() { 301 | if (this.barrier) { 302 | if (this.pressureBarrier) 303 | this.pressureBarrier.removeBarrier(this.barrier); 304 | this.barrier.destroy(); 305 | this.pressureBarrier.destroy(); 306 | this.barrier = null; 307 | } 308 | this._removeBarrierTimeoutId = 0; 309 | } 310 | 311 | updateBarrier(force) { 312 | if (force) 313 | this.removeBarrier(); 314 | 315 | if (this.barrier || !prefs.pressure_barrier) 316 | return; 317 | 318 | const Layout = imports.ui.layout; 319 | this.pressureBarrier = new Layout.PressureBarrier(100, 0.25*1000, Shell.ActionMode.NORMAL); 320 | // Show the overlay on fullscreen windows when applying pressure to the edge 321 | // The above leave-event handler will take care of hiding the overlay 322 | this.pressureBarrier.connect('trigger', () => { 323 | this.pressureBarrier._reset(); 324 | this.pressureBarrier._isTriggered = false; 325 | if (this._removeBarrierTimeoutId > 0) 326 | Mainloop.source_remove(this._removeBarrierTimeoutId); 327 | this._removeBarrierTimeoutId = Mainloop.timeout_add(100, this.removeBarrier.bind(this)); 328 | overlay.show(); 329 | }); 330 | 331 | const overlay = this.overlay; 332 | let workArea = Main.layoutManager.getWorkAreaForMonitor(this.monitor.index); 333 | let monitor = this.monitor; 334 | let x1, directions; 335 | if (this._direction === Meta.MotionDirection.LEFT) { 336 | x1 = monitor.x, 337 | directions = Meta.BarrierDirection.POSITIVE_X; 338 | } else { 339 | x1 = monitor.x + monitor.width - 1, 340 | directions = Meta.BarrierDirection.NEGATIVE_X; 341 | } 342 | this.barrier = new Meta.Barrier({ 343 | display: global.display, 344 | x1, x2: x1, 345 | y1: workArea.y + 1, 346 | y2: workArea.y + workArea.height - 1, 347 | directions 348 | }); 349 | this.pressureBarrier.addBarrier(this.barrier); 350 | } 351 | 352 | setTarget(space, index) { 353 | 354 | if (this.clone) { 355 | this.clone.destroy(); 356 | this.clone = null; 357 | } 358 | 359 | let bail = () => { 360 | this.target = null; 361 | this.overlay.width = 0; 362 | this.removeBarrier(); 363 | return false; 364 | }; 365 | 366 | if (space === null || Tiling.inPreview) { 367 | // No target. Eg. if we're at the left- or right-most window 368 | return bail(); 369 | } 370 | 371 | let mru = global.display.get_tab_list(Meta.TabList.NORMAL_ALL, 372 | space.workspace); 373 | let column = space[index]; 374 | this.target = mru.filter(w => column.includes(w))[0]; 375 | let metaWindow = this.target; 376 | if (!metaWindow) 377 | return; 378 | 379 | let overlay = this.overlay; 380 | let actor = metaWindow.get_compositor_private(); 381 | 382 | overlay.y = this.monitor.y + Main.layoutManager.panelBox.height + prefs.vertical_margin; 383 | 384 | // Assume the resize edge is at least this big (empirically found..) 385 | const minResizeEdge = 8; 386 | 387 | if (this._direction === Meta.MotionDirection.LEFT) { 388 | let column = space[space.indexOf(metaWindow) + 1]; 389 | let neighbour = column && 390 | global.display.sort_windows_by_stacking(column).reverse()[0]; 391 | 392 | if (!neighbour) 393 | return bail(); // Should normally have a neighbour. Bail! 394 | 395 | let width = neighbour.clone.targetX + space.targetX - minResizeEdge; 396 | if (space.isPlaceable(metaWindow) || Meta.is_wayland_compositor()) 397 | width = Math.min(width, 1); 398 | overlay.x = this.monitor.x; 399 | overlay.width = Math.max(width, 1); 400 | overlay.raise(neighbour.get_compositor_private()); 401 | } else { 402 | let column = space[space.indexOf(metaWindow) - 1]; 403 | let neighbour = column && 404 | global.display.sort_windows_by_stacking(column).reverse()[0]; 405 | if (!neighbour) 406 | return bail(); // Should normally have a neighbour. Bail! 407 | 408 | let frame = neighbour.get_frame_rect(); 409 | frame.x = neighbour.clone.targetX + space.targetX; 410 | let width = this.monitor.width - (frame.x + frame.width) - minResizeEdge; 411 | if (space.isPlaceable(metaWindow) || Meta.is_wayland_compositor()) 412 | width = 1; 413 | width = Math.max(width, 1); 414 | overlay.x = this.monitor.x + this.monitor.width - width; 415 | overlay.width = width; 416 | overlay.raise(neighbour.get_compositor_private()); 417 | } 418 | 419 | if (space.selectedWindow.fullscreen || space.selectedWindow.maximized_vertically) 420 | overlay.hide(); 421 | else 422 | overlay.show(); 423 | this.updateBarrier(); 424 | 425 | return true; 426 | } 427 | }; 428 | -------------------------------------------------------------------------------- /stylesheet.css: -------------------------------------------------------------------------------- 1 | .paper-mm-selected-window { 2 | border: 10px cyan; /* if we need to operate in pre-scaled dimension */ 3 | border-radius: 5px; 4 | } 5 | 6 | 7 | .workspace-icon-button { 8 | -st-icon-style: symbolic; 9 | border: none; 10 | border-radius: 8px; 11 | padding: 8px; 12 | } 13 | 14 | .workspace-icon-button StIcon { 15 | icon-size: 16px; 16 | } 17 | 18 | .paperwm-selection { 19 | border-radius: 8px; 20 | } 21 | -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # NOTE: gnome-extensions uninstall will delete all files in the linked directory 4 | 5 | REPO="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 6 | if [[ -L "$REPO" ]]; then 7 | REPO=`readlink --canonicalize "$REPO"` 8 | fi 9 | UUID=paperwm@hedning:matrix.org 10 | if type gnome-extensions > /dev/null; then 11 | gnome-extensions disable "$UUID" 12 | else 13 | gnome-shell-extension-tool --disable="$UUID" 14 | fi 15 | EXT_DIR=${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions 16 | EXT=$EXT_DIR/$UUID 17 | LINK=`readlink --canonicalize "$EXT"` 18 | if [[ "$LINK" != "$REPO" ]]; then 19 | echo "$EXT" does not link to "$REPO", refusing to remove 20 | exit 1 21 | fi 22 | if [ -L "$EXT" ]; then 23 | rm "$EXT" 24 | else 25 | read -p "Remove $EXT? (y/N): " -n 1 -r 26 | echo 27 | if [[ $REPLY =~ ^[Yy]$ ]]; then 28 | rm -rf $EXT 29 | fi 30 | fi 31 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | var Extension; 2 | if (imports.misc.extensionUtils.extensions) { 3 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 4 | } else { 5 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 6 | } 7 | var { Gdk, GLib, Clutter, Meta, GObject } = imports.gi; 8 | 9 | var workspaceManager = global.workspace_manager; 10 | var display = global.display; 11 | 12 | var version = imports.misc.config.PACKAGE_VERSION.split('.').map(Number); 13 | if (version[0] !== 3) { 14 | version = [3, ...version] 15 | } 16 | 17 | var registerClass; 18 | { 19 | if (version[0] >= 3 && version[1] > 30) { 20 | registerClass = GObject.registerClass; 21 | } else { 22 | registerClass = (x, y) => y ? y : x; 23 | } 24 | } 25 | 26 | var debug_all = false; // Turn off by default 27 | var debug_filter = {'#paperwm': true, '#stacktrace': true}; 28 | function debug() { 29 | let keyword = arguments[0]; 30 | let filter = debug_filter[keyword]; 31 | if (filter === false) 32 | return; 33 | if (debug_all || filter === true) 34 | print(Array.prototype.join.call(arguments, " | ")); 35 | } 36 | 37 | function warn(...args) { 38 | print("WARNING:", ...args); 39 | } 40 | 41 | function assert(condition, message, ...dump) { 42 | if (!condition) { 43 | throw new Error(message + "\n", dump); 44 | } 45 | } 46 | 47 | function withTimer(message, fn) { 48 | let start = GLib.get_monotonic_time(); 49 | let ret = fn(); 50 | let stop = GLib.get_monotonic_time(); 51 | log(`${message} ${((stop - start)/1000).toFixed(1)}ms`); 52 | } 53 | 54 | function print_stacktrace(error) { 55 | let trace; 56 | if (!error) { 57 | trace = (new Error()).stack.split("\n"); 58 | // Remove _this_ frame 59 | trace.splice(0, 1); 60 | } else { 61 | trace = error.stack.split("\n"); 62 | } 63 | // Remove some uninteresting frames 64 | let filtered = trace.filter((frame) => { 65 | return frame !== "wrapper@resource:///org/gnome/gjs/modules/lang.js:178"; 66 | }); 67 | log(`JS ERROR: ${error}\n ${trace.join('\n')}`); 68 | } 69 | 70 | function framestr(rect) { 71 | return "[ x:"+rect.x + ", y:" + rect.y + " w:" + rect.width + " h:"+rect.height + " ]"; 72 | } 73 | 74 | /** 75 | * Returns a human-readable enum value representation 76 | */ 77 | function ppEnumValue(value, genum) { 78 | let entry = Object.entries(genum).find(([k, v]) => v === value); 79 | if (entry) { 80 | return `${entry[0]} (${entry[1]})` 81 | } else { 82 | return ` (${value})` 83 | } 84 | } 85 | 86 | function ppModiferState(state) { 87 | let mods = []; 88 | for (let [mod, mask] of Object.entries(imports.gi.Clutter.ModifierType)) { 89 | if (mask & state) { 90 | mods.push(mod); 91 | } 92 | } 93 | return mods.join(", ") 94 | } 95 | 96 | /** 97 | * Look up the function by name at call time. This makes it convenient to 98 | * redefine the function without re-registering all signal handler, keybindings, 99 | * etc. (this is like a function symbol in lisp) 100 | */ 101 | function dynamic_function_ref(handler_name, owner_obj) { 102 | owner_obj = owner_obj || window; 103 | return function() { 104 | owner_obj[handler_name].apply(this, arguments); 105 | } 106 | } 107 | 108 | /** 109 | Find the first x in `values` that's larger than `cur`. 110 | Cycle to first value if no larger value is found. 111 | `values` should be sorted in ascending order. 112 | */ 113 | function findNext(cur, values, slack=0) { 114 | for (let i = 0; i < values.length; i++) { 115 | let x = values[i]; 116 | if (cur < x) { 117 | if (x - cur < slack) { 118 | // Consider `cur` practically equal to `x` 119 | continue; 120 | } else { 121 | return x; 122 | } 123 | } 124 | } 125 | return values[0]; // cycle 126 | } 127 | 128 | function arrayEqual(a, b) { 129 | if (a === b) 130 | return true; 131 | if (!a || !b) 132 | return false; 133 | if (a.length !== b.length) 134 | return false; 135 | for (let i = 0; i < a.length; i++) { 136 | if (a[i] !== b[i]) 137 | return false; 138 | } 139 | return true; 140 | } 141 | 142 | /** Is the floating point numbers equal enough */ 143 | function eq(a, b, epsilon=0.00000001) { 144 | return Math.abs(a-b) < epsilon; 145 | } 146 | 147 | function swap(array, i, j) { 148 | let temp = array[i]; 149 | array[i] = array[j]; 150 | array[j] = temp; 151 | } 152 | 153 | function in_bounds(array, i) { 154 | return i >= 0 && i < array.length; 155 | } 156 | 157 | function isPointInsideActor(actor, x, y) { 158 | return (actor.x <= x && x <= actor.x+actor.width) 159 | && (actor.y <= y && y <= actor.y+actor.height); 160 | } 161 | 162 | function setBackgroundImage(actor, resource_path) { 163 | // resource://{resource_path} 164 | const Clutter = imports.gi.Clutter; 165 | const GdkPixbuf = imports.gi.GdkPixbuf; 166 | const Cogl = imports.gi.Cogl; 167 | 168 | let image = new Clutter.Image(); 169 | 170 | let pixbuf = GdkPixbuf.Pixbuf.new_from_resource(resource_path) 171 | 172 | image.set_data(pixbuf.get_pixels() , 173 | pixbuf.get_has_alpha() ? Cogl.PixelFormat.RGBA_8888 174 | : Cogl.PixelFormat.RGB_888, 175 | pixbuf.get_width() , 176 | pixbuf.get_height() , 177 | pixbuf.get_rowstride()); 178 | actor.set_content(image); 179 | actor.content_repeat = Clutter.ContentRepeat.BOTH 180 | } 181 | 182 | 183 | //// Debug and development utils 184 | 185 | const Tiling = Extension.imports.tiling; 186 | 187 | function setDevGlobals() { 188 | // Accept the risk of this interfering with existing code for now 189 | metaWindow = display.focus_window; 190 | meta_window = display.focus_window; 191 | workspace = workspaceManager.get_active_workspace(); 192 | actor = metaWindow.get_compositor_private(); 193 | space = Tiling.spaces.spaceOfWindow(metaWindow); 194 | app = imports.gi.Shell.WindowTracker.get_default().get_window_app(metaWindow); 195 | } 196 | 197 | /** 198 | * Visualize the frame and buffer bounding boxes of a meta window 199 | */ 200 | function toggleWindowBoxes(metaWindow) { 201 | metaWindow = metaWindow || display.focus_window; 202 | 203 | if(metaWindow._paperDebugBoxes) { 204 | metaWindow._paperDebugBoxes.forEach(box => { 205 | box.destroy(); 206 | }); 207 | delete metaWindow._paperDebugBoxes; 208 | return []; 209 | } 210 | 211 | let frame = metaWindow.get_frame_rect() 212 | let inputFrame = metaWindow.get_buffer_rect() 213 | let actor = metaWindow.get_compositor_private(); 214 | 215 | makeFrameBox = function({x, y, width, height}, color) { 216 | let frameBox = new imports.gi.St.Widget(); 217 | frameBox.set_position(x, y) 218 | frameBox.set_size(width, height) 219 | frameBox.set_style("border: 2px" + color + " solid"); 220 | return frameBox; 221 | } 222 | 223 | let boxes = []; 224 | 225 | boxes.push(makeFrameBox(frame, "red")); 226 | boxes.push(makeFrameBox(inputFrame, "blue")); 227 | 228 | if(inputFrame.x !== actor.x || inputFrame.y !== actor.y 229 | || inputFrame.width !== actor.width || inputFrame.height !== actor.height) { 230 | boxes.push(makeFrameBox(actor, "yellow")); 231 | } 232 | 233 | boxes.forEach(box => global.stage.add_actor(box)); 234 | 235 | metaWindow._paperDebugBoxes = boxes; 236 | return boxes; 237 | } 238 | 239 | var markNewClonesSignalId = null; 240 | function toggleCloneMarks() { 241 | // NB: doesn't clean up signal on disable 242 | 243 | function markCloneOf(metaWindow) { 244 | if (metaWindow.clone) { 245 | metaWindow.clone.opacity = 190; 246 | metaWindow.clone.__oldOpacity = 190; 247 | 248 | metaWindow.clone.background_color = imports.gi.Clutter.color_from_string("red")[1]; 249 | } 250 | } 251 | function unmarkCloneOf(metaWindow) { 252 | if (metaWindow.clone) { 253 | metaWindow.clone.opacity = 255; 254 | metaWindow.clone.__oldOpacity = 255; 255 | metaWindow.clone.background_color = null; 256 | } 257 | } 258 | 259 | let windows = display.get_tab_list(Meta.TabList.NORMAL_ALL, null); 260 | 261 | if (markNewClonesSignalId) { 262 | display.disconnect(markNewClonesSignalId); 263 | markNewClonesSignalId = null; 264 | windows.forEach(unmarkCloneOf); 265 | } else { 266 | markNewClonesSignalId = display.connect_after( 267 | "window-created", (_, mw) => markCloneOf(mw)) 268 | 269 | windows.forEach(markCloneOf); 270 | } 271 | } 272 | 273 | 274 | function sum(array) { 275 | return array.reduce((a, b) => a + b, 0); 276 | } 277 | 278 | function zip(...as) { 279 | let r = []; 280 | let minLength = Math.min(...as.map(x => x.length)); 281 | for (let i = 0; i < minLength; i++) { 282 | r.push(as.map(a => a[i])); 283 | } 284 | return r; 285 | } 286 | 287 | function warpPointer(x, y) { 288 | // 3.36 added support for warping in wayland 289 | if (Meta.is_wayland_compositor() && Clutter.Backend.prototype.get_default_seat) { 290 | let backend = Clutter.get_default_backend(); 291 | let seat = backend.get_default_seat(); 292 | seat.warp_pointer(x, y); 293 | return; 294 | } else { 295 | let display = Gdk.Display.get_default(); 296 | let deviceManager = display.get_device_manager(); 297 | let pointer = deviceManager.get_client_pointer(); 298 | pointer.warp(Gdk.Screen.get_default(), x, y) 299 | } 300 | } 301 | 302 | /** 303 | * Return current modifiers state (or'ed Clutter.ModifierType.*) 304 | */ 305 | function getModiferState() { 306 | let [x, y, mods] = global.get_pointer(); 307 | return mods; 308 | } 309 | 310 | function monitorOfPoint(x, y) { 311 | // get_monitor_index_for_rect "helpfully" returns the primary monitor index for out of bounds rects.. 312 | const Main = imports.ui.main; 313 | for (let monitor of Main.layoutManager.monitors) { 314 | if ((monitor.x <= x && x <= monitor.x+monitor.width) && 315 | (monitor.y <= y && y <= monitor.y+monitor.height)) 316 | { 317 | return monitor; 318 | } 319 | } 320 | 321 | return null; 322 | } 323 | 324 | 325 | function indent(level, str) { 326 | let blank = "" 327 | for (let i = 0; i < level; i++) { 328 | blank += " " 329 | } 330 | return blank + str 331 | } 332 | 333 | 334 | function mkFmt({nameOnly}={nameOnly: false}) { 335 | function defaultFmt(actor, prefix="") { 336 | const fmtNum = num => num.toFixed(0); 337 | let extra = [ 338 | `${actor.get_position().map(fmtNum)}`, 339 | `${actor.get_size().map(fmtNum)}`, 340 | ]; 341 | let metaWindow = actor.meta_window || actor.metaWindow; 342 | if (metaWindow) { 343 | metaWindow = `(mw: ${metaWindow.title})`; 344 | extra.push(metaWindow); 345 | } 346 | const extraStr = extra.join(" | "); 347 | let actorId = ""; 348 | if (nameOnly) { 349 | actorId = actor.name ? actor.name : (prefix.length == 0 ? "" : "#") 350 | } else { 351 | actorId = actor.toString(); 352 | } 353 | actorId = prefix+actorId 354 | let spacing = actorId.length > 0 ? " " : "" 355 | return `*${spacing}${actorId} ${extraStr}`; 356 | } 357 | return defaultFmt; 358 | } 359 | 360 | function printActorTree(node, fmt=mkFmt(), options={}, state=null) { 361 | state = Object.assign({}, (state || {level: 0, actorPrefix: ""})) 362 | const defaultOptions = { 363 | limit: 9999, 364 | collapseChains: true, 365 | }; 366 | options = Object.assign(defaultOptions, options) 367 | 368 | if (state.level > options.limit) { 369 | return; 370 | } 371 | let collapse = false; 372 | if (options.collapseChains) { 373 | /* 374 | a 375 | b 376 | s 377 | t 378 | c 30,10 379 | u 380 | -> 381 | a.b.s 382 | a.b.t 383 | a.b.c ... 384 | u 385 | 386 | 387 | */ 388 | if (node.get_children().length > 0) { 389 | if (node.x === 0 && node.y === 0) { 390 | state.actorPrefix += (node.name ? node.name : "#") + "." 391 | // print("#### ", state.actorPrefix) 392 | collapse = true 393 | } else { 394 | collapse = false 395 | } 396 | } else { 397 | collapse = false 398 | } 399 | } 400 | if (!collapse) { 401 | print(indent(state.level, fmt(node, state.actorPrefix))); 402 | state.actorPrefix = ""; 403 | state.level += 1; 404 | } 405 | 406 | for (let child of node.get_children()) { 407 | printActorTree(child, fmt, options, state) 408 | } 409 | } 410 | 411 | var Signals = class Signals extends Map { 412 | static get [Symbol.species]() { return Map; } 413 | 414 | _getOrCreateSignals(object) { 415 | let signals = this.get(object); 416 | if (!signals) { 417 | signals = []; 418 | this.set(object, signals); 419 | } 420 | return signals; 421 | } 422 | 423 | connectOneShot(object, signal, handler) { 424 | let id = this.connect(object, signal, (...args) => { 425 | this.disconnect(object, id); 426 | return handler(...args); 427 | }); 428 | } 429 | 430 | connect(object, signal, handler) { 431 | let id = object.connect(signal, handler); 432 | let signals = this._getOrCreateSignals(object); 433 | signals.push(id); 434 | return id; 435 | } 436 | 437 | disconnect(object, id=null) { 438 | let ids = this.get(object); 439 | if (ids) { 440 | if (id === null) { 441 | ids.forEach(id => object.disconnect(id)); 442 | ids = []; 443 | } else { 444 | object.disconnect(id); 445 | let i = ids.indexOf(id); 446 | if (i > -1) { 447 | ids.splice(i, 1); 448 | } 449 | } 450 | if (ids.length === 0) 451 | this.delete(object); 452 | } 453 | } 454 | 455 | destroy() { 456 | for (let [object, signals] of this) { 457 | signals.forEach(id => object.disconnect(id)); 458 | this.delete(object); 459 | } 460 | } 461 | } 462 | 463 | var tweener = { 464 | addTween(actor, params) { 465 | if (params.time) { 466 | params.duration = params.time*1000; 467 | delete params.time; 468 | } 469 | if (!params.mode) 470 | params.mode = imports.gi.Clutter.AnimationMode.EASE_IN_OUT_QUAD; 471 | actor.ease(params); 472 | }, 473 | 474 | removeTweens(actor) { 475 | actor.remove_all_transitions(); 476 | }, 477 | 478 | isTweening(actor) { 479 | return actor.get_transition('x') || actor.get_transition('y') || actor.get_transition('scale-x') || actor.get_transition('scale-x'); 480 | } 481 | }; 482 | 483 | function isMetaWindow(obj) { 484 | return obj && obj.window_type && obj.get_compositor_private; 485 | } 486 | 487 | function trace(topic, ...args) { 488 | if (!topic.match(/.*/)) { 489 | return; 490 | } 491 | 492 | if (isMetaWindow(args[0])) { 493 | windowTrace(topic, ...args); 494 | } else { 495 | let trace = shortTrace(1).join(" < "); 496 | let extraInfo = args.length > 0 ? "\n\t" + args.map(x => x.toString()).join("\n\t") : "" 497 | log(topic, trace, extraInfo); 498 | } 499 | } 500 | 501 | let existingWindows = new Set(); 502 | 503 | function windowTrace(topic, metaWindow, ...rest) { 504 | if (existingWindows.has(metaWindow)) { 505 | return; 506 | } 507 | 508 | log(topic, infoMetaWindow(metaWindow).join("\n"), ...rest.join("\n")); 509 | } 510 | 511 | function infoMetaWindow(metaWindow) { 512 | let id = metaWindow.toString().split(" ")[4]; 513 | let trace = shortTrace(3).join(" < "); 514 | let info = [ 515 | `(win: ${id}) ${trace}`, 516 | `Title: ${metaWindow.title}`, 517 | ]; 518 | if (!metaWindow.window_type === Meta.WindowType.NORMAL) { 519 | info.push(`Type: ${ppEnumValue(metaWindow.window_type, Meta.WindowType)}`); 520 | } 521 | if (!metaWindow.get_compositor_private()) { 522 | info.push(`- no actor`); 523 | } 524 | if (metaWindow.is_on_all_workspaces()) { 525 | info.push(`- is_on_all_workspaces`); 526 | } 527 | if (metaWindow.above) { 528 | info.push(`- above`); 529 | } 530 | if (Extension.imports.scratch.isScratchWindow(metaWindow)) { 531 | info.push(`- scratch`); 532 | } 533 | return info; 534 | } 535 | 536 | function shortTrace(skip=0) { 537 | let trace = new Error().stack.split("\n").map(s => { 538 | let words = s.split(/[@/]/) 539 | let cols = s.split(":") 540 | let ln = parseInt(cols[2]) 541 | if (ln === null) 542 | ln = "?" 543 | 544 | return [words[0], ln] 545 | }) 546 | trace = trace.filter(([f, ln]) => f !== "dynamic_function_ref").map(([f, ln]) => f === "" ? "?" : f+":"+ln); 547 | return trace.slice(skip+1, skip+5); 548 | } 549 | 550 | 551 | // Meta.remove_verbose_topic(Meta.DebugTopic.FOCUS) 552 | -------------------------------------------------------------------------------- /virtTiling.js: -------------------------------------------------------------------------------- 1 | var Extension; 2 | if (imports.misc.extensionUtils.extensions) { 3 | Extension = imports.misc.extensionUtils.extensions["paperwm@hedning:matrix.org"]; 4 | } else { 5 | Extension = imports.ui.main.extensionManager.lookup("paperwm@hedning:matrix.org"); 6 | } 7 | 8 | var Clutter = imports.gi.Clutter 9 | var St = imports.gi.St 10 | 11 | var Tiling = Extension.imports.tiling; 12 | var Utils = Extension.imports.utils; 13 | let fitProportionally = Tiling.fitProportionally 14 | 15 | let prefs = { 16 | window_gap: 5, 17 | minimum_margin: 3, 18 | } 19 | 20 | var virtStage = null 21 | 22 | function repl() { 23 | if (virtStage) 24 | virtStage.destroy() 25 | 26 | let realMonitor = space.monitor 27 | let scale = 0.10 28 | let padding = 10 29 | const monitorWidth = realMonitor.width * scale 30 | const monitorHeight = realMonitor.height * scale 31 | let stageStyle = `background-color: white;` 32 | virtStage = new St.Widget({ 33 | name: "stage", 34 | style: stageStyle, 35 | height: monitorHeight + padding*2, 36 | width: monitorWidth*3 37 | }) 38 | 39 | let monitorStyle = `background-color: blue;` 40 | let monitor = new St.Widget({ 41 | name: "monitor0", 42 | style: monitorStyle, 43 | x: virtStage.width/2 - monitorWidth/2, y: padding, 44 | width: monitorWidth, 45 | height: virtStage.height - padding*2 46 | }) 47 | 48 | let panel = new St.Widget({ 49 | name: "panel", 50 | style: `background-color: gray`, 51 | x: 0, y: 0, 52 | width: monitor.width, 53 | height: 10 54 | 55 | }) 56 | let workArea = { 57 | x: monitor.x, 58 | y: panel.height, 59 | width: monitor.width, 60 | height: monitor.height - panel.height, 61 | } 62 | 63 | let tilingStyle = `background-color: rgba(190, 190, 0, 0.3);` 64 | let tilingContainer = new St.Widget({name: "tiling", style: tilingStyle}) 65 | 66 | global.stage.add_actor(virtStage) 67 | virtStage.x = 3000 68 | virtStage.y = 300 69 | 70 | virtStage.add_actor(monitor) 71 | monitor.add_actor(panel) 72 | monitor.add_actor(tilingContainer) 73 | 74 | function sync(space_=space) { 75 | let columns = layout( 76 | fromSpace(space_, scale), 77 | workArea, 78 | prefs 79 | ); 80 | renderAndView( 81 | tilingContainer, 82 | columns 83 | ); 84 | tilingContainer.x = space_.targetX * scale; 85 | } 86 | 87 | sync() 88 | 89 | Utils.printActorTree(virtStage, Utils.mkFmt({nameOnly: true})) 90 | 91 | movecolumntoviewportposition(tilingContainer, monitor, columns[1][0], 30) 92 | 93 | virtStage.hide() 94 | virtStage.show() 95 | virtStage.y = 400 96 | } 97 | 98 | /** tiling position given: 99 | m_s: monitor position 100 | w_m: window position (relative to monitor) 101 | w_t: window position (relative to tiling) 102 | */ 103 | function t_s(m_s, w_m, w_t) { 104 | return w_m - w_t + m_s 105 | } 106 | 107 | /** 108 | Calculates the tiling position such that column `k` is positioned at `x` 109 | relative to the viewport (or workArea?) 110 | */ 111 | function movecolumntoviewportposition(tilingActor, viewport, window, x) { 112 | tilingActor.x = t_s(viewport.x, x, window.x); 113 | } 114 | 115 | function renderAndView(container, columns) { 116 | for (let child of container.get_children()) { 117 | child.destroy(); 118 | } 119 | 120 | render(columns, container); 121 | } 122 | 123 | function fromSpace(space, scale=1) { 124 | return space.map( 125 | col => col.map( 126 | metaWindow => { 127 | let f = metaWindow.get_frame_rect(); 128 | return { 129 | width: f.width * scale, 130 | height: f.height * scale, 131 | }; 132 | } 133 | ) 134 | ) 135 | } 136 | 137 | /** Render a dummy view of the windows */ 138 | function render(columns, tiling) { 139 | const windowStyle = `border: black solid 1px; background-color: red`; 140 | 141 | function createWindowActor(window) { 142 | return new St.Widget({ 143 | style: windowStyle, 144 | width: window.width, 145 | height: window.height, 146 | x: window.x, 147 | y: window.y 148 | }); 149 | } 150 | 151 | for (let col of columns) { 152 | for (let window of col) { 153 | let windowActor = createWindowActor(window); 154 | tiling.add_actor(windowActor); 155 | } 156 | } 157 | } 158 | 159 | function allocateDefault(column, availableHeight, preAllocatedWindow) { 160 | if (column.length === 1) { 161 | return [availableHeight]; 162 | } else { 163 | // Distribute available height amongst non-selected windows in proportion to their existing height 164 | const gap = prefs.window_gap; 165 | const minHeight = 15; 166 | 167 | function heightOf(window) { 168 | return window.height 169 | } 170 | 171 | const k = preAllocatedWindow && column.indexOf(preAllocatedWindow); 172 | const selectedHeight = preAllocatedWindow && heightOf(preAllocatedWindow); 173 | 174 | let nonSelected = column.slice(); 175 | if (preAllocatedWindow) nonSelected.splice(k, 1) 176 | 177 | const nonSelectedHeights = nonSelected.map(heightOf); 178 | let availableForNonSelected = Math.max( 179 | 0, 180 | availableHeight 181 | - (column.length-1) * gap 182 | - (preAllocatedWindow ? selectedHeight : 0) 183 | ); 184 | 185 | const deficit = Math.max( 186 | 0, nonSelected.length * minHeight - availableForNonSelected); 187 | 188 | let heights = fitProportionally( 189 | nonSelectedHeights, 190 | availableForNonSelected + deficit 191 | ); 192 | 193 | if (preAllocatedWindow) 194 | heights.splice(k, 0, selectedHeight - deficit); 195 | 196 | return heights 197 | } 198 | } 199 | 200 | function allocateEqualHeight(column, available) { 201 | available = available - (column.length-1)*prefs.window_gap; 202 | return column.map(_ => Math.floor(available / column.length)); 203 | } 204 | 205 | function layoutGrabColumn(column, x, y0, targetWidth, availableHeight, grabWindow) { 206 | let needRelayout = false; 207 | 208 | function mosh(windows, height, y0) { 209 | let targetHeights = fitProportionally( 210 | windows.map(mw => mw.rect.height), 211 | height 212 | ); 213 | let [w, y] = layoutColumnSimple(windows, x, y0, targetWidth, targetHeights); 214 | return y; 215 | } 216 | 217 | const k = column.indexOf(grabWindow); 218 | if (k < 0) { 219 | throw new Error("Anchor doesn't exist in column " + grabWindow.title); 220 | } 221 | 222 | const gap = prefs.window_gap; 223 | const f = grabWindow.globalRect(); 224 | let yGrabRel = f.y - this.monitor.y; 225 | targetWidth = f.width; 226 | 227 | const H1 = (yGrabRel - y0) - gap - (k-1)*gap; 228 | const H2 = availableHeight - (yGrabRel + f.height - y0) - gap - (column.length-k-2)*gap; 229 | k > 0 && mosh(column.slice(0, k), H1, y0); 230 | let y = mosh(column.slice(k, k+1), f.height, yGrabRel); 231 | k+1 < column.length && mosh(column.slice(k+1), H2, y); 232 | 233 | return targetWidth; 234 | } 235 | 236 | 237 | function layoutColumnSimple(windows, x, y0, targetWidth, targetHeights, time) { 238 | let y = y0; 239 | 240 | for (let i = 0; i < windows.length; i++) { 241 | let virtWindow = windows[i]; 242 | let targetHeight = targetHeights[i]; 243 | 244 | virtWindow.x = x 245 | virtWindow.y = y 246 | virtWindow.width = targetWidth 247 | virtWindow.height = targetHeight 248 | 249 | y += targetHeight + prefs.window_gap; 250 | } 251 | return targetWidth, y 252 | } 253 | 254 | 255 | /** 256 | Mutates columns 257 | */ 258 | function layout(columns, workArea, prefs, options={}) { 259 | let gap = prefs.window_gap; 260 | let availableHeight = workArea.height 261 | 262 | let {inGrab, selectedWindow} = options 263 | let selectedIndex = -1 264 | 265 | if (selectedWindow) { 266 | selectedIndex = columns.findIndex(col => col.includes(selectedWindow)) 267 | } 268 | 269 | let y0 = workArea.y 270 | let x = 0 271 | 272 | for (let i = 0; i < columns.length; i++) { 273 | let column = columns[i]; 274 | 275 | let selectedInColumn = i === selectedIndex ? selectedWindow : null; 276 | 277 | let targetWidth; 278 | if (i === selectedIndex) { 279 | targetWidth = selectedInColumn.width; 280 | } else { 281 | targetWidth = Math.max(...column.map(w => w.width)); 282 | } 283 | targetWidth = Math.min(targetWidth, workArea.width - 2*prefs.minimum_margin) 284 | 285 | let resultingWidth, relayout; 286 | if (inGrab && i === selectedIndex) { 287 | layoutGrabColumn(column, x, y0, targetWidth, availableHeight, selectedInColumn); 288 | } else { 289 | let allocator = options.customAllocators && options.customAllocators[i]; 290 | allocator = allocator || allocateDefault; 291 | 292 | let targetHeights = allocator(column, availableHeight, selectedInColumn); 293 | layoutColumnSimple(column, x, y0, targetWidth, targetHeights); 294 | } 295 | 296 | x += targetWidth + gap; 297 | } 298 | 299 | return columns 300 | } 301 | --------------------------------------------------------------------------------