├── .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 |
5 | False
6 | True
7 | True
8 | True
9 |
10 |
11 |
78 |
79 |
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 |
5 | False
6 | vertical
7 |
8 |
9 | False
10 | center
11 | 12
12 |
14 |
15 |
16 |
17 |
18 | never
19 |
20 |
21 | False
22 | True
23 |
24 |
25 | True
26 | 36
27 | 36
28 | 12
29 | 36
30 | 480
31 |
35 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/KeybindingsRow.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 | False
9 | True
10 | True
11 |
12 |
13 |
14 | vertical
15 |
16 |
17 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | True
64 |
65 |
66 |
67 | True
68 | True
69 | none
70 |
73 |
75 |
76 | 0
77 | 0
78 | 2
79 |
80 |
81 |
82 |
83 |
84 |
85 | Reset
86 | keybinding.reset
87 | start
88 | 12px
89 | 8px
90 | 8px
91 |
92 | 1
93 | 0
94 |
95 |
96 |
97 |
98 |
99 |
100 | Add shortcut…
101 | keybinding.add
102 | True
103 | end
104 | 8px
105 | 12px
106 | 8px
107 |
108 |
109 | 1
110 | 1
111 |
112 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PaperWM #
2 |
3 | [](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 | 
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 | 
120 |
121 | Pressing SuperPage_Down and SuperPage_Up will slide between workspaces sequentially:
122 |
123 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------