├── .eslintrc.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md └── workflows │ └── rebase-pr.yaml ├── .gitignore ├── CONTRIBUTING.md ├── KeybindingsComboRow.ui ├── KeybindingsPane.ui ├── KeybindingsRow.ui ├── LICENSE ├── README.md ├── Settings.ui ├── WinpropsPane.ui ├── WinpropsRow.ui ├── acceleratorparse.js ├── app.js ├── background.js ├── config ├── user.css └── user.js ├── debug ├── default.nix ├── examples ├── keybindings.js ├── layouts.js └── winprops.js ├── extension.js ├── flake.lock ├── flake.nix ├── gather-system-info.sh ├── generate-extension-zip.sh ├── gestures.js ├── grab.js ├── imports.js ├── install.sh ├── keybindings.js ├── lib.js ├── liveAltTab.js ├── media ├── default-focus-mode.png ├── default-star-winprop.png ├── focus-mode-button.png ├── gesture-settings.png ├── get-it-on-ego.svg ├── gnome-pill-option.png ├── hide-focus-mode-icon.png ├── open-position-button.png ├── topbar-styling.png └── window-indicator-bar.png ├── metadata.json ├── minimap.js ├── navigator.js ├── overviewlayout.js ├── patches.js ├── prefs.js ├── prefsKeybinding.js ├── resources ├── ICONS.info ├── focus-mode-center-symbolic.svg ├── focus-mode-default-symbolic.svg ├── focus-mode-edge-symbolic.svg ├── logo.png ├── open-position-down-symbolic.svg ├── open-position-end-symbolic.svg ├── open-position-left-symbolic.svg ├── open-position-right-symbolic.svg ├── open-position-start-symbolic.svg ├── open-position-up-symbolic.svg └── prefs.css ├── schemas ├── Makefile ├── gschemas.compiled └── org.gnome.shell.extensions.paperwm.gschema.xml ├── scratch.js ├── settings.js ├── shell.nix ├── shell.sh ├── stackoverlay.js ├── stylesheet.css ├── tiling.js ├── topbar.js ├── uninstall.sh ├── utils.js ├── virtTiling.js ├── vm.nix ├── winpropsPane.js └── workspace.js /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Adapted from "GNOME Project"'s `eslintrc-gjs.yml` found at: 3 | # https://gitlab.gnome.org/GNOME/gnome-shell-extensions/-/blob/main/lint/eslintrc-gjs.yml 4 | # 5 | # SPDX-License-Identifier: MIT OR LGPL-2.0-or-later 6 | # SPDX-FileCopyrightText: 2018 Claudio André 7 | env: 8 | es2021: true 9 | extends: 'eslint:recommended' 10 | # plugins: 11 | # - jsdoc 12 | rules: 13 | array-bracket-newline: 14 | - error 15 | - consistent 16 | array-bracket-spacing: 17 | - error 18 | - never 19 | array-callback-return: error 20 | arrow-parens: 21 | - error 22 | - as-needed 23 | arrow-spacing: error 24 | block-scoped-var: error 25 | block-spacing: error 26 | #brace-style: error 27 | # Waiting for this to have matured a bit in eslint 28 | # camelcase: 29 | # - error 30 | # - properties: never 31 | # allow: [^vfunc_, ^on_, _instance_init] 32 | comma-dangle: 33 | - error 34 | - arrays: always-multiline 35 | objects: always-multiline 36 | functions: never 37 | comma-spacing: 38 | - error 39 | - before: false 40 | after: true 41 | comma-style: 42 | - error 43 | - last 44 | computed-property-spacing: error 45 | #curly: 46 | # - error 47 | # - multi-or-nest 48 | # # - consistent 49 | dot-location: 50 | - error 51 | - property 52 | eol-last: error 53 | eqeqeq: error 54 | func-call-spacing: error 55 | func-name-matching: error 56 | func-style: 57 | - error 58 | - declaration 59 | - allowArrowFunctions: true 60 | indent: 61 | - error 62 | - 4 63 | - ignoredNodes: 64 | # Allow not indenting the body of GObject.registerClass, since in the 65 | # future it's intended to be a decorator 66 | - 'CallExpression[callee.object.name=GObject][callee.property.name=registerClass] > ClassExpression:first-child' 67 | # Allow dedenting chained member expressions 68 | MemberExpression: 'off' 69 | # # disabling jsdoc while as plugin jsdoc is disabled (have always had issues with plugin) 70 | # jsdoc/check-alignment: error 71 | # jsdoc/check-param-names: error 72 | # jsdoc/check-tag-names: error 73 | # jsdoc/check-types: error 74 | # jsdoc/implements-on-classes: error 75 | # jsdoc/tag-lines: 76 | # - error 77 | # - any 78 | # - startLines: 1 79 | # jsdoc/require-jsdoc: error 80 | # jsdoc/require-param: error 81 | # jsdoc/require-param-description: error 82 | # jsdoc/require-param-name: error 83 | # jsdoc/require-param-type: error 84 | key-spacing: 85 | - error 86 | - beforeColon: false 87 | afterColon: true 88 | keyword-spacing: 89 | - error 90 | - before: true 91 | after: true 92 | linebreak-style: 93 | - error 94 | - unix 95 | lines-between-class-members: 96 | - error 97 | - always 98 | - exceptAfterSingleLine: true 99 | max-nested-callbacks: error 100 | max-statements-per-line: error 101 | new-parens: error 102 | no-array-constructor: error 103 | no-await-in-loop: error 104 | no-caller: error 105 | no-constant-condition: 106 | - error 107 | - checkLoops: false 108 | no-div-regex: error 109 | no-empty: 110 | - error 111 | - allowEmptyCatch: true 112 | no-extra-bind: error 113 | no-extra-parens: 114 | - error 115 | - all 116 | - conditionalAssign: false 117 | nestedBinaryExpressions: false 118 | returnAssign: false 119 | no-implicit-coercion: 120 | - error 121 | - allow: 122 | - '!!' 123 | # no-invalid-this: error 124 | no-iterator: error 125 | no-label-var: error 126 | no-lonely-if: error 127 | no-loop-func: error 128 | no-nested-ternary: error 129 | no-new-object: error 130 | no-new-wrappers: error 131 | no-octal-escape: error 132 | no-proto: error 133 | no-prototype-builtins: 'off' 134 | no-restricted-globals: [error, window] 135 | no-restricted-properties: 136 | - error 137 | - object: imports 138 | property: format 139 | message: Use template strings 140 | - object: pkg 141 | property: initFormat 142 | message: Use template strings 143 | - object: Lang 144 | property: copyProperties 145 | message: Use Object.assign() 146 | - object: Lang 147 | property: bind 148 | message: Use arrow notation or Function.prototype.bind() 149 | - object: Lang 150 | property: Class 151 | message: Use ES6 classes 152 | no-restricted-syntax: 153 | - error 154 | - selector: >- 155 | MethodDefinition[key.name="_init"] > 156 | FunctionExpression[params.length=1] > 157 | BlockStatement[body.length=1] 158 | CallExpression[arguments.length=1][callee.object.type="Super"][callee.property.name="_init"] > 159 | Identifier:first-child 160 | message: _init() that only calls super._init() is unnecessary 161 | - selector: >- 162 | MethodDefinition[key.name="_init"] > 163 | FunctionExpression[params.length=0] > 164 | BlockStatement[body.length=1] 165 | CallExpression[arguments.length=0][callee.object.type="Super"][callee.property.name="_init"] 166 | message: _init() that only calls super._init() is unnecessary 167 | - selector: BinaryExpression[operator="instanceof"][right.name="Array"] 168 | message: Use Array.isArray() 169 | no-return-assign: error 170 | no-return-await: error 171 | no-self-compare: error 172 | no-shadow-restricted-names: error 173 | no-spaced-func: error 174 | no-tabs: error 175 | no-template-curly-in-string: error 176 | no-throw-literal: error 177 | no-trailing-spaces: error 178 | no-undef-init: error 179 | no-unneeded-ternary: error 180 | #no-unused-expressions: error 181 | no-unused-vars: 182 | - error 183 | # Vars use a suffix _ instead of a prefix because of file-scope private vars 184 | - varsIgnorePattern: (^unused|_$) 185 | argsIgnorePattern: ^(unused|_) 186 | no-useless-call: error 187 | no-useless-computed-key: error 188 | no-useless-concat: error 189 | no-useless-constructor: error 190 | no-useless-rename: error 191 | no-useless-return: error 192 | no-whitespace-before-property: error 193 | no-with: error 194 | nonblock-statement-body-position: 195 | - error 196 | - below 197 | object-curly-newline: 198 | - error 199 | - consistent: true 200 | multiline: true 201 | #object-curly-spacing: error 202 | object-curly-spacing: 203 | - error 204 | - always 205 | object-shorthand: error 206 | operator-assignment: error 207 | operator-linebreak: error 208 | padded-blocks: 209 | - error 210 | - never 211 | # These may be a bit controversial, we can try them out and enable them later 212 | # prefer-const: error 213 | # prefer-destructuring: error 214 | prefer-numeric-literals: error 215 | prefer-promise-reject-errors: error 216 | prefer-rest-params: error 217 | prefer-spread: error 218 | prefer-template: error 219 | #quotes: 220 | # - error 221 | # - single 222 | # - avoidEscape: true 223 | require-await: error 224 | rest-spread-spacing: error 225 | semi: 226 | - error 227 | - always 228 | semi-spacing: 229 | - error 230 | - before: false 231 | after: true 232 | semi-style: error 233 | space-before-blocks: error 234 | #space-before-function-paren: 235 | # - error 236 | # - named: never 237 | # # for `function ()` and `async () =>`, preserve space around keywords 238 | # anonymous: always 239 | # asyncArrow: always 240 | #space-in-parens: error 241 | space-infix-ops: 242 | - error 243 | - int32Hint: false 244 | space-unary-ops: error 245 | spaced-comment: error 246 | switch-colon-spacing: error 247 | symbol-description: error 248 | template-curly-spacing: error 249 | template-tag-spacing: error 250 | unicode-bom: error 251 | wrap-iife: 252 | - error 253 | - inside 254 | yield-star-spacing: error 255 | yoda: error 256 | settings: 257 | jsdoc: 258 | mode: typescript 259 | globals: 260 | ARGV: readonly 261 | Debugger: readonly 262 | GIRepositoryGType: readonly 263 | global: readonly 264 | globalThis: readonly 265 | imports: readonly 266 | Intl: readonly 267 | log: readonly 268 | logError: readonly 269 | print: readonly 270 | printerr: readonly 271 | window: readonly 272 | TextEncoder: readonly 273 | TextDecoder: readonly 274 | console: readonly 275 | setTimeout: readonly 276 | setInterval: readonly 277 | clearTimeout: readonly 278 | clearInterval: readonly 279 | parserOptions: 280 | ecmaVersion: 2022 281 | sourceType: module 282 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System information:** 27 | Please provide system information: 28 | - if you installed PaperWM via [extensions.gnome.org](https://extensions.gnome.org) please open PaperWM settings and select the `About` tab (last tab) and click the `Copy to Clipboard` button and paste the information below, or; 29 | 30 | - if you installed via source code, please execute `./gather-system-info.sh` in you PaperWM clone and paste the information below 31 | 32 | ``` 33 | Example: 34 | Distribution: Fedora Linux 40 (Workstation Edition) 35 | GNOME Shell: 46.0 36 | Display server: Wayland 37 | PaperWM version: 46.4.1 38 | Enabled extensions: 39 | - paperwm@paperwm.github.com 40 | - switcher@landau.fi 41 | - dash-to-panel@jderose9.github.com 42 | - appindicatorsupport@rgcjonas.gmail.com 43 | ``` 44 | 45 | **Additional context** 46 | Add any other context about the problem here. 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: PaperWM Discussions 4 | url: https://github.com/paperwm/PaperWM/discussions/ 5 | about: Ask a question or start a discussion on PaperWM. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/rebase-pr.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # This workflow catches PRs made to 'release' and rebases them onto 'develop' 3 | # 4 | 5 | name: rebase-pr 6 | 7 | on: 8 | pull_request: 9 | types: [opened] 10 | branches: 11 | - release 12 | 13 | jobs: 14 | rebase-pr: 15 | name: Rebase pull request 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Perform rebase 19 | uses: actions/github-script@v7 20 | with: 21 | script: | 22 | github.rest.pulls.update({ 23 | pull_number: context.issue.number, 24 | owner: context.repo.owner, 25 | repo: context.repo.repo, 26 | 27 | base: "develop" 28 | }); 29 | github.rest.issues.createComment({ 30 | issue_number: context.issue.number, 31 | owner: context.repo.owner, 32 | repo: context.repo.repo, 33 | body: "Thanks for your contribution! We don't accept pull requests to the `release` branch. I have rebased your pull request onto `develop`, check for any conflicts." 34 | }); 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .config 2 | TAGS 3 | 4 | # any generated extension zip 5 | paperwm@paperwm.github.com.zip 6 | 7 | # eslint support files 8 | node_modules/ 9 | package.json 10 | package-lock.json 11 | 12 | # generated disk image for test VM 13 | nixos.qcow2 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # History / Admins 2 | 3 | PaperWM was originally written by [@hedning] and [@olejorgenb]. However, they became busy with other things and stepped away from active development. They may return some day, and you might see them around every couple months, but don't ping them and expect a response. 4 | 5 | [@smichel17] [joined](https://github.com/paperwm/PaperWM/issues/407) to facilitate transitioning PaperWM to a community project. Unfortunately, he's *also* too busy (and doesn't know Gnome Shell's code base well enough) to take over development directly. So, his role is basically to be a trustworthy person (he hopes!) to manage adding more maintainers. Including writing this document (👋). 6 | 7 | [@jtaala] stepped into PaperWM development in late 2022, having discovered PaperWM around that time. He came from i3wm and quickly fell in love with PaperWM and the concept of scrollable tiling window managers. Jay's a PaperWM maintainer, but is currently [stepping back](https://github.com/paperwm/PaperWM/issues/980). He has been keeping PaperWM up to date (including releases and maintaining/submitting PaperWM's [EGO](https://extensions.gnome.org/extension/6099/paperwm/) versions), fixing issues, developing & implementing requested features, and just trying to make PaperWM a reliable window manager that stays awesome and is loved by its users. He was active until about November 2024. 8 | 9 | [@thesola10] joined PaperWM development in early 2023. Like Jay, he was quickly enamored by the scrollable tiling paradigm. He started contributing features related to touch screens and multiple displays, as this matches his current workflow. Karim is a PaperWM maintainer, focused on keeping PaperWM up to date, and handling pull requests. 10 | 11 | ## Community Transition 12 | 13 | ### Concerns 14 | 15 | - **Focus** — without one person to enforce the vision of what the software should be, it's easy for it to try and be many different things. End result: software that is inconsistent and difficult to use. 16 | - Same thing for the code base. End result: difficult to maintain. 17 | - **Trust** — obviously we only want to give permissions to people who we trust not to push malicious (or otherwise bad) code. However, it's difficult for someone to prove they are trustworthy without trusting them first. 18 | - **Momentum** — trying to avoid pitfalls in the first two areas can lead to no actual development progress. For example, long deliberation trying to reach consensus, or a cumbersome contribution process that drives away potential maintainers. 19 | 20 | ### Plan 21 | 22 | To balance those issues, the plan is something like this (details may change): 23 | 24 | - Give out write access to the repo fairly easily. If someone makes a number of small contributions, or 1-2 large contributions, invite them as a Collaborator. 25 | - Require all contributions to be via PR. That way it's difficult for anyone to sneak changes in without others noticing. 26 | - Protect branches\*. Require 2 Collaborators to approve a PR before it's merged (so one person can't unilaterally push changes). 27 | - \*[`develop`](https://github.com/paperwm/PaperWM/tree/develop), [`release`](https://github.com/paperwm/PaperWM/tree/release), and any branch referenced in the README. Also all tags. 28 | - If there are not enough active maintainers, maybe relax the 2-person requirement. 29 | 30 | ### Governance 31 | 32 | If it's not clear who will make a decision, it's the current maintainer's decision. 33 | 34 | [@hedning]: https://github.com/hedning 35 | [@olejorgenb]: https://github.com/olejorgenb 36 | [@smichel17]: https://github.com/smichel17 37 | [@jtaala]: https://github.com/jtaala 38 | [@thesola10]: https://github.com/thesola10 39 | -------------------------------------------------------------------------------- /KeybindingsComboRow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 80 | 81 | 82 | 83 | 84 | False 85 | vertical 86 | 12 87 | 12 88 | 8 89 | 90 | 91 | 92 | Conflicts: 93 | 96 | 97 | 98 | 99 | 100 | 101 | none 102 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /KeybindingsPane.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 36 | 37 | -------------------------------------------------------------------------------- /KeybindingsRow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 121 | 122 | -------------------------------------------------------------------------------- /WinpropsPane.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 65 | 66 | -------------------------------------------------------------------------------- /WinpropsRow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 286 | 287 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib'; 2 | import Gio from 'gi://Gio'; 3 | import Shell from 'gi://Shell'; 4 | 5 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 6 | 7 | import { Patches, Tiling } from './imports.js'; 8 | 9 | /* 10 | Application functionality, like global new window actions etc. 11 | */ 12 | 13 | let Tracker = Shell.WindowTracker.get_default(); 14 | let CouldNotLaunch = Symbol(); 15 | 16 | // Lookup table for custom handlers, keys being the app id 17 | export let customHandlers, customSpawnHandlers; 18 | export function enable() { 19 | customHandlers = { 'org.gnome.Terminal.desktop': newGnomeTerminal }; 20 | customSpawnHandlers = { 21 | 'com.gexperts.Tilix.desktop': mkCommandLineSpawner('tilix --working-directory %d'), 22 | }; 23 | 24 | function spawnWithFallback(fallback, ...args) { 25 | try { 26 | return trySpawnWindow(...args); 27 | } catch (e) { 28 | return fallback(); 29 | } 30 | } 31 | 32 | let overrideWithFallback = Patches.overrideWithFallback; 33 | 34 | overrideWithFallback( 35 | Shell.App, "open_new_window", 36 | (fallback, app, workspaceId) => { 37 | return spawnWithFallback(fallback, app, global.workspace_manager.get_workspace_by_index(workspaceId)); 38 | } 39 | ); 40 | 41 | overrideWithFallback( 42 | Shell.App, "launch_action", 43 | (fallback, app, name, ...args) => { 44 | if (name === 'new-window') 45 | return spawnWithFallback(fallback, app); 46 | else { 47 | return fallback(); 48 | } 49 | } 50 | ); 51 | 52 | overrideWithFallback( 53 | Gio.DesktopAppInfo, "launch", 54 | (fallback, appInfo) => { 55 | return spawnWithFallback(fallback, appInfo.get_id()); 56 | } 57 | ); 58 | 59 | overrideWithFallback( 60 | Gio.DesktopAppInfo, "launch_action", 61 | (fallback, appInfo, name, ...args) => { 62 | if (name === 'new-window') 63 | return spawnWithFallback(fallback, appInfo.get_id()); 64 | else { 65 | return fallback(); 66 | } 67 | } 68 | ); 69 | } 70 | 71 | export function disable() { 72 | customHandlers = null; 73 | customSpawnHandlers = null; 74 | } 75 | 76 | export function launchFromWorkspaceDir(app, workspace = null) { 77 | if (typeof app === 'string') { 78 | app = new Shell.App({ app_info: Gio.DesktopAppInfo.new(app) }); 79 | } 80 | let dir = getWorkspaceDirectory(workspace); 81 | let cmd = app.app_info.get_commandline(); 82 | if (!cmd || dir == '') { 83 | throw CouldNotLaunch; 84 | } 85 | 86 | /* Note: One would think working directory could be specified in the AppLaunchContext 87 | The dbus spec https://specifications.freedesktop.org/desktop-entry-spec/1.1/ar01s07.html 88 | indicates otherwise (for dbus activated actions). Can affect arbitrary environment 89 | variables of exec activated actions, but no environment variable determine working 90 | directory of new processes. */ 91 | // TODO: substitute correct values according to https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables 92 | cmd = cmd.replace(/%./g, ""); 93 | let [success, cmdArgs] = GLib.shell_parse_argv(cmd); 94 | if (!success) { 95 | console.error("launchFromWorkspaceDir:", "Could not parse command line", cmd); 96 | throw CouldNotLaunch; 97 | } 98 | GLib.spawn_async(dir, cmdArgs, GLib.get_environ(), GLib.SpawnFlags.SEARCH_PATH, null); 99 | } 100 | 101 | export function newGnomeTerminal(metaWindow, app) { 102 | /* Note: this action activation is _not_ bound to the window - instead it 103 | relies on the window being active when called. 104 | 105 | If the new window doesn't start in the same directory it's probably 106 | because 'vte.sh' haven't been sourced by the shell in this terminal */ 107 | app.action_group.activate_action( 108 | "win.new-terminal", new GLib.Variant("(ss)", ["window", "current"])); 109 | } 110 | 111 | export function duplicateWindow(metaWindow) { 112 | metaWindow = metaWindow || global.display.focus_window; 113 | let app = Tracker.get_window_app(metaWindow); 114 | 115 | let handler = customHandlers[app.id]; 116 | if (handler) { 117 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 118 | return handler(metaWindow, app, space); 119 | } 120 | 121 | let workspaceId = metaWindow.get_workspace().workspace_index; 122 | 123 | let original = Patches.getSavedProp(Shell.App.prototype, "open_new_window"); 124 | original.call(app, workspaceId); 125 | return true; 126 | } 127 | 128 | export function trySpawnWindow(app, workspace) { 129 | if (typeof app === 'string') { 130 | app = new Shell.App({ app_info: Gio.DesktopAppInfo.new(app) }); 131 | } 132 | let handler = customSpawnHandlers[app.id]; 133 | if (handler) { 134 | let space = Tiling.spaces.selectedSpace; 135 | return handler(app, space); 136 | } else { 137 | launchFromWorkspaceDir(app, workspace); 138 | } 139 | } 140 | 141 | export function spawnWindow(app, workspace) { 142 | if (typeof app === 'string') { 143 | app = new Shell.App({ app_info: Gio.DesktopAppInfo.new(app) }); 144 | } 145 | try { 146 | return trySpawnWindow(app, workspace); 147 | } catch (e) { 148 | // Let the overide take care any fallback 149 | return app.open_new_window(-1); 150 | } 151 | } 152 | 153 | export function getWorkspaceDirectory(workspace = null) { 154 | let space = workspace ? Tiling.spaces.get(workspace) : Tiling.spaces.selectedSpace; 155 | 156 | let dir = space.settings.get_string("directory"); 157 | if (dir[0] === "~") { 158 | dir = GLib.getenv("HOME") + dir.slice(1); 159 | } 160 | return dir; 161 | } 162 | 163 | export function expandCommandline(commandline, workspace) { 164 | let dir = getWorkspaceDirectory(workspace); 165 | 166 | commandline = commandline.replace(/%d/g, () => GLib.shell_quote(dir)); 167 | 168 | return commandline; 169 | } 170 | 171 | export function mkCommandLineSpawner(commandlineTemplate, spawnInWorkspaceDir = false) { 172 | return (app, space) => { 173 | let workspace = space.workspace; 174 | let commandline = expandCommandline(commandlineTemplate, workspace); 175 | let workingDir = spawnInWorkspaceDir ? getWorkspaceDirectory(workspace) : null; 176 | let [success, cmdArgs] = GLib.shell_parse_argv(commandline); 177 | if (success) { 178 | success = GLib.spawn_async(workingDir, cmdArgs, GLib.get_environ(), GLib.SpawnFlags.SEARCH_PATH, null); 179 | } 180 | if (!success) { 181 | Main.notifyError( 182 | `Failed to run custom spawn handler for ${app.id}`, 183 | `Attempted to run '${commandline}'`); 184 | } 185 | }; 186 | } 187 | -------------------------------------------------------------------------------- /config/user.css: -------------------------------------------------------------------------------- 1 | /* 2 | Users may override the default PaperWM styles to suit their preferences. 3 | Added custom styling in this `user.css` file. 4 | 5 | `Disable` and then `enable` PaperWM extension to apply these styles 6 | (users do NOT need to logout / login). 7 | 8 | DEFAULT STYLES ARE PROVIDED BELOW FOR REFERENCE: 9 | /* 10 | 11 | /* 12 | .background-clear { 13 | background-color: rgba(0, 0, 0, 0); 14 | } 15 | 16 | .topbar-transparent-background { 17 | background-color: rgba(0, 0, 0, 0.35); 18 | box-shadow: none; 19 | } 20 | 21 | .space-workspace-indicator { 22 | padding: 0 10px 0 0; 23 | background-color: transparent; 24 | border-image: none; 25 | background-image: none; 26 | border: none; 27 | } 28 | 29 | .space-focus-mode-icon { 30 | icon-size: 16px; 31 | padding: 0 18px 0 18px; 32 | margin-left: 3px; 33 | background-color: transparent; 34 | } 35 | 36 | .open-position-icon { 37 | icon-size: 22px; 38 | padding: 0; 39 | background-color: transparent; 40 | } 41 | 42 | .focus-button-tooltip { 43 | background-color: rgba(0, 0, 0, 0.8); 44 | padding: 8px; 45 | border-radius: 8px; 46 | font-weight: 600; 47 | } 48 | 49 | .take-window-hint { 50 | background-color: rgba(0, 0, 0, 0.8); 51 | padding: 8px; 52 | border-radius: 8px; 53 | } 54 | 55 | .workspace-icon-button { 56 | -st-icon-style: symbolic; 57 | border: none; 58 | border-radius: 8px; 59 | padding: 8px; 60 | } 61 | 62 | .workspace-icon-button StIcon { 63 | icon-size: 16px; 64 | } 65 | 66 | .paperwm-minimap-selection { 67 | border-radius: 8px; 68 | } 69 | 70 | .paperwm-clone-shade { 71 | background-color: rgba(0, 0, 0, 0.7); 72 | border-radius: 7px 7px 0px 0px; 73 | } 74 | 75 | .paperwm-window-position-bar-backdrop { 76 | background-color: rgba(0, 0, 0, 0.35); 77 | } 78 | 79 | .paperwm-window-position-bar { 80 | border: 0; 81 | border-radius: 1px; 82 | } 83 | 84 | */ -------------------------------------------------------------------------------- /config/user.js: -------------------------------------------------------------------------------- 1 | import * as Tiling from './tiling.js'; 2 | import * as Keybindings from './keybindings.js'; 3 | 4 | /** 5 | * IMPORTANT: `user.js` is not working in Gnome 45 due to a change in Gnome 6 | * that stops loading a custom module. We're investigating potential 7 | * alternatives, but please be warned that this functionality might no 8 | * longer be possible in Gnome 45. 9 | * See https://github.com/paperwm/PaperWM/issues/576#issuecomment-1721315729 10 | */ 11 | 12 | export function enable() { 13 | // Runs when extension is enabled 14 | } 15 | 16 | export function disable() { 17 | // Runs when extension is disabled 18 | } 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs, stdenv, glib, ... }: 2 | 3 | let 4 | uuid = "paperwm@paperwm.github.com"; 5 | in 6 | stdenv.mkDerivation { 7 | pname = "gnome-shell-extension-paperwm"; 8 | version = "unstable"; 9 | src = ./.; 10 | 11 | nativeBuildInputs = with pkgs; 12 | [ glib 13 | ]; 14 | buildPhase = '' 15 | make -C schemas gschemas.compiled 16 | ''; 17 | 18 | installPhase = '' 19 | mkdir -p $out/share/gnome-shell/extensions 20 | cp -r -T . $out/share/gnome-shell/extensions/${uuid} 21 | ''; 22 | 23 | passthru = { 24 | extensionPortalSlug = "paperwm"; 25 | extensionUuid = uuid; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /examples/keybindings.js: -------------------------------------------------------------------------------- 1 | const Extension = imports.misc.extensionUtils.getCurrentExtension(); 2 | const Keybindings = Extension.imports.keybindings; 3 | const Main = imports.ui.main; 4 | const Tiling = Extension.imports.tiling; 5 | const Scratch = Extension.imports.scratch; 6 | const Utils = Extension.imports.utils; 7 | const Examples = Extension.imports.examples; 8 | 9 | const Meta = imports.gi.Meta; 10 | 11 | /** 12 | To use an example as-is ("gotoByIndex" for instance) add the following to the 13 | `init` function in "user.js": 14 | 15 | Example.keybindings.gotoByIndex(); 16 | */ 17 | 18 | function gotoByIndex() { 19 | function goto(k) { 20 | return () => { 21 | let space = Tiling.spaces.get(global.workspace_manager.get_active_workspace()); 22 | let metaWindow = space.getWindow(k, 0) 23 | if (!metaWindow) 24 | return; 25 | 26 | if (metaWindow.has_focus()) { 27 | // Can happen when navigator is open 28 | Tiling.ensureViewport(metaWindow); 29 | } else { 30 | Main.activateWindow(metaWindow); 31 | } 32 | } 33 | } 34 | for (let k = 1; k <= 9; k++) { 35 | Keybindings.bindkey(`${k}`, `goto-coloumn-${k}`, 36 | goto(k - 1), { activeInNavigator: true }), 37 | } 38 | } 39 | 40 | function windowMarks() { 41 | var marks = {} 42 | 43 | function setMark(k) { 44 | return (mw) => marks[k] = mw 45 | } 46 | 47 | function gotoMark(k) { 48 | return (metaWindow, space, options) => { 49 | let mark = marks[k]; 50 | if (!mark) 51 | return; 52 | 53 | if (mark.has_focus()) { 54 | // Can happen when navigator is open 55 | Tiling.ensureViewport(mark); 56 | if (!options.navigator) { 57 | let mru = global.display.get_tab_list( 58 | Meta.TabList.NORMAL_ALL, null); 59 | let nextWindow = mru[1]; 60 | if (!nextWindow) 61 | return; 62 | Main.activateWindow(nextWindow); 63 | if (Scratch.isScratchWindow(mark) && 64 | !Scratch.isScratchWindow(nextWindow)) { 65 | Scratch.hide(); 66 | } 67 | } 68 | } else { 69 | Main.activateWindow(mark); 70 | } 71 | } 72 | } 73 | 74 | for(let k = 0; k <= 9; k++) { 75 | Keybindings.bindkey(`${k}`, `goto-mark-${k}`, 76 | gotoMark(k), {activeInNavigator: true}) 77 | Keybindings.bindkey(`${k}`, `set-mark-${k}`, 78 | setMark(k), {activeInNavigator: true}) 79 | } 80 | } 81 | 82 | function swapNeighbours(binding = "y") { 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 => Utils.actor_raise(mw.clone)); 89 | } 90 | }, {activeInNavigator: true}); 91 | } 92 | 93 | /** 94 | Before: |[ A ][ *B* ]|[ C ] 95 | After: |[ A ][ *C* ]|[ B ] 96 | */ 97 | function swapWithRight(binding = "d") { 98 | Keybindings.bindkey(binding, "swap-with-right", mw => { 99 | let space = Tiling.spaces.spaceOfWindow(mw); 100 | let i = space.indexOf(mw); 101 | if (i === space.length - 1) 102 | return; 103 | 104 | Lib.swap(space, i, i + 1); 105 | space.layout(false); 106 | space.emit("full-layout"); 107 | Main.activateWindow(space[i][0]); 108 | }, {opensMinimap: true}); 109 | } 110 | 111 | function cycleMonitor(binding = "d") { 112 | Keybindings.bindkey(binding, "cycle-monitor", () => { 113 | let curMonitor = Tiling.spaces.selectedSpace.monitor 114 | let monitors = Main.layoutManager.monitors; 115 | let nextMonitorI = (curMonitor.index + 1) % monitors.length; 116 | let nextMonitor = monitors[nextMonitorI]; 117 | let nextSpace = Tiling.spaces.monitors.get(nextMonitor); 118 | if (nextSpace) { 119 | nextSpace.activate(false, false); 120 | } 121 | }); 122 | } 123 | 124 | /** 125 | Cycle the workspace settings bound to the current workspace. 126 | (among the unused settings) 127 | NB: Only relevant when using dynamic workspaces. 128 | */ 129 | function cycleWorkspaceSettings(binding = "q") { 130 | Keybindings.bindkey( 131 | binding, "next-space-setting", 132 | mw => Tiling.cycleWorkspaceSettings(-1), { activeInNavigator: true } 133 | ); 134 | Keybindings.bindkey( 135 | ""+binding, "prev-space-setting", 136 | mw => Tiling.cycleWorkspaceSettings(1), { activeInNavigator: true } 137 | ); 138 | } 139 | 140 | function showNavigator(binding = "j") { 141 | Keybindings.bindkey(binding, "show-minimap", () => null, { opensMinimap: true }) 142 | } 143 | 144 | // listFreeBindings("").join("\n") 145 | function listFreeBindings(modifierString) { 146 | let free = []; 147 | const chars = "abcdefghijklmnopqrstuvxyz1234567890".split("") 148 | const symbols = ["minus", "comma", "period", "plus"] 149 | return [].concat(chars, symbols).filter( 150 | key => Keybindings.getBoundActionId(modifierString+key) === 0 151 | ).map(key => modifierString+key) 152 | } 153 | 154 | function moveSpaceToMonitor(basebinding = '') { 155 | let display = global.display; 156 | 157 | function moveTo(direction) { 158 | let spaces = Tiling.spaces; 159 | 160 | let currentSpace = spaces.selectedSpace; 161 | let monitor = currentSpace.monitor; 162 | let i = display.get_monitor_neighbor_index(monitor.index, direction); 163 | let opposite; 164 | switch (direction) { 165 | case Meta.DisplayDirection.RIGHT: 166 | opposite = Meta.DisplayDirection.LEFT; break; 167 | case Meta.DisplayDirection.LEFT: 168 | opposite = Meta.DisplayDirection.RIGHT; break; 169 | case Meta.DisplayDirection.UP: 170 | opposite = Meta.DisplayDirection.DOWN; break; 171 | case Meta.DisplayDirection.DOWN: 172 | opposite = Meta.DisplayDirection.UP; break; 173 | } 174 | let n = i; 175 | if (i === -1) { 176 | let i = monitor.index; 177 | while (i !== -1) { 178 | n = i; 179 | i = display.get_monitor_neighbor_index(n, opposite); 180 | } 181 | } 182 | let next = spaces.monitors.get(Main.layoutManager.monitors[n]); 183 | 184 | currentSpace.setMonitor(next.monitor); 185 | spaces.setMonitors(next.monitor, currentSpace); 186 | 187 | next.setMonitor(monitor); 188 | spaces.setMonitors(monitor, next, true); // save on the last one 189 | 190 | // This is pretty hacky 191 | spaces.switchWorkspace(null, currentSpace.index, currentSpace.index); 192 | } 193 | 194 | for (let arrow of ['Down', 'Left', 'Up', 'Right']) { 195 | Keybindings.bindkey(`${basebinding}${arrow}`, `move-space-monitor-${arrow}`, 196 | () => { 197 | moveTo(Meta.DisplayDirection[arrow.toUpperCase()]); 198 | }); 199 | } 200 | } 201 | 202 | /** 203 | "KP_Add" and "KP_Subtract" to use the numpad keys 204 | */ 205 | function adjustWidth(incBinding="plus", decBinding="minus", increment=50) { 206 | function adjuster(delta) { 207 | return mw => { 208 | if (!mw) return; 209 | const f = mw.get_frame_rect(); 210 | mw.move_resize_frame(true, f.x, f.y, f.width + delta, f.height); 211 | }; 212 | } 213 | 214 | Keybindings.bindkey(incBinding, "inc-width", adjuster(increment)); 215 | Keybindings.bindkey(decBinding, "dec-width", adjuster(-increment)); 216 | } 217 | 218 | function tileInto(leftBinding="less", rightBinding="less") { 219 | Examples.layouts.bindTileInto(leftBinding, rightBinding); 220 | } 221 | 222 | function stackUnstack(basebinding = '') { 223 | // less: '<' 224 | const stackUnstackDirection = (dir = -1) => metaWindow => { 225 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 226 | let column_idx = space.indexOf(metaWindow); 227 | if (column_idx < 0) 228 | return; 229 | let column = space[column_idx]; 230 | 231 | if (column.length >= 2) { 232 | // this is a stacked window 233 | // move it into a new column 234 | let row_idx = column.indexOf(metaWindow); 235 | if (row_idx < 0) 236 | return; 237 | 238 | let removed = column.splice(row_idx, 1)[0]; 239 | let new_column_idx = column_idx; 240 | if (dir === 1) 241 | new_column_idx += 1; 242 | 243 | space.splice(new_column_idx, 0, [removed]); 244 | } 245 | else { 246 | // this is an unstacked window 247 | // move it into a stack 248 | 249 | // can't stack into a column that doesn't exist 250 | if (column_idx == 0 && dir == -1) 251 | return; 252 | if (column_idx + 1 >= space.length && dir == 1) 253 | return; 254 | 255 | let windowToMove = column[0]; 256 | space[column_idx + dir].push(windowToMove); 257 | 258 | // is it necessary to remove the window from the column before removing the column? 259 | column.splice(0, 1); 260 | 261 | space.splice(column_idx, 1); 262 | } 263 | 264 | space.layout(true, { 265 | customAllocators: { [space.indexOf(metaWindow)]: Tiling.allocateEqualHeight } 266 | }); 267 | space.emit("full-layout"); 268 | } 269 | 270 | let options = { activeInNavigator: true }; 271 | Keybindings.bindkey(`${basebinding}Left`, "stack-unstack-left", stackUnstackDirection(-1), options); 272 | Keybindings.bindkey(`${basebinding}Right`, "stack-unstack-right", stackUnstackDirection(1), options); 273 | } 274 | 275 | function cycleEdgeSnap(binding = "u") { 276 | Keybindings.bindkey(binding, "cycle-edge-snap", (mw) => { 277 | // Snaps window to the left/right monitor edge 278 | // Note: mostly the same as quickly switching left+right / right+left 279 | 280 | // Note: We work in monitor relative coordinates here 281 | let margin = Tiling.prefs.horizontal_margin; 282 | let space = Tiling.spaces.spaceOfWindow(mw); 283 | let workarea = Main.layoutManager.getWorkAreaForMonitor(space.monitor.index); 284 | let clone = mw.clone; 285 | 286 | let x = clone.targetX + space.targetX; 287 | let width = clone.width; 288 | let wax = workarea.x - space.monitor.x; 289 | 290 | let leftSnapPos = wax + margin; 291 | let rightSnapPos = wax + workarea.width - width - margin; 292 | 293 | let targetX; 294 | if (x == leftSnapPos) { 295 | targetX = rightSnapPos; 296 | } else if (x == rightSnapPos) { 297 | targetX = leftSnapPos; 298 | } else { 299 | targetX = leftSnapPos; 300 | } 301 | 302 | Tiling.move_to(space, mw, {x: targetX}); 303 | }, {activeInNavigator: true}); 304 | } 305 | 306 | function reorderWorkspace(bindingUp = "Page_Up", bindingDown = "Page_Down") { 307 | if (!global.workspace_manager.reorder_workspace) { 308 | console.warn("Reorder workspaces not supported by this gnome-shell version"); 309 | return; 310 | } 311 | function moveWorkspace(dir, metaWindow, space) { 312 | if (!space) 313 | return; 314 | 315 | let nextI = Math.min(Tiling.spaces.size-1 , Math.max(0, space.index + dir)); 316 | global.workspace_manager.reorder_workspace(space.workspace, nextI); 317 | } 318 | 319 | Keybindings.bindkey( 320 | bindingUp, "reorder-workspace-up", 321 | moveWorkspace.bind(null, -1), 322 | { activeInNavigator: true } 323 | ); 324 | 325 | Keybindings.bindkey( 326 | bindingDown, "reorder-workspace-down", 327 | moveWorkspace.bind(null, 1), 328 | { activeInNavigator: true } 329 | ); 330 | } 331 | -------------------------------------------------------------------------------- /examples/layouts.js: -------------------------------------------------------------------------------- 1 | const Extension = imports.misc.extensionUtils.getCurrentExtension(); 2 | const Keybindings = Extension.imports.keybindings; 3 | const Lib = Extension.imports.lib; 4 | const Tiling = Extension.imports.tiling; 5 | const Virt = Extension.imports.virtTiling; 6 | const Easer = Extension.imports.utils.easer; 7 | const prefs = Tiling.prefs; 8 | 9 | /** Adapts an action handler to operate on the neighbour in the given direction */ 10 | function useNeigbour(dir, action) { 11 | return (metaWindow) => { 12 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 13 | let i = space.indexOf(metaWindow); 14 | if (!space[i + dir]) 15 | return action(undefined); 16 | 17 | return action(space[i + dir][0]); 18 | }; 19 | } 20 | 21 | /** Find the index of the first not fully visible column in the given direction */ 22 | function findNonVisibleIndex(space, metaWindow, dir=1, margin=1) { 23 | let k = space.indexOf(metaWindow) + dir; 24 | while (0 <= k && k < space.length && space.isFullyVisible(space[k][0], margin)) { 25 | k += dir; 26 | } 27 | return k; 28 | } 29 | 30 | function moveTo(space, metaWindow, target) { 31 | space.startAnimate(); 32 | space.targetX = target; 33 | Easer.addEase(space.cloneContainer, 34 | { 35 | x: space.targetX, 36 | time: prefs.animation_time, 37 | onComplete: space.moveDone.bind(space) 38 | }); 39 | 40 | space.fixOverlays(); 41 | } 42 | 43 | function getLeftSnapPosition(space) { 44 | let margin = Tiling.prefs.horizontal_margin; 45 | let workarea = space.workArea(); 46 | let wax = workarea.x - space.monitor.x; 47 | 48 | return wax + margin; 49 | } 50 | 51 | function getSnapPositions(space, windowWidth) { 52 | let margin = Tiling.prefs.horizontal_margin; 53 | let workarea = space.workArea(); 54 | let wax = workarea.x - space.monitor.x; 55 | 56 | let leftSnapPos = wax + margin; 57 | let rightSnapPos = wax + workarea.width - windowWidth - margin; 58 | return [leftSnapPos, rightSnapPos] 59 | } 60 | 61 | function mkVirtTiling(space) { 62 | return Virt.layout(Virt.fromSpace(space), space.workArea(), prefs); 63 | } 64 | 65 | function moveToViewport(space, tiling, i, vx) { 66 | moveTo(space, null, vx - tiling[i][0].x); 67 | } 68 | 69 | function resize(tiling, i, width) { 70 | for (let w of tiling[i]) { 71 | w.width = width; 72 | } 73 | } 74 | 75 | 76 | ////// Actions 77 | 78 | 79 | /** 80 | Expands or shrinks the window to fit the available viewport space. 81 | Available space is space not occupied by fully visible windows 82 | Will move the tiling as necessary. 83 | */ 84 | function fitAvailable(metaWindow) { 85 | // TERMINOLOGY: mold-into ? 86 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 87 | 88 | let a = findNonVisibleIndex(space, metaWindow, -1); 89 | let b = findNonVisibleIndex(space, metaWindow, 1); 90 | 91 | let leftMost = space[a+1][0]; 92 | let availableLeft = space.targetX + leftMost.clone.targetX; 93 | 94 | let rightMost = space[b-1][0]; 95 | let rightEdge = space.targetX + rightMost.clone.targetX + rightMost.clone.width; 96 | let availableRight = space.width - rightEdge; 97 | 98 | let f = metaWindow.get_frame_rect(); 99 | let available = f.width + availableRight + availableLeft - Tiling.prefs.horizontal_margin*2; 100 | 101 | if (a+1 === b-1) { 102 | // We're the only window 103 | Tiling.toggleMaximizeHorizontally(metaWindow); 104 | } else { 105 | metaWindow.move_resize_frame(true, f.x, f.y, available, f.height); 106 | Tiling.move_to(space, space[a+1][0], { x: Tiling.prefs.horizontal_margin }); 107 | } 108 | } 109 | 110 | function cycleLayoutDirection(dir) { 111 | const splits = [ 112 | [0.5, 0.5], 113 | [0.7, 0.3], 114 | [0.3, 0.7], 115 | ]; 116 | 117 | return (metaWindow, space, {navigator}={}) => { 118 | let k = space.indexOf(metaWindow); 119 | let j = k+dir; 120 | let neighbourCol = space[j]; 121 | if (!neighbourCol) 122 | return; 123 | 124 | let neighbour = neighbourCol[0]; 125 | 126 | let tiling = mkVirtTiling(space); 127 | 128 | let available = space.width - Tiling.prefs.horizontal_margin*2 - Tiling.prefs.window_gap; 129 | 130 | let f1 = metaWindow.get_frame_rect(); 131 | let f2 = neighbour.get_frame_rect(); 132 | 133 | let s1 = f1.width / available; 134 | let s2 = f2.width / available; 135 | 136 | let state; 137 | if (!navigator["cycle-layouts"]) { 138 | navigator["cycle-layouts"] = { i: Lib.eq(s1, splits[0][0]) ? 1 : 0 }; 139 | } 140 | state = navigator["cycle-layouts"]; 141 | 142 | let [a, b] = splits[state.i % splits.length]; 143 | state.i++; 144 | 145 | let metaWindowWidth = Math.round(available * a);; 146 | metaWindow.move_resize_frame(true, f1.x, f1.y, metaWindowWidth, f1.height); 147 | resize(tiling, k, metaWindowWidth); 148 | 149 | let neighbourWidth = Math.round(available * b); 150 | neighbour.move_resize_frame(true, f2.x, f2.y, neighbourWidth, f2.height); 151 | resize(tiling, j, neighbourWidth); 152 | 153 | Virt.layout(tiling, space.workArea(), prefs); 154 | 155 | let snapLeft = getLeftSnapPosition(space); 156 | 157 | if (dir === 1) 158 | moveToViewport(space, tiling, k, snapLeft); 159 | else 160 | moveToViewport(space, tiling, j, snapLeft); 161 | } 162 | } 163 | 164 | function cycleLayouts(binding = "d") { 165 | function action(metaWindow, space, {navigator}={}) { 166 | const m = 50; 167 | space = Tiling.spaces.spaceOfWindow(metaWindow); 168 | 169 | let k = space.indexOf(metaWindow); 170 | let next = space.length > k+1 && space.isVisible(space[k+1][0], m) && space[k+1][0]; 171 | let prev = k > 0 && space.isVisible(space[k-1][0], m) && space[k-1][0]; 172 | 173 | let neighbour = next || prev; 174 | 175 | if (neighbour === next) { 176 | return cycleLayoutDirection(1)(metaWindow, space, {navigator}); 177 | } else { 178 | return cycleLayoutDirection(-1)(metaWindow, space, {navigator}); 179 | } 180 | } 181 | 182 | Keybindings.bindkey(binding, "cycle-layouts", action, { opensNavigator: true }); 183 | } 184 | 185 | 186 | function tileInto(dir=-1) { 187 | return (metaWindow, space) => { 188 | space = space || Tiling.spaces.spaceOfWindow(metaWindow); 189 | let jFrom = space.indexOf(metaWindow); 190 | if (space[jFrom].length > 1) { 191 | return tileOut(dir)(metaWindow, space); 192 | } 193 | let jTo = jFrom + dir; 194 | if (jTo < 0 || jTo >= space.length) 195 | return; 196 | 197 | space[jFrom].splice(space.rowOf(metaWindow), 1); 198 | space[jTo].push(metaWindow); 199 | 200 | if (space[jFrom].length === 0) { 201 | space.splice(jFrom, 1); 202 | } 203 | space.layout(true, { 204 | customAllocators: { [space.indexOf(metaWindow)]: Tiling.allocateEqualHeight } 205 | }); 206 | space.emit("full-layout"); 207 | } 208 | } 209 | 210 | function tileOut(dir) { 211 | return (metaWindow, space) => { 212 | space = space || Tiling.spaces.spaceOfWindow(metaWindow); 213 | let [j, i] = space.positionOf(metaWindow); 214 | if (space[j].length === 0) 215 | return; 216 | 217 | space[j].splice(i, 1); 218 | space.splice(j + (dir === 1 ? 1 : 0), 0, [metaWindow]); 219 | space.layout(); 220 | space.emit("full-layout"); 221 | space.fixOverlays(); 222 | } 223 | } 224 | 225 | 226 | ////// Bindings 227 | 228 | function bindTileInto(leftBinding="Left", rightBinding="Right") { 229 | let options = { activeInNavigator: true }; 230 | if (leftBinding) 231 | Keybindings.bindkey(leftBinding, "tile-into-left-column", tileInto(-1), options); 232 | if (rightBinding) 233 | Keybindings.bindkey(rightBinding, "tile-into-right-column", tileInto(1), options); 234 | } 235 | 236 | function bindTileOut(left="k", right="l") { 237 | Keybindings.bindkey(left, "tile-out-left", tileOut(-1), {activeInNavigator: true}); 238 | Keybindings.bindkey(right, "tile-out-right", tileOut(1), {activeInNavigator: true}); 239 | } 240 | 241 | 242 | function bindFitAvailable(left="j", focus = "k", right="l") { 243 | left && Keybindings.bindkey(left, "fit-available-width-left", useNeigbour(-1, fitAvailable), {activeInNavigator: true}); 244 | focus && Keybindings.bindkey(focus, "fit-available-width", fitAvailable, {activeInNavigator: true}); 245 | right && Keybindings.bindkey(right, "fit-available-width-right", useNeigbour(1, fitAvailable), {activeInNavigator: true}); 246 | } 247 | 248 | function bindCycleLayoutDirection(left="d", right="d") { 249 | Keybindings.bindkey(left, "cycle-layout-left", cycleLayoutDirection(-1), { opensNavigator: true }); 250 | Keybindings.bindkey(right, "cycle-layout-right", cycleLayoutDirection(1), { opensNavigator: true }); 251 | } 252 | -------------------------------------------------------------------------------- /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 | import Gio from 'gi://Gio'; 2 | import GLib from 'gi://GLib'; 3 | import St from 'gi://St'; 4 | 5 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 6 | import * as Util from 'resource:///org/gnome/shell/misc/util.js'; 7 | 8 | import { 9 | Utils, Settings, Gestures, Keybindings, LiveAltTab, Navigator, 10 | Stackoverlay, Scratch, Workspace, Tiling, Topbar, Patches, App, Grab 11 | } from './imports.js'; 12 | 13 | import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; 14 | 15 | /** 16 | The currently used modules 17 | - tiling is the main module, responsible for tiling and workspaces 18 | 19 | - navigator is used to initiate a discrete navigation. 20 | Focus is only switched when the navigation is done. 21 | 22 | - keybindings is a utility wrapper around mutters keybinding facilities. 23 | 24 | - scratch is used to manage floating windows, or scratch windows. 25 | 26 | - liveAltTab is a simple altTab implementiation with live previews. 27 | 28 | - stackoverlay is somewhat kludgy. It makes clicking on the left or right 29 | edge of the screen always activate the partially (or sometimes wholly) 30 | concealed window at the edges. 31 | 32 | - app creates new windows based on the current application. It's possible 33 | to create custom new window handlers. 34 | 35 | - Patches is used for monkey patching gnome shell behavior which simply 36 | doesn't fit paperwm. 37 | 38 | - topbar adds the workspace name to the topbar and styles it. 39 | 40 | - gestures is responsible for 3-finger swiping (only works in wayland). 41 | 42 | Notes of ordering: 43 | - several modules import settings, so settings should be before them; 44 | - settings.js should not depend on other paperwm modules; 45 | - Settings should be before Patches (for reverse order disable); 46 | */ 47 | 48 | export default class PaperWM extends Extension { 49 | modules = [ 50 | Utils, Settings, Patches, 51 | Gestures, Keybindings, LiveAltTab, Navigator, Stackoverlay, Scratch, 52 | Workspace, Tiling, Topbar, App, Grab, 53 | ]; 54 | 55 | #userStylesheet = null; 56 | 57 | enable() { 58 | console.log(`#PaperWM enabled`); 59 | this.enableUserConfig(); 60 | this.enableUserStylesheet(); 61 | 62 | // run enable method (with extension argument on all modules) 63 | this.modules.forEach(m => { 64 | if (m['enable']) { 65 | m.enable(this); 66 | } 67 | }); 68 | } 69 | 70 | disable() { 71 | console.log('#PaperWM disabled'); 72 | this.prepareForDisable(); 73 | [...this.modules].reverse().forEach(m => { 74 | if (m['disable']) { 75 | m.disable(); 76 | } 77 | }); 78 | 79 | this.disableUserStylesheet(); 80 | } 81 | 82 | /** 83 | * Prepares PaperWM for disable across modules. 84 | */ 85 | prepareForDisable() { 86 | /** 87 | * Finish any navigation (e.g. workspace switch view). 88 | * Can put PaperWM in a breakable state of lock/disable 89 | * while navigating. 90 | */ 91 | Navigator.finishNavigation(); 92 | } 93 | 94 | getConfigDir() { 95 | return Gio.file_new_for_path(`${GLib.get_user_config_dir()}/paperwm`); 96 | } 97 | 98 | configDirExists() { 99 | return this.getConfigDir().query_exists(null); 100 | } 101 | 102 | hasUserConfigFile() { 103 | return this.getConfigDir().get_child("user.js").query_exists(null); 104 | } 105 | 106 | hasUserStyleFile() { 107 | return this.getConfigDir().get_child("user.css").query_exists(null); 108 | } 109 | 110 | /** 111 | * Update the metadata.json in user config dir to always keep it up to date. 112 | * We copy metadata.json to the config directory so gnome-shell-mode 113 | * knows which extension the files belong to (ideally we'd symlink, but 114 | * that trips up the importer: Extension.imports. in 115 | * gnome-shell-mode crashes gnome-shell..) 116 | */ 117 | updateUserConfigFiles() { 118 | if (!this.configDirExists()) { 119 | return; 120 | } 121 | const configDir = this.getConfigDir(); 122 | 123 | try { 124 | const metadata = this.dir.get_child("metadata.json"); 125 | metadata.copy(configDir.get_child("metadata.json"), Gio.FileCopyFlags.OVERWRITE, null, null); 126 | } catch (error) { 127 | console.error('PaperWM', `could not update user config metadata.json: ${error}`); 128 | } 129 | 130 | if (!this.hasUserStyleFile()) { 131 | try { 132 | const user = this.dir.get_child("config/user.css"); 133 | user.copy(configDir.get_child("user.css"), Gio.FileCopyFlags.NONE, null, null); 134 | } catch (error) { 135 | console.error('PaperWM', `could not update user config metadata.json: ${error}`); 136 | } 137 | } 138 | } 139 | 140 | installConfig() { 141 | const configDir = this.getConfigDir(); 142 | // if user config folder doesn't exist, create it 143 | if (!this.configDirExists()) { 144 | configDir.make_directory_with_parents(null); 145 | } 146 | } 147 | 148 | enableUserConfig() { 149 | if (!this.configDirExists()) { 150 | try { 151 | this.installConfig(); 152 | 153 | const configDir = this.getConfigDir().get_path(); 154 | Main.notify("PaperWM", `Created user configuration folder: ${configDir}`); 155 | } catch (e) { 156 | Main.notifyError("PaperWM", `Failed create user configuration folder: ${e.message}`); 157 | } 158 | } 159 | 160 | this.updateUserConfigFiles(); 161 | 162 | /* TODO: figure out something here 163 | fmuellner: 164 | > you can't 165 | > as I said, it's part of gjs legacy imports 166 | > you'll have to do something like const userMod = await import(${this.getConfigDir()}/user.js) 167 | */ 168 | /* 169 | // add to searchpath if user has config file and action user.js 170 | if (this.hasUserConfigFile()) { 171 | let SearchPath = Extension.imports.searchPath; 172 | let path = this.getConfigDir().get_path(); 173 | if (!SearchPath.includes(path)) { 174 | SearchPath.push(path); 175 | } 176 | } 177 | */ 178 | } 179 | 180 | /** 181 | * Reloads user.css styles (if user.css present in ~/.config/paperwm). 182 | */ 183 | enableUserStylesheet() { 184 | this.#userStylesheet = this.getConfigDir().get_child("user.css"); 185 | if (this.#userStylesheet.query_exists(null)) { 186 | let themeContext = St.ThemeContext.get_for_stage(global.stage); 187 | themeContext.get_theme().load_stylesheet(this.#userStylesheet); 188 | } 189 | } 190 | 191 | /** 192 | * Unloads user.css styles (if user.css present in ~/.config/paperwm). 193 | */ 194 | disableUserStylesheet() { 195 | let themeContext = St.ThemeContext.get_for_stage(global.stage); 196 | themeContext.get_theme().unload_stylesheet(this.#userStylesheet); 197 | this.#userStylesheet = null; 198 | } 199 | 200 | spawnPager(content) { 201 | const quoted = GLib.shell_quote(content); 202 | Util.spawn(["sh", "-c", `echo -En ${quoted} | gedit --new-window -`]); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "id": "flake-utils", 17 | "type": "indirect" 18 | } 19 | }, 20 | "nixpkgs": { 21 | "locked": { 22 | "lastModified": 1742683096, 23 | "narHash": "sha256-NAJeQATypzZRofLwGRqDMVVII8GhbWjvLECCVj8v7QY=", 24 | "owner": "NixOS", 25 | "repo": "nixpkgs", 26 | "rev": "1b86a608d746f7e88cdc4416d74b49b5a95d7689", 27 | "type": "github" 28 | }, 29 | "original": { 30 | "owner": "NixOS", 31 | "repo": "nixpkgs", 32 | "type": "github" 33 | } 34 | }, 35 | "nixpkgs-gnome": { 36 | "locked": { 37 | "lastModified": 1742772691, 38 | "narHash": "sha256-zp5dBRmFJ47Wqwo3jphPRfgBVL7gx+DJw0Ni9J/gFFc=", 39 | "owner": "NixOS", 40 | "repo": "nixpkgs", 41 | "rev": "ebe5e16c4ae36aa4230f1108ca4e35157fae6697", 42 | "type": "github" 43 | }, 44 | "original": { 45 | "owner": "NixOS", 46 | "ref": "gnome", 47 | "repo": "nixpkgs", 48 | "type": "github" 49 | } 50 | }, 51 | "root": { 52 | "inputs": { 53 | "flake-utils": "flake-utils", 54 | "nixpkgs": "nixpkgs", 55 | "nixpkgs-gnome": "nixpkgs-gnome" 56 | } 57 | }, 58 | "systems": { 59 | "locked": { 60 | "lastModified": 1681028828, 61 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 62 | "owner": "nix-systems", 63 | "repo": "default", 64 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 65 | "type": "github" 66 | }, 67 | "original": { 68 | "owner": "nix-systems", 69 | "repo": "default", 70 | "type": "github" 71 | } 72 | } 73 | }, 74 | "root": "root", 75 | "version": 7 76 | } 77 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { description = "Tiled, scrollable window management for GNOME Shell"; 2 | 3 | inputs."nixpkgs".url = github:NixOS/nixpkgs; 4 | inputs."nixpkgs-gnome".url = github:NixOS/nixpkgs/gnome; 5 | 6 | outputs = { self, nixpkgs, nixpkgs-gnome, flake-utils, ... }: 7 | flake-utils.lib.eachDefaultSystem 8 | (system: 9 | let hostPkgs = import nixpkgs { inherit system; }; 10 | in 11 | { packages.default = hostPkgs.callPackage ./default.nix {}; 12 | 13 | # This allows us to build Qemu for the host system thus avoiding 14 | # double emulation. 15 | packages.vm = let hostConfig = self.nixosConfigurations.testbox; 16 | localConfig = hostConfig.extendModules { 17 | modules = [ 18 | ({ modulesPath, ... }: { 19 | imports = [ "${modulesPath}/virtualisation/qemu-vm.nix" ]; 20 | virtualisation.host.pkgs = hostPkgs; 21 | }) 22 | ]; 23 | }; 24 | in localConfig.config.system.build.vm; 25 | }) // { 26 | nixosConfigurations."testbox" = 27 | let system = "x86_64-linux"; 28 | pkgs-gnome = import nixpkgs-gnome { inherit system; }; 29 | in nixpkgs.lib.nixosSystem { 30 | inherit system; 31 | modules = [ 32 | ./vm.nix 33 | { nixpkgs.overlays = [ 34 | # Introduce PaperWM into our extensions 35 | (s: super: { paperwm = self.packages.${system}.default; }) 36 | 37 | # Pull GNOME-specific packages from GNOME staging 38 | (s: super: { 39 | gnome-desktop = pkgs-gnome.gnome-desktop; 40 | gnome-shell = pkgs-gnome.gnome-shell.override { 41 | evolution-data-server-gtk4 = super.evolution-data-server-gtk4.override { 42 | inherit (super) webkitgtk_4_1 webkitgtk_6_0; 43 | }; 44 | }; 45 | gnome-session = pkgs-gnome.gnome-session.override { 46 | inherit (s) gnome-shell; 47 | }; 48 | gnome-control-center = pkgs-gnome.gnome-control-center; 49 | gnome-initial-setup = pkgs-gnome.gnome-initial-setup.override { 50 | inherit (super) webkitgtk_6_0; 51 | }; 52 | gnome-settings-daemon = pkgs-gnome.gnome-settings-daemon; 53 | mutter = pkgs-gnome.mutter; 54 | gdm = pkgs-gnome.gdm; 55 | xdg-desktop-portal-gnome = pkgs-gnome.xdg-desktop-portal-gnome; 56 | xdg-desktop-portal-gtk = pkgs-gnome.xdg-desktop-portal-gtk; 57 | }) 58 | ]; 59 | } 60 | ]; 61 | }; 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /gather-system-info.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Gather and print version information about the system. 3 | # 4 | # Expects the following commands to be available: 5 | # - git 6 | # - gnome-shell 7 | # - gnome-extensions 8 | # Optionally: 9 | # - awk 10 | 11 | REPO="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 12 | 13 | main() { 14 | cd "${REPO}" 15 | 16 | echo "Please include this information in your bug report on GitHub!" 17 | 18 | show_distribution 19 | show_gnome_version 20 | show_display_server 21 | show_paperwm_version 22 | show_gnome_extensions 23 | } 24 | 25 | show_distribution() { 26 | echo -n "Distribution: " 27 | if [ -f /etc/os-release ]; then 28 | source /etc/os-release && echo "${NAME}" 29 | fi 30 | } 31 | 32 | show_gnome_version() { 33 | gnome-shell --version 34 | } 35 | 36 | show_display_server() { 37 | echo -n "Display server: " 38 | if [ "${XDG_SESSION_TYPE}" = "wayland" ]; then 39 | echo "Wayland" 40 | else 41 | echo "Xorg" 42 | fi 43 | } 44 | 45 | show_paperwm_version() { 46 | echo -n "PaperWM branch/tag: " 47 | git symbolic-ref --short -q HEAD || git name-rev --tags --name-only --no-undefined "$(git rev-parse HEAD)" 48 | echo -n "PaperWM commit: " 49 | git rev-parse HEAD 50 | 51 | } 52 | 53 | show_gnome_extensions() { 54 | echo "Enabled extensions:" 55 | # make a markdown list out of gnome-extensions list 56 | # use gnome-extensions if it exists and falls back to bash on older gnome 57 | # versions 58 | if command -v gnome-extensions >/dev/null; then 59 | gnome-extensions list --enabled | { 60 | if command -v awk >/dev/null; then 61 | awk '{print "- " $0}' 62 | else 63 | cat 64 | fi 65 | } 66 | else 67 | # compare enabled extensions to installed extensions 68 | # because some uninstalled extensions could still be enabled because of 69 | # stale gsettings 70 | for ext in $(_enabled_extensions); do 71 | if is_extension_installed "$ext"; then 72 | echo "- $ext" 73 | fi 74 | done 75 | fi 76 | } 77 | 78 | is_extension_installed() { 79 | local ext=$1 80 | [[ -d "$HOME/.local/share/gnome-shell/extensions/$ext" ]] || [[ -d "/usr/share/gnome-shell/extensions/$ext" ]] 81 | } 82 | 83 | _enabled_extensions() { 84 | local s matches 85 | s=$(gsettings get org.gnome.shell enabled-extensions) 86 | # $matches contains lines with the extension uuid and lines with ", " 87 | mapfile -t matches < <(_global_rematch "$s" "'([^']*)'") 88 | for match in "${matches[@]}"; do 89 | if [[ "$match" =~ ^,\s* ]]; then 90 | continue 91 | fi 92 | echo "$match" 93 | done 94 | } 95 | 96 | _global_rematch() { 97 | local s=$1 regex=$2 98 | while [[ $s =~ $regex ]]; do 99 | echo "${BASH_REMATCH[1]}" 100 | s=${s#*"${BASH_REMATCH[1]}"} 101 | done 102 | } 103 | 104 | main "$@" 105 | -------------------------------------------------------------------------------- /generate-extension-zip.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Creates a zip of (only) the necessary files required by Gnome for the PaperWM extension. 4 | # Designed for submitting a zip to extensions.gnome.org. 5 | zip -r paperwm@paperwm.github.com.zip \ 6 | metadata.json \ 7 | stylesheet.css \ 8 | *.js \ 9 | config/user.js \ 10 | config/user.css \ 11 | *.ui \ 12 | LICENSE \ 13 | schemas/gschemas.compiled \ 14 | schemas/org.gnome.shell.extensions.paperwm.gschema.xml \ 15 | resources/ 16 | -------------------------------------------------------------------------------- /imports.js: -------------------------------------------------------------------------------- 1 | export * as AcceleratorParse from './acceleratorparse.js'; 2 | export * as App from './app.js'; 3 | export * as Background from './background.js'; 4 | export * as Gestures from './gestures.js'; 5 | export * as Grab from './grab.js'; 6 | export * as Keybindings from './keybindings.js'; 7 | export * as Lib from './lib.js'; 8 | export * as LiveAltTab from './liveAltTab.js'; 9 | export * as Minimap from './minimap.js'; 10 | export * as Navigator from './navigator.js'; 11 | export * as Patches from './patches.js'; 12 | export * as Scratch from './scratch.js'; 13 | export * as Settings from './settings.js'; 14 | export * as Stackoverlay from './stackoverlay.js'; 15 | export * as Tiling from './tiling.js'; 16 | export * as Topbar from './topbar.js'; 17 | export * as Utils from './utils.js'; 18 | export * as Workspace from './workspace.js'; 19 | export * as OverviewLayout from './overviewlayout.js'; 20 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | REPO="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 4 | UUID=paperwm@paperwm.github.com 5 | EXT_DIR=${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions 6 | EXT="$EXT_DIR"/"$UUID" 7 | mkdir -p "$EXT_DIR" 8 | 9 | # Check if ext path already exists and if is a folder 10 | if [[ ! -L "$EXT" && -d "$EXT" ]]; then 11 | cat << EOF 12 | 13 | INSTALL FAILED: 14 | 15 | A previous (non-symlinked) installation of PaperWM already exists at: 16 | "$EXT". 17 | 18 | Please remove the installed version from that path and re-run this install script. 19 | 20 | EOF 21 | exit 1 22 | fi 23 | 24 | ln -snf "$REPO" "$EXT" 25 | 26 | cat << EOF 27 | 28 | INSTALL SUCCESSFUL: 29 | 30 | If this is the first time installing PaperWM, then please logout/login 31 | and enable the PaperWM extension, either with the Gnome Extensions application, 32 | or manually by executing the following command from a terminal: 33 | 34 | gnome-extensions enable ${UUID} 35 | 36 | EOF 37 | -------------------------------------------------------------------------------- /lib.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Library of simple functions for use in all other modules. 3 | * This libary should be clean and not depend on any other modules. 4 | */ 5 | 6 | /** 7 | Find the first x in `values` that's larger than `cur`. 8 | Cycle to first value if no larger value is found. 9 | `values` should be sorted in ascending order. 10 | */ 11 | export function findNext(cur, values, slack = 0) { 12 | for (let i = 0; i < values.length; i++) { 13 | let x = values[i]; 14 | if (cur < x) { 15 | if (x - cur < slack) { 16 | // Consider `cur` practically equal to `x` 17 | continue; 18 | } else { 19 | return x; 20 | } 21 | } 22 | } 23 | return values[0]; // cycle 24 | } 25 | 26 | export function findPrev(cur, values, slack = 0) { 27 | let i = 0; 28 | for (;i < values.length; i++) { 29 | let x = values[i]; 30 | if (x + slack >= cur) { 31 | break; 32 | } 33 | } 34 | let target_i = i - 1; 35 | if (target_i < 0) { 36 | target_i = values.length - 1; 37 | } 38 | 39 | return values[target_i]; 40 | } 41 | 42 | export function arrayEqual(a, b) { 43 | if (a === b) 44 | return true; 45 | if (!a || !b) 46 | return false; 47 | if (a.length !== b.length) 48 | return false; 49 | for (let i = 0; i < a.length; i++) { 50 | if (a[i] !== b[i]) 51 | return false; 52 | } 53 | return true; 54 | } 55 | 56 | /** Is the floating point numbers equal enough */ 57 | export function eq(a, b, epsilon = 0.00000001) { 58 | return Math.abs(a - b) < epsilon; 59 | } 60 | 61 | export function swap(array, i, j) { 62 | let temp = array[i]; 63 | array[i] = array[j]; 64 | array[j] = temp; 65 | } 66 | 67 | export function in_bounds(array, i) { 68 | return i >= 0 && i < array.length; 69 | } 70 | 71 | export function indent(level, str) { 72 | let blank = ""; 73 | for (let i = 0; i < level; i++) { 74 | blank += " "; 75 | } 76 | return blank + str; 77 | } 78 | 79 | export function sum(array) { 80 | return array.reduce((a, b) => a + b, 0); 81 | } 82 | 83 | export function zip(...as) { 84 | let r = []; 85 | let minLength = Math.min(...as.map(x => x.length)); 86 | for (let i = 0; i < minLength; i++) { 87 | r.push(as.map(a => a[i])); 88 | } 89 | return r; 90 | } 91 | -------------------------------------------------------------------------------- /liveAltTab.js: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import Meta from 'gi://Meta'; 3 | import Gio from 'gi://Gio'; 4 | import GObject from 'gi://GObject'; 5 | 6 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 7 | import * as AltTab from 'resource:///org/gnome/shell/ui/altTab.js'; 8 | 9 | import { Settings, Keybindings, Tiling, Scratch, Utils } from './imports.js'; 10 | import { Easer } from './utils.js'; 11 | 12 | let switcherSettings; 13 | export function enable() { 14 | switcherSettings = new Gio.Settings({ 15 | schema_id: 'org.gnome.shell.app-switcher', 16 | }); 17 | } 18 | 19 | export function disable() { 20 | switcherSettings = null; 21 | } 22 | 23 | export function liveAltTab(meta_window, space, { _display, _screen, binding }) { 24 | let tabPopup = new LiveAltTab(binding.is_reversed(), false); 25 | tabPopup.show(binding.is_reversed(), binding.get_name(), binding.get_mask()); 26 | } 27 | 28 | export function liveAltTabScratch(meta_window, space, { _display, _screen, binding }) { 29 | let tabPopup = new LiveAltTab(binding.is_reversed(), true); 30 | tabPopup.show(binding.is_reversed(), binding.get_name(), binding.get_mask()); 31 | } 32 | 33 | export const LiveAltTab = GObject.registerClass( 34 | class LiveAltTab extends AltTab.WindowSwitcherPopup { 35 | _init(reverse, scratchOnly) { 36 | this.reverse = reverse; 37 | this.scratchOnly = scratchOnly; 38 | this.space = Tiling.spaces.selectedSpace; 39 | this.monitor = Tiling.spaces.selectedSpace.monitor; 40 | super._init(); 41 | } 42 | 43 | _getWindowList(reverse) { 44 | let tabList = global.display.get_tab_list( 45 | Meta.TabList.NORMAL_ALL, 46 | switcherSettings.get_boolean('current-workspace-only') 47 | ? global.workspace_manager.get_active_workspace() : null) 48 | .filter(w => !Scratch.isScratchWindow(w)); 49 | 50 | let scratch = Scratch.getScratchWindows(); 51 | 52 | if (this.scratchOnly) { 53 | return reverse ? scratch.reverse() : scratch; 54 | } 55 | else if (Scratch.isScratchWindow(global.display.focus_window)) { 56 | // Access scratch windows in mru order with shift-super-tab 57 | return scratch.concat(reverse ? tabList.reverse() : tabList); 58 | } else { 59 | return tabList.concat(reverse ? scratch.reverse() : scratch); 60 | } 61 | } 62 | 63 | _initialSelection(backward, actionName) { 64 | this.space.startAnimate(); 65 | let workArea = Main.layoutManager.getWorkAreaForMonitor(this.monitor.index); 66 | let fog = new Clutter.Actor({ 67 | x: workArea.x, y: workArea.y, 68 | width: workArea.width, height: workArea.height, 69 | opacity: 0, background_color: Utils.color_from_string("black")[1], 70 | }); 71 | 72 | // this.blur = new Clutter.BlurEffect(); 73 | // this.space.cloneContainer.add_effect(this.blur); 74 | this.space.setSelectionInactive(); 75 | 76 | Main.uiGroup.insert_child_above(fog, global.window_group); 77 | Easer.addEase(fog, { 78 | time: Settings.prefs.animation_time, 79 | opacity: 100, 80 | }); 81 | this.fog = fog; 82 | 83 | super._initialSelection(backward, actionName); 84 | } 85 | 86 | _keyPressHandler(keysym, mutterActionId) { 87 | if (keysym === Clutter.KEY_Escape) 88 | return Clutter.EVENT_PROPAGATE; 89 | // After the first super-tab the mutterActionId we get is apparently 90 | // SWITCH_APPLICATIONS so we need to case on those too. 91 | switch (mutterActionId) { 92 | case Meta.KeyBindingAction.SWITCH_APPLICATIONS: 93 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS; 94 | break; 95 | case Meta.KeyBindingAction.SWITCH_APPLICATIONS_BACKWARD: 96 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS_BACKWARD; 97 | break; 98 | case Keybindings.idOf('live-alt-tab'): 99 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS; 100 | break; 101 | case Keybindings.idOf('live-alt-tab-backward'): 102 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS_BACKWARD; 103 | break; 104 | case Keybindings.idOf('live-alt-tab-scratch'): 105 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS; 106 | break; 107 | case Keybindings.idOf('live-alt-tab-scratch-backward'): 108 | mutterActionId = Meta.KeyBindingAction.SWITCH_WINDOWS_BACKWARD; 109 | break; 110 | } 111 | // let action = Keybindings.byId(mutterActionId); 112 | // if (action && action.options.activeInNavigator) { 113 | // let space = Tiling.spaces.selectedSpace; 114 | // let metaWindow = space.selectedWindow; 115 | // action.handler(metaWindow, space); 116 | // return true; 117 | // } 118 | return super._keyPressHandler(keysym, mutterActionId); 119 | } 120 | 121 | _select(num) { 122 | let to = this._switcherList.windows[num]; 123 | 124 | this.clone && this.clone.destroy(); 125 | this.clone = null; 126 | 127 | let actor = to.get_compositor_private(); 128 | actor.remove_clip(); 129 | let frame = to.get_frame_rect(); 130 | let clone = new Clutter.Clone({ source: actor }); 131 | clone.position = actor.position; 132 | 133 | let space = Tiling.spaces.spaceOfWindow(to); 134 | if (space.indexOf(to) !== -1) { 135 | clone.x = Tiling.ensuredX(to, space) + space.monitor.x; 136 | clone.x -= frame.x - actor.x; 137 | } 138 | 139 | this.clone = clone; 140 | Main.uiGroup.insert_child_above(clone, this.fog); 141 | 142 | // Tiling.ensureViewport(to, space); 143 | this._selectedIndex = num; 144 | this._switcherList.highlight(num); 145 | } 146 | 147 | _finish() { 148 | this.was_accepted = true; 149 | super._finish(); 150 | } 151 | 152 | _itemEnteredHandler() { 153 | // The item-enter (mouse hover) event is triggered even after a item is 154 | // accepted. This can cause _select to run on the item below the pointer 155 | // ensuring the wrong window. 156 | if (!this.was_accepted) { 157 | // eslint-disable-next-line prefer-rest-params 158 | super._itemEnteredHandler.apply(this, arguments); 159 | } 160 | } 161 | 162 | _onDestroy() { 163 | super._onDestroy(); 164 | console.debug('#preview', 'onDestroy', this.was_accepted); 165 | Easer.addEase(this.fog, { 166 | time: Settings.prefs.animation_time, 167 | opacity: 0, 168 | onStopped: () => { 169 | this.fog.destroy(); 170 | this.fog = null; 171 | // this.space.cloneContainer.remove_effect(this.blur); 172 | this.clone && this.clone.destroy(); 173 | this.clone = null; 174 | this.space.moveDone(); 175 | }, 176 | }); 177 | let index = this.was_accepted ? this._selectedIndex : 0; 178 | let to = this._switcherList.windows[index]; 179 | Tiling.focus_handler(to); 180 | let actor = to.get_compositor_private(); 181 | if (this.was_accepted) { 182 | actor.x = this.clone.x; 183 | actor.y = this.clone.y; 184 | } 185 | actor.set_scale(1, 1); 186 | } 187 | }); 188 | -------------------------------------------------------------------------------- /media/default-focus-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/media/default-focus-mode.png -------------------------------------------------------------------------------- /media/default-star-winprop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/media/default-star-winprop.png -------------------------------------------------------------------------------- /media/focus-mode-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/media/focus-mode-button.png -------------------------------------------------------------------------------- /media/gesture-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/media/gesture-settings.png -------------------------------------------------------------------------------- /media/get-it-on-ego.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/gnome-pill-option.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/media/gnome-pill-option.png -------------------------------------------------------------------------------- /media/hide-focus-mode-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/media/hide-focus-mode-icon.png -------------------------------------------------------------------------------- /media/open-position-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/media/open-position-button.png -------------------------------------------------------------------------------- /media/topbar-styling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/media/topbar-styling.png -------------------------------------------------------------------------------- /media/window-indicator-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/media/window-indicator-bar.png -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "paperwm@paperwm.github.com", 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": [ "45", "46", "47", "48" ], 8 | "version-name": "48.0.1" 9 | } 10 | -------------------------------------------------------------------------------- /minimap.js: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import St from 'gi://St'; 3 | import Pango from 'gi://Pango'; 4 | 5 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 6 | 7 | import { Settings, Utils, Lib } from './imports.js'; 8 | import { Easer } from './utils.js'; 9 | 10 | export function calcOffset(metaWindow) { 11 | let buffer = metaWindow.get_buffer_rect(); 12 | let frame = metaWindow.get_frame_rect(); 13 | let x_offset = frame.x - buffer.x; 14 | let y_offset = frame.y - buffer.y; 15 | return [x_offset, y_offset]; 16 | } 17 | 18 | export class Minimap extends Array { 19 | constructor(space, monitor) { 20 | super(); 21 | this.space = space; 22 | // initial fade 23 | space.getWindows() 24 | .forEach(w => { 25 | w.clone?.shade?.show(); 26 | if (w === space.selectedWindow) { 27 | return; 28 | } 29 | 30 | Easer.addEase(w.clone?.shade, { 31 | time: Settings.prefs.animation_time, 32 | opacity: Settings.prefs.minimap_shade_opacity, 33 | }); 34 | }); 35 | 36 | this.monitor = monitor; 37 | let actor = new St.Widget({ 38 | name: 'minimap', 39 | style_class: 'paperwm-minimap switcher-list', 40 | }); 41 | this.actor = actor; 42 | actor.height = space.height * 0.20; 43 | 44 | let highlight = new St.Widget({ 45 | name: 'minimap-selection', 46 | style_class: 'paperwm-minimap-selection item-box', 47 | }); 48 | highlight.add_style_pseudo_class('selected'); 49 | this.highlight = highlight; 50 | let label = new St.Label({ style_class: 'paperwm-minimap-label' }); 51 | label.clutter_text.ellipsize = Pango.EllipsizeMode.END; 52 | this.label = label; 53 | 54 | let clip = new St.Widget({ name: 'container-clip' }); 55 | this.clip = clip; 56 | let container = new St.Widget({ name: 'minimap-container' }); 57 | this.container = container; 58 | 59 | actor.add_child(highlight); 60 | actor.add_child(label); 61 | actor.add_child(clip); 62 | clip.add_child(container); 63 | clip.set_position(12 + Settings.prefs.window_gap, 12 + Math.round(1.5 * Settings.prefs.window_gap)); 64 | highlight.y = clip.y - 10; 65 | Main.uiGroup.add_child(this.actor); 66 | this.actor.opacity = 0; 67 | this.createClones(); 68 | 69 | this.signals = new Utils.Signals(); 70 | this.signals.connect(space, 'select', this.select.bind(this)); 71 | this.signals.connect(space, 'window-added', this.addWindow.bind(this)); 72 | this.signals.connect(space, 'window-removed', this.removeWindow.bind(this)); 73 | this.signals.connect(space, 'layout', this.layout.bind(this)); 74 | this.signals.connect(space, 'swapped', this.swapped.bind(this)); 75 | this.signals.connect(space, 'full-layout', this.reset.bind(this)); 76 | 77 | this.layout(); 78 | } 79 | 80 | static get [Symbol.species]() { return Array; } 81 | 82 | reset() { 83 | this.splice(0, this.length).forEach(c => c.forEach(x => x.destroy())); 84 | this.createClones(); 85 | this.layout(); 86 | } 87 | 88 | addWindow(space, metaWindow, index, row) { 89 | let clone = this.createClone(metaWindow); 90 | if (row !== undefined && this[index]) { 91 | let column = this[index]; 92 | column.splice(row, 0, clone); 93 | } else { 94 | row = row || 0; 95 | this.splice(index, 0, [clone]); 96 | } 97 | this.layout(); 98 | } 99 | 100 | removeWindow(space, metaWindow, index, row) { 101 | let clone = this[index][row]; 102 | let column = this[index]; 103 | column.splice(row, 1); 104 | if (column.length === 0) 105 | this.splice(index, 1); 106 | // this.container.remove_child(clone); 107 | Utils.actor_remove_child(this.container, clone); 108 | this.layout(); 109 | } 110 | 111 | swapped(space, index, targetIndex, row, targetRow) { 112 | let column = this[index]; 113 | Lib.swap(this, index, targetIndex); 114 | Lib.swap(column, row, targetRow); 115 | this.layout(); 116 | } 117 | 118 | show(animate) { 119 | if (this.destroyed) 120 | return; 121 | 122 | // if minimap_scale preference is 0, then don't show 123 | if (Settings.prefs.minimap_scale <= 0) { 124 | return; 125 | } 126 | 127 | this.layout(); 128 | let time = animate ? Settings.prefs.animation_time : 0; 129 | this.actor.show(); 130 | Easer.addEase(this.actor, 131 | { opacity: 255, time, mode: Clutter.AnimationMode.EASE_OUT_EXPO }); 132 | } 133 | 134 | hide(animate) { 135 | if (this.destroyed) 136 | return; 137 | let time = animate ? Settings.prefs.animation_time : 0; 138 | Easer.addEase(this.actor, 139 | { 140 | opacity: 0, time, mode: Clutter.AnimationMode.EASE_OUT_EXPO, 141 | onComplete: () => this.actor.hide(), 142 | }); 143 | } 144 | 145 | createClones() { 146 | for (let column of this.space) { 147 | this.push(column.map(this.createClone.bind(this))); 148 | } 149 | } 150 | 151 | createClone(mw) { 152 | const windowActor = mw.get_compositor_private(); 153 | const clone = new Clutter.Clone({ source: windowActor }); 154 | const container = new Clutter.Actor({ 155 | // layout_manager: new WindowCloneLayout(this), 156 | name: "window-clone-container", 157 | }); 158 | clone.meta_window = mw; 159 | container.clone = clone; 160 | container.meta_window = mw; 161 | container.add_child(clone); 162 | this.container.add_child(container); 163 | return container; 164 | } 165 | 166 | _allocateClone(container) { 167 | let clone = container.clone; 168 | let meta_window = clone.meta_window; 169 | let buffer = meta_window.get_buffer_rect(); 170 | let frame = meta_window.get_frame_rect(); 171 | let scale = Settings.prefs.minimap_scale; 172 | clone.set_size(buffer.width * scale, buffer.height * scale - Settings.prefs.window_gap); 173 | clone.set_position((buffer.x - frame.x) * scale, (buffer.y - frame.y) * scale); 174 | container.set_size(frame.width * scale, frame.height * scale); 175 | } 176 | 177 | layout() { 178 | if (this.destroyed) 179 | return; 180 | let gap = Settings.prefs.window_gap; 181 | let x = 0; 182 | for (let column of this) { 183 | let y = 0, w = 0; 184 | for (let c of column) { 185 | c.set_position(x, y); 186 | this._allocateClone(c); 187 | w = Math.max(w, c.width); 188 | y += c.height; 189 | } 190 | x += w + gap; 191 | } 192 | 193 | this.clip.width = Math.min(this.container.width, 194 | this.monitor.width - this.clip.x * 2 - 24); 195 | this.actor.width = this.clip.width + this.clip.x * 2; 196 | this.clip.set_clip(0, 0, this.clip.width, this.clip.height); 197 | this.label.set_style(`max-width: ${this.clip.width}px;`); 198 | this.actor.set_position( 199 | this.monitor.x + Math.floor((this.monitor.width - this.actor.width) / 2), 200 | this.monitor.y + Math.floor((this.monitor.height - this.actor.height) / 2)); 201 | this.select(); 202 | } 203 | 204 | select() { 205 | let position = this.space.positionOf(); 206 | let highlight = this.highlight; 207 | if (!position) { 208 | this.highlight.hide(); 209 | return; 210 | } 211 | let [index, row] = position; 212 | if (!(index in this && row in this[index])) 213 | return; 214 | highlight.show(); 215 | let clip = this.clip; 216 | let container = this.container; 217 | let label = this.label; 218 | let selected = this[index][row]; 219 | if (!selected) 220 | return; 221 | 222 | this.space.getWindows().forEach(w => { 223 | const shade = w.clone.shade; 224 | // if selected 225 | if (w === selected.meta_window) { 226 | shade.opacity = 0; 227 | return; 228 | } 229 | 230 | // others 231 | Easer.addEase(shade, { 232 | time: Settings.prefs.animation_time, 233 | opacity: Settings.prefs.minimap_shade_opacity, 234 | }); 235 | }); 236 | 237 | label.text = selected.meta_window.title; 238 | 239 | if (selected.x + selected.width + container.x > clip.width) { 240 | // Align right edge of selected with the clip 241 | container.x = clip.width - (selected.x + selected.width); 242 | container.x -= 500; // margin 243 | } 244 | if (selected.x + container.x < 0) { 245 | // Align left edge of selected with the clip 246 | container.x = -selected.x; 247 | container.x += 500; // margin 248 | } 249 | 250 | if (container.x + container.width < clip.width) 251 | container.x = clip.width - container.width; 252 | 253 | if (container.x > 0) 254 | container.x = 0; 255 | 256 | let gap = Settings.prefs.window_gap; 257 | highlight.x = Math.round( 258 | clip.x + container.x + selected.x - gap / 2); 259 | highlight.y = Math.round( 260 | clip.y + selected.y - Settings.prefs.window_gap); 261 | highlight.set_size(Math.round(selected.width + gap), 262 | Math.round(Math.min(selected.height, this.clip.height + gap) + gap)); 263 | 264 | let x = highlight.x + (highlight.width - label.width) / 2; 265 | if (x + label.width > clip.x + clip.width) 266 | x = clip.x + clip.width - label.width + 5; 267 | if (x < 0) 268 | x = clip.x - 5; 269 | 270 | label.set_position(Math.round(x), this.clip.y + this.clip.height + 8); 271 | this.actor.height = this.clip.y + this.clip.height + 40; 272 | } 273 | 274 | destroy() { 275 | if (this.destroyed) 276 | return; 277 | this.space.getWindows() 278 | .forEach(w => { 279 | Easer.addEase(w.clone?.shade, { 280 | time: Settings.prefs.animation_time, 281 | opacity: 0, 282 | onComplete: () => w.clone?.shade.hide(), 283 | }); 284 | }); 285 | this.destroyed = true; 286 | this.signals.destroy(); 287 | this.signals = null; 288 | this.splice(0, this.length); 289 | this.actor.destroy(); 290 | this.actor = null; 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /overviewlayout.js: -------------------------------------------------------------------------------- 1 | import * as Workspace from 'resource:///org/gnome/shell/ui/workspace.js'; 2 | import * as Util from 'resource:///org/gnome/shell/misc/util.js'; 3 | import * as Params from 'resource:///org/gnome/shell/misc/params.js'; 4 | 5 | import { Settings, Tiling } from './imports.js'; 6 | 7 | /** 8 | * Gnome 45's UnalignedLayoutStrategy is not exported. Hence, we recreate this class 9 | * with modifications to ensure window ordering reflects tiling window order in overview. 10 | * 11 | * See https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/gnome-45/js/ui/workspace.js 12 | */ 13 | export class UnalignedLayoutStrategy extends Workspace.LayoutStrategy { 14 | _newRow() { 15 | // Row properties: 16 | // 17 | // * x, y are the position of row, relative to area 18 | // 19 | // * width, height are the scaled versions of fullWidth, fullHeight 20 | // 21 | // * width also has the spacing in between windows. It's not in 22 | // fullWidth, as the spacing is constant, whereas fullWidth is 23 | // meant to be scaled 24 | // 25 | // * neither height/fullHeight have any sort of spacing or padding 26 | return { 27 | x: 0, y: 0, 28 | width: 0, height: 0, 29 | fullWidth: 0, fullHeight: 0, 30 | windows: [], 31 | }; 32 | } 33 | 34 | // Computes and returns an individual scaling factor for @window, 35 | // to be applied in addition to the overall layout scale. 36 | _computeWindowScale(window) { 37 | // Since we align windows next to each other, the height of the 38 | // thumbnails is much more important to preserve than the width of 39 | // them, so two windows with equal height, but maybe differering 40 | // widths line up. 41 | let ratio = window.boundingBox.height / this._monitor.height; 42 | 43 | // The purpose of this manipulation here is to prevent windows 44 | // from getting too small. For something like a calculator window, 45 | // we need to bump up the size just a bit to make sure it looks 46 | // good. We'll use a multiplier of 1.5 for this. 47 | 48 | // Map from [0, 1] to [1.5, 1] 49 | return Util.lerp(1.5, 1, ratio); 50 | } 51 | 52 | _computeRowSizes(layout) { 53 | let { rows, scale } = layout; 54 | for (let i = 0; i < rows.length; i++) { 55 | let row = rows[i]; 56 | row.width = row.fullWidth * scale + (row.windows.length - 1) * this._columnSpacing; 57 | row.height = row.fullHeight * scale; 58 | } 59 | } 60 | 61 | _keepSameRow(row, window, width, idealRowWidth) { 62 | // enforce a minimum number of windows per overview row 63 | if (row.windows.length < Settings.prefs.overview_min_windows_per_row) { 64 | return true; 65 | } 66 | 67 | if (row.fullWidth + width <= idealRowWidth) 68 | return true; 69 | 70 | let oldRatio = row.fullWidth / idealRowWidth; 71 | let newRatio = (row.fullWidth + width) / idealRowWidth; 72 | 73 | if (Math.abs(1 - newRatio) < Math.abs(1 - oldRatio)) 74 | return true; 75 | 76 | return false; 77 | } 78 | 79 | computeLayout(windows, layoutParams) { 80 | layoutParams = Params.parse(layoutParams, { 81 | numRows: 0, 82 | }); 83 | 84 | if (layoutParams.numRows === 0) 85 | throw new Error(`${this.constructor.name}: No numRows given in layout params`); 86 | 87 | let numRows = layoutParams.numRows; 88 | 89 | let rows = []; 90 | let totalWidth = 0; 91 | for (let i = 0; i < windows.length; i++) { 92 | let window = windows[i]; 93 | let s = this._computeWindowScale(window); 94 | totalWidth += window.boundingBox.width * s; 95 | } 96 | 97 | let idealRowWidth = totalWidth / numRows; 98 | 99 | let sortedWindows = windows.slice(); 100 | // sorting needs to be done here to address moved windows 101 | sortedWindows.sort(sortWindows); 102 | 103 | let windowIdx = 0; 104 | for (let i = 0; i < numRows; i++) { 105 | let row = this._newRow(); 106 | rows.push(row); 107 | 108 | for (; windowIdx < sortedWindows.length; windowIdx++) { 109 | let window = sortedWindows[windowIdx]; 110 | let s = this._computeWindowScale(window); 111 | let width = window.boundingBox.width * s; 112 | let height = window.boundingBox.height * s; 113 | row.fullHeight = Math.max(row.fullHeight, height); 114 | 115 | // either new width is < idealWidth or new width is nearer from idealWidth then oldWidth 116 | if (this._keepSameRow(row, window, width, idealRowWidth) || (i === numRows - 1)) { 117 | row.windows.push(window); 118 | row.fullWidth += width; 119 | } else { 120 | break; 121 | } 122 | } 123 | } 124 | 125 | let gridHeight = 0; 126 | let maxRow; 127 | for (let i = 0; i < numRows; i++) { 128 | let row = rows[i]; 129 | 130 | if (!maxRow || row.fullWidth > maxRow.fullWidth) 131 | maxRow = row; 132 | gridHeight += row.fullHeight; 133 | } 134 | 135 | return { 136 | numRows, 137 | rows, 138 | maxColumns: maxRow.windows.length, 139 | gridWidth: maxRow.fullWidth, 140 | gridHeight, 141 | }; 142 | } 143 | 144 | computeScaleAndSpace(layout, area) { 145 | let hspacing = (layout.maxColumns - 1) * this._columnSpacing; 146 | let vspacing = (layout.numRows - 1) * this._rowSpacing; 147 | 148 | let spacedWidth = area.width - hspacing; 149 | let spacedHeight = area.height - vspacing; 150 | 151 | let horizontalScale = spacedWidth / layout.gridWidth; 152 | let verticalScale = spacedHeight / layout.gridHeight; 153 | 154 | // Thumbnails should be less than 70% of the original size 155 | let scale = Math.min( 156 | horizontalScale, verticalScale, Settings.prefs.overview_max_window_scale); 157 | 158 | let scaledLayoutWidth = layout.gridWidth * scale + hspacing; 159 | let scaledLayoutHeight = layout.gridHeight * scale + vspacing; 160 | let space = (scaledLayoutWidth * scaledLayoutHeight) / (area.width * area.height); 161 | 162 | layout.scale = scale; 163 | 164 | return [scale, space]; 165 | } 166 | 167 | computeWindowSlots(layout, area) { 168 | this._computeRowSizes(layout); 169 | 170 | let { rows, scale } = layout; 171 | 172 | let slots = []; 173 | 174 | // Do this in three parts. 175 | let heightWithoutSpacing = 0; 176 | for (let i = 0; i < rows.length; i++) { 177 | let row = rows[i]; 178 | heightWithoutSpacing += row.height; 179 | } 180 | 181 | let verticalSpacing = (rows.length - 1) * this._rowSpacing; 182 | let additionalVerticalScale = Math.min(1, (area.height - verticalSpacing) / heightWithoutSpacing); 183 | 184 | // keep track how much smaller the grid becomes due to scaling 185 | // so it can be centered again 186 | let compensation = 0; 187 | let y = 0; 188 | 189 | for (let i = 0; i < rows.length; i++) { 190 | let row = rows[i]; 191 | 192 | // If this window layout row doesn't fit in the actual 193 | // geometry, then apply an additional scale to it. 194 | let horizontalSpacing = (row.windows.length - 1) * this._columnSpacing; 195 | let widthWithoutSpacing = row.width - horizontalSpacing; 196 | let additionalHorizontalScale = Math.min(1, (area.width - horizontalSpacing) / widthWithoutSpacing); 197 | 198 | if (additionalHorizontalScale < additionalVerticalScale) { 199 | row.additionalScale = additionalHorizontalScale; 200 | // Only consider the scaling in addition to the vertical scaling for centering. 201 | compensation += (additionalVerticalScale - additionalHorizontalScale) * row.height; 202 | } else { 203 | row.additionalScale = additionalVerticalScale; 204 | // No compensation when scaling vertically since centering based on a too large 205 | // height would undo what vertical scaling is trying to achieve. 206 | } 207 | 208 | row.x = area.x + (Math.max(area.width - (widthWithoutSpacing * row.additionalScale + horizontalSpacing), 0) / 2); 209 | row.y = area.y + (Math.max(area.height - (heightWithoutSpacing + verticalSpacing), 0) / 2) + y; 210 | y += row.height * row.additionalScale + this._rowSpacing; 211 | } 212 | 213 | compensation /= 2; 214 | 215 | for (let i = 0; i < rows.length; i++) { 216 | const row = rows[i]; 217 | const rowY = row.y + compensation; 218 | const rowHeight = row.height * row.additionalScale; 219 | 220 | let x = row.x; 221 | for (let j = 0; j < row.windows.length; j++) { 222 | let window = row.windows[j]; 223 | 224 | let s = scale * this._computeWindowScale(window) * row.additionalScale; 225 | let cellWidth = window.boundingBox.width * s; 226 | let cellHeight = window.boundingBox.height * s; 227 | 228 | s = Math.min(s, Settings.prefs.overview_max_window_scale); 229 | let cloneWidth = window.boundingBox.width * s; 230 | const cloneHeight = window.boundingBox.height * s; 231 | 232 | let cloneX = x + (cellWidth - cloneWidth) / 2; 233 | let cloneY; 234 | 235 | // If there's only one row, align windows vertically centered inside the row 236 | if (rows.length === 1) 237 | cloneY = rowY + (rowHeight - cloneHeight) / 2; 238 | // If there are multiple rows, align windows to the bottom edge of the row 239 | else 240 | cloneY = rowY + rowHeight - cellHeight; 241 | 242 | // Align with the pixel grid to prevent blurry windows at scale = 1 243 | cloneX = Math.floor(cloneX); 244 | cloneY = Math.floor(cloneY); 245 | 246 | slots.push([cloneX, cloneY, cloneWidth, cloneHeight, window]); 247 | x += cellWidth + this._columnSpacing; 248 | } 249 | } 250 | return slots; 251 | } 252 | } 253 | 254 | /** 255 | * Ensures windows are sorted correctly in overview (correctly being the tiled order in the space). 256 | */ 257 | export function sortWindows(a, b) { 258 | let aw = a.metaWindow; 259 | let bw = b.metaWindow; 260 | if (!aw && !bw) { 261 | return 0; 262 | } 263 | if (!aw) { 264 | return -1; 265 | } 266 | if (!bw) { 267 | return 1; 268 | } 269 | 270 | let spaceA = Tiling.spaces.spaceOfWindow(aw); 271 | let spaceB = Tiling.spaces.spaceOfWindow(bw); 272 | let ia = spaceA.indexOf(aw); 273 | let ib = spaceB.indexOf(bw); 274 | if (ia === -1 && ib === -1) { 275 | return aw.get_stable_sequence() - bw.get_stable_sequence(); 276 | } 277 | if (ia === -1) { 278 | return -1; 279 | } 280 | if (ib === -1) { 281 | return 1; 282 | } 283 | return ia - ib; 284 | } 285 | -------------------------------------------------------------------------------- /resources/ICONS.info: -------------------------------------------------------------------------------- 1 | SVG icons adapted from "GNOME Project" adwaita-icon-theme (https://github.com/GNOME/adwaita-icon-theme): 2 | 3 | - `sidebar-show-right-symbolic.svg` 4 | - `preferences-desktop-multitasking-symbolic.svg` 5 | - `zoom-fit-best-symbolic.svg` 6 | 7 | See http://www.gnome.org for more information. 8 | 9 | The icons are licensed under Creative Commons Attribution-Share Alike 3.0 United States License. 10 | 11 | To view a copy of the CC-BY-SA licence, visit 12 | http://creativecommons.org/licenses/by-sa/3.0/ or send a letter to Creative 13 | Commons, 171 Second Street, Suite 300, San Francisco, California 94105, USA. 14 | -------------------------------------------------------------------------------- /resources/focus-mode-center-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/focus-mode-default-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /resources/focus-mode-edge-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 28 | 30 | 50 | 55 | 61 | 67 | 68 | 73 | 80 | 87 | 88 | -------------------------------------------------------------------------------- /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/resources/logo.png -------------------------------------------------------------------------------- /resources/open-position-down-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/open-position-end-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/open-position-left-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/open-position-right-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/open-position-start-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/open-position-up-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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.winprops > row .header, 20 | list.combos > row { 21 | padding: 8px 12px; 22 | min-height: 32px; 23 | } 24 | 25 | list.keybindings > box { 26 | background-color: @theme_fg_color; 27 | } 28 | 29 | list.keybindings > box > label { 30 | color: @theme_base_color; 31 | font-size: larger; 32 | margin-top: 16px; 33 | margin-bottom: 16px; 34 | } 35 | 36 | list.keybindings > row .header:hover { 37 | background-color: alpha(@theme_fg_color, 0.10); 38 | } 39 | 40 | list.keybindings > row .header:hover:backdrop { 41 | background-color: alpha(@theme_unfocused_fg_color, 0.10); 42 | } 43 | 44 | list.keybindings > row.expanded label.description { 45 | font-weight: bold; 46 | } 47 | 48 | list.combos { 49 | background-color: transparent; 50 | } 51 | 52 | list.combos > .editing { 53 | background-color: @theme_selected_bg_color; 54 | color: @theme_selected_fg_color; 55 | } 56 | 57 | .winprops .option-list { 58 | background-color: transparent; 59 | } 60 | -------------------------------------------------------------------------------- /schemas/Makefile: -------------------------------------------------------------------------------- 1 | gschemas.compiled: phony 2 | glib-compile-schemas . 3 | 4 | .PHONY: phony 5 | -------------------------------------------------------------------------------- /schemas/gschemas.compiled: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paperwm/PaperWM/c15fceee98109f3d2900aaac943dd6fd2bfd718b/schemas/gschemas.compiled -------------------------------------------------------------------------------- /scratch.js: -------------------------------------------------------------------------------- 1 | import Meta from 'gi://Meta'; 2 | import Mtk from 'gi://Mtk'; 3 | 4 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 5 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 6 | import * as WindowMenu from 'resource:///org/gnome/shell/ui/windowMenu.js'; 7 | 8 | import { Settings, Utils, Tiling, Topbar } from './imports.js'; 9 | import { Easer } from './utils.js'; 10 | 11 | let originalBuildMenu; 12 | let float, scratchFrame; // symbols used for expando properties on metawindow 13 | export function enable() { 14 | originalBuildMenu = WindowMenu.WindowMenu.prototype._buildMenu; 15 | float = Symbol(); 16 | scratchFrame = Symbol(); 17 | WindowMenu.WindowMenu.prototype._buildMenu = 18 | function (window) { 19 | let item; 20 | item = this.addAction(_('Scratch'), () => { 21 | toggle(window); 22 | }); 23 | if (isScratchWindow(window)) 24 | item.setOrnament(PopupMenu.Ornament.CHECK); 25 | 26 | originalBuildMenu.call(this, window); 27 | }; 28 | } 29 | 30 | export function disable() { 31 | WindowMenu.WindowMenu.prototype._buildMenu = originalBuildMenu; 32 | originalBuildMenu = null; 33 | float = null; 34 | scratchFrame = null; 35 | } 36 | 37 | /** 38 | Tween window to "frame-coordinate" (targetX, targetY). 39 | The frame is moved once the tween is done. 40 | 41 | The actual window actor (not clone) is tweened to ensure it's on top of the 42 | other windows/clones (clones if the space animates) 43 | */ 44 | export function easeScratch(metaWindow, targetX, targetY, params = {}) { 45 | const complete = params?.onComplete ?? function() {}; 46 | const f = metaWindow.get_frame_rect(); 47 | const b = metaWindow.get_buffer_rect(); 48 | const dx = f.x - b.x; 49 | const dy = f.y - b.y; 50 | 51 | Easer.addEase(metaWindow.get_compositor_private(), { 52 | x: targetX - dx, 53 | y: targetY - dy, 54 | time: Settings.prefs.animation_time, 55 | onComplete: () => { 56 | metaWindow.move_frame(true, targetX, targetY); 57 | complete(); 58 | }, 59 | }); 60 | } 61 | 62 | export function makeScratch(metaWindow) { 63 | let fromNonScratch = !metaWindow[float]; 64 | let fromTiling = false; 65 | // Relevant when called while navigating. Use the position the user actually sees. 66 | let windowPositionSeen; 67 | 68 | if (fromNonScratch) { 69 | // Figure out some stuff before the window is removed from the tiling 70 | let space = Tiling.spaces.spaceOfWindow(metaWindow); 71 | fromTiling = space.indexOf(metaWindow) > -1; 72 | if (fromTiling) { 73 | windowPositionSeen = metaWindow.clone 74 | .get_transformed_position() 75 | .map(Math.round); 76 | } 77 | } 78 | 79 | metaWindow[float] = true; 80 | metaWindow.make_above(); 81 | metaWindow.stick(); // NB! Removes the window from the tiling (synchronously) 82 | 83 | if (!metaWindow.minimized) 84 | Tiling.showWindow(metaWindow); 85 | 86 | if (fromTiling) { 87 | let f = metaWindow.get_frame_rect(); 88 | let targetFrame = null; 89 | 90 | if (metaWindow[scratchFrame]) { 91 | let sf = metaWindow[scratchFrame]; 92 | if (Utils.monitorOfPoint(sf.x, sf.y) === Tiling.focusMonitor()) { 93 | targetFrame = sf; 94 | } 95 | } 96 | 97 | if (!targetFrame) { 98 | // Default to moving the window slightly down and reducing the height 99 | let vDisplacement = 30; 100 | let [x, y] = windowPositionSeen; // The window could be non-placable so can't use frame 101 | 102 | targetFrame = new Mtk.Rectangle({ 103 | x, y: y + vDisplacement, 104 | width: f.width, 105 | height: Math.min(f.height - vDisplacement, Math.floor(f.height * 0.9)), 106 | }); 107 | } 108 | 109 | if (!metaWindow.minimized) { 110 | metaWindow.move_resize_frame(true, f.x, f.y, 111 | targetFrame.width, targetFrame.height); 112 | easeScratch( 113 | metaWindow, 114 | targetFrame.x, 115 | targetFrame.y, 116 | { 117 | onComplete: () => { 118 | delete metaWindow[scratchFrame]; 119 | Main.activateWindow(metaWindow); 120 | }, 121 | }); 122 | } else { 123 | // Can't restore the scratch geometry immediately since it distort the minimize animation 124 | // ASSUMPTION: minimize animation is not disabled and not already done 125 | let actor = metaWindow.get_compositor_private(); 126 | let signal = actor.connect('effects-completed', () => { 127 | metaWindow.move_resize_frame(true, targetFrame.x, targetFrame.y, 128 | targetFrame.width, targetFrame.height); 129 | actor.disconnect(signal); 130 | }); 131 | } 132 | } 133 | 134 | Tiling.focusMonitor()?.clickOverlay?.hide(); 135 | } 136 | 137 | export function unmakeScratch(metaWindow) { 138 | if (!metaWindow[scratchFrame]) 139 | metaWindow[scratchFrame] = metaWindow.get_frame_rect(); 140 | metaWindow[float] = false; 141 | metaWindow.unmake_above(); 142 | metaWindow.unstick(); 143 | } 144 | 145 | export function toggle(metaWindow) { 146 | if (isScratchWindow(metaWindow)) { 147 | unmakeScratch(metaWindow); 148 | } else { 149 | makeScratch(metaWindow); 150 | 151 | if (metaWindow.has_focus) { 152 | let space = Tiling.spaces.activeSpace; 153 | space.setSelectionInactive(); 154 | } 155 | } 156 | } 157 | 158 | export function isScratchWindow(metaWindow) { 159 | return metaWindow && metaWindow[float]; 160 | } 161 | 162 | /** Return scratch windows in MRU order */ 163 | export function getScratchWindows() { 164 | return global.display.get_tab_list(Meta.TabList.NORMAL, null) 165 | .filter(isScratchWindow); 166 | } 167 | 168 | export function isScratchActive() { 169 | return getScratchWindows().some(metaWindow => !metaWindow.minimized); 170 | } 171 | 172 | export function toggleScratch() { 173 | if (isScratchActive()) 174 | hide(); 175 | else 176 | show(); 177 | } 178 | 179 | export function toggleScratchWindow() { 180 | let focus = global.display.focus_window; 181 | if (isScratchWindow(focus)) 182 | hide(); 183 | else 184 | show(true); 185 | } 186 | 187 | export function show(top) { 188 | let windows = getScratchWindows(); 189 | if (windows.length === 0) { 190 | return; 191 | } 192 | if (top) 193 | windows = windows.slice(0, 1); 194 | 195 | Topbar.fixTopBar(); 196 | 197 | windows.slice().reverse() 198 | .map(function(meta_window) { 199 | meta_window.unminimize(); 200 | meta_window.make_above(); 201 | meta_window.get_compositor_private().show(); 202 | }); 203 | windows[0].activate(global.get_current_time()); 204 | 205 | let monitor = Tiling.focusMonitor(); 206 | monitor.clickOverlay?.hide(); 207 | } 208 | 209 | export function hide() { 210 | let windows = getScratchWindows(); 211 | windows.map(function(meta_window) { 212 | meta_window.minimize(); 213 | }); 214 | } 215 | 216 | export function animateWindows() { 217 | let ws = getScratchWindows().filter(w => !w.minimized); 218 | ws = global.display.sort_windows_by_stacking(ws); 219 | for (let w of ws) { 220 | // let parent = w.clone.get_parent(); 221 | // parent && parent.remove_child(w.clone); 222 | Utils.actor_remove_parent(w.clone); 223 | 224 | Main.uiGroup.insert_child_above(w.clone, global.window_group); 225 | let f = w.get_frame_rect(); 226 | w.clone.set_position(f.x, f.y); 227 | Tiling.animateWindow(w); 228 | } 229 | } 230 | 231 | export function showWindows() { 232 | let ws = getScratchWindows().filter(w => !w.minimized); 233 | ws.forEach(Tiling.showWindow); 234 | } 235 | -------------------------------------------------------------------------------- /settings.js: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import GLib from 'gi://GLib'; 3 | 4 | import { AcceleratorParse } from './acceleratorparse.js'; 5 | 6 | /** 7 | Settings utility shared between the running extension and the preference UI. 8 | settings.js shouldn't depend on other modules (e.g with `imports` for other modules 9 | at the top). 10 | */ 11 | 12 | const KEYBINDINGS_KEY = 'org.gnome.shell.extensions.paperwm.keybindings'; 13 | const RESTORE_KEYBINDS_KEY = 'restore-keybinds'; 14 | 15 | // This is the value mutter uses for the keyvalue of above_tab 16 | const META_KEY_ABOVE_TAB = 0x2f7259c9; 17 | 18 | // position to open window at (e.g. to the right of current window) 19 | export const OpenWindowPositions = { RIGHT: 0, LEFT: 1, START: 2, END: 3, DOWN: 4, UP: 5 }; 20 | 21 | // Animation used when ensuring viewport on a window 22 | export const EnsureViewportAnimation = { NONE: 0, TRANSLATE: 1, FADE: 2 }; 23 | 24 | export let prefs; 25 | let gsettings, keybindSettings, _overriddingConflicts; 26 | let acceleratorParse; 27 | export function enable(extension) { 28 | gsettings = extension.getSettings(); 29 | keybindSettings = extension.getSettings(KEYBINDINGS_KEY); 30 | 31 | acceleratorParse = new AcceleratorParse(); 32 | _overriddingConflicts = false; 33 | prefs = {}; 34 | [ 35 | 'window-gap', 36 | 'vertical-margin', 37 | 'vertical-margin-bottom', 38 | 'horizontal-margin', 39 | 'workspace-colors', 40 | 'default-background', 41 | 'animation-time', 42 | 'drift-speed', 43 | 'drag-drift-speed', 44 | 'default-show-top-bar', 45 | 'swipe-sensitivity', 46 | 'swipe-friction', 47 | 'cycle-width-steps', 48 | 'cycle-height-steps', 49 | 'maximize-width-percent', 50 | 'maximize-within-tiling', 51 | 'minimap-scale', 52 | 'edge-preview-enable', 53 | 'edge-preview-scale', 54 | 'edge-preview-click-enable', 55 | 'edge-preview-timeout-enable', 56 | 'edge-preview-timeout', 57 | 'edge-preview-timeout-continual', 58 | 'window-switcher-preview-scale', 59 | 'winprops', 60 | 'show-workspace-indicator', 61 | 'show-window-position-bar', 62 | 'show-focus-mode-icon', 63 | 'show-open-position-icon', 64 | 'topbar-mouse-scroll-enable', 65 | 'disable-topbar-styling', 66 | 'default-focus-mode', 67 | 'gesture-enabled', 68 | 'gesture-horizontal-fingers', 69 | 'gesture-workspace-fingers', 70 | 'open-window-position', 71 | 'overview-ensure-viewport-animation', 72 | 'overview-min-windows-per-row', 73 | 'overview-max-window-scale', 74 | 'minimap-shade-opacity', 75 | 'selection-border-size', 76 | 'selection-border-radius-top', 77 | 'selection-border-radius-bottom', 78 | 'open-window-position-option-right', 79 | 'open-window-position-option-left', 80 | 'open-window-position-option-start', 81 | 'open-window-position-option-end', 82 | 'open-window-position-option-down', 83 | 'open-window-position-option-up', 84 | ] 85 | .forEach(k => setState(null, k)); 86 | prefs.__defineGetter__("minimum_margin", () => { 87 | return Math.min(15, prefs.horizontal_margin); 88 | }); 89 | gsettings.connect('changed', setState); 90 | 91 | // connect to settings and update winprops array when it's updated 92 | gsettings.connect('changed::winprops', () => reloadWinpropsFromGSettings()); 93 | 94 | // A intermediate window is created before the prefs dialog is created. 95 | // Prevent it from being inserted into the tiling causing flickering and general disorder 96 | defwinprop({ 97 | wm_class: "Gnome-shell-extension-prefs", 98 | scratch_layer: true, 99 | focus: true, 100 | }); 101 | defwinprop({ 102 | wm_class: /gnome-screenshot/i, 103 | scratch_layer: true, 104 | focus: true, 105 | }); 106 | 107 | addWinpropsFromGSettings(); 108 | } 109 | 110 | export function disable() { 111 | gsettings = null; 112 | acceleratorParse = null; 113 | _overriddingConflicts = null; 114 | prefs = null; 115 | conflictSettings = null; 116 | } 117 | 118 | export function setState($, key) { 119 | let value = gsettings.get_value(key); 120 | let name = key.replace(/-/g, '_'); 121 | prefs[name] = value.deep_unpack(); 122 | } 123 | 124 | export let conflictSettings; // exported 125 | export function getConflictSettings() { 126 | if (!conflictSettings) { 127 | // Schemas that may contain conflicting keybindings 128 | conflictSettings = []; 129 | addSchemaToConflictSettings('org.gnome.mutter.keybindings'); 130 | addSchemaToConflictSettings('org.gnome.mutter.wayland.keybindings'); 131 | addSchemaToConflictSettings('org.gnome.desktop.wm.keybindings'); 132 | addSchemaToConflictSettings('org.gnome.shell.keybindings'); 133 | 134 | // below schemas are checked but may not exist in all distributions 135 | addSchemaToConflictSettings('org.gnome.settings-daemon.plugins.media-keys', false); 136 | // ubuntu tiling-assistant (enabled by default on Ubuntu 23.10) 137 | addSchemaToConflictSettings('org.gnome.shell.extensions.tiling-assistant', false); 138 | } 139 | 140 | return conflictSettings; 141 | } 142 | 143 | /** 144 | * Adds a Gio.Settings object to conflictSettings. Fails gracefully. 145 | * @param {Gio.Settings} schemaId 146 | */ 147 | export function addSchemaToConflictSettings(schemaId, warn = true) { 148 | try { 149 | conflictSettings.push(new Gio.Settings({ schema_id: schemaId })); 150 | } 151 | catch (e) { 152 | if (warn) { 153 | console.warn(`Invalid schema_id '${schemaId}': could not add to keybind conflict checks`); 154 | } 155 | } 156 | } 157 | 158 | // / Keybindings 159 | 160 | export function accelerator_parse(keystr) { 161 | return acceleratorParse.accelerator_parse(keystr); 162 | } 163 | 164 | /** 165 | * Two keystrings can represent the same key combination 166 | */ 167 | export function keystrToKeycombo(keystr) { 168 | // Above_Tab is a fake keysymbol provided by mutter 169 | let aboveTab = false; 170 | if (keystr.match(/Above_Tab/) || keystr.match(/grave/)) { 171 | keystr = keystr.replace('Above_Tab', 'a'); 172 | aboveTab = true; 173 | } 174 | 175 | let [, key, mask] = accelerator_parse(keystr); 176 | if (aboveTab) 177 | key = META_KEY_ABOVE_TAB; 178 | return `${key}|${mask}`; // Since js doesn't have a mapable tuple type 179 | } 180 | 181 | export function generateKeycomboMap(settings) { 182 | let map = {}; 183 | for (let name of settings.list_keys()) { 184 | let value = settings.get_value(name); 185 | if (value.get_type_string() !== 'as') 186 | continue; 187 | 188 | for (let combo of value.deep_unpack().map(keystrToKeycombo)) { 189 | if (combo === '0|0') 190 | continue; 191 | if (map[combo]) { 192 | map[combo].push(name); 193 | } else { 194 | map[combo] = [name]; 195 | } 196 | } 197 | } 198 | return map; 199 | } 200 | 201 | export function findConflicts(schemas) { 202 | schemas = schemas || getConflictSettings(); 203 | let conflicts = []; 204 | const paperMap = generateKeycomboMap(keybindSettings); 205 | 206 | for (let settings of schemas) { 207 | const against = generateKeycomboMap(settings); 208 | for (let combo in paperMap) { 209 | if (against[combo]) { 210 | conflicts.push({ 211 | name: paperMap[combo][0], 212 | conflicts: against[combo], 213 | settings, combo, 214 | }); 215 | } 216 | } 217 | } 218 | return conflicts; 219 | } 220 | 221 | /** 222 | * Returns / reconstitutes saved overrides list. 223 | */ 224 | export function getSavedOverrides() { 225 | let saveListJson = gsettings.get_string(RESTORE_KEYBINDS_KEY); 226 | let saveList; 227 | try { 228 | saveList = new Map(Object.entries(JSON.parse(saveListJson))); 229 | } catch (error) { 230 | saveList = new Map(); 231 | } 232 | return saveList; 233 | } 234 | 235 | /** 236 | * Saves an overrides list. 237 | */ 238 | export function saveOverrides(overrides) { 239 | gsettings.set_string(RESTORE_KEYBINDS_KEY, JSON.stringify(Object.fromEntries(overrides))); 240 | } 241 | 242 | export function conflictKeyChanged(settings, key) { 243 | if (_overriddingConflicts) { 244 | return; 245 | } 246 | 247 | const newKeybind = settings.get_value(key).deep_unpack(); 248 | if (Array.isArray(newKeybind) && newKeybind.length === 0) { 249 | return; 250 | } 251 | 252 | const saveList = getSavedOverrides(); 253 | saveList.delete(key); 254 | saveOverrides(saveList); 255 | 256 | // check for new conflicts 257 | return overrideConflicts(key); 258 | } 259 | 260 | /** 261 | * Override conflicts and save original values for restore. 262 | */ 263 | export function overrideConflicts(checkKey = null) { 264 | if (_overriddingConflicts) { 265 | return; 266 | } 267 | 268 | _overriddingConflicts = true; 269 | let saveList = getSavedOverrides(); 270 | 271 | // restore orignal keybinds prior to conflict overriding 272 | restoreConflicts(); 273 | 274 | let disableAll = []; 275 | const foundConflicts = findConflicts(); 276 | for (let conflict of foundConflicts) { 277 | // save conflicts (list of names of conflicting keybinds) 278 | let { conflicts, settings } = conflict; 279 | 280 | conflicts.forEach(c => { 281 | // get current value 282 | const keybind = settings.get_value(c); 283 | saveList.set(c, { 284 | bind: JSON.stringify(keybind.deep_unpack()), 285 | schema_id: settings.schema_id, 286 | }); 287 | 288 | // now disable conflict 289 | disableAll.push(() => settings.set_value(c, new GLib.Variant('as', []))); 290 | }); 291 | } 292 | 293 | // save override list 294 | saveOverrides(saveList); 295 | 296 | // now disable all conflicts 297 | disableAll.forEach(d => d()); 298 | _overriddingConflicts = false; 299 | 300 | return checkKey ? saveList.has(checkKey) : false; 301 | } 302 | 303 | /** 304 | * Update overrides to their current keybinds. 305 | */ 306 | export function updateOverrides() { 307 | let saveList = getSavedOverrides(); 308 | saveList.forEach((saved, key) => { 309 | const settings = getConflictSettings().find(s => s.schema_id === saved.schema_id); 310 | if (settings) { 311 | const newKeybind = settings.get_value(key).deep_unpack(); 312 | if (Array.isArray(newKeybind) && newKeybind.length === 0) { 313 | return; 314 | } 315 | 316 | saveList.set(key, { 317 | bind: JSON.stringify(newKeybind), 318 | schema_id: settings.schema_id, 319 | }); 320 | } 321 | }); 322 | 323 | // save override list 324 | saveOverrides(saveList); 325 | } 326 | 327 | /** 328 | * Restores previously overridden conflicts. 329 | */ 330 | export function restoreConflicts() { 331 | let saveList = getSavedOverrides(); 332 | const toRemove = []; 333 | saveList.forEach((saved, key) => { 334 | const settings = getConflictSettings().find(s => s.schema_id === saved.schema_id); 335 | if (settings) { 336 | const keybind = JSON.parse(saved.bind); 337 | toRemove.push({ key, remove: () => settings.set_value(key, new GLib.Variant('as', keybind)) }); 338 | } 339 | }); 340 | 341 | // now remove retored keybinds from list 342 | toRemove.forEach(r => { 343 | r.remove(); 344 | saveList.delete(r.key); 345 | }); 346 | saveOverrides(saveList); 347 | } 348 | 349 | // / Winprops 350 | 351 | /** 352 | Modelled after notion/ion3's system 353 | 354 | Examples: 355 | 356 | defwinprop({ 357 | wm_class: "Riot", 358 | scratch_layer: true 359 | }) 360 | */ 361 | export let winprops = []; 362 | export function winprop_match_p(meta_window, prop) { 363 | let wm_class = meta_window.wm_class || ""; 364 | let title = meta_window.title; 365 | if (prop.wm_class) { 366 | if (prop.wm_class instanceof RegExp) { 367 | if (!wm_class.match(prop.wm_class)) 368 | return false; 369 | } else if (prop.wm_class !== wm_class) { 370 | return false; 371 | } 372 | } 373 | if (prop.title) { 374 | if (prop.title instanceof RegExp) { 375 | if (!title.match(prop.title)) 376 | return false; 377 | } else if (prop.title !== title) 378 | return false; 379 | } 380 | 381 | return true; 382 | } 383 | 384 | export function find_winprop(meta_window) { 385 | // sort by title first (prioritise title over wm_class) 386 | let props = winprops.filter(winprop_match_p.bind(null, meta_window)); 387 | 388 | // if matching props found, return first one 389 | if (props.length > 0) { 390 | return props[0]; 391 | } 392 | 393 | // fall back, if star (catch-all) winprop exists, return the first one 394 | let starProps = winprops.filter(w => w.wm_class === "*" || w.title === "*"); 395 | if (starProps.length > 0) { 396 | return starProps[0]; 397 | } 398 | 399 | return null; 400 | } 401 | 402 | export function defwinprop(spec) { 403 | // process preferredWidth - expects inputs like 50% or 400px 404 | if (spec.preferredWidth) { 405 | spec.preferredWidth = { 406 | // value is first contiguous block of digits 407 | // eslint-disable-next-line no-new-wrappers 408 | value: new Number((spec.preferredWidth.match(/\d+/) ?? ['0'])[0]), 409 | // unit is first contiguous block of apha chars or % char 410 | unit: (spec.preferredWidth.match(/[a-zA-Z%]+/) ?? ['NO_UNIT'])[0], 411 | }; 412 | } 413 | 414 | /** 415 | * we order specs with gsettings rirst ==> gsetting winprops take precedence 416 | * over winprops defined in user.js. This was done since gsetting winprops 417 | * are easier to add/remove (and can be added/removed/edited instantly without 418 | * restarting shell). 419 | */ 420 | // add winprop 421 | winprops.push(spec); 422 | 423 | // now order winprops with gsettings first, then title over wm_class 424 | winprops.sort((a, b) => { 425 | let firstresult = 0; 426 | if (a.gsetting && !b.gsetting) { 427 | firstresult = -1; 428 | } 429 | else if (!a.gsetting && b.gsetting) { 430 | firstresult = 1; 431 | } 432 | 433 | // second compare, prioritise title 434 | let secondresult = 0; 435 | if (a.title && !b.title) { 436 | secondresult = -1; 437 | } 438 | else if (!a.title && b.title) { 439 | secondresult = 1; 440 | } 441 | 442 | return firstresult || secondresult; 443 | }); 444 | } 445 | 446 | /** 447 | * Adds user-defined winprops from gsettings (as defined in 448 | * org.gnome.shell.extensions.paperwm.winprops) to the winprops array. 449 | */ 450 | export function addWinpropsFromGSettings() { 451 | // add gsetting (user config) winprops 452 | gsettings.get_value('winprops').deep_unpack() 453 | .map(value => JSON.parse(value)) 454 | .forEach(prop => { 455 | // test if wm_class or title is a regex expression 456 | if (/^\/.+\/[igmsuy]*$/.test(prop.wm_class)) { 457 | // extract inner regex and flags from wm_class 458 | let matches = prop.wm_class.match(/^\/(.+)\/([igmsuy]*)$/); 459 | let inner = matches[1]; 460 | let flags = matches[2]; 461 | prop.wm_class = new RegExp(inner, flags); 462 | } 463 | if (/^\/.+\/[igmsuy]*$/.test(prop.title)) { 464 | // extract inner regex and flags from title 465 | let matches = prop.title.match(/^\/(.+)\/([igmsuy]*)$/); 466 | let inner = matches[1]; 467 | let flags = matches[2]; 468 | prop.title = new RegExp(inner, flags); 469 | } 470 | prop.gsetting = true; // set property that is from user gsettings 471 | defwinprop(prop); 472 | }); 473 | } 474 | 475 | /** 476 | * Removes winprops with the `gsetting:true` property from the winprops array. 477 | */ 478 | export function removeGSettingWinpropsFromArray() { 479 | winprops = winprops.filter(prop => !prop.gsetting ?? true); 480 | } 481 | 482 | /** 483 | * Effectively reloads winprops from gsettings. 484 | * This is a convenience function which removes gsetting winprops from winprops 485 | * array and then adds the currently defined 486 | * org.gnome.shell.extensions.paperwm.winprops winprops. 487 | */ 488 | export function reloadWinpropsFromGSettings() { 489 | removeGSettingWinpropsFromArray(); 490 | addWinpropsFromGSettings(); 491 | } 492 | -------------------------------------------------------------------------------- /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@paperwm.github.com']" 45 | 46 | gnome-shell $args 47 | 48 | -------------------------------------------------------------------------------- /stackoverlay.js: -------------------------------------------------------------------------------- 1 | import Clutter from 'gi://Clutter'; 2 | import GLib from 'gi://GLib'; 3 | import Meta from 'gi://Meta'; 4 | import Shell from 'gi://Shell'; 5 | import St from 'gi://St'; 6 | 7 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 8 | import * as PointerWatcher from 'resource:///org/gnome/shell/ui/pointerWatcher.js'; 9 | 10 | import { Settings, Utils, Tiling, Grab, Scratch } from './imports.js'; 11 | 12 | /* 13 | The stack overlay decorates the top stacked window with its icon and 14 | captures mouse input such that a mouse click only _activates_ the 15 | window. A very limited portion of the window is visible and due to 16 | the animation the button-up event will be triggered at an 17 | unpredictable position 18 | 19 | See #10 20 | */ 21 | 22 | /* 23 | Parent of the overlay? 24 | 25 | Most natural parent is the window actor, but then the overlay 26 | becomes visible in the clones too. 27 | 28 | Since the stacked windows doesn't really move it's not a big problem 29 | that the overlay doesn't track the window. The main challenge with 30 | using a different parent becomes controlling the "z-index". 31 | 32 | If I understand clutter correctly that can only be done by managing 33 | the order of the scene graph nodes. Descendants of node A will thus 34 | always be drawn in the same plane compared to a non-descendants. 35 | 36 | The overlay thus have to be parented to `global.window_group`. One 37 | would think that was ok, but unfortunately mutter keeps syncing the 38 | window_group with the window stacking and in the process destroy the 39 | stacking of any non-window actors. 40 | 41 | Adding a "clutter restack" to the `MetaScreen` `restacked` signal 42 | seems keep the stacking in sync (without entering into infinite 43 | restack loops) 44 | */ 45 | 46 | let pointerWatch, previewPointerWatcher; 47 | export function enable(_extension) { 48 | 49 | } 50 | 51 | export function disable() { 52 | previewPointerWatcher?.remove(); 53 | previewPointerWatcher = null; 54 | disableMultimonitorSupport(); 55 | } 56 | 57 | /** 58 | * Checks for multiple monitors and if so, then enables multimonitor 59 | * support in PaperWM. 60 | */ 61 | export function multimonitorSupport() { 62 | // if only one monitor, return 63 | if (Tiling.spaces.monitors?.size > 1) { 64 | enableMultimonitorSupport(); 65 | } 66 | else { 67 | disableMultimonitorSupport(); 68 | } 69 | } 70 | 71 | export function enableMultimonitorSupport() { 72 | pointerWatch = PointerWatcher.getPointerWatcher().addWatch(100, 73 | () => { 74 | // if overview return 75 | if (Main.overview.visible) { 76 | return; 77 | } 78 | 79 | const monitor = Utils.monitorAtCurrentPoint(); 80 | const space = Tiling.spaces.monitors.get(monitor); 81 | 82 | // same space 83 | if (space === Tiling.spaces.activeSpace) { 84 | return; 85 | } 86 | 87 | // check if in the midst of a window resize action 88 | if (Tiling.inGrab && 89 | Tiling.inGrab instanceof Grab.ResizeGrab) { 90 | const window = global.display?.focus_window; 91 | if (window) { 92 | Scratch.makeScratch(window); 93 | } 94 | return; 95 | } 96 | 97 | // if drag/grabbing window, do simple activate 98 | if (Tiling.inGrab) { 99 | space?.activate(false, false); 100 | return; 101 | } 102 | 103 | const selected = space?.selectedWindow; 104 | space?.activateWithFocus(selected, false, false); 105 | }); 106 | console.debug('paperwm multimonitor support is ENABLED'); 107 | } 108 | 109 | export function disableMultimonitorSupport() { 110 | pointerWatch?.remove(); 111 | pointerWatch = null; 112 | console.debug('paperwm multimonitor support is DISABLED'); 113 | } 114 | 115 | export function createAppIcon(metaWindow, size) { 116 | let tracker = Shell.WindowTracker.get_default(); 117 | let app = tracker.get_window_app(metaWindow); 118 | let appIcon = app ? app.create_icon_texture(size) 119 | : new St.Icon({ 120 | icon_name: 'icon-missing', 121 | icon_size: size, 122 | }); 123 | appIcon.x_expand = appIcon.y_expand = true; 124 | appIcon.x_align = appIcon.y_align = Clutter.ActorAlign.END; 125 | 126 | return appIcon; 127 | } 128 | 129 | export class ClickOverlay { 130 | constructor(monitor, onlyOnPrimary) { 131 | this.monitor = monitor; 132 | this.onlyOnPrimary = onlyOnPrimary; 133 | this.left = new StackOverlay(Meta.MotionDirection.LEFT, monitor); 134 | this.right = new StackOverlay(Meta.MotionDirection.RIGHT, monitor); 135 | } 136 | 137 | reset() { 138 | this.left.setTarget(null); 139 | this.right.setTarget(null); 140 | } 141 | 142 | hide() { 143 | this.left.overlay.hide(); 144 | this.right.overlay.hide(); 145 | } 146 | 147 | show() { 148 | if (Main.overview.visible) 149 | return; 150 | this.left.overlay.show(); 151 | this.right.overlay.show(); 152 | } 153 | 154 | destroy() { 155 | for (let overlay of [this.left, this.right]) { 156 | let actor = overlay.overlay; 157 | overlay.signals.destroy(); 158 | overlay.signals = null; 159 | if (overlay.clone) { 160 | overlay.clone.destroy(); 161 | overlay.clone = null; 162 | } 163 | actor.destroy(); 164 | } 165 | } 166 | } 167 | 168 | export class StackOverlay { 169 | constructor(direction, monitor) { 170 | this.SHOW_DELAY = 100; 171 | 172 | this._direction = direction; 173 | 174 | const overlay = new Clutter.Actor({ 175 | reactive: true, 176 | name: "stack-overlay", 177 | }); 178 | 179 | // Uncomment to debug the overlays 180 | // overlay.background_color = Utils.color_from_string('green')[1]; 181 | // overlay.opacity = 100; 182 | 183 | this.monitor = monitor; 184 | const panelBox = Main.layoutManager.panelBox; 185 | overlay.y = monitor.y + panelBox.height + Settings.prefs.vertical_margin; 186 | overlay.height = this.monitor.height - panelBox.height - Settings.prefs.vertical_margin; 187 | overlay.width = Tiling.stack_margin; 188 | 189 | this.signals = new Utils.Signals(); 190 | 191 | // preview timeouts 192 | this.triggerPreviewTimeout = null; 193 | this.showPreviewTimeout = null; 194 | this.activatePreviewTimeout = null; 195 | 196 | this.signals.connect(overlay, 'button-press-event', () => { 197 | if (!Settings.prefs.edge_preview_enable) { 198 | return; 199 | } 200 | 201 | if (!Settings.prefs.edge_preview_click_enable) { 202 | return; 203 | } 204 | 205 | this._activateTarget(); 206 | }); 207 | 208 | this.signals.connect(overlay, 'enter-event', () => this.triggerPreview()); 209 | this.signals.connect(overlay, 'leave-event', () => this.removePreview()); 210 | 211 | global.window_group.add_child(overlay); 212 | Main.layoutManager.trackChrome(overlay); 213 | 214 | this.overlay = overlay; 215 | this.setTarget(null); 216 | } 217 | 218 | _activateTarget() { 219 | Main.activateWindow(this.target); 220 | // remove/cleanup the previous preview 221 | this.removePreview(); 222 | 223 | // if pointer is still at edge (within 2px), trigger preview 224 | this.triggerPreviewTimeout = GLib.timeout_add( 225 | GLib.PRIORITY_DEFAULT, 226 | (Settings.prefs.animation_time * 1000) + 50, 227 | () => { 228 | if (this._pointerIsAtEdge()) { 229 | this.triggerPreview(true); 230 | } 231 | 232 | this.triggerPreviewTimeout = null; 233 | return false; // on return false destroys timeout 234 | }); 235 | } 236 | 237 | /** 238 | * Returns true if pointer x position is at monitor edge. 239 | * @returns Boolean 240 | */ 241 | _pointerIsAtEdge() { 242 | const [x] = global.get_pointer(); 243 | switch (this._direction) { 244 | case Meta.MotionDirection.LEFT: 245 | if ( 246 | x >= this.monitor.x && 247 | x <= this.monitor.x + 2 248 | ) { 249 | return true; 250 | } 251 | break; 252 | case Meta.MotionDirection.RIGHT: 253 | if ( 254 | x <= this.monitor.x + this.monitor.width && 255 | x >= this.monitor.x + this.monitor.width - 2 256 | ) { 257 | return true; 258 | } 259 | break; 260 | } 261 | 262 | return false; 263 | } 264 | 265 | /** 266 | * Triggers edge window preview. 267 | * @param {Boolean} postActivatePreview: true if an auto preview after previous activation 268 | * @returns 269 | */ 270 | triggerPreview(postActivatePreview = false) { 271 | if (!Settings.prefs.edge_preview_enable) { 272 | return; 273 | } 274 | 275 | if (this.showPreviewTimeout) { 276 | return; 277 | } 278 | 279 | if (!this.target) { 280 | return; 281 | } 282 | 283 | // create pointerwatcher to ensure preview is removed 284 | previewPointerWatcher?.remove(); 285 | previewPointerWatcher = PointerWatcher.getPointerWatcher().addWatch(200, () => { 286 | if (!this._pointerIsAtEdge()) { 287 | this.removePreview(); 288 | previewPointerWatcher?.remove(); 289 | previewPointerWatcher = null; 290 | } 291 | }); 292 | 293 | this.showPreviewTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, this.SHOW_DELAY, () => { 294 | this.removePreview(); 295 | this.showPreview(); 296 | this.showPreviewTimeout = null; 297 | 298 | // activate preview on timeout 299 | if (Settings.prefs.edge_preview_timeout_enable) { 300 | // if no continual activation 301 | if (postActivatePreview && 302 | !Settings.prefs.edge_preview_timeout_continual) { 303 | // check have a target 304 | if (!this.target) { 305 | return; 306 | } 307 | 308 | // push pointer back 309 | let [, py] = global.get_pointer(); 310 | const offset = 3; 311 | let x; 312 | switch (this._direction) { 313 | case Meta.MotionDirection.LEFT: 314 | x = this.monitor.x + offset; 315 | break; 316 | case Meta.MotionDirection.RIGHT: 317 | x = this.monitor.x + this.monitor.width - offset; 318 | break; 319 | } 320 | Utils.warpPointer( 321 | x, 322 | py, 323 | false 324 | ); 325 | return; 326 | } 327 | 328 | this.activatePreviewTimeout = GLib.timeout_add( 329 | GLib.PRIORITY_DEFAULT, 330 | Settings.prefs.edge_preview_timeout, () => { 331 | // check if still at edge 332 | if (this._pointerIsAtEdge()) { 333 | this._activateTarget(); 334 | } 335 | }); 336 | } 337 | 338 | return false; // on return false destroys timeout 339 | }); 340 | } 341 | 342 | removePreview() { 343 | if (this.showPreviewTimeout) { 344 | Utils.timeout_remove(this.showPreviewTimeout); 345 | this.showPreviewTimeout = null; 346 | } 347 | 348 | if (this.clone) { 349 | this.clone.destroy(); 350 | this.clone = null; 351 | } 352 | } 353 | 354 | /** 355 | * Shows the window preview in from the side it was triggered on. 356 | */ 357 | showPreview() { 358 | // only show if have valid scale 359 | const scale = Settings.prefs.edge_preview_scale; 360 | if (scale <= 0) { 361 | return; 362 | } 363 | 364 | // don't show if window grabbed 365 | if (Grab.grabbed) { 366 | return; 367 | } 368 | 369 | /** 370 | * if timeout is enabled, only show if valid timeout (e.g. if SHOW_DELAY <= timeout, 371 | * then won't see the preview anyway). 372 | */ 373 | if (Settings.prefs.edge_preview_timeout_enable && 374 | Settings.prefs.edge_preview_timeout <= this.SHOW_DELAY 375 | ) { 376 | return; 377 | } 378 | 379 | let [x, y] = global.get_pointer(); 380 | const actor = this.target.get_compositor_private(); 381 | const clone = new Clutter.Clone({ source: actor }); 382 | this.clone = clone; 383 | 384 | // Remove any window clips, and show the metaWindow.clone's 385 | actor.remove_clip(); 386 | Tiling.animateWindow(this.target); 387 | 388 | // set clone parameters 389 | clone.opacity = 255 * 0.95; 390 | 391 | clone.set_scale(scale, scale); 392 | Main.uiGroup.add_child(clone); 393 | 394 | const monitor = this.monitor; 395 | const scaleWidth = scale * clone.width; 396 | const scaleHeight = scale * clone.height; 397 | if (this._direction === Meta.MotionDirection.RIGHT) { 398 | x = monitor.x + monitor.width - scaleWidth; 399 | } 400 | else { 401 | x = monitor.x; 402 | } 403 | 404 | // calculate y position - center of mouse 405 | y -= (scale * clone.height) / 2; 406 | 407 | // bound to remain within view 408 | const workArea = this.getWorkArea(); 409 | y = Math.max(y, workArea.y); 410 | y = Math.min(y, workArea.y + workArea.height - scaleHeight); 411 | 412 | clone.set_position(x, y); 413 | } 414 | 415 | setTarget(space, index) { 416 | this.removePreview(); 417 | 418 | let bail = () => { 419 | this.target = null; 420 | this.overlay.width = 0; 421 | return false; 422 | }; 423 | 424 | if (space === null || Tiling.inPreview) { 425 | // No target. Eg. if we're at the left- or right-most window 426 | return bail(); 427 | } 428 | 429 | let mru = global.display.get_tab_list(Meta.TabList.NORMAL_ALL, 430 | space.workspace); 431 | let column = space[index]; 432 | this.target = mru.filter(w => column.includes(w))[0]; 433 | let metaWindow = this.target; 434 | if (!metaWindow) 435 | return; 436 | 437 | let overlay = this.overlay; 438 | overlay.y = this.monitor.y + Main.layoutManager.panelBox.height + Settings.prefs.vertical_margin; 439 | 440 | // Assume the resize edge is at least this big (empirically found..) 441 | const minResizeEdge = 8; 442 | 443 | if (this._direction === Meta.MotionDirection.LEFT) { 444 | let column = space[space.indexOf(metaWindow) + 1]; 445 | let neighbour = column && 446 | global.display.sort_windows_by_stacking(column).reverse()[0]; 447 | 448 | if (!neighbour) 449 | return bail(); // Should normally have a neighbour. Bail! 450 | 451 | let width = neighbour.clone.targetX + space.targetX - minResizeEdge; 452 | if (space.isPlaceable(metaWindow) || Meta.is_wayland_compositor()) 453 | width = Math.min(width, 1); 454 | overlay.x = this.monitor.x; 455 | overlay.width = Math.max(width, 1); 456 | Utils.actor_raise(overlay, neighbour.get_compositor_private()); 457 | } else { 458 | let column = space[space.indexOf(metaWindow) - 1]; 459 | let neighbour = column && 460 | global.display.sort_windows_by_stacking(column).reverse()[0]; 461 | if (!neighbour) 462 | return bail(); // Should normally have a neighbour. Bail! 463 | 464 | let frame = neighbour.get_frame_rect(); 465 | frame.x = neighbour.clone.targetX + space.targetX; 466 | let width = this.monitor.width - (frame.x + frame.width) - minResizeEdge; 467 | if (space.isPlaceable(metaWindow) || Meta.is_wayland_compositor()) 468 | width = 1; 469 | width = Math.max(width, 1); 470 | overlay.x = this.monitor.x + this.monitor.width - width; 471 | overlay.width = width; 472 | Utils.actor_raise(overlay, neighbour.get_compositor_private()); 473 | } 474 | 475 | if (space.selectedWindow.fullscreen || space.selectedWindow.maximized_vertically) 476 | overlay.hide(); 477 | else 478 | overlay.show(); 479 | 480 | return true; 481 | } 482 | 483 | destroy() { 484 | Utils.timeout_remove(this.triggerPreviewTimeout); 485 | this.triggerPreviewTimeout = null; 486 | 487 | Utils.timeout_remove(this.showPreviewTimeout); 488 | this.showPreviewTimeout = null; 489 | 490 | Utils.timeout_remove(this.activatePreviewTimeout); 491 | this.activatePreviewTimeout = null; 492 | 493 | this.signals.destroy(); 494 | this.signals = null; 495 | this.removePreview(); 496 | 497 | Main.layoutManager.untrackChrome(this.overlay); 498 | this.overlay.destroy(); 499 | } 500 | 501 | /** 502 | * Convenience method to return WorkArea for current monitor. 503 | * @returns WorkArea 504 | */ 505 | getWorkArea() { 506 | return Main.layoutManager.getWorkAreaForMonitor(this.monitor.index); 507 | } 508 | } 509 | -------------------------------------------------------------------------------- /stylesheet.css: -------------------------------------------------------------------------------- 1 | /* 2 | NOTE: please update `./config/users.css` with any new styles added here. 3 | */ 4 | .background-clear { 5 | background-color: rgba(0, 0, 0, 0); 6 | } 7 | 8 | .topbar-transparent-background { 9 | background-color: rgba(0, 0, 0, 0.35); 10 | box-shadow: none; 11 | } 12 | 13 | .space-workspace-indicator { 14 | padding: 0 10px 0 0; 15 | background-color: transparent; 16 | border-image: none; 17 | background-image: none; 18 | border: none; 19 | } 20 | 21 | .space-focus-mode-icon { 22 | icon-size: 16px; 23 | padding: 0 18px 0 18px; 24 | margin-left: 3px; 25 | background-color: transparent; 26 | } 27 | 28 | .open-position-icon { 29 | icon-size: 22px; 30 | padding: 0; 31 | background-color: transparent; 32 | } 33 | 34 | .focus-button-tooltip { 35 | background-color: rgba(0, 0, 0, 0.8); 36 | padding: 8px; 37 | border-radius: 8px; 38 | font-weight: 600; 39 | } 40 | 41 | .take-window-hint { 42 | background-color: rgba(0, 0, 0, 0.8); 43 | padding: 8px; 44 | border-radius: 8px; 45 | } 46 | 47 | .workspace-icon-button { 48 | -st-icon-style: symbolic; 49 | border: none; 50 | border-radius: 8px; 51 | padding: 8px; 52 | } 53 | 54 | .workspace-icon-button StIcon { 55 | icon-size: 16px; 56 | } 57 | 58 | .paperwm-minimap-selection { 59 | border-radius: 8px; 60 | } 61 | 62 | .paperwm-clone-shade { 63 | background-color: rgba(0, 0, 0, 0.7); 64 | border-radius: 7px 7px 0px 0px; 65 | } 66 | 67 | .paperwm-window-position-bar-backdrop { 68 | background-color: rgba(0, 0, 0, 0.35); 69 | } 70 | 71 | .paperwm-window-position-bar { 72 | border: 0; /* disable border to calm it down a bit */ 73 | border-radius: 1px; 74 | } 75 | -------------------------------------------------------------------------------- /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@paperwm.github.com 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 | -------------------------------------------------------------------------------- /virtTiling.js: -------------------------------------------------------------------------------- 1 | import St from 'gi://St'; 2 | 3 | import { Utils, Tiling } from './imports.js'; 4 | 5 | let fitProportionally = Tiling.fitProportionally; 6 | let prefs = { 7 | window_gap: 5, 8 | minimum_margin: 3, 9 | }; 10 | 11 | let virtStage = null; 12 | 13 | export function repl() { 14 | if (virtStage) { 15 | virtStage.destroy(); 16 | } 17 | 18 | let realMonitor = space.monitor; 19 | let scale = 0.10; 20 | let padding = 10; 21 | const monitorWidth = realMonitor.width * scale; 22 | const monitorHeight = realMonitor.height * scale; 23 | let stageStyle = 'background-color: white;'; 24 | virtStage = new St.Widget({ 25 | name: 'stage', 26 | style: stageStyle, 27 | height: monitorHeight + padding * 2, 28 | width: monitorWidth * 3, 29 | }); 30 | 31 | let monitorStyle = `background-color: blue;`; 32 | let monitor = new St.Widget({ 33 | name: "monitor0", 34 | style: monitorStyle, 35 | x: virtStage.width / 2 - monitorWidth / 2, y: padding, 36 | width: monitorWidth, 37 | height: virtStage.height - padding * 2, 38 | }); 39 | 40 | let panel = new St.Widget({ 41 | name: "panel", 42 | style: `background-color: gray`, 43 | x: 0, y: 0, 44 | width: monitor.width, 45 | height: 10, 46 | 47 | }); 48 | let workArea = { 49 | x: monitor.x, 50 | y: panel.height, 51 | width: monitor.width, 52 | height: monitor.height - panel.height, 53 | }; 54 | 55 | let tilingStyle = `background-color: rgba(190, 190, 0, 0.3);`; 56 | let tilingContainer = new St.Widget({ name: "tiling", style: tilingStyle }); 57 | 58 | global.stage.add_child(virtStage); 59 | virtStage.x = 3000; 60 | virtStage.y = 300; 61 | 62 | virtStage.add_child(monitor); 63 | monitor.add_child(panel); 64 | monitor.add_child(tilingContainer); 65 | 66 | function sync(space_ = space) { 67 | let columns = layout( 68 | fromSpace(space_, scale), 69 | workArea, 70 | prefs 71 | ); 72 | renderAndView( 73 | tilingContainer, 74 | columns 75 | ); 76 | tilingContainer.x = space_.targetX * scale; 77 | } 78 | 79 | sync(); 80 | 81 | Utils.printActorTree(virtStage, Utils.mkFmt({ nameOnly: true })); 82 | 83 | movecolumntoviewportposition(tilingContainer, monitor, columns[1][0], 30); 84 | 85 | virtStage.hide(); 86 | virtStage.show(); 87 | virtStage.y = 400; 88 | } 89 | 90 | /** tiling position given: 91 | m_s: monitor position 92 | w_m: window position (relative to monitor) 93 | w_t: window position (relative to tiling) 94 | */ 95 | export function t_s(m_s, w_m, w_t) { 96 | return w_m - w_t + m_s; 97 | } 98 | 99 | /** 100 | Calculates the tiling position such that column `k` is positioned at `x` 101 | relative to the viewport (or workArea?) 102 | */ 103 | export function movecolumntoviewportposition(tilingActor, viewport, window, x) { 104 | tilingActor.x = t_s(viewport.x, x, window.x); 105 | } 106 | 107 | export function renderAndView(container, columns) { 108 | for (let child of container.get_children()) { 109 | child.destroy(); 110 | } 111 | 112 | render(columns, container); 113 | } 114 | 115 | export function fromSpace(space, scale = 1) { 116 | return space.map( 117 | col => col.map( 118 | metaWindow => { 119 | let f = metaWindow.get_frame_rect(); 120 | return { 121 | width: f.width * scale, 122 | height: f.height * scale, 123 | }; 124 | } 125 | ) 126 | ); 127 | } 128 | 129 | /** Render a dummy view of the windows */ 130 | export function render(columns, tiling) { 131 | const windowStyle = `border: black solid 1px; background-color: red`; 132 | 133 | function createWindowActor(window) { 134 | return new St.Widget({ 135 | style: windowStyle, 136 | width: window.width, 137 | height: window.height, 138 | x: window.x, 139 | y: window.y, 140 | }); 141 | } 142 | 143 | for (let col of columns) { 144 | for (let window of col) { 145 | let windowActor = createWindowActor(window); 146 | tiling.add_child(windowActor); 147 | } 148 | } 149 | } 150 | 151 | export function allocateDefault(column, availableHeight, preAllocatedWindow) { 152 | if (column.length === 1) { 153 | return [availableHeight]; 154 | } else { 155 | // Distribute available height amongst non-selected windows in proportion to their existing height 156 | const gap = prefs.window_gap; 157 | const minHeight = 15; 158 | 159 | const heightOf = window => { 160 | return window.height; 161 | }; 162 | 163 | const k = preAllocatedWindow && column.indexOf(preAllocatedWindow); 164 | const selectedHeight = preAllocatedWindow && heightOf(preAllocatedWindow); 165 | 166 | let nonSelected = column.slice(); 167 | if (preAllocatedWindow) { 168 | nonSelected.splice(k, 1); 169 | } 170 | 171 | const nonSelectedHeights = nonSelected.map(heightOf); 172 | let availableForNonSelected = Math.max( 173 | 0, 174 | availableHeight - 175 | (column.length - 1) * gap - 176 | (preAllocatedWindow ? selectedHeight : 0) 177 | ); 178 | 179 | const deficit = Math.max( 180 | 0, nonSelected.length * minHeight - availableForNonSelected); 181 | 182 | let heights = fitProportionally( 183 | nonSelectedHeights, 184 | availableForNonSelected + deficit 185 | ); 186 | 187 | if (preAllocatedWindow) { 188 | heights.splice(k, 0, selectedHeight - deficit); 189 | } 190 | 191 | return heights; 192 | } 193 | } 194 | 195 | export function allocateEqualHeight(column, available) { 196 | available -= (column.length - 1) * prefs.window_gap; 197 | return column.map(_ => Math.floor(available / column.length)); 198 | } 199 | 200 | export function layoutGrabColumn(column, x, y0, targetWidth, availableHeight, grabWindow) { 201 | function mosh(windows, height, y0) { 202 | let targetHeights = fitProportionally( 203 | windows.map(mw => mw.rect.height), 204 | height 205 | ); 206 | let [w, y] = layoutColumnSimple(windows, x, y0, targetWidth, targetHeights); 207 | return y; 208 | } 209 | 210 | const k = column.indexOf(grabWindow); 211 | if (k < 0) { 212 | throw new Error(`Anchor doesn't exist in column ${grabWindow.title}`); 213 | } 214 | 215 | const gap = prefs.window_gap; 216 | const f = grabWindow.globalRect(); 217 | let yGrabRel = f.y - this.monitor.y; 218 | targetWidth = f.width; 219 | 220 | const H1 = (yGrabRel - y0) - gap - (k - 1) * gap; 221 | const H2 = availableHeight - (yGrabRel + f.height - y0) - gap - (column.length - k - 2) * gap; 222 | k > 0 && mosh(column.slice(0, k), H1, y0); 223 | let y = mosh(column.slice(k, k + 1), f.height, yGrabRel); 224 | k + 1 < column.length && mosh(column.slice(k + 1), H2, y); 225 | 226 | return targetWidth; 227 | } 228 | 229 | 230 | export function layoutColumnSimple(windows, x, y0, targetWidth, targetHeights, time) { 231 | let y = y0; 232 | 233 | for (let i = 0; i < windows.length; i++) { 234 | let virtWindow = windows[i]; 235 | let targetHeight = targetHeights[i]; 236 | 237 | virtWindow.x = x; 238 | virtWindow.y = y; 239 | virtWindow.width = targetWidth; 240 | virtWindow.height = targetHeight; 241 | 242 | y += targetHeight + prefs.window_gap; 243 | } 244 | return targetWidth, y; 245 | } 246 | 247 | 248 | /** 249 | Mutates columns 250 | */ 251 | export function layout(columns, workArea, prefs, options = {}) { 252 | let gap = prefs.window_gap; 253 | let availableHeight = workArea.height; 254 | 255 | let { inGrab, selectedWindow } = options; 256 | let selectedIndex = -1; 257 | 258 | if (selectedWindow) { 259 | selectedIndex = columns.findIndex(col => col.includes(selectedWindow)); 260 | } 261 | 262 | let y0 = workArea.y; 263 | let x = 0; 264 | 265 | for (let i = 0; i < columns.length; i++) { 266 | let column = columns[i]; 267 | 268 | let selectedInColumn = i === selectedIndex ? selectedWindow : null; 269 | 270 | let targetWidth; 271 | if (i === selectedIndex) { 272 | targetWidth = selectedInColumn.width; 273 | } else { 274 | targetWidth = Math.max(...column.map(w => w.width)); 275 | } 276 | targetWidth = Math.min(targetWidth, workArea.width - 2 * prefs.minimum_margin); 277 | 278 | if (inGrab && i === selectedIndex) { 279 | layoutGrabColumn(column, x, y0, targetWidth, availableHeight, selectedInColumn); 280 | } else { 281 | let allocator = options.customAllocators && options.customAllocators[i]; 282 | allocator = allocator || allocateDefault; 283 | 284 | let targetHeights = allocator(column, availableHeight, selectedInColumn); 285 | layoutColumnSimple(column, x, y0, targetWidth, targetHeights); 286 | } 287 | 288 | x += targetWidth + gap; 289 | } 290 | 291 | return columns; 292 | } 293 | -------------------------------------------------------------------------------- /vm.nix: -------------------------------------------------------------------------------- 1 | { pkgs, config, lib, ... }: 2 | 3 | { 4 | ### Make PaperWM available in system environment 5 | environment.systemPackages = with pkgs; 6 | [ paperwm 7 | (lib.getBin libinput) 8 | ]; 9 | 10 | ### Set graphical session to auto-login GNOME 11 | services.xserver = 12 | { enable = true; 13 | displayManager.autoLogin = 14 | { enable = true; 15 | user = "user"; 16 | }; 17 | displayManager.gdm.enable = true; 18 | desktopManager.gnome.enable = true; 19 | }; 20 | 21 | ### Set dconf to enable PaperWM out of the box 22 | programs.dconf = 23 | { enable = true; 24 | profiles."user".databases = [ 25 | { settings = 26 | { "org/gnome/shell" = 27 | { enabled-extensions = [ "paperwm@paperwm.github.com" ]; 28 | disable-user-extensions = false; 29 | }; 30 | }; 31 | #NOTE: You can add more dconf settings to test with here! 32 | } 33 | ]; 34 | }; 35 | 36 | ### Remove unnecessary dependencies 37 | #NOTE: This drops many GTK4 apps, re-enable if needed for testing. 38 | services.gnome.core-utilities.enable = false; 39 | 40 | ### Set default user 41 | users.users."user" = 42 | { isNormalUser = true; 43 | createHome = true; 44 | home = "/home"; 45 | description = "PaperWM test user"; 46 | extraGroups = [ "wheel" ]; 47 | password = "paperwm"; 48 | }; 49 | 50 | ### No-password sudo 51 | security.sudo = 52 | { enable = true; 53 | extraConfig = "%wheel ALL=(ALL) NOPASSWD: ALL"; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /winpropsPane.js: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib'; 2 | import GObject from 'gi://GObject'; 3 | import Gtk from 'gi://Gtk'; 4 | 5 | export const WinpropsPane = GObject.registerClass({ 6 | GTypeName: 'WinpropsPane', 7 | Template: GLib.uri_resolve_relative(import.meta.url, './WinpropsPane.ui', GLib.UriFlags.NONE), 8 | InternalChildren: [ 9 | 'search', 10 | 'listbox', 11 | 'addButton', 12 | 'scrolledWindow', 13 | ], 14 | Signals: { 15 | 'changed': {}, 16 | }, 17 | }, class WinpropsPane extends Gtk.Box { 18 | _init(params = {}) { 19 | super._init(params); 20 | 21 | // define search box filter function (searches wm_class, title, and accelLabel) 22 | this._listbox.set_filter_func(row => { 23 | let search = this._search.get_text().toLowerCase(); 24 | let wmclass = row.winprop.wm_class?.toLowerCase() ?? ''; 25 | let title = row.winprop.title?.toLowerCase() ?? ''; 26 | let accelLabel = row._accelLabel.label?.toLowerCase() ?? ''; 27 | return wmclass.includes(search) || title.includes(search) || accelLabel.includes(search); 28 | }); 29 | this._search.connect('changed', () => { 30 | this._listbox.invalidate_filter(); 31 | }); 32 | 33 | this._expandedRow = null; 34 | this.rows = []; 35 | this.workspaces = []; 36 | } 37 | 38 | addWinprops(winprops) { 39 | winprops.forEach(winprop => { 40 | this._listbox.insert(this._createRow(winprop), -1); 41 | }); 42 | } 43 | 44 | setWorkspaces(workspaces) { 45 | this.workspaces = workspaces; 46 | } 47 | 48 | _removeRow(row) { 49 | this._listbox.remove(row); 50 | let remove = this.rows.findIndex(r => r === row); 51 | if (remove >= 0) { 52 | this.rows.splice(remove, 1); 53 | } 54 | this.emit('changed'); 55 | } 56 | 57 | _onAddButtonClicked() { 58 | // first clear search text, otherwise won't be able to see new row 59 | this._search.set_text(''); 60 | 61 | let row = this._createRow(); 62 | row.expanded = true; 63 | this._listbox.insert(row, 0); 64 | this._scrolledWindow.get_vadjustment().set_value(0); 65 | } 66 | 67 | _createRow(winprop) { 68 | let wp = winprop ?? { wm_class: '' }; 69 | const row = new WinpropsRow({ winprop: wp, workspaces: this.workspaces }); 70 | this.rows.push(row); 71 | row.connect('notify::expanded', row => this._onRowExpanded(row)); 72 | row.connect('row-deleted', row => this._removeRow(row)); 73 | row.connect('changed', () => this.emit('changed')); 74 | return row; 75 | } 76 | 77 | _onRowActivated(list, row) { 78 | if (!row.is_focus()) { 79 | return; 80 | } 81 | row.expanded = !row.expanded; 82 | } 83 | 84 | _onRowExpanded(row) { 85 | if (row.expanded) { 86 | if (this._expandedRow) { 87 | this._expandedRow.expanded = false; 88 | } 89 | this._expandedRow = row; 90 | } else if (this._expandedRow === row) { 91 | this._expandedRow = null; 92 | } 93 | } 94 | }); 95 | 96 | export const WinpropsRow = GObject.registerClass({ 97 | GTypeName: 'WinpropsRow', 98 | Template: GLib.uri_resolve_relative(import.meta.url, './WinpropsRow.ui', GLib.UriFlags.NONE), 99 | InternalChildren: [ 100 | 'header', 101 | 'descLabel', 102 | 'accelLabel', 103 | 'revealer', 104 | 'optionList', 105 | 'wmClass', 106 | 'title', 107 | 'scratchLayer', 108 | 'preferredWidth', 109 | 'space', 110 | 'focus', 111 | 'deleteButton', 112 | ], 113 | Properties: { 114 | winprop: GObject.ParamSpec.jsobject( 115 | 'winprop', 116 | 'winprop', 117 | 'Winprop', 118 | GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY 119 | ), 120 | workspaces: GObject.ParamSpec.jsobject( 121 | 'workspaces', 122 | 'workspaces', 123 | 'Workspaces', 124 | GObject.ParamFlags.READWRITE 125 | ), 126 | expanded: GObject.ParamSpec.boolean( 127 | 'expanded', 128 | 'Expanded', 129 | 'Expanded', 130 | GObject.ParamFlags.READWRITE, 131 | false 132 | ), 133 | }, 134 | Signals: { 135 | 'changed': {}, 136 | 'row-deleted': {}, 137 | }, 138 | }, class WinpropsRow extends Gtk.ListBoxRow { 139 | _init(params = {}) { 140 | super._init(params); 141 | 142 | // description label 143 | this._setDescLabel(); 144 | 145 | // set the values to current state and connect to 'changed' signal 146 | this._wmClass.set_text(this.winprop.wm_class ?? ''); 147 | this._wmClass.connect('changed', () => { 148 | // check if null or empty (we still emit changed if wm_class is wiped) 149 | this.checkHasWmClassOrTitle(); 150 | this.winprop.wm_class = this._wmClass.get_text(); 151 | this._setDescLabel(); 152 | this.emit('changed'); 153 | }); 154 | 155 | this._title.set_text(this.winprop.title ?? ''); 156 | this._title.connect('changed', () => { 157 | this.checkHasWmClassOrTitle(); 158 | this.winprop.title = this._title.get_text(); 159 | this._setDescLabel(); 160 | this.emit('changed'); 161 | }); 162 | 163 | this._scratchLayer.set_active(this.winprop.scratch_layer ?? false); 164 | this._scratchLayer.connect('state-set', () => { 165 | let isActive = this._scratchLayer.get_active(); 166 | this.winprop.scratch_layer = isActive; 167 | 168 | // if is active then disable the preferredWidth input 169 | this._preferredWidth.set_sensitive(!isActive); 170 | 171 | this.emit('changed'); 172 | }); 173 | 174 | this._preferredWidth.set_text(this.winprop.preferredWidth ?? ''); 175 | // if scratchLayer is active then users can't edit preferredWidth 176 | this._preferredWidth.set_sensitive(!this.winprop.scratch_layer ?? true); 177 | 178 | this._preferredWidth.connect('changed', () => { 179 | // if has value, needs to be valid (have a value or unit) 180 | if (this._preferredWidth.get_text()) { 181 | let value = this._preferredWidth.get_text(); 182 | let digits = (value.match(/\d+/) ?? [null])[0]; 183 | let isPercent = /^.*%$/.test(value); 184 | let isPixel = /^.*px$/.test(value); 185 | 186 | // check had valid number 187 | if (!digits) { 188 | this._setError(this._preferredWidth); 189 | } 190 | // if no unit defined 191 | else if (!isPercent && !isPixel) { 192 | this._setError(this._preferredWidth); 193 | } 194 | else { 195 | this._setError(this._preferredWidth, false); 196 | this.winprop.preferredWidth = this._preferredWidth.get_text(); 197 | this.emit('changed'); 198 | } 199 | } else { 200 | // having no preferredWidth is valid 201 | this._setError(this._preferredWidth, false); 202 | delete this.winprop.preferredWidth; 203 | this.emit('changed'); 204 | } 205 | }); 206 | 207 | this._space.append_text("CURRENT"); 208 | for (const [i, name] of this.workspaces.entries()) { 209 | // Combo box entries in normal workspace index order 210 | this._space.append_text(`${i}: ${name}`); 211 | } 212 | // index 0 is CURRENT, so add 1 213 | this._space.set_active((this.winprop.spaceIndex ?? -1) + 1); 214 | this._space.connect('changed', () => { 215 | let value = this._space.get_active() - 1; 216 | if (value < 0) { 217 | value = undefined; 218 | } 219 | this.winprop.spaceIndex = value; 220 | this.emit('changed'); 221 | }); 222 | 223 | this._focus.set_active(this.winprop.focus ?? true); 224 | this._focus.connect('state-set', () => { 225 | let isActive = this._focus.get_active(); 226 | this.winprop.focus = isActive; 227 | this.emit('changed'); 228 | }); 229 | 230 | this._updateState(); 231 | } 232 | 233 | /** 234 | * Checks has an input for either wmClass or title. 235 | * Sets 'error' cssClass is neither. 236 | */ 237 | checkHasWmClassOrTitle() { 238 | if (!this._wmClass.get_text() && !this._title.get_text()) { 239 | this._setError(this._wmClass); 240 | this._setError(this._title); 241 | return false; 242 | } else { 243 | this._setError(this._wmClass, false); 244 | this._setError(this._title, false); 245 | return true; 246 | } 247 | } 248 | 249 | /** 250 | * Get the wmClass if it exists, otherwise returns the title. 251 | * @returns String 252 | */ 253 | getWmClassOrTitle() { 254 | if (this.winprop.wm_class) { 255 | return this.winprop.wm_class; 256 | } 257 | else if (this.winprop.title) { 258 | return this.winprop.title; 259 | } 260 | else { 261 | return ''; 262 | } 263 | } 264 | 265 | _setError(child, option = true) { 266 | if (child) { 267 | if (option) { 268 | child.add_css_class('error'); 269 | } else { 270 | child.remove_css_class('error'); 271 | } 272 | } 273 | } 274 | 275 | get expanded() { 276 | if (this._expanded === undefined) 277 | this._expanded = false; 278 | return this._expanded; 279 | } 280 | 281 | set expanded(value) { 282 | if (this._expanded === value) 283 | return; 284 | 285 | this._expanded = value; 286 | this.notify('expanded'); 287 | this._updateState(); 288 | } 289 | 290 | _onDeleteButtonClicked() { 291 | this.emit('row-deleted'); 292 | } 293 | 294 | _onRowActivated(list, row) { 295 | if (row.is_focus()) { 296 | row.editing = !row.editing; 297 | } 298 | } 299 | 300 | _setAccelLabel() { 301 | if (this.winprop.scratch_layer ?? false) { 302 | return 'scratch layer'; 303 | } 304 | else if (this.winprop.preferredWidth ?? false) { 305 | return 'preferred width'; 306 | } 307 | else if (this.winprop.spaceIndex !== undefined) { 308 | return 'workspace'; 309 | } 310 | else { 311 | return 'no setting'; 312 | } 313 | } 314 | 315 | /** 316 | * Sets the description label for this row. 317 | * @returns boolean 318 | */ 319 | _setDescLabel() { 320 | // if wmClass, use that, otherwise use title (fallback) 321 | if (this.winprop.wm_class) { 322 | this._descLabel.label = this.winprop.wm_class; 323 | } 324 | else if (this.winprop.title) { 325 | this._descLabel.label = this.winprop.title; 326 | } 327 | } 328 | 329 | _updateState() { 330 | GLib.idle_add(0, () => { 331 | this._accelLabel.label = this._setAccelLabel(); 332 | if (this.expanded) { 333 | this._accelLabel.hide(); 334 | this._revealer.reveal_child = true; 335 | this.add_css_class('expanded'); 336 | } else { 337 | this._accelLabel.show(); 338 | this._revealer.reveal_child = false; 339 | this.remove_css_class('expanded'); 340 | } 341 | }); 342 | } 343 | }); 344 | -------------------------------------------------------------------------------- /workspace.js: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import GLib from 'gi://GLib'; 3 | 4 | import * as Lib from './lib.js'; 5 | 6 | /** 7 | * Workspace related utility functions used by other modules. 8 | */ 9 | const WORKSPACE_LIST_KEY = 'org.gnome.shell.extensions.paperwm.workspacelist'; 10 | const WORKSPACE_KEY = 'org.gnome.shell.extensions.paperwm.workspace'; 11 | 12 | export class WorkspaceSettings { 13 | constructor(extension) { 14 | this.workspaceSettingsCache = {}; 15 | this.schemaSource = Gio.SettingsSchemaSource.new_from_directory( 16 | GLib.build_filenamev([extension.path, "schemas"]), 17 | Gio.SettingsSchemaSource.get_default(), 18 | false 19 | ); 20 | 21 | this.workspaceList = new Gio.Settings({ 22 | settings_schema: this.getSchemaSource().lookup(WORKSPACE_LIST_KEY, true), 23 | }); 24 | } 25 | 26 | getSchemaSource() { 27 | return this.schemaSource; 28 | } 29 | 30 | getWorkspaceName(settings, index) { 31 | let name = settings.get_string('name') ?? `Workspace ${index + 1}`; 32 | if (!name || name === '') { 33 | name = `Workspace ${index + 1}`; 34 | } 35 | return name; 36 | } 37 | 38 | getWorkspaceList() { 39 | return this.workspaceList; 40 | } 41 | 42 | /** 43 | * Returns list of ordered workspace UUIDs. 44 | */ 45 | getListUUID() { 46 | return this.getWorkspaceList().get_strv('list'); 47 | } 48 | 49 | getWorkspaceSettings(index) { 50 | let list = this.getListUUID(); 51 | for (let uuid of list) { 52 | let settings = this.getWorkspaceSettingsByUUID(uuid); 53 | if (settings.get_int('index') === index) { 54 | return [uuid, settings]; 55 | } 56 | } 57 | return this.getNewWorkspaceSettings(index); 58 | } 59 | 60 | getNewWorkspaceSettings(index) { 61 | let uuid = GLib.uuid_string_random(); 62 | let settings = this.getWorkspaceSettingsByUUID(uuid); 63 | let list = this.getListUUID(); 64 | list.push(uuid); 65 | this.getWorkspaceList().set_strv('list', list); 66 | settings.set_int('index', index); 67 | return [uuid, settings]; 68 | } 69 | 70 | getWorkspaceSettingsByUUID(uuid) { 71 | if (!this.workspaceSettingsCache[uuid]) { 72 | let settings = new Gio.Settings({ 73 | settings_schema: this.getSchemaSource().lookup(WORKSPACE_KEY, true), 74 | path: `/org/gnome/shell/extensions/paperwm/workspaces/${uuid}/`, 75 | }); 76 | this.workspaceSettingsCache[uuid] = settings; 77 | } 78 | return this.workspaceSettingsCache[uuid]; 79 | } 80 | 81 | /** Returns [[uuid, settings, name], ...] (Only used for debugging/development atm.) */ 82 | findWorkspaceSettingsByName(regex) { 83 | let list = this.getListUUID(); 84 | let settings = list.map(this.getWorkspaceSettingsByUUID); 85 | return Lib.zip(list, settings, settings.map(s => s.get_string('name'))) 86 | .filter(([uuid, s, name]) => name.match(regex)); 87 | } 88 | 89 | /** Only used for debugging/development atm. */ 90 | deleteWorkspaceSettingsByName(regex, dryrun = true) { 91 | let out = ""; 92 | function rprint(...args) { console.debug(...args); out += `${args.join(" ")}\n`; } 93 | let n = global.workspace_manager.get_n_workspaces(); 94 | for (let [uuid, s, name] of this.findWorkspaceSettingsByName(regex)) { 95 | let index = s.get_int('index'); 96 | if (index < n) { 97 | rprint("Skipping in-use settings", name, index); 98 | continue; 99 | } 100 | rprint(dryrun ? "[dry]" : "", `Delete settings for '${name}' (${uuid})`); 101 | if (!dryrun) { 102 | this.deleteWorkspaceSettings(uuid); 103 | } 104 | } 105 | return out; 106 | } 107 | 108 | /** Only used for debugging/development atm. */ 109 | deleteWorkspaceSettings(uuid) { 110 | // NB! Does not check if the settings is currently in use. Does not reindex subsequent settings. 111 | let list = this.getListUUID(); 112 | let i = list.indexOf(uuid); 113 | let settings = this.getWorkspaceSettingsByUUID(list[i]); 114 | for (let key of settings.list_keys()) { 115 | // Hopefully resetting all keys will delete the relocatable settings from dconf? 116 | settings.reset(key); 117 | } 118 | 119 | list.splice(i, 1); 120 | this.getWorkspaceList().set_strv('list', list); 121 | } 122 | 123 | // Useful for debugging 124 | printWorkspaceSettings() { 125 | let list = this.getListUUID(); 126 | let settings = list.map(this.getWorkspaceSettingsByUUID); 127 | let zipped = Lib.zip(list, settings); 128 | const key = s => s[1].get_int('index'); 129 | zipped.sort((a, b) => key(a) - key(b)); 130 | for (let [uuid, s] of zipped) { 131 | console.log('index:', s.get_int('index'), s.get_string('name'), s.get_string('color'), uuid); 132 | } 133 | } 134 | } 135 | --------------------------------------------------------------------------------