├── .editorconfig ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── doc ├── TilingShellOverview.mp4 ├── example-layouts.json ├── horiz_summary.jpg ├── json-internal-documentation.md ├── layout_editor.webm ├── layout_selection.webm ├── multiple_selection.webm ├── snap_assistant.webm └── tiling_system.webm ├── esbuild.mjs ├── eslint.config.mjs ├── layouts.example.json ├── logo.png ├── package.json ├── resources ├── icons │ ├── add-symbolic.svg │ ├── cancel-symbolic.svg │ ├── delete-symbolic.svg │ ├── edit-symbolic.svg │ ├── indicator-symbolic.svg │ ├── info-symbolic.svg │ ├── menu-symbolic.svg │ ├── prefs-symbolic.svg │ └── save-symbolic.svg ├── locale │ ├── cs │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── de.mo │ │ │ └── tilingshell.mo │ ├── es │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ ├── it │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ ├── nl │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ ├── pl │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ ├── ru │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ ├── uk │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ ├── zh_CN │ │ └── LC_MESSAGES │ │ │ └── tilingshell.mo │ └── zh_TW │ │ └── LC_MESSAGES │ │ └── tilingshell.mo ├── metadata.json ├── monitorDescription.js └── schemas │ └── org.gnome.shell.extensions.tilingshell.gschema.xml ├── src ├── ambient.d.ts ├── components │ ├── altTab │ │ ├── MetaWindowGroup.ts │ │ ├── MultipleWindowsIcon.ts │ │ ├── overriddenAltTab.ts │ │ └── tilePreviewWithWindow.ts │ ├── editor │ │ ├── editableTilePreview.ts │ │ ├── editorDialog.ts │ │ ├── hoverLine.ts │ │ ├── layoutEditor.ts │ │ └── slider.ts │ ├── layout │ │ ├── Layout.ts │ │ ├── LayoutWidget.ts │ │ ├── Tile.ts │ │ └── TileUtils.ts │ ├── snapassist │ │ ├── snapAssist.ts │ │ ├── snapAssistLayout.ts │ │ ├── snapAssistTile.ts │ │ └── snapAssistTileButton.ts │ ├── tilepreview │ │ ├── blurTilePreview.ts │ │ ├── selectionTilePreview.ts │ │ └── tilePreview.ts │ ├── tilingsystem │ │ ├── edgeTilingManager.ts │ │ ├── extendedWindow.ts │ │ ├── resizeManager.ts │ │ ├── tilingLayout.ts │ │ ├── tilingManager.ts │ │ └── touchPointer.ts │ ├── windowBorderManager.ts │ ├── windowManager │ │ └── tilingShellWindowManager.ts │ ├── window_menu │ │ ├── layoutIcon.ts │ │ ├── layoutTileButtons.ts │ │ └── overriddenWindowMenu.ts │ └── windowsSuggestions │ │ ├── masonryLayoutManager.ts │ │ ├── suggestedWindowPreview.ts │ │ ├── suggestionsTilePreview.ts │ │ └── tilingLayoutWithSuggestions.ts ├── dbus.ts ├── extension.ts ├── gi.ext.ts ├── gi.prefs.ts ├── gi.shared.ts ├── indicator │ ├── currentMenu.ts │ ├── defaultMenu.ts │ ├── editingMenu.ts │ ├── indicator.ts │ ├── layoutButton.ts │ └── utils.ts ├── keybindings.ts ├── polyfill.ts ├── prefs.ts ├── settings │ ├── settings.ts │ ├── settingsExport.ts │ └── settingsOverride.ts ├── styles │ ├── constants.scss │ ├── editor.scss │ ├── functions.scss │ ├── indicator.scss │ ├── layout_button.scss │ ├── layout_icon.scss │ ├── snap_assist.scss │ ├── stylesheet.scss │ ├── tile_preview.scss │ ├── tiling_popup.scss │ ├── window_border.scss │ └── window_menu.scss ├── translations.ts └── utils │ ├── gjs.ts │ ├── globalState.ts │ ├── logger.ts │ ├── signalHandling.ts │ └── ui.ts ├── translations ├── cs.po ├── de.po ├── es.po ├── fr.po ├── it.po ├── nl.po ├── pl.po ├── pt_BR.po ├── ru.po ├── tilingshell@ferrarodomenico.com.pot ├── uk.po ├── zh_CN.po └── zh_TW.po └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # http://editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | # Change these settings to your own preference 9 | indent_style = space 10 | indent_size = 4 11 | 12 | # We recommend you to keep these unchanged 13 | end_of_line = lf 14 | charset = utf-8 15 | trim_trailing_whitespace = true 16 | insert_final_newline = true 17 | 18 | [*.md] 19 | indent_size = 2 20 | trim_trailing_whitespace = false 21 | 22 | [{package.json}] 23 | indent_size = 2 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: domferr 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: domferr 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 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 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Information (please complete the following):** 24 | - Tiling Shell version: [e.g. v9.0] 25 | - GNOME version: [e.g. 42, 46]. If you don't know, run `gnome-shell --version` 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the feature you'd like** 11 | A clear and concise description of what you want to happen (Ex. it would be nice to have [...]). 12 | Is your feature request related to a problem? Please describe (Ex. I'm always frustrated when [...]) 13 | 14 | **Additional context** 15 | Add any other context or screenshots about the feature request here. 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Linux ### 2 | *~ 3 | 4 | # temporary files which can be created if a process still has a handle open of a deleted file 5 | .fuse_hidden* 6 | 7 | # KDE directory preferences 8 | .directory 9 | 10 | # Linux trash folder which might appear on any partition or disk 11 | .Trash-* 12 | 13 | # .nfs files are created when an open file is removed but is still being accessed 14 | .nfs* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | stats.html 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Dependency directories 27 | node_modules/ 28 | 29 | # TypeScript cache 30 | *.tsbuildinfo 31 | 32 | # Optional npm cache directory 33 | .npm 34 | 35 | # Optional eslint cache 36 | .eslintcache 37 | 38 | # dotenv environment variables file 39 | .env 40 | .env.test 41 | 42 | # build outputs 43 | dist/ 44 | dist_legacy/ 45 | 46 | ### VisualStudioCode ### 47 | .vscode/* 48 | 49 | ### VisualStudioCode Patch ### 50 | # Ignore all local history of files 51 | .history 52 | 53 | ### IntelliJ IDEA 54 | .idea/ 55 | 56 | package-lock.json 57 | 58 | *tilingshell@ferrarodomenico.com.zip -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "semi": true, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Submitting a fix 6 | - Implementing new features 7 | - Becoming a maintainer 8 | 9 | ## We Develop with Github 10 | When contributing to this repository, please first discuss with the owners of this repository before making a change. To do so, create a new issue. 11 | 12 | ## Pull Request Process 13 | 14 | 1. Fork the repo and create your branch from `main`. 15 | 2. If you've added code, test it for GNOME shell versions <= 44 and >= 45. (If don't know how to do it, don't worry, we can do it for you!) 16 | 3. If you've changed behaviour, record a demonstration video or describe what's new. 17 | 4. Ensure the other features are still working. 18 | 5. Make sure your code lints. 19 | 6. Issue that pull request! 🥳 20 | 21 | ## Any contributions you make will be under the GPLv2 Software License 22 | In short, when you submit code changes, your submissions are understood to be under the same [GPLv2 License](https://github.com/domferr/tilingshell/blob/main/LICENSE) that covers the project. 23 | Feel free to contact the maintainers if that's a concern. 24 | -------------------------------------------------------------------------------- /doc/TilingShellOverview.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/doc/TilingShellOverview.mp4 -------------------------------------------------------------------------------- /doc/example-layouts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "split-half", 4 | "tiles": [ 5 | { 6 | "x": 0, 7 | "y": 0, 8 | "width": 0.5, 9 | "height": 1, 10 | "groups": [ 11 | 2 12 | ] 13 | }, 14 | { 15 | "x": 0.5, 16 | "y": 0, 17 | "width": 0.5, 18 | "height": 1, 19 | "groups": [ 20 | 1 21 | ] 22 | } 23 | ] 24 | }, 25 | { 26 | "id": "split-thirds", 27 | "tiles": [ 28 | { 29 | "x": 0, 30 | "y": 0, 31 | "width": 0.333, 32 | "height": 1, 33 | "groups": [ 34 | 2, 35 | 3 36 | ] 37 | }, 38 | { 39 | "x": 0.333, 40 | "y": 0, 41 | "width": 0.333, 42 | "height": 1, 43 | "groups": [ 44 | 3, 45 | 1 46 | ] 47 | }, 48 | { 49 | "x": 0.666, 50 | "y": 0, 51 | "width": 0.333, 52 | "height": 1, 53 | "groups": [ 54 | 2, 55 | 1 56 | ] 57 | } 58 | ] 59 | } 60 | ] 61 | -------------------------------------------------------------------------------- /doc/horiz_summary.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/doc/horiz_summary.jpg -------------------------------------------------------------------------------- /doc/json-internal-documentation.md: -------------------------------------------------------------------------------- 1 | # Documentation for JSON exported layouts 2 | 3 | *Tiling Shell* supports importing and exporting its layouts as a JSON file. With this you can create your own custom layouts, or fine-tune already existing layouts. 4 | 5 | The exported layouts (from the preferences) are a collection of `Layout` objects. A `Layout` object is an object with two (2) properties: 6 | 7 | - identifier as a `string` 8 | - a list of `Tile` objects 9 | 10 | Example JSON of a `Layout` object would look like 11 | 12 | ```json 13 | { 14 | "id": "The identifier", 15 | "tiles": [ 16 | ... 17 | ] 18 | } 19 | ``` 20 | 21 | A `Tile` object has five (5) properties: 22 | 23 | - The X (`x`) axis as a `float` 24 | - The Y (`y`) axis as a `float` 25 | - The width (`width`) as a `float` 26 | - The height (`height`) as a `float` 27 | - A list of identifiers `groups` 28 | 29 | The `x`, `y`, `width` and `height` are percentages relative to the screen size. Both `x` and `y` start from the top left of a `Tile`. 30 | 31 | So a `Tile` with `x` = 0.5 and `y` = 0.5, on a screen with a resolution of 1920x1080 pixels is placed at `x = 0.5 * 1920 = 960px` and `y = 0.5 * 1080 = 540px`. For example, if the `width` and `height` of the `Tile` are set to `0.25`, this gives a `Tile` of `width = 0.25 * 1920 = 480px` and `height = 0.25 * 1080 = 270px`. 32 | 33 | The `group` attribute is mainly used in the layout editor where it determines which `Tile`(s) are "linked": if you resize a single `Tile` it's linked neighbour(s) are also updated. 34 | 35 | For more in depth information you can look at an [in depth explanation](https://github.com/domferr/tilingshell/issues/177#issuecomment-2458322208) of `group`(s). 36 | 37 | Example JSON of a `Tile` object would look like this 38 | 39 | ```json 40 | { 41 | "x": 0, 42 | "y": 0, 43 | "width": 1, 44 | "height": 1, 45 | "groups": [ 46 | 1 47 | ] 48 | } 49 | ``` 50 | 51 | ## Example JSON file 52 | 53 | Finally, an example JSON file describing one Layout with two tiles. 54 | 55 | ```json 56 | { 57 | "id": "Equal split", 58 | "tiles": [ 59 | { 60 | "x": 0, 61 | "y": 0, 62 | "width": 0.5, 63 | "height": 1, 64 | "groups": [ 65 | 1 66 | ] 67 | }, 68 | { 69 | "x": 0.5, 70 | "y": 0, 71 | "width": 0.5, 72 | "height": 1, 73 | "groups": [ 74 | 1 75 | ] 76 | } 77 | ] 78 | } 79 | ``` 80 | -------------------------------------------------------------------------------- /doc/layout_editor.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/doc/layout_editor.webm -------------------------------------------------------------------------------- /doc/layout_selection.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/doc/layout_selection.webm -------------------------------------------------------------------------------- /doc/multiple_selection.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/doc/multiple_selection.webm -------------------------------------------------------------------------------- /doc/snap_assistant.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/doc/snap_assistant.webm -------------------------------------------------------------------------------- /doc/tiling_system.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/doc/tiling_system.webm -------------------------------------------------------------------------------- /esbuild.mjs: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild'; 2 | import { sassPlugin } from 'esbuild-sass-plugin' 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { glob } from 'glob'; 6 | 7 | const resourcesDir = "resources"; 8 | const distDir = "dist"; 9 | const distLegacyDir = "dist_legacy"; 10 | 11 | const extensionBanner = `// For GNOME Shell version before 45 12 | class Extension { 13 | constructor(meta) { // meta has type ExtensionMeta 14 | this.metadata = meta.metadata; 15 | this.uuid = meta.uuid; 16 | this.path = meta.path; 17 | } 18 | getSettings() { 19 | return imports.misc.extensionUtils.getSettings(); 20 | } 21 | 22 | static openPrefs() { 23 | return imports.misc.extensionUtils.openPrefs(); 24 | } 25 | } 26 | 27 | class Mtk { Rectangle } 28 | Mtk.Rectangle = function (params = {}) { 29 | return new imports.gi.Meta.Rectangle(params); 30 | }; 31 | Mtk.Rectangle.$gtype = imports.gi.Meta.Rectangle.$gtype; 32 | `; 33 | 34 | const extensionFooter = ` 35 | function init(meta) { 36 | imports.misc.extensionUtils.initTranslations(); 37 | return new TilingShellExtension(meta); 38 | } 39 | `; 40 | 41 | const prefsBanner = `// For GNOME Shell version before 45 42 | const Config = imports.misc.config; 43 | 44 | class ExtensionPreferences { 45 | constructor(metadata) { 46 | this.metadata = metadata; 47 | } 48 | 49 | getSettings() { 50 | return imports.misc.extensionUtils.getSettings(); 51 | } 52 | } 53 | `; 54 | 55 | const prefsFooter = ` 56 | function init() { 57 | imports.misc.extensionUtils.initTranslations(); 58 | } 59 | 60 | function fillPreferencesWindow(window) { 61 | const metadata = imports.misc.extensionUtils.getCurrentExtension().metadata; 62 | const prefs = new TilingShellExtensionPreferences(metadata); 63 | prefs.fillPreferencesWindow(window); 64 | } 65 | `; 66 | 67 | /// Converts imports on the form 68 | /// import { a, b, c } from 'gi://Source' 69 | /// 70 | /// to 71 | /// 72 | /// const Source = imports.gi; 73 | /// and 74 | /// const { a, b, c } = imports.gi.Source; 75 | /// If the imported module is Mtk, it is aliased with Meta 76 | function convertImports(text) { 77 | // drop import of Extension class 78 | text = text.replaceAll('import { Extension } from "resource:///org/gnome/shell/extensions/extension.js";', ""); 79 | 80 | // drop import of ExtensionPreferences class 81 | text = text.replaceAll('import { ExtensionPreferences } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js";', ""); 82 | 83 | // drop import of Config from preferences 84 | text = text.replaceAll('import * as Config from "resource:///org/gnome/Shell/Extensions/js/misc/config.js";', ""); 85 | 86 | // replace import of translation related code 87 | const regexTranslation = new RegExp(`import {(.*|\n.*)?gettext as _[^from]*[^;]*;`, 'gm'); 88 | text = text.replaceAll(regexTranslation, "const { gettext: _, ngettext, pgettext } = imports.misc.extensionUtils;"); 89 | 90 | // replace import of all translation stuff made in translation.ts 91 | //text = text.replaceAll('import { gettext as _, ngettext, pgettext } from "resource:///org/gnome/shell/extensions/extension.js";', "const { gettext: _, ngettext, pgettext } = imports.misc.extensionUtils;"); 92 | 93 | const regexExportExtension = new RegExp(`export {((.|\n)*)(.+) as default((.|\n)*)};`, 'gm'); 94 | text = text.replaceAll(regexExportExtension, ""); 95 | 96 | // replace import Source from "gi://Source" with const Source = imports.gi.Source; 97 | const regexGi = new RegExp('import (.+) from \\"gi:\\/\\/(.+)\\"', 'gm'); 98 | text = text.replaceAll(regexGi, (match, imported, module) => { 99 | if (module.indexOf("Mtk") >= 0) { 100 | // remove first occurrence of Mtk. 101 | // it will be defined by the extension banner 102 | if (imported === "Mtk") return ""; 103 | // alias the imported Mtk with the extension banner's Mtk 104 | return `const ${imported} = Mtk`; 105 | } 106 | return `const ${imported} = imports.gi.${module}`; 107 | }); 108 | 109 | // replace import * as Source from "resource:///org/gnome/shell/path/to/source.js"; with const Source = imports.path.to.Source; 110 | const regexResource = new RegExp('import \\* as (.+) from \\"resource:\\/\\/\\/org\\/gnome\\/shell\\/(.+)\\.js\\"', 'gm'); 111 | text = text.replaceAll(regexResource, (match, imported, module) => `const ${imported} = imports.${module.replace('/', '.')}`); 112 | 113 | return text; 114 | } 115 | 116 | // build extension 117 | build({ 118 | logLevel: "info", 119 | entryPoints: ['src/extension.ts', 'src/prefs.ts'], 120 | outdir: distDir, 121 | bundle: true, 122 | treeShaking: false, 123 | // firefox60 // Since GJS 1.53.90 124 | // firefox68 // Since GJS 1.63.90 125 | // firefox78 // Since GJS 1.65.90 126 | // firefox91 // Since GJS 1.71.1 127 | // firefox102 // Since GJS 1.73.2 128 | target: 'firefox78', 129 | platform: 'node', 130 | format: 'esm', 131 | external: ['gi://*', 'resource://*'], 132 | plugins: [sassPlugin()], 133 | }).then(() => { 134 | fs.renameSync(path.resolve(distDir, "extension.css"), path.resolve(distDir, "stylesheet.css")); 135 | fs.cpSync(resourcesDir, distDir, { recursive: true }); 136 | // warn if you imported GTK libraries in GNOME Shell (Gdk, Gtk or Adw) 137 | const extensionJSContent = fs.readFileSync(`${distDir}/extension.js`).toString().split('\n'); 138 | ['Gdk', 'Gtk', 'Adw'].forEach(moduleName => { 139 | for (let lineNumber = 0; lineNumber < extensionJSContent.length; lineNumber++) { 140 | if (extensionJSContent[lineNumber].indexOf(`import ${moduleName}`) >= 0) { 141 | console.error(`⚠️ Error: "${moduleName}" was imported in extension.js at line ${lineNumber}`); 142 | } 143 | } 144 | }); 145 | // warn if you imported GNOME Shell libraries in Preferences (Clutter, Meta, St or Shell) 146 | const prefsJSContent = fs.readFileSync(`${distDir}/prefs.js`).toString().split('\n'); 147 | ['Clutter', 'Meta', 'Mtk', 'St', 'Shell'].forEach(moduleName => { 148 | for (let lineNumber = 0; lineNumber < prefsJSContent.length; lineNumber++) { 149 | if (prefsJSContent[lineNumber].indexOf(`import ${moduleName}`) >= 0) { 150 | console.error(`⚠️ Error: "${moduleName}" was imported in prefs.js at line ${lineNumber}`); 151 | } 152 | } 153 | }); 154 | }).then(async () => { 155 | console.log(" 💡", "Generating legacy version..."); 156 | // duplicate the build into distLegacyDir 157 | fs.cpSync(distDir, distLegacyDir, { recursive: true }); 158 | // for each js file in distLegacyDir, apply conversion 159 | const files = await glob(`${distLegacyDir}/**/*.js`, {}); 160 | for (let file of files) { 161 | let jsFileContent = fs.readFileSync(file).toString(); 162 | let convertedContent = convertImports(jsFileContent); 163 | if (file.indexOf("extension.js") >= 0) { 164 | fs.writeFileSync(file, `${extensionBanner}${convertedContent}${extensionFooter}`); 165 | } else if (file.indexOf("prefs.js") >= 0) { 166 | fs.writeFileSync(file, `${prefsBanner}${convertedContent}${prefsFooter}`); 167 | } else { 168 | fs.writeFileSync(file, convertedContent); 169 | } 170 | } 171 | const metadataFile = path.resolve(resourcesDir, 'metadata.json'); 172 | const metadataJson = JSON.parse(fs.readFileSync(metadataFile)); 173 | const legacyShellVersions = metadataJson["shell-version"].filter(version => Number(version) <= 44); 174 | const nonLegacyShellVersions = metadataJson["shell-version"].filter(version => Number(version) > 44); 175 | 176 | console.log(" 🚀", "Updating metadata.json file..."); 177 | // remove legacy versions from main version's metadata file 178 | metadataJson["shell-version"] = nonLegacyShellVersions; 179 | fs.writeFileSync(path.resolve(distDir, 'metadata.json'), JSON.stringify(metadataJson, null, 4)); 180 | 181 | // keep legacy versions only from legacy extension's metadata file 182 | metadataJson["shell-version"] = legacyShellVersions; 183 | fs.writeFileSync(path.resolve(distLegacyDir, 'metadata.json'), JSON.stringify(metadataJson, null, 4)); 184 | console.log(); 185 | console.log("📁 ", "Main version directory: ", distDir); 186 | console.log("📁 ", "Legacy version directory:", distLegacyDir); 187 | console.log("📖 ", "Main version for GNOME Shells: ", nonLegacyShellVersions); 188 | console.log("📖 ", "Legacy version for GNOME Shells:", legacyShellVersions); 189 | }); 190 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from 'typescript-eslint'; 2 | import eslint from '@eslint/js'; 3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 4 | import globals from 'globals'; 5 | 6 | export default tseslint.config( 7 | { 8 | ignores: ['node_modules/**', 'dist/**', 'dist_legacy/**'], 9 | }, 10 | { 11 | languageOptions: { 12 | parser: tseslint.parser, 13 | parserOptions: { 14 | project: true, 15 | }, 16 | sourceType: 'module', 17 | ecmaVersion: 2022, 18 | globals: { 19 | ...globals.es2021, 20 | ARGV: 'readonly', 21 | Debugger: 'readonly', 22 | GIRepositoryGType: 'readonly', 23 | globalThis: 'readonly', 24 | imports: 'readonly', 25 | Intl: 'readonly', 26 | log: 'readonly', 27 | logError: 'readonly', 28 | print: 'readonly', 29 | printerr: 'readonly', 30 | window: 'readonly', 31 | TextEncoder: 'readonly', 32 | TextDecoder: 'readonly', 33 | console: 'readonly', 34 | setTimeout: 'readonly', 35 | setInterval: 'readonly', 36 | clearTimeout: 'readonly', 37 | clearInterval: 'readonly', 38 | }, 39 | }, 40 | extends: [eslint.configs.recommended, eslintPluginPrettierRecommended, ...tseslint.configs.recommended], 41 | plugins: { 42 | '@typescript-eslint': tseslint.plugin, 43 | }, 44 | rules: { 45 | /* Allow unused variables starting with underscores */ 46 | 'no-unused-vars': 'off', 47 | '@typescript-eslint/no-unused-vars': [ 48 | 'warn', 49 | { 50 | varsIgnorePattern: '(^unused|_$)', 51 | argsIgnorePattern: '^(unused|_)', 52 | destructuredArrayIgnorePattern: '^_', 53 | }, 54 | ], 55 | "no-shadow": "off", 56 | "@typescript-eslint/no-shadow": "error", 57 | '@typescript-eslint/no-non-null-assertion': 'warn', 58 | 'array-bracket-newline': ['error', 'consistent'], 59 | 'array-bracket-spacing': ['error', 'never'], 60 | 'array-callback-return': 'error', 61 | 'arrow-spacing': 'error', 62 | 'block-scoped-var': 'error', 63 | 'block-spacing': 'error', 64 | 'brace-style': 'error', 65 | camelcase: [ 66 | 'off', 67 | { 68 | properties: 'never', 69 | }, 70 | ], 71 | 'comma-spacing': [ 72 | 'error', 73 | { 74 | before: false, 75 | after: true, 76 | }, 77 | ], 78 | 'comma-style': ['error', 'last'], 79 | 'computed-property-spacing': 'error', 80 | curly: ['error', 'multi-or-nest', 'consistent'], 81 | 'dot-location': ['error', 'property'], 82 | 'eol-last': 'error', 83 | eqeqeq: 'error', 84 | 'func-call-spacing': 'error', 85 | 'func-name-matching': 'error', 86 | 'func-style': [ 87 | 'error', 88 | 'declaration', 89 | { 90 | allowArrowFunctions: true, 91 | }, 92 | ], 93 | 'key-spacing': [ 94 | 'error', 95 | { 96 | beforeColon: false, 97 | afterColon: true, 98 | }, 99 | ], 100 | 'keyword-spacing': [ 101 | 'error', 102 | { 103 | before: true, 104 | after: true, 105 | }, 106 | ], 107 | 'linebreak-style': ['error', 'unix'], 108 | 'max-nested-callbacks': 'error', 109 | 'max-statements-per-line': 'error', 110 | 'new-parens': 'error', 111 | 'no-array-constructor': 'error', 112 | 'no-await-in-loop': 'error', 113 | 'no-caller': 'error', 114 | 'no-constant-condition': [ 115 | 'error', 116 | { 117 | checkLoops: false, 118 | }, 119 | ], 120 | 'no-div-regex': 'error', 121 | 'no-empty': [ 122 | 'error', 123 | { 124 | allowEmptyCatch: true, 125 | }, 126 | ], 127 | 'no-extra-bind': 'error', 128 | 'no-implicit-coercion': [ 129 | 'error', 130 | { 131 | allow: ['!!'], 132 | }, 133 | ], 134 | 'no-invalid-this': 'error', 135 | 'no-iterator': 'error', 136 | 'no-label-var': 'error', 137 | 'no-lonely-if': 'error', 138 | 'no-loop-func': 'error', 139 | 'no-new-object': 'error', 140 | 'no-new-wrappers': 'error', 141 | 'no-octal-escape': 'error', 142 | 'no-proto': 'error', 143 | 'no-prototype-builtins': 'off', 144 | 'no-restricted-globals': ['error', 'window'], 145 | 'no-restricted-properties': [ 146 | 'error', 147 | { 148 | object: 'Lang', 149 | property: 'copyProperties', 150 | message: 'Use Object.assign()', 151 | }, 152 | { 153 | object: 'Lang', 154 | property: 'bind', 155 | message: 'Use arrow notation or Function.prototype.bind()', 156 | }, 157 | { 158 | object: 'Lang', 159 | property: 'Class', 160 | message: 'Use ES6 classes', 161 | }, 162 | ], 163 | 'no-restricted-syntax': [ 164 | 'error', 165 | { 166 | selector: 167 | 'MethodDefinition[key.name="_init"] > FunctionExpression[params.length=1] > BlockStatement[body.length=1] CallExpression[arguments.length=1][callee.object.type="Super"][callee.property.name="_init"] > Identifier:first-child', 168 | message: 169 | '_init() that only calls super._init() is unnecessary', 170 | }, 171 | { 172 | selector: 173 | 'MethodDefinition[key.name="_init"] > FunctionExpression[params.length=0] > BlockStatement[body.length=1] CallExpression[arguments.length=0][callee.object.type="Super"][callee.property.name="_init"]', 174 | message: 175 | '_init() that only calls super._init() is unnecessary', 176 | }, 177 | { 178 | selector: 179 | 'BinaryExpression[operator="instanceof"][right.name="Array"]', 180 | message: 'Use Array.isArray()', 181 | }, 182 | ], 183 | 'no-return-assign': 'error', 184 | 'no-return-await': 'error', 185 | 'no-self-compare': 'error', 186 | 'no-shadow-restricted-names': 'error', 187 | 'no-spaced-func': 'error', 188 | 'no-tabs': 'error', 189 | 'no-template-curly-in-string': 'error', 190 | 'no-throw-literal': 'error', 191 | 'no-trailing-spaces': 'error', 192 | 'no-undef-init': 'error', 193 | 'no-unneeded-ternary': 'error', 194 | 'no-unused-expressions': 'error', 195 | 'no-useless-call': 'error', 196 | 'no-useless-computed-key': 'error', 197 | 'no-useless-concat': 'error', 198 | 'no-useless-constructor': 'error', 199 | 'no-useless-rename': 'error', 200 | 'no-useless-return': 'error', 201 | 'no-whitespace-before-property': 'error', 202 | 'no-with': 'error', 203 | 'object-curly-newline': [ 204 | 'error', 205 | { 206 | consistent: true, 207 | multiline: true, 208 | }, 209 | ], 210 | 'object-shorthand': 'error', 211 | 'operator-assignment': 'error', 212 | 'operator-linebreak': 'error', 213 | 'padded-blocks': ['error', 'never'], 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 | { 223 | avoidEscape: true, 224 | }, 225 | ], 226 | 'require-await': 'error', 227 | 'rest-spread-spacing': 'error', 228 | semi: ['error', 'always'], 229 | 'semi-spacing': [ 230 | 'error', 231 | { 232 | before: false, 233 | after: true, 234 | }, 235 | ], 236 | 'semi-style': 'error', 237 | 'space-before-blocks': 'error', 238 | 'space-before-function-paren': [ 239 | 'error', 240 | { 241 | named: 'never', 242 | anonymous: 'always', 243 | asyncArrow: 'always', 244 | }, 245 | ], 246 | 'space-in-parens': 'error', 247 | 'space-infix-ops': [ 248 | 'error', 249 | { 250 | int32Hint: false, 251 | }, 252 | ], 253 | 'space-unary-ops': 'error', 254 | 'spaced-comment': 'error', 255 | 'switch-colon-spacing': 'error', 256 | 'symbol-description': 'error', 257 | 'template-curly-spacing': 'error', 258 | 'template-tag-spacing': 'error', 259 | 'unicode-bom': 'error', 260 | 'wrap-iife': ['error', 'inside'], 261 | 'yield-star-spacing': 'error', 262 | yoda: 'error', 263 | }, 264 | files: ['**/*.ts'], 265 | }, 266 | { 267 | // disable type-aware linting on JS files 268 | files: ['**/*.js'], 269 | ...tseslint.configs.disableTypeChecked, 270 | }, 271 | ); 272 | -------------------------------------------------------------------------------- /layouts.example.json: -------------------------------------------------------------------------------- 1 | [{"id":"Layout 1","tiles":[{"x":0,"y":0,"width":0.22,"height":0.5,"groups":[1,2]},{"x":0,"y":0.5,"width":0.22,"height":0.5,"groups":[1,2]},{"x":0.22,"y":0,"width":0.56,"height":1,"groups":[2,3]},{"x":0.78,"y":0,"width":0.22,"height":0.5,"groups":[3,4]},{"x":0.78,"y":0.5,"width":0.22,"height":0.5,"groups":[3,4]}]},{"id":"Layout 2","tiles":[{"x":0,"y":0,"width":0.22,"height":1,"groups":[1]},{"x":0.22,"y":0,"width":0.56,"height":1,"groups":[1,2]},{"x":0.78,"y":0,"width":0.22,"height":1,"groups":[2]}]},{"id":"Layout 3","tiles":[{"x":0,"y":0,"width":0.33,"height":1,"groups":[1]},{"x":0.33,"y":0,"width":0.67,"height":1,"groups":[1]}]},{"id":"Layout 4","tiles":[{"x":0,"y":0,"width":0.67,"height":1,"groups":[1]},{"x":0.67,"y":0,"width":0.33,"height":1,"groups":[1]}]}] -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tilingshell", 3 | "version": "16.4", 4 | "author": "Domenico Ferraro ", 5 | "private": true, 6 | "license": "GPL v2.0", 7 | "scripts": { 8 | "build": "npm run clean && node esbuild.mjs && npm run build:schema", 9 | "clean": "rm -rf dist; rm -rf dist_legacy", 10 | "update-translations": "npm run create:translations && npm run merge:translations && npm run build:translations", 11 | "build:schema": "npm run clean:schema && glib-compile-schemas ./resources/schemas --targetdir=./dist/schemas/ && cp ./dist/schemas/ ./dist_legacy/ -r", 12 | "clean:schema": "rm -rf ./dist/schemas/*.compiled; rm -rf ./dist_legacy/schemas/*.compiled", 13 | "build:package": "npm run clean:package; npm run build && cd ./dist && zip -qr ../tilingshell@ferrarodomenico.com.zip * && cd ../dist_legacy && zip -qr ../GNOME.42-44.tilingshell@ferrarodomenico.com.zip *", 14 | "clean:package": "rm -rf './dist/tilingshell@ferrarodomenico.com.zip'; rm -rf './dist_legacy/tilingshell@ferrarodomenico.com.zip'", 15 | "install:extension": "mkdir -p ~/.local/share/gnome-shell/extensions/tilingshell@ferrarodomenico.com && cp ./dist$([ $(gnome-shell --version | grep -o -E '[0-9]+' | head -n 1) -le 44 ] && echo '_legacy')/* ~/.local/share/gnome-shell/extensions/tilingshell@ferrarodomenico.com/ -r", 16 | "wayland-session": "dbus-run-session -- env MUTTER_DEBUG_NUM_DUMMY_MONITORS=1 MUTTER_DEBUG_DUMMY_MODE_SPECS=1920x1080 gnome-shell --nested --wayland", 17 | "dev:wayland": "npm run build && npm run install:extension && npm run wayland-session", 18 | "build:translations": "for file in $(ls translations/*.po); do mkdir -p resources/locale/$(basename $file .po)/LC_MESSAGES; msgfmt -c $file -o resources/locale/$(basename $file .po)/LC_MESSAGES/tilingshell.mo; done", 19 | "create:translations": "rm -f translations/tilingshell@ferrarodomenico.com.pot && xgettext --from-code=UTF-8 --output=translations/tilingshell@ferrarodomenico.com.pot --language=javascript --force-po dist/prefs.js dist/extension.js", 20 | "merge:translations": "for file in $(ls translations/*.po); do msgmerge -U $file translations/tilingshell@ferrarodomenico.com.pot --backup=none; done", 21 | "lint": "eslint .", 22 | "lint:fix": "eslint . --fix", 23 | "prettier:check": "prettier --check \"**/*.{ts,scss}\"", 24 | "prettier:fix": "prettier --write \"**/*.{ts,scss}\"" 25 | }, 26 | "devDependencies": { 27 | "esbuild": "^0.20.2", 28 | "esbuild-sass-plugin": "^3.2.0", 29 | "eslint": "^8.57.0", 30 | "eslint-config-prettier": "^9.1.0", 31 | "eslint-plugin-prettier": "^5.1.3", 32 | "glob": "^10.3.12", 33 | "globals": "^15.8.0", 34 | "prettier": "^3.3.2", 35 | "typescript": "^5.4.5", 36 | "typescript-eslint": "^7.15.0" 37 | }, 38 | "dependencies": { 39 | "@girs/gnome-shell": "^48.0.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /resources/icons/add-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/icons/cancel-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/icons/delete-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /resources/icons/edit-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/indicator-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /resources/icons/info-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/menu-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/prefs-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/save-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /resources/locale/cs/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/cs/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/de/LC_MESSAGES/de.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/de/LC_MESSAGES/de.mo -------------------------------------------------------------------------------- /resources/locale/de/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/de/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/es/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/es/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/fr/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/fr/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/it/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/it/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/nl/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/nl/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/pl/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/pl/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/pt_BR/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/pt_BR/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/ru/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/ru/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/uk/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/uk/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/zh_CN/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/zh_CN/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/locale/zh_TW/LC_MESSAGES/tilingshell.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domferr/tilingshell/c12f6eecde7279aed0bf5dd8e3631c2551afe068/resources/locale/zh_TW/LC_MESSAGES/tilingshell.mo -------------------------------------------------------------------------------- /resources/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tiling Shell", 3 | "description": "Extend Gnome Shell with advanced tiling window management. Supports multiple monitors, Windows 11 Snap Assistant, Fancy Zones, automatic tiling, keyboard shortcuts, customised tiling layouts and more!", 4 | "uuid": "tilingshell@ferrarodomenico.com", 5 | "shell-version": [ 6 | "42", 7 | "43", 8 | "44", 9 | "45", 10 | "46", 11 | "47", 12 | "48" 13 | ], 14 | "version": 99, 15 | "version-name": "16.4", 16 | "url": "https://github.com/domferr/tilingshell", 17 | "settings-schema": "org.gnome.shell.extensions.tilingshell", 18 | "gettext-domain": "tilingshell", 19 | "donations": { 20 | "kofi": "domferr", 21 | "patreon": "domferr" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /resources/monitorDescription.js: -------------------------------------------------------------------------------- 1 | #!@GJS@ -m 2 | 3 | import Gtk from "gi://Gtk"; 4 | import Gdk from "gi://Gdk"; 5 | //const { Gtk, Gdk } = imports.gi; 6 | 7 | Gtk.init(); 8 | const monitors = Gdk.Display.get_default().get_monitors(); 9 | const details = []; 10 | for (const m of monitors) { 11 | const { x, y, width, height } = m.get_geometry(); 12 | details.push({ name: m.get_description(), x, y, width, height }); 13 | } 14 | 15 | print(JSON.stringify(details)); -------------------------------------------------------------------------------- /src/ambient.d.ts: -------------------------------------------------------------------------------- 1 | import '@girs/gjs'; 2 | import '@girs/gjs/dom'; 3 | import '@girs/gnome-shell/ambient'; 4 | import '@girs/gnome-shell/extensions/global'; 5 | -------------------------------------------------------------------------------- /src/components/altTab/MetaWindowGroup.ts: -------------------------------------------------------------------------------- 1 | import { Meta } from '@gi.ext'; 2 | 3 | /** 4 | * Represents a group of windows and allows executing methods on all of them simultaneously. 5 | */ 6 | export default class MetaWindowGroup { 7 | private _windows: Meta.Window[]; 8 | 9 | /** 10 | * Initializes a WindowsGroup with a list of Meta.Window instances. 11 | * @param windows - An array of Meta.Window objects to manage as a group. 12 | */ 13 | constructor(windows: Meta.Window[]) { 14 | this._windows = windows; 15 | 16 | return new Proxy(this, { 17 | get: (target, prop, receiver) => { 18 | // If the property exists in WindowsGroup itself, return it 19 | if (prop in target) return Reflect.get(target, prop, receiver); 20 | 21 | // If the property exists on a Meta.Window instance, proxy the call to all windows 22 | // @ts-expect-error "This is expected" 23 | if (typeof this._windows[0]?.[prop] === 'function') { 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | return (...args: any[]) => { 26 | // debug(`Called function: ${String(prop)}`); 27 | 28 | // Execute the method on each window in the group 29 | this._windows.forEach((win) => 30 | // @ts-expect-error "This is expected" 31 | // eslint-disable-next-line @typescript-eslint/ban-types 32 | (win[prop] as Function)(...args), 33 | ); 34 | }; 35 | } 36 | 37 | // If it's a property (not a function), return the value from the first window 38 | // @ts-expect-error "This is expected" 39 | return this._windows[0]?.[prop]; 40 | }, 41 | }); 42 | } 43 | 44 | public get_workspace(): Meta.Workspace { 45 | return this._windows[0].get_workspace(); 46 | } 47 | 48 | public activate(time: number) { 49 | // avoid activating with the same time 50 | this._windows.forEach((win) => { 51 | win.activate(time); 52 | time = global.get_current_time(); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/altTab/MultipleWindowsIcon.ts: -------------------------------------------------------------------------------- 1 | import { registerGObjectClass } from '@utils/gjs'; 2 | import { Clutter, Mtk, Meta, St } from '@gi.ext'; 3 | import LayoutWidget from '@components/layout/LayoutWidget'; 4 | import Tile from '@components/layout/Tile'; 5 | import Layout from '@components/layout/Layout'; 6 | import { buildMarginOf, buildRectangle } from '@utils/ui'; 7 | import { logger } from '@utils/logger'; 8 | import TilePreviewWithWindow from './tilePreviewWithWindow'; 9 | import MetaWindowGroup from './MetaWindowGroup'; 10 | import { _ } from '../../translations'; 11 | 12 | const debug = logger('MultipleWindowsIcon'); 13 | 14 | @registerGObjectClass 15 | export default class MultipleWindowsIcon extends LayoutWidget { 16 | private _label: St.Label; 17 | private _window: MetaWindowGroup; 18 | 19 | constructor(params: { 20 | width: number; 21 | height: number; 22 | tiles: Tile[]; 23 | windows: Meta.Window[]; 24 | innerGaps: Clutter.Margin; 25 | }) { 26 | super({ 27 | layout: new Layout(params.tiles, ''), 28 | innerGaps: params.innerGaps.copy(), 29 | outerGaps: buildMarginOf(2), 30 | }); 31 | this.set_size(params.width, params.height); 32 | super.relayout({ 33 | containerRect: buildRectangle({ 34 | x: 0, 35 | y: 0, 36 | width: params.width, 37 | height: params.height, 38 | }), 39 | }); 40 | 41 | this._previews.forEach((preview, index) => { 42 | const window = params.windows[index]; 43 | if (!window) { 44 | // bad input given to the constructor! 45 | preview.hide(); 46 | return; 47 | } 48 | 49 | const winClone = new Clutter.Clone({ 50 | source: window.get_compositor_private(), 51 | width: preview.innerWidth, 52 | height: preview.innerHeight, 53 | }); 54 | preview.add_child(winClone); 55 | }); 56 | 57 | this._label = new St.Label({ 58 | text: _('Tiled windows'), 59 | }); 60 | // gnome shell accesses to this window, we need to abstract operations to work for a group of windows instead of one 61 | this._window = new MetaWindowGroup(params.windows); 62 | } 63 | 64 | buildTile( 65 | parent: Clutter.Actor, 66 | rect: Mtk.Rectangle, 67 | gaps: Clutter.Margin, 68 | tile: Tile, 69 | ): TilePreviewWithWindow { 70 | return new TilePreviewWithWindow({ parent, rect, gaps, tile }); 71 | } 72 | 73 | public get window() { 74 | return this._window; 75 | } 76 | 77 | public get label() { 78 | return this._label; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/altTab/overriddenAltTab.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as AltTab from 'resource:///org/gnome/shell/ui/altTab.js'; 3 | import { St, Meta, Clutter } from '@gi.ext'; 4 | import { logger } from '@utils/logger'; 5 | import ExtendedWindow from '@components/tilingsystem/extendedWindow'; 6 | import MultipleWindowsIcon from './MultipleWindowsIcon'; 7 | import { buildMargin } from '@utils/ui'; 8 | import Settings from '@settings/settings'; 9 | 10 | const GAPS = 3; 11 | 12 | const debug = logger('OverriddenAltTab'); 13 | 14 | export default class OverriddenAltTab { 15 | private static _instance: OverriddenAltTab | null = null; 16 | private static _old_show: { 17 | (): boolean; 18 | (backward: boolean, binding: any, mask: any): boolean; 19 | } | null; 20 | private static _enabled: boolean = false; 21 | 22 | // AltTab has these private fields 23 | private _switcherList: any; 24 | private _items: any; 25 | 26 | static get(): OverriddenAltTab { 27 | if (this._instance === null) this._instance = new OverriddenAltTab(); 28 | return this._instance; 29 | } 30 | 31 | static enable() { 32 | // if it is already enabled, do not enable again 33 | if (this._enabled) return; 34 | 35 | const owm = this.get(); 36 | 37 | OverriddenAltTab._old_show = AltTab.WindowSwitcherPopup.prototype.show; 38 | // @ts-expect-error "This is expected" 39 | AltTab.WindowSwitcherPopup.prototype.show = owm.newShow; 40 | 41 | this._enabled = true; 42 | } 43 | 44 | static disable() { 45 | // if it is not enabled, do not disable 46 | if (!this._enabled) return; 47 | 48 | // @ts-expect-error "This is expected" 49 | AltTab.WindowSwitcherPopup.prototype.show = OverriddenAltTab._old_show; 50 | this._old_show = null; 51 | 52 | this._enabled = false; 53 | } 54 | 55 | static destroy() { 56 | this.disable(); 57 | this._instance = null; 58 | } 59 | 60 | // the function will be treated as a method of class WindowMenu 61 | private newShow(backward: boolean, binding: any, mask: any): boolean { 62 | // allow the list to show NON-squared widgets 63 | this._switcherList._list.get_layout_manager().homogeneous = false; 64 | this._switcherList._squareItems = false; 65 | 66 | // Call original show function 67 | const oldFunction = OverriddenAltTab._old_show?.bind(this); 68 | const res = !oldFunction || oldFunction(backward, binding, mask); 69 | 70 | const tiledWindows: Meta.Window[] = ( 71 | this._getWindowList() as Meta.Window[] 72 | ).filter((win) => (win as ExtendedWindow).assignedTile); 73 | 74 | if (tiledWindows.length <= 1) return res; 75 | 76 | const tiles = tiledWindows 77 | .map((win) => (win as ExtendedWindow).assignedTile) 78 | .filter((tile) => tile !== undefined); 79 | 80 | const inner_gaps = Settings.get_inner_gaps(); 81 | const height = this._items[0].height; 82 | const width = Math.floor((height * 16) / 9); 83 | const gaps = 84 | GAPS * 85 | St.ThemeContext.get_for_stage(global.stage as Clutter.Stage) 86 | .scale_factor; 87 | 88 | // Create new group entry 89 | const groupWindowsIcon = new MultipleWindowsIcon({ 90 | tiles, 91 | width, 92 | height, 93 | innerGaps: buildMargin({ 94 | top: inner_gaps.top === 0 ? 0 : gaps, 95 | bottom: inner_gaps.bottom === 0 ? 0 : gaps, 96 | left: inner_gaps.left === 0 ? 0 : gaps, 97 | right: inner_gaps.right === 0 ? 0 : gaps, 98 | }), 99 | windows: tiledWindows, 100 | }); 101 | 102 | // Append the group item to the list 103 | this._switcherList.addItem(groupWindowsIcon, groupWindowsIcon.label); 104 | this._items.push(groupWindowsIcon); 105 | 106 | return res; 107 | } 108 | private _getWindowList(): Meta.Window[] { 109 | throw new Error('Method not implemented.'); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/components/altTab/tilePreviewWithWindow.ts: -------------------------------------------------------------------------------- 1 | import { Clutter } from '@gi.ext'; 2 | import { registerGObjectClass } from '@/utils/gjs'; 3 | import { buildRectangle } from '@utils/ui'; 4 | import Tile from '@components/layout/Tile'; 5 | import TilePreview, { 6 | TilePreviewConstructorProperties, 7 | } from '@components/tilepreview/tilePreview'; 8 | 9 | @registerGObjectClass 10 | export default class TilePreviewWithWindow extends TilePreview { 11 | constructor(params: Partial) { 12 | super(params); 13 | if (params.parent) params.parent.add_child(this); 14 | 15 | this._showing = false; 16 | this._rect = params.rect || buildRectangle({}); 17 | this._gaps = new Clutter.Margin(); 18 | this.gaps = params.gaps || new Clutter.Margin(); 19 | this._tile = 20 | params.tile || 21 | new Tile({ x: 0, y: 0, width: 0, height: 0, groups: [] }); 22 | } 23 | 24 | public override set gaps(gaps: Clutter.Margin) { 25 | this._gaps = gaps.copy(); 26 | 27 | if ( 28 | this._gaps.top === 0 && 29 | this._gaps.bottom === 0 && 30 | this._gaps.right === 0 && 31 | this._gaps.left === 0 32 | ) 33 | this.remove_style_class_name('custom-tile-preview'); 34 | else this.add_style_class_name('custom-tile-preview'); 35 | } 36 | 37 | public override _init() { 38 | super._init(); 39 | this.remove_style_class_name('tile-preview'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/editor/editableTilePreview.ts: -------------------------------------------------------------------------------- 1 | import TilePreview from '../tilepreview/tilePreview'; 2 | import { GObject, St, Clutter, Mtk } from '@gi.ext'; 3 | import Tile from '../layout/Tile'; 4 | import Slider from './slider'; 5 | import TileUtils from '../layout/TileUtils'; 6 | import { registerGObjectClass } from '@utils/gjs'; 7 | import { buildTileGaps } from '@utils/ui'; 8 | 9 | @registerGObjectClass 10 | export default class EditableTilePreview extends TilePreview { 11 | static metaInfo: GObject.MetaInfo = { 12 | Signals: { 13 | 'size-changed': { 14 | param_types: [Mtk.Rectangle.$gtype, Mtk.Rectangle.$gtype], // oldSize, newSize 15 | }, 16 | }, 17 | GTypeName: 'EditableTilePreview', 18 | }; 19 | public static MIN_TILE_SIZE: number = 140; 20 | 21 | private readonly _btn: St.Button; 22 | private readonly _containerRect: Mtk.Rectangle; 23 | 24 | private _sliders: (Slider | null)[]; 25 | private _signals: (number | null)[]; 26 | 27 | constructor(params: { 28 | tile: Tile; 29 | containerRect: Mtk.Rectangle; 30 | parent?: Clutter.Actor; 31 | rect?: Mtk.Rectangle; 32 | gaps?: Clutter.Margin; 33 | }) { 34 | super(params); 35 | this.add_style_class_name('editable-tile-preview'); 36 | this._tile = params.tile; 37 | this._containerRect = params.containerRect; 38 | this._sliders = [null, null, null, null]; 39 | this._signals = [null, null, null, null]; 40 | this._btn = new St.Button({ 41 | styleClass: 'editable-tile-preview-button', 42 | xExpand: true, 43 | trackHover: true, 44 | }); 45 | this.add_child(this._btn); 46 | this._btn.set_size(this.innerWidth, this.innerHeight); 47 | // handle both left and right clicks 48 | this._btn.set_button_mask(St.ButtonMask.ONE | St.ButtonMask.THREE); 49 | this._updateLabelText(); 50 | 51 | this.connect('destroy', this._onDestroy.bind(this)); 52 | } 53 | 54 | public override set gaps(newGaps: Clutter.Margin) { 55 | super.gaps = newGaps; 56 | this.updateBorderRadius( 57 | this._gaps.top > 0, 58 | this._gaps.right > 0, 59 | this._gaps.bottom > 0, 60 | this._gaps.left > 0, 61 | ); 62 | } 63 | 64 | public getSlider(side: St.Side): Slider | null { 65 | return this._sliders[side]; 66 | } 67 | 68 | public getAllSliders(): (Slider | null)[] { 69 | return [...this._sliders]; 70 | } 71 | 72 | public get hover(): boolean { 73 | return this._btn.hover; 74 | } 75 | 76 | public addSlider(slider: Slider, side: St.Side) { 77 | // if there were another slider on that side, disconnect the signal 78 | const sig = this._signals[side]; 79 | if (sig) this._sliders[side]?.disconnect(sig); 80 | 81 | // add this slider 82 | this._sliders[side] = slider; 83 | this._signals[side] = slider.connect('slide', () => 84 | this._onSliderMove(side), 85 | ); 86 | 87 | // update tile's groups 88 | this._tile.groups = []; 89 | this._sliders.forEach((sl) => sl && this._tile.groups.push(sl.groupId)); 90 | } 91 | 92 | public removeSlider(side: St.Side) { 93 | if (this._sliders[side] === null) return; 94 | 95 | // disconnect signals 96 | const sig = this._signals[side]; 97 | if (sig) this._sliders[side]?.disconnect(sig); 98 | 99 | // remove slider 100 | this._sliders[side] = null; 101 | 102 | // update tile's groups 103 | this._tile.groups = []; 104 | this._sliders.forEach((sl) => sl && this._tile.groups.push(sl.groupId)); 105 | } 106 | 107 | public updateTile({ 108 | x, 109 | y, 110 | width, 111 | height, 112 | innerGaps, 113 | outerGaps, 114 | }: { 115 | x: number; 116 | y: number; 117 | width: number; 118 | height: number; 119 | innerGaps?: Clutter.Margin; 120 | outerGaps?: Clutter.Margin; 121 | }) { 122 | const oldSize = this._rect.copy(); 123 | this._tile.x = x; 124 | this._tile.y = y; 125 | this._tile.width = width; 126 | this._tile.height = height; 127 | this._rect = TileUtils.apply_props(this._tile, this._containerRect); 128 | if (innerGaps && outerGaps) { 129 | this.gaps = buildTileGaps( 130 | this._rect, 131 | innerGaps, 132 | outerGaps, 133 | this._containerRect, 134 | ).gaps; 135 | } 136 | 137 | this.set_size(this.innerWidth, this.innerHeight); 138 | this.set_position(this.innerX, this.innerY); 139 | 140 | this._btn.set_size(this.width, this.height); 141 | this._updateLabelText(); 142 | 143 | const newSize = this._rect.copy(); 144 | this.emit('size-changed', oldSize, newSize); 145 | } 146 | 147 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 148 | public connect(id: string, callback: (...args: any[]) => any): number; 149 | public connect( 150 | signal: 'size-changed', 151 | callback: ( 152 | _source: this, 153 | oldSize: Mtk.Rectangle, 154 | newSize: Mtk.Rectangle, 155 | ) => void, 156 | ): number; 157 | public connect( 158 | signal: 'notify::hover', 159 | callback: (_source: this) => void, 160 | ): number; 161 | public connect( 162 | signal: 'clicked', 163 | callback: (_source: this, clicked_button: number) => void, 164 | ): number; 165 | public connect(signal: string, callback: never): number { 166 | if ( 167 | signal === 'clicked' || 168 | signal === 'notify::hover' || 169 | signal === 'motion-event' 170 | ) 171 | return this._btn.connect(signal, callback); 172 | 173 | return super.connect(signal, callback); 174 | } 175 | 176 | private _updateLabelText() { 177 | this._btn.label = `${this.innerWidth}x${this.innerHeight}`; 178 | } 179 | 180 | private _onSliderMove(side: St.Side) { 181 | const slider = this._sliders[side]; 182 | if (slider === null) return; 183 | 184 | const posHoriz = 185 | (slider.x + slider.width / 2 - this._containerRect.x) / 186 | this._containerRect.width; 187 | const posVert = 188 | (slider.y + slider.height / 2 - this._containerRect.y) / 189 | this._containerRect.height; 190 | switch (side) { 191 | case St.Side.TOP: 192 | this._tile.height += this._tile.y - posVert; 193 | this._tile.y = posVert; 194 | break; 195 | case St.Side.RIGHT: 196 | this._tile.width = posHoriz - this._tile.x; 197 | break; 198 | case St.Side.BOTTOM: 199 | this._tile.height = posVert - this._tile.y; 200 | break; 201 | case St.Side.LEFT: 202 | this._tile.width += this._tile.x - posHoriz; 203 | this._tile.x = posHoriz; 204 | break; 205 | } 206 | 207 | this.updateTile({ ...this._tile }); 208 | } 209 | 210 | private _onDestroy(): void { 211 | this._signals.forEach( 212 | (id, side) => id && this._sliders[side]?.disconnect(id), 213 | ); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/components/editor/editorDialog.ts: -------------------------------------------------------------------------------- 1 | import Settings from '@settings/settings'; 2 | import { registerGObjectClass } from '@/utils/gjs'; 3 | import { St, Clutter, Gio } from '@gi.ext'; 4 | import LayoutButton from '../../indicator/layoutButton'; 5 | import GlobalState from '@utils/globalState'; 6 | import Layout from '@/components/layout/Layout'; 7 | 8 | import Tile from '@/components/layout/Tile'; 9 | import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js'; 10 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 11 | import { 12 | enableScalingFactorSupport, 13 | getMonitorScalingFactor, 14 | widgetOrientation, 15 | } from '@utils/ui'; 16 | import { _ } from '../../translations'; 17 | 18 | @registerGObjectClass 19 | export default class EditorDialog extends ModalDialog.ModalDialog { 20 | private readonly _layoutHeight: number = 72; 21 | private readonly _layoutWidth: number = 128; // 16:9 ratio. -> (16*layoutHeight) / 9 and then rounded to int 22 | private readonly _gapsSize: number = 3; 23 | 24 | private _layoutsBoxLayout: St.BoxLayout; 25 | 26 | constructor(params: { 27 | enableScaling: boolean; 28 | onDeleteLayout: (ind: number, lay: Layout) => void; 29 | onSelectLayout: (ind: number, lay: Layout) => void; 30 | onNewLayout: () => void; 31 | legend: boolean; 32 | onClose: () => void; 33 | path: string; 34 | }) { 35 | super({ 36 | destroyOnClose: true, 37 | styleClass: 'editor-dialog', 38 | }); 39 | 40 | if (params.enableScaling) { 41 | const monitor = Main.layoutManager.findMonitorForActor(this); 42 | const scalingFactor = getMonitorScalingFactor( 43 | monitor?.index || Main.layoutManager.primaryIndex, 44 | ); 45 | enableScalingFactorSupport(this, scalingFactor); 46 | } 47 | 48 | this.contentLayout.add_child( 49 | new St.Label({ 50 | text: _('Select the layout to edit'), 51 | xAlign: Clutter.ActorAlign.CENTER, 52 | xExpand: true, 53 | styleClass: 'editor-dialog-title', 54 | }), 55 | ); 56 | 57 | this._layoutsBoxLayout = new St.BoxLayout({ 58 | styleClass: 'layouts-box-layout', 59 | xAlign: Clutter.ActorAlign.CENTER, 60 | }); 61 | this.contentLayout.add_child(this._layoutsBoxLayout); 62 | 63 | if (!params.legend) { 64 | this._drawLayouts({ 65 | layouts: GlobalState.get().layouts, 66 | ...params, 67 | }); 68 | /* this._signals.connect(GlobalState.get(), GlobalState.SIGNAL_LAYOUTS_CHANGED, () => { 69 | this._drawLayouts({ layouts: GlobalState.get().layouts, ...params }); 70 | });*/ 71 | } 72 | 73 | this.addButton({ 74 | label: _('Close'), 75 | default: true, 76 | key: Clutter.KEY_Escape, 77 | action: () => params.onClose(), 78 | }); 79 | 80 | if (params.legend) { 81 | this._makeLegendDialog({ 82 | onClose: params.onClose, 83 | path: params.path, 84 | }); 85 | } 86 | } 87 | 88 | private _makeLegendDialog(params: { onClose: () => void; path: string }) { 89 | const suggestion1 = new St.BoxLayout(); 90 | // LEFT-CLICK to split a tile 91 | suggestion1.add_child( 92 | new St.Label({ 93 | text: 'LEFT CLICK', 94 | xAlign: Clutter.ActorAlign.CENTER, 95 | yAlign: Clutter.ActorAlign.CENTER, 96 | styleClass: 'button kbd', 97 | xExpand: false, 98 | pseudoClass: 'active', 99 | }), 100 | ); 101 | suggestion1.add_child( 102 | new St.Label({ 103 | text: ` ${_('to split a tile')}.`, 104 | xAlign: Clutter.ActorAlign.CENTER, 105 | yAlign: Clutter.ActorAlign.CENTER, 106 | styleClass: '', 107 | xExpand: false, 108 | }), 109 | ); 110 | 111 | const suggestion2 = new St.BoxLayout(); 112 | // LEFT-CLICK + CTRL to split a tile vertically 113 | suggestion2.add_child( 114 | new St.Label({ 115 | text: 'LEFT CLICK', 116 | xAlign: Clutter.ActorAlign.CENTER, 117 | yAlign: Clutter.ActorAlign.CENTER, 118 | styleClass: 'button kbd', 119 | xExpand: false, 120 | pseudoClass: 'active', 121 | }), 122 | ); 123 | suggestion2.add_child( 124 | new St.Label({ 125 | text: ' + ', 126 | xAlign: Clutter.ActorAlign.CENTER, 127 | yAlign: Clutter.ActorAlign.CENTER, 128 | styleClass: '', 129 | xExpand: false, 130 | }), 131 | ); 132 | suggestion2.add_child( 133 | new St.Label({ 134 | text: 'CTRL', 135 | xAlign: Clutter.ActorAlign.CENTER, 136 | yAlign: Clutter.ActorAlign.CENTER, 137 | styleClass: 'button kbd', 138 | xExpand: false, 139 | pseudoClass: 'active', 140 | }), 141 | ); 142 | suggestion2.add_child( 143 | new St.Label({ 144 | text: ` ${_('to split a tile vertically')}.`, 145 | xAlign: Clutter.ActorAlign.CENTER, 146 | yAlign: Clutter.ActorAlign.CENTER, 147 | styleClass: '', 148 | xExpand: false, 149 | }), 150 | ); 151 | 152 | const suggestion3 = new St.BoxLayout(); 153 | // RIGHT-CLICK to delete a tile 154 | suggestion3.add_child( 155 | new St.Label({ 156 | text: 'RIGHT CLICK', 157 | xAlign: Clutter.ActorAlign.CENTER, 158 | yAlign: Clutter.ActorAlign.CENTER, 159 | styleClass: 'button kbd', 160 | xExpand: false, 161 | pseudoClass: 'active', 162 | }), 163 | ); 164 | suggestion3.add_child( 165 | new St.Label({ 166 | text: ` ${_('to delete a tile')}.`, 167 | xAlign: Clutter.ActorAlign.CENTER, 168 | yAlign: Clutter.ActorAlign.CENTER, 169 | styleClass: '', 170 | xExpand: false, 171 | }), 172 | ); 173 | 174 | const suggestion4 = new St.BoxLayout({ 175 | xExpand: true, 176 | margin_top: 16, 177 | }); 178 | // use indicator to save or cancel 179 | suggestion4.add_child( 180 | new St.Icon({ 181 | iconSize: 16, 182 | yAlign: Clutter.ActorAlign.CENTER, 183 | gicon: Gio.icon_new_for_string( 184 | `${params.path}/icons/indicator-symbolic.svg`, 185 | ), 186 | styleClass: 'button kbd', 187 | pseudoClass: 'active', 188 | }), 189 | ); 190 | suggestion4.add_child( 191 | new St.Label({ 192 | text: ` ${_('use the indicator button to save or cancel')}.`, 193 | xAlign: Clutter.ActorAlign.CENTER, 194 | yAlign: Clutter.ActorAlign.CENTER, 195 | styleClass: '', 196 | xExpand: false, 197 | }), 198 | ); 199 | 200 | const legend = new St.BoxLayout({ 201 | styleClass: 'legend', 202 | ...widgetOrientation(true), 203 | }); 204 | legend.add_child(suggestion1); 205 | legend.add_child(suggestion2); 206 | legend.add_child(suggestion3); 207 | legend.add_child(suggestion4); 208 | 209 | this.contentLayout.destroy_all_children(); 210 | this.contentLayout.add_child( 211 | new St.Label({ 212 | text: _('How to use the editor'), 213 | xAlign: Clutter.ActorAlign.CENTER, 214 | xExpand: true, 215 | styleClass: 'editor-dialog-title', 216 | }), 217 | ); 218 | this.contentLayout.add_child(legend); 219 | 220 | this.clearButtons(); 221 | this.addButton({ 222 | label: _('Start editing'), 223 | default: true, 224 | key: Clutter.KEY_Escape, 225 | action: params.onClose, 226 | }); 227 | } 228 | 229 | private _drawLayouts(params: { 230 | layouts: Layout[]; 231 | onDeleteLayout: (ind: number, lay: Layout) => void; 232 | onSelectLayout: (ind: number, lay: Layout) => void; 233 | onNewLayout: () => void; 234 | onClose: () => void; 235 | path: string; 236 | }) { 237 | const gaps = Settings.get_inner_gaps(1).top > 0 ? this._gapsSize : 0; 238 | this._layoutsBoxLayout.destroy_all_children(); 239 | 240 | params.layouts.forEach((lay, btnInd) => { 241 | const box = new St.BoxLayout({ 242 | xAlign: Clutter.ActorAlign.CENTER, 243 | styleClass: 'layout-button-container', 244 | ...widgetOrientation(true), 245 | }); 246 | this._layoutsBoxLayout.add_child(box); 247 | const btn = new LayoutButton( 248 | box, 249 | lay, 250 | gaps, 251 | this._layoutHeight, 252 | this._layoutWidth, 253 | ); 254 | if (params.layouts.length > 1) { 255 | const deleteBtn = new St.Button({ 256 | xExpand: false, 257 | xAlign: Clutter.ActorAlign.CENTER, 258 | styleClass: 259 | 'message-list-clear-button icon-button button delete-layout-button', 260 | }); 261 | deleteBtn.child = new St.Icon({ 262 | gicon: Gio.icon_new_for_string( 263 | `${params.path}/icons/delete-symbolic.svg`, 264 | ), 265 | iconSize: 16, 266 | }); 267 | deleteBtn.connect('clicked', () => { 268 | params.onDeleteLayout(btnInd, lay); 269 | this._drawLayouts({ 270 | ...params, 271 | layouts: GlobalState.get().layouts, 272 | }); 273 | }); 274 | box.add_child(deleteBtn); 275 | } 276 | btn.connect('clicked', () => { 277 | params.onSelectLayout(btnInd, lay); 278 | this._makeLegendDialog({ 279 | onClose: params.onClose, 280 | path: params.path, 281 | }); 282 | }); 283 | return btn; 284 | }); 285 | 286 | const box = new St.BoxLayout({ 287 | xAlign: Clutter.ActorAlign.CENTER, 288 | styleClass: 'layout-button-container', 289 | ...widgetOrientation(true), 290 | }); 291 | this._layoutsBoxLayout.add_child(box); 292 | const newLayoutBtn = new LayoutButton( 293 | box, 294 | new Layout( 295 | [new Tile({ x: 0, y: 0, width: 1, height: 1, groups: [] })], 296 | 'New Layout', 297 | ), 298 | gaps, 299 | this._layoutHeight, 300 | this._layoutWidth, 301 | ); 302 | const icon = new St.Icon({ 303 | gicon: Gio.icon_new_for_string( 304 | `${params.path}/icons/add-symbolic.svg`, 305 | ), 306 | iconSize: 32, 307 | }); 308 | icon.set_size(newLayoutBtn.child.width, newLayoutBtn.child.height); 309 | newLayoutBtn.child.add_child(icon); 310 | newLayoutBtn.connect('clicked', () => { 311 | params.onNewLayout(); 312 | this._makeLegendDialog({ 313 | onClose: params.onClose, 314 | path: params.path, 315 | }); 316 | }); 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/components/editor/hoverLine.ts: -------------------------------------------------------------------------------- 1 | import { registerGObjectClass } from '@/utils/gjs'; 2 | import { GLib, St, Clutter, Shell } from '@gi.ext'; 3 | import EditableTilePreview from './editableTilePreview'; 4 | import { getScalingFactorOf } from '@utils/ui'; 5 | 6 | @registerGObjectClass 7 | export default class HoverLine extends St.Widget { 8 | private readonly _hoverTimer: number; 9 | private readonly _size: number; 10 | 11 | private _hoveredTile: EditableTilePreview | null; 12 | 13 | constructor(parent: Clutter.Actor) { 14 | super({ styleClass: 'hover-line' }); 15 | parent.add_child(this); 16 | 17 | this._hoveredTile = null; 18 | 19 | const [, scalingFactor] = getScalingFactorOf(this); 20 | this._size = 16 * scalingFactor; 21 | 22 | this.hide(); 23 | 24 | this._hoverTimer = GLib.timeout_add( 25 | GLib.PRIORITY_DEFAULT_IDLE, 26 | 100, 27 | this._handleModifierChange.bind(this), 28 | ); 29 | 30 | this.connect('destroy', this._onDestroy.bind(this)); 31 | } 32 | 33 | public handleTileDestroy(tile: EditableTilePreview) { 34 | if (this._hoveredTile === tile) { 35 | this._hoveredTile = null; 36 | this.hide(); 37 | } 38 | } 39 | 40 | public handleMouseMove(tile: EditableTilePreview, x: number, y: number) { 41 | this._hoveredTile = tile; 42 | 43 | const modifier = Shell.Global.get().get_pointer()[2]; 44 | 45 | // split horizontally when CTRL is NOT pressed, split vertically instead 46 | const splitHorizontally = 47 | (modifier & Clutter.ModifierType.CONTROL_MASK) === 0; 48 | this._drawLine(splitHorizontally, x, y); 49 | } 50 | 51 | private _handleModifierChange(): boolean { 52 | if (!this._hoveredTile) return GLib.SOURCE_CONTINUE; 53 | 54 | // if the button is not hovered, remove this timer 55 | if (!this._hoveredTile.hover) { 56 | this.hide(); 57 | return GLib.SOURCE_CONTINUE; 58 | } 59 | 60 | const [x, y, modifier] = global.get_pointer(); 61 | // split horizontally when CTRL is NOT pressed, split vertically instead 62 | const splitHorizontally = 63 | (modifier & Clutter.ModifierType.CONTROL_MASK) === 0; 64 | 65 | this._drawLine( 66 | splitHorizontally, 67 | x - (this.get_parent()?.x || 0), 68 | y - (this.get_parent()?.y || 0), 69 | ); 70 | 71 | return GLib.SOURCE_CONTINUE; 72 | } 73 | 74 | private _drawLine(splitHorizontally: boolean, x: number, y: number) { 75 | if (!this._hoveredTile) return; 76 | 77 | if (splitHorizontally) { 78 | const newX = x - this._size / 2; 79 | if ( 80 | newX < this._hoveredTile.x || 81 | newX + this._size > 82 | this._hoveredTile.x + this._hoveredTile.width 83 | ) 84 | return; 85 | 86 | this.set_size(this._size, this._hoveredTile.height); 87 | this.set_position(newX, this._hoveredTile.y); 88 | } else { 89 | const newY = y - this._size / 2; 90 | if ( 91 | newY < this._hoveredTile.y || 92 | newY + this._size > 93 | this._hoveredTile.y + this._hoveredTile.height 94 | ) 95 | return; 96 | 97 | this.set_size(this._hoveredTile.width, this._size); 98 | this.set_position(this._hoveredTile.x, newY); 99 | } 100 | 101 | this.show(); 102 | } 103 | 104 | private _onDestroy() { 105 | GLib.Source.remove(this._hoverTimer); 106 | this._hoveredTile = null; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/components/layout/Layout.ts: -------------------------------------------------------------------------------- 1 | import Tile from './Tile'; 2 | 3 | export default class Layout { 4 | id: string; 5 | tiles: Tile[]; 6 | 7 | constructor(tiles: Tile[], id: string) { 8 | this.tiles = tiles; 9 | this.id = id; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/layout/LayoutWidget.ts: -------------------------------------------------------------------------------- 1 | import { St, Clutter, Mtk } from '@gi.ext'; 2 | import TilePreview from '../tilepreview/tilePreview'; 3 | import { 4 | buildRectangle, 5 | buildTileGaps, 6 | enableScalingFactorSupport, 7 | isTileOnContainerBorder, 8 | } from '@/utils/ui'; 9 | import { logger } from '@utils/logger'; 10 | import Layout from './Layout'; 11 | import Tile from './Tile'; 12 | import TileUtils from './TileUtils'; 13 | import { registerGObjectClass } from '@utils/gjs'; 14 | 15 | const debug = logger('LayoutWidget'); 16 | 17 | // export module LayoutWidget { 18 | export interface LayoutWidgetConstructorProperties 19 | extends Partial { 20 | parent?: Clutter.Actor; 21 | layout: Layout; 22 | innerGaps: Clutter.Margin; 23 | outerGaps: Clutter.Margin; 24 | containerRect?: Mtk.Rectangle; 25 | scalingFactor?: number; 26 | } 27 | // } 28 | 29 | // A widget to draw a layout 30 | @registerGObjectClass 31 | export default class LayoutWidget< 32 | TileType extends TilePreview, 33 | > extends St.Widget { 34 | protected _previews: TileType[]; 35 | protected _containerRect: Mtk.Rectangle; 36 | protected _layout: Layout; 37 | protected _innerGaps: Clutter.Margin; 38 | protected _outerGaps: Clutter.Margin; 39 | protected _scalingFactor: number; 40 | 41 | constructor(params: LayoutWidgetConstructorProperties) { 42 | super({ styleClass: params.styleClass || '' }); 43 | if (params.parent) params.parent.add_child(this); 44 | this._scalingFactor = 1; 45 | if (params.scalingFactor) this.scalingFactor = params.scalingFactor; 46 | 47 | this._previews = []; 48 | this._containerRect = params.containerRect || buildRectangle(); 49 | this._layout = params.layout || new Layout([], ''); 50 | this._innerGaps = params.innerGaps || new Clutter.Margin(); 51 | this._outerGaps = params.outerGaps || new Clutter.Margin(); 52 | } 53 | 54 | public set scalingFactor(value: number) { 55 | enableScalingFactorSupport(this, value); 56 | this._scalingFactor = value; 57 | } 58 | 59 | public get scalingFactor(): number { 60 | return this._scalingFactor; 61 | } 62 | 63 | public get innerGaps(): Clutter.Margin { 64 | return this._innerGaps.copy(); 65 | } 66 | 67 | public get outerGaps(): Clutter.Margin { 68 | return this._outerGaps.copy(); 69 | } 70 | 71 | public get layout(): Layout { 72 | return this._layout; 73 | } 74 | 75 | protected draw_layout(): void { 76 | const containerWithoutOuterGaps = buildRectangle({ 77 | x: this._outerGaps.left + this._containerRect.x, 78 | y: this._outerGaps.top + this._containerRect.y, 79 | width: 80 | this._containerRect.width - 81 | this._outerGaps.left - 82 | this._outerGaps.right, 83 | height: 84 | this._containerRect.height - 85 | this._outerGaps.top - 86 | this._outerGaps.bottom, 87 | }); 88 | this._previews = this._layout.tiles.map((tile) => { 89 | const tileRect = TileUtils.apply_props( 90 | tile, 91 | containerWithoutOuterGaps, 92 | ); 93 | const { gaps, isTop, isRight, isBottom, isLeft } = buildTileGaps( 94 | tileRect, 95 | this._innerGaps, 96 | this._outerGaps, 97 | containerWithoutOuterGaps, 98 | ); 99 | 100 | if (isTop) { 101 | tileRect.height += this._outerGaps.top; 102 | tileRect.y -= this._outerGaps.top; 103 | } 104 | if (isLeft) { 105 | tileRect.width += this._outerGaps.left; 106 | tileRect.x -= this._outerGaps.left; 107 | } 108 | if (isRight) tileRect.width += this._outerGaps.right; 109 | 110 | if (isBottom) tileRect.height += this._outerGaps.bottom; 111 | 112 | return this.buildTile(this, tileRect, gaps, tile); 113 | }); 114 | } 115 | 116 | protected buildTile( 117 | _parent: Clutter.Actor, 118 | _rect: Mtk.Rectangle, 119 | _margin: Clutter.Margin, 120 | _tile: Tile, 121 | ): TileType { 122 | throw new Error( 123 | "This class shouldn't be instantiated but it should be extended instead", 124 | ); 125 | } 126 | 127 | public relayout( 128 | params?: Partial<{ 129 | layout: Layout; 130 | containerRect: Mtk.Rectangle; 131 | innerGaps: Clutter.Margin; 132 | outerGaps: Clutter.Margin; 133 | }>, 134 | ): boolean { 135 | let trigger_relayout = this._previews.length === 0; 136 | if (params?.layout && this._layout !== params.layout) { 137 | this._layout = params.layout; 138 | trigger_relayout = true; 139 | } 140 | if (params?.innerGaps) { 141 | trigger_relayout ||= !this._areGapsEqual( 142 | this._innerGaps, 143 | params.innerGaps, 144 | ); 145 | this._innerGaps = params.innerGaps.copy(); 146 | } 147 | if (params?.outerGaps && this._outerGaps !== params.outerGaps) { 148 | trigger_relayout ||= !this._areGapsEqual( 149 | this._outerGaps, 150 | params.outerGaps, 151 | ); 152 | this._outerGaps = params.outerGaps.copy(); 153 | } 154 | if ( 155 | params?.containerRect && 156 | !this._containerRect.equal(params.containerRect) 157 | ) { 158 | this._containerRect = params.containerRect.copy(); 159 | trigger_relayout = true; 160 | } 161 | 162 | if (!trigger_relayout) { 163 | debug('relayout not needed'); 164 | return false; 165 | } 166 | 167 | this._previews?.forEach((preview) => { 168 | if (preview.get_parent() === this) this.remove_child(preview); 169 | 170 | preview.destroy(); 171 | }); 172 | this._previews = []; 173 | if (this._containerRect.width === 0 || this._containerRect.height === 0) 174 | return true; 175 | 176 | this.draw_layout(); 177 | this._previews.forEach((lay) => lay.open()); 178 | 179 | return true; 180 | } 181 | 182 | private _areGapsEqual( 183 | first: Clutter.Margin, 184 | second: Clutter.Margin, 185 | ): boolean { 186 | return ( 187 | first.bottom === second.bottom && 188 | first.top === second.top && 189 | first.left === second.left && 190 | first.right === second.right 191 | ); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/components/layout/Tile.ts: -------------------------------------------------------------------------------- 1 | import { GObject } from '@gi.shared'; // gi.shared because it is imported by Layout which is also imported in prefs.ts 2 | 3 | export default class Tile { 4 | static $gtype = GObject.TYPE_JSOBJECT; 5 | 6 | x: number; 7 | y: number; 8 | width: number; 9 | height: number; 10 | groups: number[]; 11 | 12 | constructor({ 13 | x, 14 | y, 15 | width, 16 | height, 17 | groups, 18 | }: { 19 | x: number; 20 | y: number; 21 | width: number; 22 | height: number; 23 | groups: number[]; 24 | }) { 25 | this.x = x; 26 | this.y = y; 27 | this.width = width; 28 | this.height = height; 29 | this.groups = groups; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/layout/TileUtils.ts: -------------------------------------------------------------------------------- 1 | import { Mtk } from '@gi.ext'; 2 | import Tile from './Tile'; 3 | import { buildRectangle } from '@utils/ui'; 4 | 5 | export default class TileUtils { 6 | static apply_props(tile: Tile, container: Mtk.Rectangle): Mtk.Rectangle { 7 | return buildRectangle({ 8 | x: Math.round(container.width * tile.x + container.x), 9 | y: Math.round(container.height * tile.y + container.y), 10 | width: Math.round(container.width * tile.width), 11 | height: Math.round(container.height * tile.height), 12 | }); 13 | } 14 | 15 | static apply_props_relative_to( 16 | tile: Tile, 17 | container: Mtk.Rectangle, 18 | ): Mtk.Rectangle { 19 | return buildRectangle({ 20 | x: Math.round(container.width * tile.x), 21 | y: Math.round(container.height * tile.y), 22 | width: Math.round(container.width * tile.width), 23 | height: Math.round(container.height * tile.height), 24 | }); 25 | } 26 | 27 | static build_tile(rect: Mtk.Rectangle, container: Mtk.Rectangle): Tile { 28 | return new Tile({ 29 | x: (rect.x - container.x) / container.width, 30 | y: (rect.y - container.y) / container.height, 31 | width: rect.width / container.width, 32 | height: rect.height / container.height, 33 | groups: [], 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/snapassist/snapAssistLayout.ts: -------------------------------------------------------------------------------- 1 | import { registerGObjectClass } from '@/utils/gjs'; 2 | import { Clutter, Mtk, St } from '@gi.ext'; 3 | import LayoutWidget from '../layout/LayoutWidget'; 4 | import Tile from '../layout/Tile'; 5 | import SnapAssistTile from './snapAssistTile'; 6 | import Layout from '@components/layout/Layout'; 7 | import { buildRectangle } from '@utils/ui'; 8 | 9 | @registerGObjectClass 10 | export default class SnapAssistLayout extends LayoutWidget { 11 | constructor( 12 | parent: St.Widget, 13 | layout: Layout, 14 | innerGaps: Clutter.Margin, 15 | outerGaps: Clutter.Margin, 16 | width: number, 17 | height: number, 18 | ) { 19 | super({ 20 | containerRect: buildRectangle({ x: 0, y: 0, width, height }), 21 | parent, 22 | layout, 23 | innerGaps, 24 | outerGaps, 25 | }); 26 | this.set_size(width, height); 27 | super.relayout(); 28 | } 29 | 30 | buildTile( 31 | parent: Clutter.Actor, 32 | rect: Mtk.Rectangle, 33 | gaps: Clutter.Margin, 34 | tile: Tile, 35 | ): SnapAssistTile { 36 | return new SnapAssistTile({ parent, rect, gaps, tile }); 37 | } 38 | 39 | public getTileBelow(cursorPos: { 40 | x: number; 41 | y: number; 42 | }): SnapAssistTile | undefined { 43 | const [x, y] = this.get_transformed_position(); 44 | 45 | for (let i = 0; i < this._previews.length; i++) { 46 | const preview = this._previews[i]; 47 | const pos = { x: x + preview.rect.x, y: y + preview.rect.y }; 48 | 49 | const isHovering = 50 | cursorPos.x >= pos.x && 51 | cursorPos.x <= pos.x + preview.rect.width && 52 | cursorPos.y >= pos.y && 53 | cursorPos.y <= pos.y + preview.rect.height; 54 | if (isHovering) return preview; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/snapassist/snapAssistTile.ts: -------------------------------------------------------------------------------- 1 | import { registerGObjectClass } from '@/utils/gjs'; 2 | import TilePreview from '../tilepreview/tilePreview'; 3 | import Tile from '../layout/Tile'; 4 | import { St, Clutter, Mtk } from '@gi.ext'; 5 | import { getScalingFactorOf } from '@utils/ui'; 6 | import { logger } from '@utils/logger'; 7 | 8 | const debug = logger('SnapAssistTile'); 9 | 10 | const MIN_RADIUS = 2; 11 | 12 | /** 13 | * SnapAssistTile 14 | * 15 | * This class represents a visual preview of a tile in the snap assist feature. 16 | * It extends TilePreview and provides styling logic to adjust the border radius, 17 | * border width, and theme colors dynamically. 18 | * 19 | * Features: 20 | * - Determines whether the tile is positioned at the edges of the screen or near 21 | * another tile and adjusts border radius and widths accordingly. 22 | * - Computes the appropriate scaling factor for styling based on display scaling. 23 | * - Adjusts the theme between light and dark based on text color contrast. 24 | * - Listens for theme changes and updates styles dynamically. 25 | */ 26 | @registerGObjectClass 27 | export default class SnapAssistTile extends TilePreview { 28 | private _styleChangedSignalID: number | undefined; 29 | 30 | constructor(params: { 31 | parent?: Clutter.Actor; 32 | rect?: Mtk.Rectangle; 33 | gaps?: Clutter.Margin; 34 | tile: Tile; 35 | }) { 36 | super({ 37 | parent: params.parent, 38 | rect: params.rect, 39 | gaps: params.gaps, 40 | tile: params.tile, 41 | }); 42 | 43 | const isLeft = this._tile.x <= 0.001; 44 | const isTop = this._tile.y <= 0.001; 45 | const isRight = this._tile.x + this._tile.width >= 0.99; 46 | const isBottom = this._tile.y + this._tile.height >= 0.99; 47 | 48 | const [alreadyScaled, scalingFactor] = getScalingFactorOf(this); 49 | // the value got is already scaled if the tile is on primary monitor 50 | const radiusValue = 51 | (alreadyScaled ? 1 : scalingFactor) * 52 | (this.get_theme_node().get_length('border-radius-value') / 53 | (alreadyScaled ? scalingFactor : 1)); 54 | const borderWidthValue = 55 | (alreadyScaled ? 1 : scalingFactor) * 56 | (this.get_theme_node().get_length('border-width-value') / 57 | (alreadyScaled ? scalingFactor : 1)); 58 | // top-left top-right bottom-right bottom-left 59 | const radius = [ 60 | this._gaps.top === 0 && this._gaps.left === 0 ? 0 : MIN_RADIUS, 61 | this._gaps.top === 0 && this._gaps.right === 0 ? 0 : MIN_RADIUS, 62 | this._gaps.bottom === 0 && this._gaps.right === 0 ? 0 : MIN_RADIUS, 63 | this._gaps.bottom === 0 && this._gaps.left === 0 ? 0 : MIN_RADIUS, 64 | ]; 65 | if (isTop && isLeft) radius[St.Corner.TOPLEFT] = radiusValue; 66 | if (isTop && isRight) radius[St.Corner.TOPRIGHT] = radiusValue; 67 | if (isBottom && isRight) radius[St.Corner.BOTTOMRIGHT] = radiusValue; 68 | if (isBottom && isLeft) radius[St.Corner.BOTTOMLEFT] = radiusValue; 69 | 70 | // Without gaps, two borders of two near tiles may 71 | // look like the border width is double the size. 72 | // The initial width takes into account there are no gaps, 73 | // tiles are very near, so the width is half the final one. 74 | const borderWidth = [ 75 | borderWidthValue, 76 | borderWidthValue, 77 | borderWidthValue, 78 | borderWidthValue, 79 | ]; 80 | // we double the width if the tile NOT alone another tile. In case the width is a floating value (like 0.5) it will be 81 | // equal to 1 and two near tiles will appear with a border of a total of 2. In case of top and right borders we floor the 82 | // value for example from 0.5 to 0, to keep consistency since the near tile will NOT floor the value on the oppisite side 83 | if (isTop || this._gaps.top > 0) borderWidth[St.Side.TOP] *= 2; 84 | else borderWidth[St.Side.TOP] = Math.floor(borderWidth[St.Side.TOP]); 85 | if (isRight || this._gaps.right > 0) borderWidth[St.Side.RIGHT] *= 2; 86 | else 87 | borderWidth[St.Side.RIGHT] = Math.floor(borderWidth[St.Side.RIGHT]); 88 | if (isBottom || this._gaps.bottom > 0) borderWidth[St.Side.BOTTOM] *= 2; 89 | if (isLeft || this._gaps.left > 0) borderWidth[St.Side.LEFT] *= 2; 90 | 91 | // the border radius and width values set will be scaled if the tile is on primary monitor 92 | this.set_style(` 93 | border-radius: ${radius[St.Corner.TOPLEFT]}px ${radius[St.Corner.TOPRIGHT]}px ${radius[St.Corner.BOTTOMRIGHT]}px ${radius[St.Corner.BOTTOMLEFT]}px; 94 | border-top-width: ${borderWidth[St.Side.TOP]}px; 95 | border-right-width: ${borderWidth[St.Side.RIGHT]}px; 96 | border-bottom-width: ${borderWidth[St.Side.BOTTOM]}px; 97 | border-left-width: ${borderWidth[St.Side.LEFT]}px;`); 98 | 99 | this._applyStyle(); 100 | this._styleChangedSignalID = St.ThemeContext.get_for_stage( 101 | global.get_stage(), 102 | ).connect('changed', () => { 103 | this._applyStyle(); 104 | }); 105 | this.connect('destroy', () => this.onDestroy()); 106 | } 107 | 108 | _init() { 109 | super._init(); 110 | this.set_style_class_name('snap-assist-tile'); 111 | } 112 | 113 | _applyStyle() { 114 | // the tile will be light or dark, following the text color 115 | const [hasColor, { red, green, blue }] = 116 | this.get_theme_node().lookup_color('color', true); 117 | if (!hasColor) return; 118 | // if the text color is light, apply light theme, otherwise apply dark theme 119 | if (red * 0.299 + green * 0.587 + blue * 0.114 > 186) { 120 | // apply light theme (which is the default, e.g. remove dark theme) 121 | this.remove_style_class_name('dark'); 122 | } else { 123 | // apply dark theme 124 | this.add_style_class_name('dark'); 125 | } 126 | } 127 | 128 | onDestroy(): void { 129 | if (this._styleChangedSignalID) { 130 | St.ThemeContext.get_for_stage(global.get_stage()).disconnect( 131 | this._styleChangedSignalID, 132 | ); 133 | this._styleChangedSignalID = undefined; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/components/snapassist/snapAssistTileButton.ts: -------------------------------------------------------------------------------- 1 | import Tile from '@components/layout/Tile'; 2 | import { registerGObjectClass } from '@utils/gjs'; 3 | import { St, Clutter, Mtk } from '@gi.ext'; 4 | import SnapAssistTile from '@components/snapassist/snapAssistTile'; 5 | 6 | @registerGObjectClass 7 | export default class SnapAssistTileButton extends SnapAssistTile { 8 | private readonly _btn: St.Button; 9 | 10 | constructor(params: { 11 | parent?: Clutter.Actor; 12 | rect?: Mtk.Rectangle; 13 | gaps?: Clutter.Margin; 14 | tile: Tile; 15 | }) { 16 | super(params); 17 | this._btn = new St.Button({ 18 | xExpand: true, 19 | yExpand: true, 20 | trackHover: true, 21 | }); 22 | this.add_child(this._btn); 23 | this._btn.set_size(this.innerWidth, this.innerHeight); 24 | 25 | // for some reason this doesn't work: this.bind_property("hover", this._btn, "hover", GObject.BindingFlags.DEFAULT); 26 | this._btn.connect('notify::hover', () => 27 | this.set_hover(this._btn.hover), 28 | ); 29 | } 30 | 31 | public get tile(): Tile { 32 | return this._tile; 33 | } 34 | 35 | public get checked(): boolean { 36 | return this._btn.checked; 37 | } 38 | 39 | public set_checked(newVal: boolean) { 40 | this._btn.set_checked(newVal); 41 | } 42 | 43 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 44 | public connect(id: string, callback: (...args: any[]) => any): number; 45 | public connect( 46 | signal: 'clicked', 47 | callback: (_source: this, clicked_button: number) => void, 48 | ): number; 49 | public connect(signal: string, callback: never): number { 50 | if (signal === 'clicked') return this._btn.connect(signal, callback); 51 | 52 | return super.connect(signal, callback); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/tilepreview/blurTilePreview.ts: -------------------------------------------------------------------------------- 1 | import { registerGObjectClass } from '@/utils/gjs'; 2 | import { Shell } from '@gi.ext'; 3 | import TilePreview from './tilePreview'; 4 | 5 | @registerGObjectClass 6 | export default class BlurTilePreview extends TilePreview { 7 | _init() { 8 | super._init(); 9 | 10 | // changes in GNOME 46+ 11 | // The sigma in Shell.BlurEffect should be replaced by radius. Since the sigma value 12 | // is radius / 2.0, the radius value will be sigma * 2.0. 13 | const sigma = 36; 14 | this.add_effect( 15 | new Shell.BlurEffect({ 16 | // @ts-expect-error "sigma is available" 17 | sigma, 18 | // radius: sigma * 2, 19 | brightness: 1, 20 | mode: Shell.BlurMode.BACKGROUND, // blur what is behind the widget 21 | }), 22 | ); 23 | this.add_style_class_name('blur-tile-preview'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/tilepreview/selectionTilePreview.ts: -------------------------------------------------------------------------------- 1 | import { registerGObjectClass } from '@/utils/gjs'; 2 | import { GObject, St, Clutter, Gio, Mtk } from '@gi.ext'; 3 | import TilePreview from './tilePreview'; 4 | import Settings from '@settings/settings'; 5 | import { buildBlurEffect } from '@utils/ui'; 6 | import Tile from '@components/layout/Tile'; 7 | import { logger } from '@utils/logger'; 8 | 9 | const debug = logger('SelectionTilePreview'); 10 | 11 | @registerGObjectClass 12 | export default class SelectionTilePreview extends TilePreview { 13 | static metaInfo: GObject.MetaInfo = { 14 | GTypeName: 'SelectionTilePreview', 15 | Properties: { 16 | blur: GObject.ParamSpec.boolean( 17 | 'blur', 18 | 'blur', 19 | 'Enable or disable the blur effect', 20 | GObject.ParamFlags.READWRITE, 21 | false, 22 | ), 23 | }, 24 | }; 25 | 26 | private _blur: boolean; 27 | 28 | constructor(params: { 29 | parent: Clutter.Actor; 30 | tile?: Tile; 31 | rect?: Mtk.Rectangle; 32 | gaps?: Clutter.Margin; 33 | }) { 34 | super(params); 35 | 36 | this._blur = false; 37 | 38 | Settings.bind( 39 | Settings.KEY_ENABLE_BLUR_SELECTED_TILEPREVIEW, 40 | this, 41 | 'blur', 42 | Gio.SettingsBindFlags.GET, 43 | ); 44 | 45 | this._recolor(); 46 | const styleChangedSignalID = St.ThemeContext.get_for_stage( 47 | global.get_stage(), 48 | ).connect('changed', () => { 49 | this._recolor(); 50 | }); 51 | this.connect('destroy', () => 52 | St.ThemeContext.get_for_stage(global.get_stage()).disconnect( 53 | styleChangedSignalID, 54 | ), 55 | ); 56 | this._rect.width = this.gaps.left + this.gaps.right; 57 | this._rect.height = this.gaps.top + this.gaps.bottom; 58 | } 59 | 60 | set blur(value: boolean) { 61 | if (this._blur === value) return; 62 | 63 | this._blur = value; 64 | this.get_effect('blur')?.set_enabled(value); 65 | if (this._blur) this.add_style_class_name('blur-tile-preview'); 66 | else this.remove_style_class_name('blur-tile-preview'); 67 | 68 | this._recolor(); 69 | } 70 | 71 | _init() { 72 | super._init(); 73 | 74 | const effect = buildBlurEffect(48); 75 | effect.set_name('blur'); 76 | effect.set_enabled(this._blur); 77 | this.add_effect(effect); 78 | 79 | this.add_style_class_name('selection-tile-preview'); 80 | } 81 | 82 | _recolor() { 83 | this.set_style(null); 84 | 85 | const backgroundColor = this.get_theme_node() 86 | .get_background_color() 87 | .copy(); 88 | // since an alpha value lower than 160 is not so much visible, enforce a minimum value of 160 89 | const newAlpha = Math.max( 90 | Math.min(backgroundColor.alpha + 35, 255), 91 | 160, 92 | ); 93 | // The final alpha value is divided by 255 since CSS needs a value from 0 to 1, but ClutterColor expresses alpha from 0 to 255 94 | this.set_style(` 95 | background-color: rgba(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue}, ${newAlpha / 255}) !important; 96 | `); 97 | } 98 | 99 | close(ease: boolean = false) { 100 | if (!this._showing) return; 101 | 102 | this._rect.width = this.gaps.left + this.gaps.right; 103 | this._rect.height = this.gaps.top + this.gaps.bottom; 104 | super.close(ease); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/components/tilepreview/tilePreview.ts: -------------------------------------------------------------------------------- 1 | import { St, Clutter, Mtk, Meta } from '@gi.ext'; 2 | import { registerGObjectClass } from '@/utils/gjs'; 3 | import { buildRectangle, getScalingFactorOf } from '@utils/ui'; 4 | import GlobalState from '@utils/globalState'; 5 | import Tile from '@components/layout/Tile'; 6 | import { logger } from '@utils/logger'; 7 | 8 | const debug = logger('TilePreview'); 9 | 10 | // export module TilePreview { 11 | export interface TilePreviewConstructorProperties 12 | extends St.Widget.ConstructorProps { 13 | parent: Clutter.Actor; 14 | rect: Mtk.Rectangle; 15 | gaps: Clutter.Margin; 16 | tile: Tile; 17 | } 18 | // } 19 | 20 | @registerGObjectClass 21 | export default class TilePreview extends St.Widget { 22 | protected _rect: Mtk.Rectangle; 23 | protected _showing: boolean; 24 | protected _tile: Tile; 25 | protected _gaps: Clutter.Margin; 26 | 27 | constructor(params: Partial) { 28 | super(params); 29 | if (params.parent) params.parent.add_child(this); 30 | 31 | this._showing = false; 32 | this._rect = params.rect || buildRectangle({}); 33 | this._gaps = new Clutter.Margin(); 34 | this.gaps = params.gaps || new Clutter.Margin(); 35 | this._tile = 36 | params.tile || 37 | new Tile({ x: 0, y: 0, width: 0, height: 0, groups: [] }); 38 | } 39 | 40 | public set gaps(gaps: Clutter.Margin) { 41 | const [, scalingFactor] = getScalingFactorOf(this); 42 | this._gaps.top = gaps.top * scalingFactor; 43 | this._gaps.right = gaps.right * scalingFactor; 44 | this._gaps.bottom = gaps.bottom * scalingFactor; 45 | this._gaps.left = gaps.left * scalingFactor; 46 | } 47 | 48 | public updateBorderRadius( 49 | hasNeighborTop: boolean, 50 | hasNeighborRight: boolean, 51 | hasNeighborBottom: boolean, 52 | hasNeighborLeft: boolean, 53 | ) { 54 | this.remove_style_class_name('top-left-border-radius'); 55 | this.remove_style_class_name('top-right-border-radius'); 56 | this.remove_style_class_name('bottom-right-border-radius'); 57 | this.remove_style_class_name('bottom-left-border-radius'); 58 | this.remove_style_class_name('custom-tile-preview'); 59 | 60 | const topLeft = hasNeighborTop && hasNeighborLeft; 61 | const topRight = hasNeighborTop && hasNeighborRight; 62 | const bottomRight = hasNeighborBottom && hasNeighborRight; 63 | const bottomLeft = hasNeighborBottom && hasNeighborLeft; 64 | 65 | if (topLeft) this.add_style_class_name('top-left-border-radius'); 66 | if (topRight) this.add_style_class_name('top-right-border-radius'); 67 | if (bottomRight) 68 | this.add_style_class_name('bottom-right-border-radius'); 69 | if (bottomLeft) this.add_style_class_name('bottom-left-border-radius'); 70 | } 71 | 72 | public get gaps(): Clutter.Margin { 73 | return this._gaps; 74 | } 75 | 76 | public get tile() { 77 | return this._tile; 78 | } 79 | 80 | _init() { 81 | super._init(); 82 | this.set_style_class_name('tile-preview'); 83 | this.hide(); 84 | } 85 | 86 | public get innerX(): number { 87 | return this._rect.x + this._gaps.left; 88 | } 89 | 90 | public get innerY(): number { 91 | return this._rect.y + this._gaps.top; 92 | } 93 | 94 | public get innerWidth(): number { 95 | return this._rect.width - this._gaps.right - this._gaps.left; 96 | } 97 | 98 | public get innerHeight(): number { 99 | return this._rect.height - this._gaps.top - this._gaps.bottom; 100 | } 101 | 102 | public get rect(): Mtk.Rectangle { 103 | return this._rect; 104 | } 105 | 106 | public get showing(): boolean { 107 | return this._showing; 108 | } 109 | 110 | public open(ease: boolean = false, position?: Mtk.Rectangle) { 111 | if (position) this._rect = position; 112 | 113 | const fadeInMove = this._showing; 114 | this._showing = true; 115 | this.show(); 116 | if (fadeInMove) { 117 | this.ease({ 118 | x: this.innerX, 119 | y: this.innerY, 120 | width: this.innerWidth, 121 | height: this.innerHeight, 122 | opacity: 255, 123 | duration: ease ? GlobalState.get().tilePreviewAnimationTime : 0, 124 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 125 | }); 126 | } else { 127 | this.set_position(this.innerX, this.innerY); 128 | this.set_size(this.innerWidth, this.innerHeight); 129 | this.ease({ 130 | opacity: 255, 131 | duration: ease ? GlobalState.get().tilePreviewAnimationTime : 0, 132 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 133 | }); 134 | } 135 | } 136 | 137 | public openBelow( 138 | window: Meta.Window, 139 | ease: boolean = false, 140 | position?: Mtk.Rectangle, 141 | ) { 142 | if (this.get_parent() === global.windowGroup) { 143 | const windowActor = 144 | window.get_compositor_private() as Clutter.Actor; 145 | if (!windowActor) return; 146 | global.windowGroup.set_child_below_sibling(this, windowActor); 147 | } 148 | 149 | this.open(ease, position); 150 | } 151 | 152 | public openAbove( 153 | window: Meta.Window, 154 | ease: boolean = false, 155 | position?: Mtk.Rectangle, 156 | ) { 157 | if (this.get_parent() === global.windowGroup) { 158 | /* const windowActor = 159 | window.get_compositor_private() as Clutter.Actor; 160 | if (!windowActor) return;*/ 161 | global.windowGroup.set_child_above_sibling(this, null); 162 | } 163 | 164 | this.open(ease, position); 165 | } 166 | 167 | public close(ease: boolean = false) { 168 | if (!this._showing) return; 169 | 170 | this._showing = false; 171 | this.ease({ 172 | opacity: 0, 173 | duration: ease ? GlobalState.get().tilePreviewAnimationTime : 0, 174 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 175 | onComplete: () => this.hide(), 176 | }); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/components/tilingsystem/edgeTilingManager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildRectangle, 3 | isPointInsideRect, 4 | clampPointInsideRect, 5 | } from '@utils/ui'; 6 | import { GObject, Mtk, St } from '@gi.ext'; 7 | import Settings from '@settings/settings'; 8 | import { registerGObjectClass } from '@utils/gjs'; 9 | import { logger } from '@utils/logger'; 10 | 11 | const EDGE_TILING_OFFSET = 16; 12 | const TOP_EDGE_TILING_OFFSET = 8; 13 | const QUARTER_PERCENTAGE = 0.5; 14 | 15 | @registerGObjectClass 16 | export default class EdgeTilingManager extends GObject.Object { 17 | static metaInfo: GObject.MetaInfo = { 18 | GTypeName: 'EdgeTilingManager', 19 | Properties: { 20 | quarterActivationPercentage: GObject.ParamSpec.uint( 21 | 'quarterActivationPercentage', 22 | 'quarterActivationPercentage', 23 | 'Threshold to trigger quarter tiling', 24 | GObject.ParamFlags.READWRITE, 25 | 1, 26 | 50, 27 | 40, 28 | ), 29 | }, 30 | }; 31 | private _workArea: Mtk.Rectangle; 32 | private _quarterActivationPercentage: number; 33 | 34 | // activation zones 35 | private _topLeft: Mtk.Rectangle; 36 | private _topRight: Mtk.Rectangle; 37 | private _bottomLeft: Mtk.Rectangle; 38 | private _bottomRight: Mtk.Rectangle; 39 | private _topCenter: Mtk.Rectangle; 40 | private _leftCenter: Mtk.Rectangle; 41 | private _rightCenter: Mtk.Rectangle; 42 | 43 | // current active zone 44 | private _activeEdgeTile: Mtk.Rectangle | null; 45 | 46 | constructor(initialWorkArea: Mtk.Rectangle) { 47 | super(); 48 | this._workArea = buildRectangle(); 49 | this._topLeft = buildRectangle(); 50 | this._topRight = buildRectangle(); 51 | this._bottomLeft = buildRectangle(); 52 | this._bottomRight = buildRectangle(); 53 | this._topCenter = buildRectangle(); 54 | this._leftCenter = buildRectangle(); 55 | this._rightCenter = buildRectangle(); 56 | this._activeEdgeTile = null; 57 | this.workarea = initialWorkArea; 58 | this._quarterActivationPercentage = Settings.QUARTER_TILING_THRESHOLD; 59 | Settings.bind( 60 | Settings.KEY_QUARTER_TILING_THRESHOLD, 61 | this, 62 | 'quarterActivationPercentage', 63 | ); 64 | } 65 | 66 | private set quarterActivationPercentage(value: number) { 67 | this._quarterActivationPercentage = value / 100; 68 | this._updateActivationZones(); 69 | } 70 | 71 | public set workarea(newWorkArea: Mtk.Rectangle) { 72 | this._workArea.x = newWorkArea.x; 73 | this._workArea.y = newWorkArea.y; 74 | this._workArea.width = newWorkArea.width; 75 | this._workArea.height = newWorkArea.height; 76 | 77 | this._updateActivationZones(); 78 | } 79 | 80 | private _updateActivationZones() { 81 | const width = Math.ceil( 82 | this._workArea.width * this._quarterActivationPercentage, 83 | ); 84 | const height = Math.ceil( 85 | this._workArea.height * this._quarterActivationPercentage, 86 | ); 87 | 88 | this._topLeft.x = this._workArea.x; 89 | this._topLeft.y = this._workArea.y; 90 | this._topLeft.width = width; 91 | this._topLeft.height = height; 92 | 93 | this._topRight.x = 94 | this._workArea.x + this._workArea.width - this._topLeft.width; 95 | this._topRight.y = this._topLeft.y; 96 | this._topRight.width = width; 97 | this._topRight.height = height; 98 | 99 | this._bottomLeft.x = this._workArea.x; 100 | this._bottomLeft.y = this._workArea.y + this._workArea.height - height; 101 | this._bottomLeft.width = width; 102 | this._bottomLeft.height = height; 103 | 104 | this._bottomRight.x = this._topRight.x; 105 | this._bottomRight.y = this._bottomLeft.y; 106 | this._bottomRight.width = width; 107 | this._bottomRight.height = height; 108 | 109 | this._topCenter.x = this._topLeft.x + this._topLeft.width; 110 | this._topCenter.y = this._topRight.y; 111 | this._topCenter.height = this._topRight.height; 112 | this._topCenter.width = this._topRight.x - this._topCenter.x; 113 | 114 | this._leftCenter.x = this._topLeft.x; 115 | this._leftCenter.y = this._topLeft.y + this._topLeft.height; 116 | this._leftCenter.height = this._bottomLeft.y - this._leftCenter.y; 117 | this._leftCenter.width = this._topLeft.width; 118 | 119 | this._rightCenter.x = this._topRight.x; 120 | this._rightCenter.y = this._topRight.y + this._topRight.height; 121 | this._rightCenter.height = this._bottomRight.y - this._rightCenter.y; 122 | this._rightCenter.width = this._topRight.width; 123 | } 124 | 125 | public canActivateEdgeTiling(pointerPos: { 126 | x: number; 127 | y: number; 128 | }): boolean { 129 | return ( 130 | pointerPos.x <= this._workArea.x + EDGE_TILING_OFFSET || 131 | pointerPos.y <= this._workArea.y + TOP_EDGE_TILING_OFFSET || 132 | pointerPos.x >= 133 | this._workArea.x + this._workArea.width - EDGE_TILING_OFFSET || 134 | pointerPos.y >= 135 | this._workArea.y + this._workArea.height - EDGE_TILING_OFFSET 136 | ); 137 | } 138 | 139 | public isPerformingEdgeTiling(): boolean { 140 | return this._activeEdgeTile !== null; 141 | } 142 | 143 | public startEdgeTiling(pointerPos: { x: number; y: number }): { 144 | changed: boolean; 145 | rect: Mtk.Rectangle; 146 | } { 147 | const { x, y } = clampPointInsideRect(pointerPos, this._workArea); 148 | const previewRect = buildRectangle(); 149 | 150 | if ( 151 | this._activeEdgeTile && 152 | isPointInsideRect({ x, y }, this._activeEdgeTile) 153 | ) { 154 | return { 155 | changed: false, 156 | rect: previewRect, 157 | }; 158 | } 159 | 160 | if (!this._activeEdgeTile) this._activeEdgeTile = buildRectangle(); 161 | 162 | previewRect.width = this._workArea.width * QUARTER_PERCENTAGE; 163 | previewRect.height = this._workArea.height * QUARTER_PERCENTAGE; 164 | previewRect.y = this._workArea.y; 165 | previewRect.x = this._workArea.x; 166 | if (isPointInsideRect({ x, y }, this._topCenter)) { 167 | previewRect.width = this._workArea.width; 168 | previewRect.height = this._workArea.height; 169 | 170 | this._activeEdgeTile = this._topCenter; 171 | // center-left (full edge tile) 172 | } else if (isPointInsideRect({ x, y }, this._leftCenter)) { 173 | previewRect.width = this._workArea.width * QUARTER_PERCENTAGE; 174 | previewRect.height = this._workArea.height; 175 | 176 | this._activeEdgeTile = this._leftCenter; 177 | // center-right (full edge tile) 178 | } else if (isPointInsideRect({ x, y }, this._rightCenter)) { 179 | previewRect.x = 180 | this._workArea.x + this._workArea.width - previewRect.width; 181 | previewRect.width = this._workArea.width * QUARTER_PERCENTAGE; 182 | previewRect.height = this._workArea.height; 183 | 184 | this._activeEdgeTile = this._rightCenter; 185 | // left side 186 | } else if (x <= this._workArea.x + this._workArea.width / 2) { 187 | // top-left corner 188 | if (isPointInsideRect({ x, y }, this._topLeft)) { 189 | this._activeEdgeTile = this._topLeft; 190 | // bottom-left corner 191 | } else if (isPointInsideRect({ x, y }, this._bottomLeft)) { 192 | previewRect.y = 193 | this._workArea.y + 194 | this._workArea.height - 195 | previewRect.height; 196 | this._activeEdgeTile = this._bottomLeft; 197 | // bottom-center 198 | } else { 199 | return { 200 | changed: false, 201 | rect: previewRect, 202 | }; 203 | } 204 | // right side 205 | } else { 206 | previewRect.x = 207 | this._workArea.x + this._workArea.width - previewRect.width; 208 | // top-right corner 209 | if (isPointInsideRect({ x, y }, this._topRight)) { 210 | this._activeEdgeTile = this._topRight; 211 | // bottom-right corner 212 | } else if (isPointInsideRect({ x, y }, this._bottomRight)) { 213 | previewRect.y = 214 | this._workArea.y + 215 | this._workArea.height - 216 | previewRect.height; 217 | this._activeEdgeTile = this._bottomRight; 218 | // bottom-center 219 | } else { 220 | return { 221 | changed: false, 222 | rect: previewRect, 223 | }; 224 | } 225 | } 226 | 227 | // uncomment to show active tile debugging 228 | /* global.windowGroup.get_children().filter(c => c.get_name() === "debug")[0]?.destroy(); 229 | const debug = new St.Widget({ 230 | x: this._activeEdgeTile.x, 231 | y: this._activeEdgeTile.y, 232 | height: this._activeEdgeTile.height, 233 | width: this._activeEdgeTile.width, 234 | style: "border: 2px solid red", 235 | name: "debug" 236 | }); 237 | global.windowGroup.add_child(debug);*/ 238 | 239 | return { 240 | changed: true, 241 | rect: previewRect, 242 | }; 243 | } 244 | 245 | public needMaximize(): boolean { 246 | return ( 247 | this._activeEdgeTile !== null && 248 | Settings.TOP_EDGE_MAXIMIZE && 249 | this._activeEdgeTile === this._topCenter 250 | ); 251 | } 252 | 253 | public abortEdgeTiling() { 254 | this._activeEdgeTile = null; 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/components/tilingsystem/extendedWindow.ts: -------------------------------------------------------------------------------- 1 | import Tile from '@components/layout/Tile'; 2 | import { Mtk, Meta } from '@gi.ext'; 3 | 4 | interface ExtendedWindow extends Meta.Window { 5 | originalSize: Mtk.Rectangle | undefined; 6 | assignedTile: Tile | undefined; 7 | } 8 | 9 | export default ExtendedWindow; 10 | -------------------------------------------------------------------------------- /src/components/tilingsystem/touchPointer.ts: -------------------------------------------------------------------------------- 1 | import { buildRectangle } from '@utils/ui'; 2 | import { Mtk, Meta } from '@gi.ext'; 3 | 4 | export default class TouchPointer { 5 | private static _instance: TouchPointer | null = null; 6 | 7 | private _x: number; 8 | private _y: number; 9 | private _windowPos: Mtk.Rectangle; 10 | 11 | private constructor() { 12 | this._x = -1; 13 | this._y = -1; 14 | this._windowPos = buildRectangle(); 15 | } 16 | 17 | public static get(): TouchPointer { 18 | if (!this._instance) this._instance = new TouchPointer(); 19 | 20 | return this._instance; 21 | } 22 | 23 | public isTouchDeviceActive(): boolean { 24 | return ( 25 | this._x !== -1 && 26 | this._y !== -1 && 27 | this._windowPos.x !== -1 && 28 | this._windowPos.y !== -1 29 | ); 30 | } 31 | 32 | public onTouchEvent(x: number, y: number) { 33 | this._x = x; 34 | this._y = y; 35 | } 36 | 37 | public updateWindowPosition(newSize: Mtk.Rectangle) { 38 | this._windowPos.x = newSize.x; 39 | this._windowPos.y = newSize.y; 40 | } 41 | 42 | public reset() { 43 | this._x = -1; 44 | this._y = -1; 45 | this._windowPos.x = -1; 46 | this._windowPos.y = -1; 47 | } 48 | 49 | public get_pointer(window: Meta.Window): [number, number, number] { 50 | const currPos = window.get_frame_rect(); 51 | this._x += currPos.x - this._windowPos.x; 52 | this._y += currPos.y - this._windowPos.y; 53 | this._windowPos.x = currPos.x; 54 | this._windowPos.y = currPos.y; 55 | return [this._x, this._y, global.get_pointer()[2]]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/windowManager/tilingShellWindowManager.ts: -------------------------------------------------------------------------------- 1 | import { registerGObjectClass } from '@utils/gjs'; 2 | import { logger } from '@utils/logger'; 3 | import SignalHandling from '@utils/signalHandling'; 4 | import { GObject, Meta, Mtk, Clutter, Graphene } from '@gi.ext'; 5 | 6 | const debug = logger('TilingShellWindowManager'); 7 | 8 | class CachedWindowProperties { 9 | private _is_initialized: boolean = false; 10 | public maximized: boolean = false; 11 | 12 | constructor(window: Meta.Window, manager: TilingShellWindowManager) { 13 | this.update(window, manager); 14 | this._is_initialized = true; 15 | } 16 | 17 | public update(window: Meta.Window, manager: TilingShellWindowManager) { 18 | const newMaximized = 19 | window.maximizedVertically && window.maximizedHorizontally; 20 | if (this._is_initialized) { 21 | if (this.maximized && !newMaximized) 22 | manager.emit('unmaximized', window); 23 | else if (!this.maximized && newMaximized) 24 | manager.emit('maximized', window); 25 | } 26 | 27 | this.maximized = newMaximized; 28 | } 29 | } 30 | 31 | interface WindowWithCachedProps extends Meta.Window { 32 | __ts_cached: CachedWindowProperties | undefined; 33 | } 34 | 35 | @registerGObjectClass 36 | export default class TilingShellWindowManager extends GObject.Object { 37 | static metaInfo: GObject.MetaInfo = { 38 | GTypeName: 'TilingShellWindowManager', 39 | Signals: { 40 | unmaximized: { 41 | param_types: [Meta.Window.$gtype], 42 | }, 43 | maximized: { 44 | param_types: [Meta.Window.$gtype], 45 | }, 46 | }, 47 | }; 48 | 49 | private static _instance: TilingShellWindowManager | null; 50 | 51 | private readonly _signals: SignalHandling; 52 | 53 | static get(): TilingShellWindowManager { 54 | if (!this._instance) this._instance = new TilingShellWindowManager(); 55 | 56 | return this._instance; 57 | } 58 | 59 | static destroy() { 60 | if (this._instance) { 61 | this._instance._signals.disconnect(); 62 | this._instance = null; 63 | } 64 | } 65 | 66 | constructor() { 67 | super(); 68 | 69 | this._signals = new SignalHandling(); 70 | global.get_window_actors().forEach((winActor) => { 71 | (winActor.metaWindow as WindowWithCachedProps).__ts_cached = 72 | new CachedWindowProperties(winActor.metaWindow, this); 73 | }); 74 | 75 | this._signals.connect( 76 | global.display, 77 | 'window-created', 78 | (_, window: Meta.Window) => { 79 | (window as WindowWithCachedProps).__ts_cached = 80 | new CachedWindowProperties(window, this); 81 | }, 82 | ); 83 | this._signals.connect( 84 | global.windowManager, 85 | 'minimize', 86 | (_, actor: Meta.WindowActor) => { 87 | (actor.metaWindow as WindowWithCachedProps).__ts_cached?.update( 88 | actor.metaWindow, 89 | this, 90 | ); 91 | }, 92 | ); 93 | this._signals.connect( 94 | global.windowManager, 95 | 'unminimize', 96 | (_, actor: Meta.WindowActor) => { 97 | (actor.metaWindow as WindowWithCachedProps).__ts_cached?.update( 98 | actor.metaWindow, 99 | this, 100 | ); 101 | }, 102 | ); 103 | this._signals.connect( 104 | global.windowManager, 105 | 'size-changed', 106 | (_, actor: Meta.WindowActor) => { 107 | // TODO disable default window animations Main.wm.skipNextEffect(actor); 108 | (actor.metaWindow as WindowWithCachedProps).__ts_cached?.update( 109 | actor.metaWindow, 110 | this, 111 | ); 112 | }, 113 | ); 114 | } 115 | 116 | public static easeMoveWindow(params: { 117 | window: Meta.Window; 118 | from: Mtk.Rectangle; 119 | to: Mtk.Rectangle; 120 | duration: number; 121 | monitorIndex?: number; 122 | }): void { 123 | const winActor = 124 | params.window.get_compositor_private() as Meta.WindowActor; 125 | if (!winActor) return; 126 | 127 | // create a clone and hide the window actor 128 | // then we can change the actual window size 129 | // without showing that to the user 130 | const winRect = params.window.get_frame_rect(); 131 | const xExcludingShadow = winRect.x - winActor.get_x(); 132 | const yExcludingShadow = winRect.y - winActor.get_y(); 133 | const staticClone = new Clutter.Clone({ 134 | source: winActor, 135 | reactive: false, 136 | scale_x: 1, 137 | scale_y: 1, 138 | x: params.from.x, 139 | y: params.from.y, 140 | width: params.from.width, 141 | height: params.from.height, 142 | pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), 143 | }); 144 | global.windowGroup.add_child(staticClone); 145 | winActor.opacity = 0; 146 | staticClone.ease({ 147 | x: params.to.x - xExcludingShadow, 148 | y: params.to.y - yExcludingShadow, 149 | width: params.to.width + 2 * yExcludingShadow, 150 | height: params.to.height + 2 * xExcludingShadow, 151 | duration: params.duration, 152 | onStopped: () => { 153 | winActor.opacity = 255; 154 | winActor.set_scale(1, 1); 155 | staticClone.destroy(); 156 | }, 157 | }); 158 | // finally move the window 159 | // the actor has opacity = 0, so this is not seen by the user 160 | winActor.set_pivot_point(0, 0); 161 | winActor.set_position(params.to.x, params.to.y); 162 | winActor.set_size(params.to.width, params.to.height); 163 | const user_op = false; 164 | if (params.monitorIndex) 165 | params.window.move_to_monitor(params.monitorIndex); 166 | params.window.move_frame(user_op, params.to.x, params.to.y); 167 | params.window.move_resize_frame( 168 | user_op, 169 | params.to.x, 170 | params.to.y, 171 | params.to.width, 172 | params.to.height, 173 | ); 174 | // while we hide the preview, show the actor to the new position, 175 | // this has opacity of 0 so it is hidden. Later we immediately swap 176 | // the animating actor with this 177 | winActor.show(); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/components/window_menu/layoutIcon.ts: -------------------------------------------------------------------------------- 1 | import Layout from '@components/layout/Layout'; 2 | import LayoutWidget from '@components/layout/LayoutWidget'; 3 | import Tile from '@components/layout/Tile'; 4 | import SnapAssistTile from '@components/snapassist/snapAssistTile'; 5 | import { registerGObjectClass } from '@utils/gjs'; 6 | import { buildRectangle, getScalingFactorOf } from '@utils/ui'; 7 | import { Clutter, Mtk } from '@gi.ext'; 8 | 9 | @registerGObjectClass 10 | export default class LayoutIcon extends LayoutWidget { 11 | constructor( 12 | parent: Clutter.Actor, 13 | importantTiles: Tile[], 14 | tiles: Tile[], 15 | innerGaps: Clutter.Margin, 16 | outerGaps: Clutter.Margin, 17 | width: number, 18 | height: number, 19 | ) { 20 | super({ 21 | parent, 22 | layout: new Layout(tiles, ''), 23 | innerGaps: innerGaps.copy(), 24 | outerGaps: outerGaps.copy(), 25 | containerRect: buildRectangle(), 26 | styleClass: 'layout-icon button', 27 | }); 28 | 29 | const [, scalingFactor] = getScalingFactorOf(this); 30 | width *= scalingFactor; 31 | height *= scalingFactor; 32 | 33 | super.relayout({ 34 | containerRect: buildRectangle({ x: 0, y: 0, width, height }), 35 | }); 36 | this.set_size(width, height); 37 | this.set_x_expand(false); 38 | this.set_y_expand(false); 39 | 40 | importantTiles.forEach((t) => { 41 | const preview = this._previews.find( 42 | (snap) => snap.tile.x === t.x && snap.tile.y === t.y, 43 | ); 44 | if (preview) preview.add_style_class_name('important'); 45 | }); 46 | } 47 | 48 | buildTile( 49 | parent: Clutter.Actor, 50 | rect: Mtk.Rectangle, 51 | gaps: Clutter.Margin, 52 | tile: Tile, 53 | ): SnapAssistTile { 54 | return new SnapAssistTile({ parent, rect, gaps, tile }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/window_menu/layoutTileButtons.ts: -------------------------------------------------------------------------------- 1 | import Layout from '@components/layout/Layout'; 2 | import LayoutWidget from '@components/layout/LayoutWidget'; 3 | import { registerGObjectClass } from '@utils/gjs'; 4 | import { buildMarginOf, buildRectangle, getScalingFactorOf } from '@utils/ui'; 5 | import { Clutter, Mtk } from '@gi.ext'; 6 | import SnapAssistTileButton from '../snapassist/snapAssistTileButton'; 7 | import Tile from '@components/layout/Tile'; 8 | 9 | @registerGObjectClass 10 | export default class LayoutTileButtons extends LayoutWidget { 11 | constructor( 12 | parent: Clutter.Actor, 13 | layout: Layout, 14 | gapSize: number, 15 | height: number, 16 | width: number, 17 | ) { 18 | super({ 19 | parent, 20 | layout, 21 | containerRect: buildRectangle(), 22 | innerGaps: buildMarginOf(gapSize), 23 | outerGaps: buildMarginOf(gapSize), 24 | styleClass: 'window-menu-layout', 25 | }); 26 | 27 | const [, scalingFactor] = getScalingFactorOf(this); 28 | 29 | this.relayout({ 30 | containerRect: buildRectangle({ 31 | x: 0, 32 | y: 0, 33 | width: width * scalingFactor, 34 | height: height * scalingFactor, 35 | }), 36 | }); 37 | this._fixFloatingPointErrors(); 38 | } 39 | 40 | buildTile( 41 | parent: Clutter.Actor, 42 | rect: Mtk.Rectangle, 43 | gaps: Clutter.Margin, 44 | tile: Tile, 45 | ): SnapAssistTileButton { 46 | return new SnapAssistTileButton({ parent, rect, gaps, tile }); 47 | } 48 | 49 | public get buttons(): SnapAssistTileButton[] { 50 | return this._previews; 51 | } 52 | 53 | private _fixFloatingPointErrors() { 54 | const xMap: Map = new Map(); 55 | const yMap: Map = new Map(); 56 | this._previews.forEach((prev) => { 57 | const tile = prev.tile; 58 | const newX = xMap.get(tile.x); 59 | if (!newX) xMap.set(tile.x, prev.rect.x); 60 | const newY = yMap.get(tile.y); 61 | if (!newY) yMap.set(tile.y, prev.rect.y); 62 | 63 | if (newX || newY) { 64 | prev.open( 65 | false, 66 | buildRectangle({ 67 | x: newX ?? prev.rect.x, 68 | y: newY ?? prev.rect.y, 69 | width: prev.rect.width, 70 | height: prev.rect.height, 71 | }), 72 | ); 73 | } 74 | xMap.set( 75 | tile.x + tile.width, 76 | xMap.get(tile.x + tile.width) ?? prev.rect.x + prev.rect.width, 77 | ); 78 | yMap.set( 79 | tile.y + tile.height, 80 | yMap.get(tile.y + tile.height) ?? 81 | prev.rect.y + prev.rect.height, 82 | ); 83 | }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/components/windowsSuggestions/suggestionsTilePreview.ts: -------------------------------------------------------------------------------- 1 | import { registerGObjectClass } from '@/utils/gjs'; 2 | import { GObject, St, Clutter, Mtk } from '@gi.ext'; 3 | import TilePreview from '../tilepreview/tilePreview'; 4 | import { buildBlurEffect, widgetOrientation } from '@utils/ui'; 5 | import Tile from '@components/layout/Tile'; 6 | import MasonryLayoutManager from './masonryLayoutManager'; 7 | 8 | const MASONRY_LAYOUT_SPACING = 32; 9 | const SCROLLBARS_SHOW_ANIM_DURATION = 100; // ms 10 | 11 | @registerGObjectClass 12 | export default class SuggestionsTilePreview extends TilePreview { 13 | static metaInfo: GObject.MetaInfo = { 14 | GTypeName: 'PopupTilePreview', 15 | Properties: { 16 | blur: GObject.ParamSpec.boolean( 17 | 'blur', 18 | 'blur', 19 | 'Enable or disable the blur effect', 20 | GObject.ParamFlags.READWRITE, 21 | false, 22 | ), 23 | }, 24 | }; 25 | 26 | private _blur: boolean; 27 | private _container: St.BoxLayout; 28 | private _scrollView: St.ScrollView; 29 | 30 | constructor(params: { 31 | parent: Clutter.Actor; 32 | tile?: Tile; 33 | rect?: Mtk.Rectangle; 34 | gaps?: Clutter.Margin; 35 | }) { 36 | super(params); 37 | 38 | // blur not supported due to GNOME shell known bug 39 | this._blur = false; 40 | /* Settings.bind( 41 | Settings.KEY_ENABLE_BLUR_SELECTED_TILEPREVIEW, 42 | this, 43 | 'blur', 44 | Gio.SettingsBindFlags.GET, 45 | );*/ 46 | 47 | this._recolor(); 48 | const styleChangedSignalID = St.ThemeContext.get_for_stage( 49 | global.get_stage(), 50 | ).connect('changed', () => { 51 | this._recolor(); 52 | }); 53 | this.connect('destroy', () => 54 | St.ThemeContext.get_for_stage(global.get_stage()).disconnect( 55 | styleChangedSignalID, 56 | ), 57 | ); 58 | 59 | this.reactive = true; 60 | this.layout_manager = new Clutter.BinLayout(); 61 | 62 | this._container = new St.BoxLayout({ 63 | x_expand: true, 64 | y_align: Clutter.ActorAlign.CENTER, 65 | style: `spacing: ${MASONRY_LAYOUT_SPACING}px;`, 66 | ...widgetOrientation(true), 67 | }); 68 | this._scrollView = new St.ScrollView({ 69 | style_class: 'vfade', 70 | vscrollbar_policy: St.PolicyType.AUTOMATIC, 71 | hscrollbar_policy: St.PolicyType.NEVER, 72 | overlay_scrollbars: true, 73 | clip_to_allocation: true, // Ensure clipping 74 | x_expand: true, 75 | y_expand: true, 76 | }); 77 | 78 | // @ts-expect-error "add_actor is valid" 79 | if (this._scrollView.add_actor) 80 | // @ts-expect-error "add_actor is valid" 81 | this._scrollView.add_actor(this._container); 82 | else this._scrollView.add_child(this._container); 83 | this.add_child(this._scrollView); 84 | 85 | // From GNOME 48 we don't have get_hscroll_bar() anymore 86 | if ( 87 | // @ts-expect-error "get_hscroll_bar is valid for GNOME < 48" 88 | this._scrollView.get_hscroll_bar && 89 | // @ts-expect-error "get_vscroll_bar is valid for GNOME < 48" 90 | this._scrollView.get_vscroll_bar 91 | ) { 92 | // @ts-expect-error "get_hscroll_bar is valid for GNOME < 48" 93 | this._scrollView.get_hscroll_bar().opacity = 0; 94 | // @ts-expect-error "get_vscroll_bar is valid for GNOME < 48" 95 | this._scrollView.get_vscroll_bar().opacity = 0; 96 | } 97 | } 98 | 99 | set blur(value: boolean) { 100 | if (this._blur === value) return; 101 | 102 | this._blur = value; 103 | // blur not supported due to GNOME shell known bug 104 | /* this.get_effect('blur')?.set_enabled(value); 105 | if (this._blur) this.add_style_class_name('blur-tile-preview'); 106 | else this.remove_style_class_name('blur-tile-preview'); 107 | 108 | this._recolor();*/ 109 | } 110 | 111 | public override set gaps(newGaps: Clutter.Margin) { 112 | super.gaps = newGaps; 113 | this.updateBorderRadius( 114 | this._gaps.top > 0, 115 | this._gaps.right > 0, 116 | this._gaps.bottom > 0, 117 | this._gaps.left > 0, 118 | ); 119 | } 120 | 121 | _init() { 122 | super._init(); 123 | 124 | const effect = buildBlurEffect(48); 125 | effect.set_name('blur'); 126 | effect.set_enabled(this._blur); 127 | this.add_effect(effect); 128 | 129 | this.add_style_class_name('selection-tile-preview'); 130 | } 131 | 132 | _recolor() { 133 | this.set_style(null); 134 | 135 | const backgroundColor = this.get_theme_node() 136 | .get_background_color() 137 | .copy(); 138 | // since an alpha value lower than 160 is not so much visible, enforce a minimum value of 160 139 | const newAlpha = Math.max( 140 | Math.min(backgroundColor.alpha + 35, 255), 141 | 160, 142 | ); 143 | // The final alpha value is divided by 255 since CSS needs a value from 0 to 1, but ClutterColor expresses alpha from 0 to 255 144 | this.set_style(` 145 | background-color: rgba(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue}, ${newAlpha / 255}) !important; 146 | `); 147 | } 148 | 149 | private _showScrollBars(): void { 150 | if ( 151 | // @ts-expect-error "get_hscroll_bar is valid for GNOME < 48" 152 | this._scrollView.get_hscroll_bar && 153 | // @ts-expect-error "get_vscroll_bar is valid for GNOME < 48" 154 | this._scrollView.get_vscroll_bar 155 | ) { 156 | [ 157 | // @ts-expect-error "get_hscroll_bar is valid for GNOME < 48" 158 | this._scrollView.get_hscroll_bar(), 159 | // @ts-expect-error "get_vscroll_bar is valid for GNOME < 48" 160 | this._scrollView.get_vscroll_bar(), 161 | ].forEach((bar) => 162 | bar?.ease({ 163 | opacity: 255, 164 | duration: SCROLLBARS_SHOW_ANIM_DURATION, 165 | }), 166 | ); 167 | } 168 | } 169 | 170 | private _hideScrollBars(): void { 171 | if ( 172 | // @ts-expect-error "get_hscroll_bar is valid for GNOME < 48" 173 | this._scrollView.get_hscroll_bar && 174 | // @ts-expect-error "get_vscroll_bar is valid for GNOME < 48" 175 | this._scrollView.get_vscroll_bar 176 | ) { 177 | [ 178 | // @ts-expect-error "get_hscroll_bar is valid for GNOME < 48" 179 | this._scrollView.get_hscroll_bar(), 180 | // @ts-expect-error "get_vscroll_bar is valid for GNOME < 48" 181 | this._scrollView.get_vscroll_bar(), 182 | ].forEach((bar) => 183 | bar?.ease({ 184 | opacity: 0, 185 | duration: SCROLLBARS_SHOW_ANIM_DURATION, 186 | }), 187 | ); 188 | } 189 | } 190 | 191 | vfunc_enter_event(event: Clutter.Event) { 192 | this._showScrollBars(); 193 | return super.vfunc_enter_event(event); 194 | } 195 | 196 | vfunc_leave_event(event: Clutter.Event) { 197 | this._hideScrollBars(); 198 | return super.vfunc_leave_event(event); 199 | } 200 | 201 | public addWindows(windows: Clutter.Actor[], maxRowHeight: number) { 202 | // little trick: we hide the container and add all the windows 203 | // then we queue_relayout and we can compute the sizes of the windows 204 | // to compute placements and scale them preserving aspect ratio 205 | this._container.hide(); 206 | // empty out the container 207 | this._container.destroy_all_children(); 208 | windows.forEach((actor) => this._container.add_child(actor)); 209 | this._container.queue_relayout(); 210 | const placements = MasonryLayoutManager.computePlacements( 211 | windows, 212 | this.innerWidth - 2 * MASONRY_LAYOUT_SPACING, 213 | this.innerHeight, 214 | maxRowHeight, 215 | ); 216 | // we remove all the windows and show back the container 217 | this._container.remove_all_children(); 218 | this._container.show(); 219 | 220 | // add top space 221 | this._container.add_child( 222 | new St.Widget({ height: MASONRY_LAYOUT_SPACING }), 223 | ); 224 | // add each row 225 | placements.forEach((row) => { 226 | const rowBox = new St.BoxLayout({ 227 | x_align: Clutter.ActorAlign.CENTER, 228 | style: `spacing: ${MASONRY_LAYOUT_SPACING}px;`, 229 | }); 230 | this._container.add_child(rowBox); 231 | row.forEach((pl) => { 232 | rowBox.add_child(pl.actor); 233 | pl.actor.set_height(pl.height); 234 | pl.actor.set_width(pl.width); 235 | }); 236 | }); 237 | // add bottom space 238 | this._container.add_child( 239 | new St.Widget({ height: MASONRY_LAYOUT_SPACING }), 240 | ); 241 | } 242 | 243 | public removeAllWindows() { 244 | this._container.destroy_all_children(); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/dbus.ts: -------------------------------------------------------------------------------- 1 | const node = ` 2 | 3 | 4 | 5 | `; 6 | 7 | import { Gio } from '@gi.ext'; 8 | 9 | export default class DBus { 10 | private _dbus: Gio.DBusExportedObject | null; 11 | 12 | constructor() { 13 | this._dbus = null; 14 | } 15 | 16 | public enable(ext: unknown) { 17 | if (this._dbus) return; 18 | 19 | this._dbus = Gio.DBusExportedObject.wrapJSObject(node, ext); 20 | this._dbus.export( 21 | Gio.DBus.session, 22 | '/org/gnome/Shell/Extensions/TilingShell', 23 | ); 24 | } 25 | 26 | public disable() { 27 | this._dbus?.flush(); 28 | this._dbus?.unexport(); 29 | this._dbus = null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/gi.ext.ts: -------------------------------------------------------------------------------- 1 | import { Gio, GLib, GObject } from '@gi.shared'; 2 | import Clutter from 'gi://Clutter'; 3 | import Meta from 'gi://Meta'; 4 | import Mtk from 'gi://Mtk'; 5 | import Shell from 'gi://Shell'; 6 | import St from 'gi://St'; 7 | import Graphene from 'gi://Graphene'; 8 | import Atk from 'gi://Atk'; 9 | import Pango from 'gi://Pango'; 10 | 11 | export { 12 | Clutter, 13 | Gio, 14 | GLib, 15 | GObject, 16 | Meta, 17 | Mtk, 18 | Shell, 19 | St, 20 | Graphene, 21 | Atk, 22 | Pango, 23 | }; 24 | -------------------------------------------------------------------------------- /src/gi.prefs.ts: -------------------------------------------------------------------------------- 1 | import { Gio, GLib, GObject } from '@gi.shared'; 2 | import Gdk from 'gi://Gdk'; 3 | import Gtk from 'gi://Gtk'; // Starting from GNOME 40, the preferences dialog uses GTK4 4 | import Adw from 'gi://Adw'; 5 | 6 | export { Adw, Gio, GLib, GObject, Gdk, Gtk }; 7 | -------------------------------------------------------------------------------- /src/gi.shared.ts: -------------------------------------------------------------------------------- 1 | // import this file from places that are imported by both prefs.js and extension.js 2 | // ensuring you do not import GNOME Shell libraries in Preferences (Clutter, Meta, St or Shell) 3 | // and you do not import GTK libraries in GNOME Shell (Gdk, Gtk or Adw) 4 | 5 | import Gio from 'gi://Gio'; 6 | import GLib from 'gi://GLib'; 7 | import GObject from 'gi://GObject'; 8 | 9 | export { Gio, GLib, GObject }; 10 | -------------------------------------------------------------------------------- /src/indicator/currentMenu.ts: -------------------------------------------------------------------------------- 1 | interface CurrentMenu { 2 | destroy(): void; 3 | } 4 | 5 | export default CurrentMenu; 6 | -------------------------------------------------------------------------------- /src/indicator/editingMenu.ts: -------------------------------------------------------------------------------- 1 | import { St, Clutter } from '@gi.ext'; 2 | import Indicator from './indicator'; 3 | import * as IndicatorUtils from './utils'; 4 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 5 | import CurrentMenu from './currentMenu'; 6 | import { _ } from '../translations'; 7 | import { widgetOrientation } from '@utils/ui'; 8 | 9 | export default class EditingMenu implements CurrentMenu { 10 | private readonly _indicator: Indicator; 11 | 12 | constructor(indicator: Indicator) { 13 | this._indicator = indicator; 14 | 15 | const boxLayout = new St.BoxLayout({ 16 | styleClass: 'buttons-box-layout', 17 | xExpand: true, 18 | style: 'spacing: 8px', 19 | ...widgetOrientation(true), 20 | }); 21 | 22 | const openMenuBtn = IndicatorUtils.createButton( 23 | 'menu-symbolic', 24 | _('Menu'), 25 | this._indicator.path, 26 | ); 27 | openMenuBtn.connect('clicked', () => this._indicator.openMenu(false)); 28 | boxLayout.add_child(openMenuBtn); 29 | 30 | const infoMenuBtn = IndicatorUtils.createButton( 31 | 'info-symbolic', 32 | _('Info'), 33 | this._indicator.path, 34 | ); 35 | infoMenuBtn.connect('clicked', () => this._indicator.openMenu(true)); 36 | boxLayout.add_child(infoMenuBtn); 37 | 38 | const saveBtn = IndicatorUtils.createButton( 39 | 'save-symbolic', 40 | _('Save'), 41 | this._indicator.path, 42 | ); 43 | saveBtn.connect('clicked', () => { 44 | this._indicator.menu.toggle(); 45 | this._indicator.saveLayoutOnClick(); 46 | }); 47 | boxLayout.add_child(saveBtn); 48 | 49 | const cancelBtn = IndicatorUtils.createButton( 50 | 'cancel-symbolic', 51 | _('Cancel'), 52 | this._indicator.path, 53 | ); 54 | cancelBtn.connect('clicked', () => { 55 | this._indicator.menu.toggle(); 56 | this._indicator.cancelLayoutOnClick(); 57 | }); 58 | boxLayout.add_child(cancelBtn); 59 | 60 | const menuItem = new PopupMenu.PopupBaseMenuItem({ 61 | style_class: 'indicator-menu-item', 62 | }); 63 | menuItem.add_child(boxLayout); 64 | 65 | (this._indicator.menu as PopupMenu.PopupMenu).addMenuItem(menuItem); 66 | } 67 | 68 | destroy(): void { 69 | (this._indicator.menu as PopupMenu.PopupMenu).removeAll(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/indicator/indicator.ts: -------------------------------------------------------------------------------- 1 | import { St, Clutter, Shell, Gio } from '@gi.ext'; 2 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 3 | import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; 4 | import Settings from '@settings/settings'; 5 | import Layout from '@/components/layout/Layout'; 6 | import Tile from '@/components/layout/Tile'; 7 | import LayoutEditor from '@/components/editor/layoutEditor'; 8 | import DefaultMenu from './defaultMenu'; 9 | import GlobalState from '@utils/globalState'; 10 | import EditingMenu from './editingMenu'; 11 | import EditorDialog from '../components/editor/editorDialog'; 12 | import CurrentMenu from './currentMenu'; 13 | import { registerGObjectClass } from '@utils/gjs'; 14 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 15 | import { getWindows } from '@utils/ui'; 16 | import ExtendedWindow from '@components/tilingsystem/extendedWindow'; 17 | 18 | enum IndicatorState { 19 | DEFAULT = 1, 20 | CREATE_NEW, 21 | EDITING_LAYOUT, 22 | } 23 | 24 | @registerGObjectClass 25 | export default class Indicator extends PanelMenu.Button { 26 | private _layoutEditor: LayoutEditor | null; 27 | private _editorDialog: EditorDialog | null; 28 | private _currentMenu: CurrentMenu | null; 29 | private _state: IndicatorState; 30 | private _enableScaling: boolean; 31 | private _path: string; 32 | private _keyPressEvent: number | null; 33 | 34 | constructor(path: string, uuid: string) { 35 | super(0.5, 'Tiling Shell Indicator', false); 36 | Main.panel.addToStatusArea(uuid, this, 1, 'right'); 37 | 38 | // Bind the "show-indicator" setting to the "visible" property 39 | Settings.bind( 40 | Settings.KEY_SHOW_INDICATOR, 41 | this, 42 | 'visible', 43 | Gio.SettingsBindFlags.GET, 44 | ); 45 | 46 | const icon = new St.Icon({ 47 | gicon: Gio.icon_new_for_string( 48 | `${path}/icons/indicator-symbolic.svg`, 49 | ), 50 | styleClass: 'system-status-icon indicator-icon', 51 | }); 52 | 53 | this.add_child(icon); 54 | this._layoutEditor = null; 55 | this._editorDialog = null; 56 | this._currentMenu = null; 57 | this._state = IndicatorState.DEFAULT; 58 | this._keyPressEvent = null; 59 | this._enableScaling = false; 60 | this._path = path; 61 | 62 | this.connect('destroy', this._onDestroy.bind(this)); 63 | } 64 | 65 | public get path(): string { 66 | return this._path; 67 | } 68 | 69 | public set enableScaling(value: boolean) { 70 | if (this._enableScaling === value) return; 71 | this._enableScaling = value; 72 | 73 | if (this._currentMenu && this._state === IndicatorState.DEFAULT) { 74 | this._currentMenu.destroy(); 75 | this._currentMenu = new DefaultMenu(this, this._enableScaling); 76 | } 77 | } 78 | 79 | public enable() { 80 | (this.menu as PopupMenu.PopupMenu).removeAll(); 81 | this._currentMenu = new DefaultMenu(this, this._enableScaling); 82 | } 83 | 84 | public selectLayoutOnClick(monitorIndex: number, layoutToSelectId: string) { 85 | // get the currently selected layouts 86 | const selected = Settings.get_selected_layouts(); 87 | // select the layout for the given monitor 88 | selected[global.workspaceManager.get_active_workspace_index()][ 89 | monitorIndex 90 | ] = layoutToSelectId; 91 | 92 | // if there are 2 or more workspaces, if the last workspace is empty 93 | // it must follow the layout of the second-last workspace 94 | // if we changed the second-last workspace we take care of changing 95 | // the last workspace as well, if there aren't tiled windows (is empty) 96 | const n_workspaces = global.workspaceManager.get_n_workspaces(); 97 | if ( 98 | global.workspaceManager.get_active_workspace_index() === 99 | n_workspaces - 2 100 | ) { 101 | const lastWs = global.workspaceManager.get_workspace_by_index( 102 | n_workspaces - 1, 103 | ); 104 | if (!lastWs) return; 105 | 106 | // check if there are tiled windows on that monitor and in the last workspace 107 | const tiledWindows = getWindows(lastWs).find( 108 | (win) => 109 | (win as ExtendedWindow).assignedTile && 110 | win.get_monitor() === monitorIndex, 111 | ); 112 | if (!tiledWindows) { 113 | // the last workspace, on that monitor, is empty 114 | // select the same layout for last workspace as well 115 | selected[lastWs.index()][monitorIndex] = layoutToSelectId; 116 | } 117 | } 118 | 119 | Settings.save_selected_layouts(selected); 120 | this.menu.toggle(); 121 | } 122 | 123 | public newLayoutOnClick(showLegendOnly: boolean) { 124 | this.menu.close(true); 125 | 126 | const newLayout = new Layout( 127 | [ 128 | new Tile({ x: 0, y: 0, width: 0.3, height: 1, groups: [1] }), 129 | new Tile({ x: 0.3, y: 0, width: 0.7, height: 1, groups: [1] }), 130 | ], 131 | `${Shell.Global.get().get_current_time()}`, 132 | ); 133 | 134 | if (this._layoutEditor) { 135 | this._layoutEditor.layout = newLayout; 136 | } else { 137 | this._layoutEditor = new LayoutEditor( 138 | newLayout, 139 | Main.layoutManager.monitors[Main.layoutManager.primaryIndex], 140 | this._enableScaling, 141 | ); 142 | } 143 | this._setState(IndicatorState.CREATE_NEW); 144 | if (showLegendOnly) this.openMenu(true); 145 | } 146 | 147 | public openMenu(showLegend: boolean) { 148 | if (this._editorDialog) return; 149 | 150 | this._editorDialog = new EditorDialog({ 151 | enableScaling: this._enableScaling, 152 | onNewLayout: () => { 153 | this.newLayoutOnClick(false); 154 | }, 155 | onDeleteLayout: (ind: number, lay: Layout) => { 156 | GlobalState.get().deleteLayout(lay); 157 | 158 | if ( 159 | this._layoutEditor && 160 | this._layoutEditor.layout.id === lay.id 161 | ) 162 | this.cancelLayoutOnClick(); 163 | }, 164 | onSelectLayout: (ind: number, lay: Layout) => { 165 | const layCopy = new Layout( 166 | lay.tiles.map( 167 | (t) => 168 | new Tile({ 169 | x: t.x, 170 | y: t.y, 171 | width: t.width, 172 | height: t.height, 173 | groups: [...t.groups], 174 | }), 175 | ), 176 | lay.id, 177 | ); 178 | 179 | if (this._layoutEditor) { 180 | this._layoutEditor.layout = layCopy; 181 | } else { 182 | this._layoutEditor = new LayoutEditor( 183 | layCopy, 184 | Main.layoutManager.monitors[ 185 | Main.layoutManager.primaryIndex 186 | ], 187 | this._enableScaling, 188 | ); 189 | } 190 | 191 | this._setState(IndicatorState.EDITING_LAYOUT); 192 | }, 193 | onClose: () => { 194 | this._editorDialog?.destroy(); 195 | this._editorDialog = null; 196 | }, 197 | path: this._path, 198 | legend: showLegend, 199 | }); 200 | this._editorDialog.open(); 201 | } 202 | 203 | public openLayoutEditor() { 204 | this.openMenu(false); 205 | } 206 | 207 | public saveLayoutOnClick() { 208 | if ( 209 | this._layoutEditor === null || 210 | this._state === IndicatorState.DEFAULT 211 | ) 212 | return; 213 | const newLayout = this._layoutEditor.layout; 214 | 215 | if (this._state === IndicatorState.CREATE_NEW) 216 | GlobalState.get().addLayout(newLayout); 217 | else GlobalState.get().editLayout(newLayout); 218 | 219 | this.menu.toggle(); 220 | 221 | this._layoutEditor.destroy(); 222 | this._layoutEditor = null; 223 | 224 | this._setState(IndicatorState.DEFAULT); 225 | } 226 | 227 | public cancelLayoutOnClick() { 228 | if ( 229 | this._layoutEditor === null || 230 | this._state === IndicatorState.DEFAULT 231 | ) 232 | return; 233 | 234 | this.menu.toggle(); 235 | 236 | this._layoutEditor.destroy(); 237 | this._layoutEditor = null; 238 | 239 | this._setState(IndicatorState.DEFAULT); 240 | } 241 | 242 | private _setState(newState: IndicatorState) { 243 | if (this._state === newState) return; 244 | this._state = newState; 245 | this._currentMenu?.destroy(); 246 | switch (newState) { 247 | case IndicatorState.DEFAULT: 248 | this._currentMenu = new DefaultMenu(this, this._enableScaling); 249 | if (!Settings.SHOW_INDICATOR) this.hide(); 250 | if (this._keyPressEvent) { 251 | global.stage.disconnect(this._keyPressEvent); 252 | this._keyPressEvent = null; 253 | } 254 | break; 255 | case IndicatorState.CREATE_NEW: 256 | case IndicatorState.EDITING_LAYOUT: 257 | this._currentMenu = new EditingMenu(this); 258 | this.show(); 259 | if (this._keyPressEvent) 260 | global.stage.disconnect(this._keyPressEvent); 261 | this._keyPressEvent = global.stage.connect_after( 262 | 'key-press-event', 263 | (_, event) => { 264 | const symbol = event.get_key_symbol(); 265 | if (symbol === Clutter.KEY_Escape) 266 | this.cancelLayoutOnClick(); 267 | 268 | return Clutter.EVENT_PROPAGATE; 269 | }, 270 | ); 271 | break; 272 | } 273 | } 274 | 275 | private _onDestroy() { 276 | this._editorDialog?.destroy(); 277 | this._editorDialog = null; 278 | this._layoutEditor?.destroy(); 279 | this._layoutEditor = null; 280 | this._currentMenu?.destroy(); 281 | this._currentMenu = null; 282 | (this.menu as PopupMenu.PopupMenu).removeAll(); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/indicator/layoutButton.ts: -------------------------------------------------------------------------------- 1 | import { St, Clutter, Mtk } from '@gi.ext'; 2 | import LayoutWidget from '@/components/layout/LayoutWidget'; 3 | import SnapAssistTile from '@/components/snapassist/snapAssistTile'; 4 | import Layout from '@/components/layout/Layout'; 5 | import Tile from '@/components/layout/Tile'; 6 | import { buildMarginOf, buildRectangle, getScalingFactorOf } from '@utils/ui'; 7 | import { registerGObjectClass } from '@utils/gjs'; 8 | 9 | @registerGObjectClass 10 | class LayoutButtonWidget extends LayoutWidget { 11 | constructor( 12 | parent: Clutter.Actor, 13 | layout: Layout, 14 | gapSize: number, 15 | height: number, 16 | width: number, 17 | ) { 18 | super({ 19 | parent, 20 | layout, 21 | containerRect: buildRectangle({ x: 0, y: 0, width, height }), 22 | innerGaps: buildMarginOf(gapSize), 23 | outerGaps: new Clutter.Margin(), 24 | }); 25 | this.relayout(); 26 | } 27 | 28 | buildTile( 29 | parent: Clutter.Actor, 30 | rect: Mtk.Rectangle, 31 | gaps: Clutter.Margin, 32 | tile: Tile, 33 | ): SnapAssistTile { 34 | return new SnapAssistTile({ parent, rect, gaps, tile }); 35 | } 36 | } 37 | 38 | @registerGObjectClass 39 | export default class LayoutButton extends St.Button { 40 | constructor( 41 | parent: Clutter.Actor, 42 | layout: Layout, 43 | gapSize: number, 44 | height: number, 45 | width: number, 46 | ) { 47 | super({ 48 | styleClass: 'layout-button button', 49 | xExpand: false, 50 | yExpand: false, 51 | }); 52 | 53 | parent.add_child(this); 54 | 55 | const scalingFactor = getScalingFactorOf(this)[1]; 56 | 57 | this.child = new St.Widget(); // the child is just a container 58 | new LayoutButtonWidget( 59 | this.child, 60 | layout, 61 | gapSize, 62 | height * scalingFactor, 63 | width * scalingFactor, 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/indicator/utils.ts: -------------------------------------------------------------------------------- 1 | import { St, Clutter, Gio } from '@gi.ext'; 2 | 3 | export const createButton = ( 4 | iconName: string, 5 | text: string, 6 | path?: string, 7 | ): St.Button => { 8 | const btn = createIconButton(iconName, path, 8); 9 | btn.set_style('padding-left: 5px !important;'); // bring back the right padding 10 | btn.child.add_child( 11 | new St.Label({ 12 | marginBottom: 4, 13 | marginTop: 4, 14 | text, 15 | yAlign: Clutter.ActorAlign.CENTER, 16 | }), 17 | ); 18 | return btn; 19 | }; 20 | 21 | export const createIconButton = ( 22 | iconName: string, 23 | path?: string, 24 | spacing = 0, 25 | ): St.Button => { 26 | const btn = new St.Button({ 27 | styleClass: 'message-list-clear-button button', 28 | canFocus: true, 29 | xExpand: true, 30 | style: 'padding-left: 5px !important; padding-right: 5px !important;', 31 | child: new St.BoxLayout({ 32 | clipToAllocation: true, 33 | xAlign: Clutter.ActorAlign.CENTER, 34 | yAlign: Clutter.ActorAlign.CENTER, 35 | reactive: true, 36 | xExpand: true, 37 | style: spacing > 0 ? `spacing: ${spacing}px` : '', 38 | }), 39 | }); 40 | 41 | const icon = new St.Icon({ 42 | iconSize: 16, 43 | yAlign: Clutter.ActorAlign.CENTER, 44 | style: 'padding: 6px', 45 | }); 46 | if (path) 47 | icon.gicon = Gio.icon_new_for_string(`${path}/icons/${iconName}.svg`); 48 | else icon.iconName = iconName; 49 | 50 | btn.child.add_child(icon); 51 | return btn; 52 | }; 53 | -------------------------------------------------------------------------------- /src/polyfill.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; 2 | 3 | function openPrefs() { 4 | // @ts-expect-error "This will be ok in GNOME <= 44 because 5 | // the build system will provide such function" 6 | if (Extension.openPrefs) { 7 | // GNOME <= 44 8 | // @ts-expect-error "This will be ok in GNOME" 9 | Extension.openPrefs(); 10 | } else { 11 | // GNOME 45+ 12 | Extension.lookupByUUID( 13 | 'tilingshell@ferrarodomenico.com', 14 | )?.openPreferences(); 15 | } 16 | } 17 | 18 | export { Extension, openPrefs }; 19 | -------------------------------------------------------------------------------- /src/settings/settingsExport.ts: -------------------------------------------------------------------------------- 1 | import { Gio, GLib } from '@gi.prefs'; 2 | import Settings from '@settings/settings'; 3 | import SettingsOverride from '@settings/settingsOverride'; 4 | 5 | const dconfPath = '/org/gnome/shell/extensions/tilingshell/'; 6 | const excludedKeys: string[] = [ 7 | Settings.KEY_SETTING_LAYOUTS_JSON, 8 | Settings.KEY_LAST_VERSION_NAME_INSTALLED, 9 | Settings.KEY_OVERRIDDEN_SETTINGS, 10 | ]; 11 | 12 | export default class SettingsExport { 13 | private readonly _gioSettings: Gio.Settings; 14 | 15 | constructor(gioSettings: Gio.Settings) { 16 | this._gioSettings = gioSettings; 17 | } 18 | 19 | exportToString(): string { 20 | return this._excludeKeys(this._dumpDconf()); 21 | } 22 | 23 | importFromString(content: string) { 24 | this.restoreToDefault(); 25 | 26 | const proc = Gio.Subprocess.new( 27 | ['dconf', 'load', dconfPath], 28 | Gio.SubprocessFlags.STDIN_PIPE, 29 | ); 30 | 31 | proc.communicate_utf8(content, null); 32 | 33 | if (!proc.get_successful()) { 34 | this.restoreToDefault(); 35 | 36 | throw new Error( 37 | 'Failed to import dconf dump file. Restoring to default...', 38 | ); 39 | } 40 | } 41 | 42 | restoreToDefault() { 43 | Settings.ACTIVE_SCREEN_EDGES = false; 44 | Settings.ENABLE_MOVE_KEYBINDINGS = false; 45 | SettingsOverride.get().restoreAll(); 46 | this._gioSettings 47 | .list_keys() 48 | .filter((key) => key.length > 0 && !excludedKeys.includes(key)) 49 | .forEach((key) => this._gioSettings.reset(key)); 50 | } 51 | 52 | private _dumpDconf(): string { 53 | const proc = Gio.Subprocess.new( 54 | ['dconf', 'dump', dconfPath], 55 | Gio.SubprocessFlags.STDOUT_PIPE, 56 | ); 57 | 58 | const [, dump] = proc.communicate_utf8(null, null); 59 | 60 | if (proc.get_successful()) return dump; 61 | else throw new Error('Failed to dump dconf'); 62 | } 63 | 64 | private _excludeKeys(dconfDump: string) { 65 | if (dconfDump.length === 0) throw new Error('Empty dconf dump'); 66 | 67 | const keyFile = new GLib.KeyFile(); 68 | const length = new TextEncoder().encode(dconfDump).length; 69 | 70 | if (!keyFile.load_from_data(dconfDump, length, GLib.KeyFileFlags.NONE)) 71 | throw new Error('Failed to load from dconf dump'); 72 | 73 | const [key_list] = keyFile.get_keys('/'); 74 | 75 | key_list.forEach((key) => { 76 | if (excludedKeys.includes(key)) keyFile.remove_key('/', key); 77 | }); 78 | 79 | const [data] = keyFile.to_data(); 80 | if (data) return data; 81 | else throw new Error('Failed to exclude dconf keys'); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/settings/settingsOverride.ts: -------------------------------------------------------------------------------- 1 | import Settings from '@settings/settings'; 2 | import { Gio, GLib } from '@gi.shared'; 3 | 4 | export default class SettingsOverride { 5 | // map schema_id with map of keys and old values 6 | private _overriddenKeys: Map>; 7 | private static _instance: SettingsOverride | null; 8 | 9 | private constructor() { 10 | this._overriddenKeys = this._jsonToOverriddenKeys( 11 | Settings.OVERRIDDEN_SETTINGS, 12 | ); 13 | } 14 | 15 | static get(): SettingsOverride { 16 | if (!this._instance) this._instance = new SettingsOverride(); 17 | 18 | return this._instance; 19 | } 20 | 21 | static destroy() { 22 | if (!this._instance) return; 23 | 24 | this._instance.restoreAll(); 25 | this._instance = null; 26 | } 27 | 28 | /* 29 | json will have the following structure 30 | { 31 | "schema.id": { 32 | "overridden.key.one": oldvalue, 33 | "overridden.key.two": oldvalue 34 | ... 35 | }, 36 | ... 37 | } 38 | */ 39 | private _overriddenKeysToJSON(): string { 40 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 41 | const obj: any = {}; 42 | this._overriddenKeys.forEach((override, schemaId) => { 43 | obj[schemaId] = {}; 44 | override.forEach((oldValue, key) => { 45 | obj[schemaId][key] = oldValue.print(true); 46 | }); 47 | }); 48 | return JSON.stringify(obj); 49 | } 50 | 51 | private _jsonToOverriddenKeys( 52 | json: string, 53 | ): Map> { 54 | const result: Map> = new Map(); 55 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 | const obj: any = JSON.parse(json); 57 | 58 | for (const schemaId in obj) { 59 | const schemaMap = new Map(); 60 | result.set(schemaId, schemaMap); 61 | 62 | const overrideObj = obj[schemaId]; 63 | for (const key in overrideObj) { 64 | schemaMap.set( 65 | key, 66 | GLib.Variant.parse(null, overrideObj[key], null, null), 67 | ); 68 | } 69 | } 70 | 71 | return result; 72 | } 73 | 74 | public override( 75 | giosettings: Gio.Settings, 76 | keyToOverride: string, 77 | newValue: GLib.Variant, 78 | ): GLib.Variant | null { 79 | const schemaId = giosettings.schemaId; 80 | const schemaMap = this._overriddenKeys.get(schemaId) || new Map(); 81 | if (!this._overriddenKeys.has(schemaId)) 82 | this._overriddenKeys.set(schemaId, schemaMap); 83 | 84 | const oldValue = schemaMap.has(keyToOverride) 85 | ? schemaMap.get(keyToOverride) 86 | : giosettings.get_value(keyToOverride); 87 | 88 | const res = giosettings.set_value(keyToOverride, newValue); 89 | if (!res) return null; 90 | 91 | if (!schemaMap.has(keyToOverride)) { 92 | schemaMap.set(keyToOverride, oldValue); 93 | 94 | Settings.OVERRIDDEN_SETTINGS = this._overriddenKeysToJSON(); 95 | } 96 | 97 | return oldValue; 98 | } 99 | 100 | public restoreKey( 101 | giosettings: Gio.Settings, 102 | keyToOverride: string, 103 | ): GLib.Variant | null { 104 | const overridden = this._overriddenKeys.get(giosettings.schemaId); 105 | if (!overridden) return null; 106 | 107 | const oldValue = overridden.get(keyToOverride); 108 | if (!oldValue) return null; 109 | 110 | const res = giosettings.set_value(keyToOverride, oldValue); 111 | 112 | if (res) { 113 | overridden.delete(keyToOverride); 114 | if (overridden.size === 0) 115 | this._overriddenKeys.delete(giosettings.schemaId); 116 | 117 | Settings.OVERRIDDEN_SETTINGS = this._overriddenKeysToJSON(); 118 | } 119 | 120 | return oldValue; 121 | } 122 | 123 | public restoreAll() { 124 | const schemaToDelete: string[] = []; 125 | this._overriddenKeys.forEach( 126 | (map: Map, schemaId: string) => { 127 | const giosettings = new Gio.Settings({ schemaId }); 128 | const overridden = this._overriddenKeys.get( 129 | giosettings.schemaId, 130 | ); 131 | if (!overridden) return; 132 | 133 | const toDelete: string[] = []; 134 | overridden.forEach((oldValue: GLib.Variant, key: string) => { 135 | const done = giosettings.set_value(key, oldValue); 136 | if (done) toDelete.push(key); 137 | }); 138 | toDelete.forEach((key) => overridden.delete(key)); 139 | if (overridden.size === 0) schemaToDelete.push(schemaId); 140 | }, 141 | ); 142 | schemaToDelete.forEach((schemaId) => { 143 | this._overriddenKeys.delete(schemaId); 144 | }); 145 | 146 | if (this._overriddenKeys.size === 0) this._overriddenKeys = new Map(); 147 | 148 | Settings.OVERRIDDEN_SETTINGS = this._overriddenKeysToJSON(); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/styles/constants.scss: -------------------------------------------------------------------------------- 1 | @use "functions"; 2 | 3 | // From https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/43.0/data/theme/gnome-shell-sass/_common.scss 4 | $base_font_size: 11pt; // font size 5 | $base_padding: functions.to_em(6px); //6px; 6 | $base_margin: 4px; 7 | $base_border_radius: 8px; //to_em(8px); 8 | -------------------------------------------------------------------------------- /src/styles/editor.scss: -------------------------------------------------------------------------------- 1 | @use "constants"; 2 | @use "functions"; 3 | 4 | .layout-editor { 5 | background-color: rgba(255, 255, 255, 0.14); 6 | } 7 | 8 | .layout-editor-slider { 9 | background-color: rgba(255, 255, 255, 0.8); 10 | border-radius: 6px; 11 | border: 2px solid rgb(65, 65, 65, 0.8); 12 | } 13 | 14 | .editable-tile-preview-button { 15 | text-align: center; 16 | font-weight: bold; 17 | font-size: 20px; 18 | color: white; 19 | } 20 | 21 | .editor-dialog { 22 | .layouts-box-layout { 23 | spacing: 18px; 24 | } 25 | 26 | .layout-button-container { 27 | spacing: 8px; 28 | } 29 | 30 | .delete-layout-button { 31 | padding: constants.$base_padding; 32 | } 33 | 34 | .editor-dialog-title { 35 | text-align: center; 36 | font-weight: 800; 37 | @include functions.fontsize(15pt); 38 | } 39 | 40 | .legend { 41 | spacing: 12px; 42 | } 43 | 44 | .kbd { 45 | @include functions.fontsize(constants.$base_font_size); 46 | font-weight: bold; 47 | text-align: center; 48 | font-family: monospace; 49 | padding: calc(constants.$base-padding / 3) calc(constants.$base-padding * 2); 50 | box-shadow: inset 0px -3px 0px 0px rgba(0, 0, 0, 0.2); 51 | } 52 | } 53 | 54 | .hover-line { 55 | background-color: rgba(255, 255, 255, 0.2); 56 | } 57 | -------------------------------------------------------------------------------- /src/styles/functions.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | // Function to convert px values to em 3 | // From https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/data/theme/gnome-shell-sass/_drawing.scss#L6 4 | @function to_em($input, $base: 16px) { 5 | // multiplied and divided by 1000 to make up for round() shortcoming 6 | $em_value: calc($input / $base) * 1.091 * 1000; 7 | @return calc(math.round($em_value) / 1000) * 1em; 8 | } 9 | 10 | 11 | 12 | // From https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/data/theme/gnome-shell-sass/_drawing.scss#L66 13 | // Mixin to convert provided font size in pt to em units 14 | @mixin fontsize($size, $base: 16px, $unit: pt) { 15 | // if pt, convert into unitless value with the assumption: 1pt = 1.091px 16 | $adjusted_size: if($unit == pt, $size * 1.091, $size) * 1000; 17 | $rounded_size: calc(math.round(calc($adjusted_size / $base)) / 1000); 18 | font-size: $rounded_size * 1em; 19 | // font-size: round($size) + pt; 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/indicator.scss: -------------------------------------------------------------------------------- 1 | @use "functions"; 2 | @use "snap_assist"; 3 | 4 | .indicator-menu-item { 5 | background-color: transparent !important; 6 | 7 | .buttons-box-layout { 8 | spacing: 8px; 9 | } 10 | 11 | // workaround to hide the empty space created by the popup ornament 12 | .popup-menu-ornament { 13 | width: 0; 14 | height: 0; 15 | } 16 | 17 | .snap-assist-tile { 18 | border-radius-value: snap_assist.$snap-assist-tile-border-radius - 1; 19 | border-width-value: calc(snap_assist.$snap-assist-tile-border-width / 2); 20 | } 21 | } 22 | 23 | .default-menu-container { 24 | spacing: 16px; 25 | } 26 | 27 | .monitor-layouts-title { 28 | //font-family: monospace; 29 | @include functions.fontsize(9pt); 30 | text-align: center; 31 | } 32 | -------------------------------------------------------------------------------- /src/styles/layout_button.scss: -------------------------------------------------------------------------------- 1 | @use "constants"; 2 | @use "snap_assist"; 3 | 4 | .layouts-box-layout { 5 | spacing: 12px; 6 | } 7 | 8 | .layout-button { 9 | transition: 100ms ease all; 10 | padding: calc(constants.$base_padding / 2); 11 | border-radius: snap_assist.$snap-assist-tile-border-radius; 12 | border-width: snap_assist.$snap-assist-tile-border-width; 13 | border-color: transparent; 14 | 15 | .snap-assist-tile { 16 | border-color: rgba(255, 255, 255, 0.4); 17 | background-color: rgba(255, 255, 255, 0.1); 18 | } 19 | 20 | .snap-assist-tile.dark { 21 | border-color: #939393; 22 | background-color: #e2e2e2; 23 | } 24 | } 25 | 26 | .layout-button:hover, 27 | .layout-button:checked { 28 | border-color: rgba(255, 255, 255, 0.7); 29 | 30 | .snap-assist-tile { 31 | border-color: rgba(255, 255, 255, 0.7); 32 | background-color: rgba(255, 255, 255, 0.25); 33 | } 34 | 35 | .snap-assist-tile.dark { 36 | border-color: #6c6c6c; 37 | background-color: #c4c4c4; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/styles/layout_icon.scss: -------------------------------------------------------------------------------- 1 | @use "snap_assist"; 2 | 3 | .layout-icon { 4 | .snap-assist-tile { 5 | border-radius-value: snap_assist.$snap-assist-tile-border-radius - 1; 6 | border-width-value: calc(snap_assist.$snap-assist-tile-border-width / 2); 7 | border-color: rgba(255, 255, 255, 0.2); 8 | background-color: rgba(255, 255, 255, 0.2); 9 | } 10 | 11 | .snap-assist-tile.dark { 12 | border-color: rgba(121, 121, 121, 0.2); 13 | background-color: rgba(121, 121, 121, 0.2); 14 | } 15 | 16 | .snap-assist-tile.important { 17 | border-color: rgba(255, 255, 255, 0.6); 18 | background-color: rgba(255, 255, 255, 0.6); 19 | } 20 | 21 | .snap-assist-tile.dark.important { 22 | border-color: rgba(121, 121, 121, 0.6); 23 | background-color: rgba(121, 121, 121, 0.6); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/snap_assist.scss: -------------------------------------------------------------------------------- 1 | @use "constants"; 2 | 3 | $snap-assist-tile-border-width: 1px; 4 | $snap-assist-tile-border-radius: 6px; 5 | 6 | .snap-assistant { 7 | box-shadow: rgba(0, 0, 0, 0.16) 0px 3px 6px; 8 | border: 0; 9 | border-radius: constants.$base_border_radius + 2; 10 | padding-value: 16px; 11 | } 12 | 13 | .snap-assist-tile { 14 | transition: 300ms ease all; 15 | border-radius-value: $snap-assist-tile-border-radius; 16 | border-width-value: $snap-assist-tile-border-width; 17 | border-style: solid; 18 | // light theme as default 19 | border-color: rgba(255, 255, 255, 0.2); 20 | background-color: rgba(255, 255, 255, 0.25); 21 | } 22 | 23 | .snap-assist-tile:hover { 24 | border-color: rgba(255, 255, 255, 0.7); 25 | background-color: rgba(255, 255, 255, 0.4); 26 | } 27 | 28 | .snap-assist-tile.dark { 29 | border-color: rgba(128, 128, 128, 0.65); 30 | background-color: rgba(180, 180, 180, 0.45); 31 | } 32 | 33 | .snap-assist-tile.dark:hover { 34 | border-color: rgba(94, 94, 94, 0.7); 35 | background-color: rgba(154, 154, 154, 0.6); 36 | } 37 | -------------------------------------------------------------------------------- /src/styles/stylesheet.scss: -------------------------------------------------------------------------------- 1 | @use 'constants.scss'; 2 | @use 'functions.scss'; 3 | @use 'tile_preview.scss'; 4 | @use 'snap_assist.scss'; 5 | @use 'indicator.scss'; 6 | @use 'layout_button.scss'; 7 | @use 'editor.scss'; 8 | @use 'window_menu.scss'; 9 | @use 'layout_icon.scss'; 10 | @use 'window_border.scss'; 11 | @use 'tiling_popup.scss'; 12 | 13 | -------------------------------------------------------------------------------- /src/styles/tile_preview.scss: -------------------------------------------------------------------------------- 1 | @use "constants"; 2 | 3 | .custom-tile-preview { 4 | border-radius: constants.$base_border_radius; 5 | } 6 | 7 | .top-left-border-radius { 8 | border-radius: constants.$base_border_radius 0 0 0; 9 | } 10 | .top-right-border-radius { 11 | border-radius: 0 constants.$base_border_radius 0 0; 12 | } 13 | .bottom-right-border-radius { 14 | border-radius: 0 0 constants.$base_border_radius 0; 15 | } 16 | .bottom-left-border-radius { 17 | border-radius: 0 0 0 constants.$base_border_radius; 18 | } 19 | 20 | .top-left-border-radius.top-right-border-radius { 21 | border-radius: constants.$base_border_radius constants.$base_border_radius 0 0; 22 | } 23 | 24 | .top-right-border-radius.bottom-right-border-radius { 25 | border-radius: 0 constants.$base_border_radius constants.$base_border_radius 0; 26 | } 27 | 28 | .bottom-right-border-radius.bottom-left-border-radius { 29 | border-radius: 0 0 constants.$base_border_radius constants.$base_border_radius; 30 | } 31 | 32 | .top-left-border-radius.bottom-left-border-radius { 33 | border-radius: constants.$base_border_radius 0 0 constants.$base_border_radius; 34 | } 35 | 36 | .top-left-border-radius.top-right-border-radius.bottom-right-border-radius.bottom-left-border-radius { 37 | border-radius: constants.$base_border_radius; 38 | } 39 | 40 | .selection-tile-preview { 41 | /*box-shadow: 0px 0px 8px 4px rgba(0,0,0,0.2);*/ 42 | } 43 | 44 | .blur-tile-preview { 45 | border: 0; 46 | box-shadow: 0px 0px 16px 4px rgba(0, 0, 0, 0.2); 47 | } 48 | -------------------------------------------------------------------------------- /src/styles/tiling_popup.scss: -------------------------------------------------------------------------------- 1 | .popup-window-preview-container { 2 | background-color: rgba(255, 255, 255, 0.7); 3 | border-radius: 8px; 4 | padding: 6px; 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/window_border.scss: -------------------------------------------------------------------------------- 1 | .window-border { 2 | transition: 200ms ease all; 3 | border-style: solid; 4 | border-color: none; 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/window_menu.scss: -------------------------------------------------------------------------------- 1 | @use "snap_assist"; 2 | 3 | .window-menu-layout { 4 | .snap-assist-tile { 5 | border-color: rgba(255, 255, 255, 0.2); 6 | background-color: rgba(255, 255, 255, 0.2); 7 | border-radius-value: snap_assist.$snap-assist-tile-border-radius - 2; 8 | border-width-value: calc(snap_assist.$snap-assist-tile-border-width / 2); 9 | } 10 | 11 | .snap-assist-tile.dark { 12 | border-color: rgba(121, 121, 121, 0.2); 13 | background-color: rgba(121, 121, 121, 0.2); 14 | } 15 | 16 | .snap-assist-tile:hover { 17 | border-color: rgba(255, 255, 255, 0.78); 18 | background-color: rgba(255, 255, 255, 0.6); 19 | } 20 | 21 | .snap-assist-tile.dark:hover { 22 | border-color: rgba(81, 81, 81, 0.78); 23 | background-color: rgba(81, 81, 81, 0.6); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/translations.ts: -------------------------------------------------------------------------------- 1 | // entry point file for all the translation related stuff. It is easier for the build system to 2 | // work with this file only to support GNOME shells <= 44 (e.g converting the imports) 3 | // Note: DO NOT import this file from prefs.ts or any preferences related file 4 | 5 | // eslint-disable-next-line prettier/prettier 6 | import { gettext as _, ngettext, pgettext } from 'resource:///org/gnome/shell/extensions/extension.js'; 7 | 8 | export { 9 | _, 10 | ngettext, // meant for strings that may or may not be plural like "1 Apple" and "2 Apples" 11 | pgettext, // used when the translator may require context for the string. For example, irregular verbs like "Read" in English, or two elements like a window title and a button which use the same word (e.g. "Restart") 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/gjs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | import { GObject } from '@gi.ext'; 4 | 5 | // Taken from https://github.com/material-shell/material-shell/blob/main/src/utils/gjs.ts 6 | // Decorator function to call `GObject.registerClass` with the given class. 7 | // Use like 8 | // ``` 9 | // @registerGObjectClass 10 | // export class MyThing extends GObject.Object { ... } 11 | // ``` 12 | export function registerGObjectClass< 13 | K, 14 | // eslint-disable-next-line space-before-function-paren 15 | T extends { metaInfo?: any; new (...params: any[]): K }, 16 | >(target: T) { 17 | // Note that we use 'hasOwnProperty' because otherwise we would get inherited meta infos. 18 | // This would be bad because we would inherit the GObjectName too, which is supposed to be unique. 19 | if (Object.prototype.hasOwnProperty.call(target, 'metaInfo')) { 20 | // @ts-ignore 21 | return GObject.registerClass( 22 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 23 | target.metaInfo!, 24 | target, 25 | ) as typeof target; 26 | } else { 27 | // @ts-ignore 28 | return GObject.registerClass(target) as typeof target; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/globalState.ts: -------------------------------------------------------------------------------- 1 | import { registerGObjectClass } from '@utils/gjs'; 2 | import Layout from '../components/layout/Layout'; 3 | import Settings from '../settings/settings'; 4 | import SignalHandling from './signalHandling'; 5 | import { GObject, Meta, Gio } from '@gi.ext'; 6 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 7 | import { logger } from './logger'; 8 | 9 | const debug = logger('GlobalState'); 10 | 11 | @registerGObjectClass 12 | export default class GlobalState extends GObject.Object { 13 | static metaInfo: GObject.MetaInfo = { 14 | GTypeName: 'GlobalState', 15 | Signals: { 16 | 'layouts-changed': { 17 | param_types: [], 18 | }, 19 | }, 20 | Properties: { 21 | tilePreviewAnimationTime: GObject.ParamSpec.uint( 22 | 'tilePreviewAnimationTime', 23 | 'tilePreviewAnimationTime', 24 | 'Animation time of tile previews in milliseconds', 25 | GObject.ParamFlags.READWRITE, 26 | 0, 27 | 2000, 28 | 100, 29 | ), 30 | }, 31 | }; 32 | 33 | public static SIGNAL_LAYOUTS_CHANGED = 'layouts-changed'; 34 | 35 | private static _instance: GlobalState | null; 36 | 37 | private _signals: SignalHandling; 38 | private _layouts: Layout[]; 39 | private _tilePreviewAnimationTime: number; 40 | // if workspaces are reordered, we use this map to know which layouts where selected 41 | // to each workspace and we save the new ordering in the settings 42 | private _selected_layouts: Map; // used to handle reordering of workspaces 43 | 44 | static get(): GlobalState { 45 | if (!this._instance) this._instance = new GlobalState(); 46 | 47 | return this._instance; 48 | } 49 | 50 | static destroy() { 51 | if (this._instance) { 52 | this._instance._signals.disconnect(); 53 | this._instance._layouts = []; 54 | this._instance = null; 55 | } 56 | } 57 | 58 | constructor() { 59 | super(); 60 | 61 | this._signals = new SignalHandling(); 62 | this._layouts = Settings.get_layouts_json(); 63 | this._tilePreviewAnimationTime = 100; 64 | this._selected_layouts = new Map(); 65 | this.validate_selected_layouts(); 66 | 67 | Settings.bind( 68 | Settings.KEY_TILE_PREVIEW_ANIMATION_TIME, 69 | this, 70 | 'tilePreviewAnimationTime', 71 | Gio.SettingsBindFlags.GET, 72 | ); 73 | this._signals.connect( 74 | Settings, 75 | Settings.KEY_SETTING_LAYOUTS_JSON, 76 | () => { 77 | this._layouts = Settings.get_layouts_json(); 78 | this.emit(GlobalState.SIGNAL_LAYOUTS_CHANGED); 79 | }, 80 | ); 81 | 82 | this._signals.connect( 83 | Settings, 84 | Settings.KEY_SETTING_SELECTED_LAYOUTS, 85 | () => { 86 | const selected_layouts = Settings.get_selected_layouts(); 87 | if (selected_layouts.length === 0) { 88 | this.validate_selected_layouts(); 89 | return; 90 | } 91 | 92 | const defaultLayout: Layout = this._layouts[0]; 93 | const n_monitors = Main.layoutManager.monitors.length; 94 | const n_workspaces = global.workspaceManager.get_n_workspaces(); 95 | for (let i = 0; i < n_workspaces; i++) { 96 | const ws = 97 | global.workspaceManager.get_workspace_by_index(i); 98 | if (!ws) continue; 99 | 100 | const monitors_layouts = 101 | i < selected_layouts.length 102 | ? selected_layouts[i] 103 | : [defaultLayout.id]; 104 | while (monitors_layouts.length < n_monitors) 105 | monitors_layouts.push(defaultLayout.id); 106 | while (monitors_layouts.length > n_monitors) 107 | monitors_layouts.pop(); 108 | 109 | this._selected_layouts.set(ws, monitors_layouts); 110 | } 111 | }, 112 | ); 113 | 114 | this._signals.connect( 115 | global.workspaceManager, 116 | 'workspace-added', 117 | (_, index: number) => { 118 | const n_workspaces = global.workspaceManager.get_n_workspaces(); 119 | const newWs = 120 | global.workspaceManager.get_workspace_by_index(index); 121 | if (!newWs) return; 122 | 123 | debug(`added workspace ${index}`); 124 | 125 | const secondLastWs = 126 | global.workspaceManager.get_workspace_by_index( 127 | n_workspaces - 2, 128 | ); 129 | 130 | // the new workspace must start with the same layout of the last workspace 131 | // use the layout at index 0 if for some reason we cannot find the layout 132 | // of the last workspace 133 | const secondLastWsLayoutsId = secondLastWs 134 | ? (this._selected_layouts.get(secondLastWs) ?? []) 135 | : []; 136 | if (secondLastWsLayoutsId.length === 0) { 137 | secondLastWsLayoutsId.push( 138 | ...Main.layoutManager.monitors.map( 139 | () => this._layouts[0].id, 140 | ), 141 | ); 142 | } 143 | 144 | this._selected_layouts.set( 145 | newWs, 146 | secondLastWsLayoutsId, // Main.layoutManager.monitors.map(() => layout.id), 147 | ); 148 | 149 | const to_be_saved: string[][] = []; 150 | for (let i = 0; i < n_workspaces; i++) { 151 | const ws = 152 | global.workspaceManager.get_workspace_by_index(i); 153 | if (!ws) continue; 154 | const monitors_layouts = this._selected_layouts.get(ws); 155 | if (!monitors_layouts) continue; 156 | to_be_saved.push(monitors_layouts); 157 | } 158 | 159 | Settings.save_selected_layouts(to_be_saved); 160 | }, 161 | ); 162 | 163 | this._signals.connect( 164 | global.workspaceManager, 165 | 'workspace-removed', 166 | (_) => { 167 | const newMap: Map = new Map(); 168 | const n_workspaces = global.workspaceManager.get_n_workspaces(); 169 | const to_be_saved: string[][] = []; 170 | for (let i = 0; i < n_workspaces; i++) { 171 | const ws = 172 | global.workspaceManager.get_workspace_by_index(i); 173 | if (!ws) continue; 174 | const monitors_layouts = this._selected_layouts.get(ws); 175 | if (!monitors_layouts) continue; 176 | 177 | this._selected_layouts.delete(ws); 178 | newMap.set(ws, monitors_layouts); 179 | to_be_saved.push(monitors_layouts); 180 | } 181 | Settings.save_selected_layouts(to_be_saved); 182 | 183 | this._selected_layouts.clear(); 184 | this._selected_layouts = newMap; 185 | debug('deleted workspace'); 186 | }, 187 | ); 188 | 189 | this._signals.connect( 190 | global.workspaceManager, 191 | 'workspaces-reordered', 192 | (_) => { 193 | this._save_selected_layouts(); 194 | debug('reordered workspaces'); 195 | }, 196 | ); 197 | } 198 | 199 | public validate_selected_layouts() { 200 | const n_monitors = Main.layoutManager.monitors.length; 201 | const old_selected_layouts = Settings.get_selected_layouts(); 202 | for (let i = 0; i < global.workspaceManager.get_n_workspaces(); i++) { 203 | const ws = global.workspaceManager.get_workspace_by_index(i); 204 | if (!ws) continue; 205 | 206 | const monitors_layouts = 207 | i < old_selected_layouts.length ? old_selected_layouts[i] : []; 208 | while (monitors_layouts.length < n_monitors) 209 | monitors_layouts.push(this._layouts[0].id); 210 | while (monitors_layouts.length > n_monitors) monitors_layouts.pop(); 211 | 212 | monitors_layouts.forEach((_, ind) => { 213 | if ( 214 | this._layouts.findIndex( 215 | (lay) => lay.id === monitors_layouts[ind], 216 | ) === -1 217 | ) 218 | monitors_layouts[ind] = monitors_layouts[0]; 219 | }); 220 | 221 | this._selected_layouts.set(ws, monitors_layouts); 222 | } 223 | 224 | this._save_selected_layouts(); 225 | } 226 | 227 | private _save_selected_layouts() { 228 | const to_be_saved: string[][] = []; 229 | const n_workspaces = global.workspaceManager.get_n_workspaces(); 230 | for (let i = 0; i < n_workspaces; i++) { 231 | const ws = global.workspaceManager.get_workspace_by_index(i); 232 | if (!ws) continue; 233 | const monitors_layouts = this._selected_layouts.get(ws); 234 | if (!monitors_layouts) continue; 235 | to_be_saved.push(monitors_layouts); 236 | } 237 | 238 | Settings.save_selected_layouts(to_be_saved); 239 | } 240 | 241 | get layouts(): Layout[] { 242 | return this._layouts; 243 | } 244 | 245 | public addLayout(newLay: Layout) { 246 | this._layouts.push(newLay); 247 | // easy way to trigger save and signal emission 248 | this.layouts = this._layouts; 249 | } 250 | 251 | public deleteLayout(layoutToDelete: Layout) { 252 | const layFoundIndex = this._layouts.findIndex( 253 | (lay) => lay.id === layoutToDelete.id, 254 | ); 255 | if (layFoundIndex === -1) return; 256 | 257 | this._layouts.splice(layFoundIndex, 1); 258 | 259 | // easy way to trigger a save and emit layouts-changed signal 260 | this.layouts = this._layouts; 261 | 262 | this._selected_layouts.forEach((monitors_selected) => { 263 | if ( 264 | layoutToDelete.id === 265 | monitors_selected[Main.layoutManager.primaryIndex] 266 | ) { 267 | monitors_selected[Main.layoutManager.primaryIndex] = 268 | this._layouts[0].id; 269 | this._save_selected_layouts(); 270 | } 271 | }); 272 | } 273 | 274 | public editLayout(newLay: Layout) { 275 | const layFoundIndex = this._layouts.findIndex( 276 | (lay) => lay.id === newLay.id, 277 | ); 278 | if (layFoundIndex === -1) return; 279 | 280 | this._layouts[layFoundIndex] = newLay; 281 | // easy way to trigger save and signal emission 282 | this.layouts = this._layouts; 283 | } 284 | 285 | set layouts(layouts: Layout[]) { 286 | this._layouts = layouts; 287 | Settings.save_layouts_json(layouts); 288 | this.emit(GlobalState.SIGNAL_LAYOUTS_CHANGED); 289 | } 290 | 291 | public getSelectedLayoutOfMonitor( 292 | monitorIndex: number, 293 | workspaceIndex: number, 294 | ): Layout { 295 | const selectedLayouts = Settings.get_selected_layouts(); 296 | if (workspaceIndex < 0 || workspaceIndex >= selectedLayouts.length) 297 | workspaceIndex = 0; 298 | 299 | const monitors_selected = 300 | workspaceIndex < selectedLayouts.length 301 | ? selectedLayouts[workspaceIndex] 302 | : GlobalState.get().layouts[0].id; 303 | if (monitorIndex < 0 || monitorIndex >= monitors_selected.length) 304 | monitorIndex = 0; 305 | 306 | return ( 307 | this._layouts.find( 308 | (lay) => lay.id === monitors_selected[monitorIndex], 309 | ) || this._layouts[0] 310 | ); 311 | } 312 | 313 | public get tilePreviewAnimationTime(): number { 314 | return this._tilePreviewAnimationTime; 315 | } 316 | 317 | public set tilePreviewAnimationTime(value: number) { 318 | this._tilePreviewAnimationTime = value; 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export function rect_to_string(rect: { 2 | x: number; 3 | y: number; 4 | width: number; 5 | height: number; 6 | }) { 7 | return `{x: ${rect.x}, y: ${rect.y}, width: ${rect.width}, height: ${rect.height}}`; 8 | } 9 | 10 | export const logger = 11 | (prefix: string) => 12 | (...content: unknown[]): void => 13 | console.log('[tilingshell]', `[${prefix}]`, ...content); 14 | -------------------------------------------------------------------------------- /src/utils/signalHandling.ts: -------------------------------------------------------------------------------- 1 | type ObjectWithSignals = { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | connect: (...args: any[]) => number; 4 | disconnect: (id: number) => void; 5 | }; 6 | 7 | export default class SignalHandling { 8 | private readonly _signalsIds: { 9 | [key: string]: { id: number; obj: ObjectWithSignals }; 10 | }; 11 | 12 | constructor() { 13 | this._signalsIds = {}; 14 | } 15 | 16 | public connect( 17 | obj: ObjectWithSignals, 18 | key: string, 19 | fun: (..._args: never[]) => void, 20 | ) { 21 | const signalId = obj.connect(key, fun); 22 | this._signalsIds[key] = { id: signalId, obj }; 23 | } 24 | 25 | public disconnect(): boolean; 26 | public disconnect(obj: ObjectWithSignals): boolean; 27 | public disconnect(obj?: ObjectWithSignals) { 28 | if (!obj) { 29 | const toDelete: string[] = []; 30 | Object.keys(this._signalsIds).forEach((key) => { 31 | this._signalsIds[key].obj.disconnect(this._signalsIds[key].id); 32 | toDelete.push(key); 33 | }); 34 | const result = toDelete.length > 0; 35 | toDelete.forEach((key) => delete this._signalsIds[key]); 36 | return result; 37 | } else { 38 | const keyFound = Object.keys(this._signalsIds).find( 39 | (key) => this._signalsIds[key].obj === obj, 40 | ); 41 | if (keyFound) { 42 | obj.disconnect(this._signalsIds[keyFound].id); 43 | delete this._signalsIds[keyFound]; 44 | } 45 | return keyFound; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/ui.ts: -------------------------------------------------------------------------------- 1 | import { St, Meta, Mtk, Clutter, Shell } from '@gi.ext'; 2 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 3 | import { Monitor } from 'resource:///org/gnome/shell/ui/layout.js'; 4 | 5 | export const getMonitors = (): Monitor[] => Main.layoutManager.monitors; 6 | 7 | export const isPointInsideRect = ( 8 | point: { x: number; y: number }, 9 | rect: Mtk.Rectangle, 10 | ): boolean => { 11 | return ( 12 | point.x >= rect.x && 13 | point.x <= rect.x + rect.width && 14 | point.y >= rect.y && 15 | point.y <= rect.y + rect.height 16 | ); 17 | }; 18 | 19 | export const clampPointInsideRect = ( 20 | point: { x: number; y: number }, 21 | rect: Mtk.Rectangle, 22 | ): { x: number; y: number } => { 23 | const clamp = (n: number, min: number, max: number) => 24 | Math.min(Math.max(n, min), max); 25 | return { 26 | x: clamp(point.x, rect.x, rect.x + rect.width), 27 | y: clamp(point.y, rect.y, rect.y + rect.height), 28 | }; 29 | }; 30 | 31 | export const isTileOnContainerBorder = ( 32 | tilePos: Mtk.Rectangle, 33 | container: Mtk.Rectangle, 34 | ): { 35 | isTop: boolean; 36 | isRight: boolean; 37 | isLeft: boolean; 38 | isBottom: boolean; 39 | } => { 40 | // compare two values and return true if their are equal with a max error of 2 41 | const almostEqual = (first: number, second: number) => 42 | Math.abs(first - second) <= 1; 43 | const isLeft = almostEqual(tilePos.x, container.x); 44 | const isTop = almostEqual(tilePos.y, container.y); 45 | const isRight = almostEqual( 46 | tilePos.x + tilePos.width, 47 | container.x + container.width, 48 | ); 49 | const isBottom = almostEqual( 50 | tilePos.y + tilePos.height, 51 | container.y + container.height, 52 | ); 53 | return { 54 | isTop, 55 | isRight, 56 | isBottom, 57 | isLeft, 58 | }; 59 | }; 60 | 61 | export type TileGapsInfo = { 62 | gaps: Clutter.Margin; 63 | isTop: boolean; 64 | isRight: boolean; 65 | isBottom: boolean; 66 | isLeft: boolean; 67 | }; 68 | 69 | export const buildTileGaps = ( 70 | tilePos: Mtk.Rectangle, 71 | innerGaps: Clutter.Margin, 72 | outerGaps: Clutter.Margin, 73 | container: Mtk.Rectangle, 74 | scalingFactor: number = 1, 75 | ): TileGapsInfo => { 76 | const { isTop, isRight, isBottom, isLeft } = isTileOnContainerBorder( 77 | tilePos, 78 | container, 79 | ); 80 | const margin = new Clutter.Margin(); 81 | margin.top = (isTop ? outerGaps.top : innerGaps.top / 2) * scalingFactor; 82 | margin.bottom = 83 | (isBottom ? outerGaps.bottom : innerGaps.bottom / 2) * scalingFactor; 84 | margin.left = 85 | (isLeft ? outerGaps.left : innerGaps.left / 2) * scalingFactor; 86 | margin.right = 87 | (isRight ? outerGaps.right : innerGaps.right / 2) * scalingFactor; 88 | 89 | return { 90 | gaps: margin, 91 | isTop, 92 | isRight, 93 | isBottom, 94 | isLeft, 95 | }; 96 | }; 97 | 98 | export const getMonitorScalingFactor = (monitorIndex: number) => { 99 | const scalingFactor = St.ThemeContext.get_for_stage( 100 | global.get_stage(), 101 | ).get_scale_factor(); 102 | if (scalingFactor === 1) 103 | return global.display.get_monitor_scale(monitorIndex); 104 | return scalingFactor; 105 | }; 106 | 107 | export const getScalingFactorOf = (widget: St.Widget): [boolean, number] => { 108 | const [hasReference, scalingReference] = widget 109 | .get_theme_node() 110 | .lookup_length('scaling-reference', true); 111 | // if the reference is missing, then the parent opted out of scaling the child 112 | if (!hasReference) return [true, 1]; 113 | // if the scalingReference is not 1, then the scaling factor is already applied on styles (but not on width and height) 114 | 115 | const [hasValue, monitorScalingFactor] = widget 116 | .get_theme_node() 117 | .lookup_length('monitor-scaling-factor', true); 118 | if (!hasValue) return [true, 1]; 119 | 120 | return [scalingReference !== 1, monitorScalingFactor / scalingReference]; 121 | }; 122 | 123 | export const enableScalingFactorSupport = ( 124 | widget: St.Widget, 125 | monitorScalingFactor?: number, 126 | ) => { 127 | if (!monitorScalingFactor) return; 128 | widget.set_style(`${getScalingFactorSupportString(monitorScalingFactor)};`); 129 | }; 130 | 131 | export const getScalingFactorSupportString = (monitorScalingFactor: number) => { 132 | return `scaling-reference: 1px; monitor-scaling-factor: ${monitorScalingFactor}px`; 133 | }; 134 | 135 | export function buildMarginOf(value: number): Clutter.Margin { 136 | const margin = new Clutter.Margin(); 137 | margin.top = value; 138 | margin.bottom = value; 139 | margin.left = value; 140 | margin.right = value; 141 | return margin; 142 | } 143 | 144 | export function buildMargin(params: { 145 | top?: number; 146 | bottom?: number; 147 | left?: number; 148 | right?: number; 149 | }): Clutter.Margin { 150 | const margin = new Clutter.Margin(); 151 | if (params.top) margin.top = params.top; 152 | if (params.bottom) margin.bottom = params.bottom; 153 | if (params.left) margin.left = params.left; 154 | if (params.right) margin.right = params.right; 155 | return margin; 156 | } 157 | 158 | export function buildRectangle( 159 | params: { x?: number; y?: number; width?: number; height?: number } = {}, 160 | ): Mtk.Rectangle { 161 | return new Mtk.Rectangle({ 162 | x: params.x || 0, 163 | y: params.y || 0, 164 | width: params.width || 0, 165 | height: params.height || 0, 166 | }); 167 | } 168 | 169 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 170 | export function getEventCoords(event: any): number[] { 171 | return event.get_coords ? event.get_coords() : [event.x, event.y]; // GNOME 40-44 172 | } 173 | 174 | export function buildBlurEffect(sigma: number): Shell.BlurEffect { 175 | // changes in GNOME 46+ 176 | // The sigma in Shell.BlurEffect should be replaced by radius. Since the sigma value 177 | // is radius / 2.0, the radius value will be sigma * 2.0. 178 | 179 | const effect = new Shell.BlurEffect(); 180 | effect.set_mode(Shell.BlurMode.BACKGROUND); // blur what is behind the widget 181 | effect.set_brightness(1); 182 | if (effect.set_radius) { 183 | effect.set_radius(sigma * 2); 184 | } else { 185 | // @ts-expect-error "set_sigma is available in old shell versions (<= 45)" 186 | effect.set_sigma(sigma); 187 | } 188 | return effect; 189 | } 190 | 191 | function getTransientOrParent(window: Meta.Window): Meta.Window { 192 | const transient = window.get_transient_for(); 193 | return window.is_attached_dialog() && transient !== null 194 | ? transient 195 | : window; 196 | } 197 | 198 | export function filterUnfocusableWindows( 199 | windows: Meta.Window[], 200 | ): Meta.Window[] { 201 | // we want to filter out 202 | // - top-level windows which are precluded by dialogs 203 | // - anything tagged skip-taskbar 204 | // - duplicates 205 | return windows 206 | .map(getTransientOrParent) 207 | .filter((win: Meta.Window, idx: number, arr: Meta.Window[]) => { 208 | // typings indicate win will not be null, but this check is found 209 | // in the source, so... 210 | return win !== null && !win.skipTaskbar && arr.indexOf(win) === idx; 211 | }); 212 | } 213 | 214 | /** From Gnome Shell: https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/altTab.js#L53 */ 215 | export function getWindows(workspace?: Meta.Workspace): Meta.Window[] { 216 | if (!workspace) workspace = global.workspaceManager.get_active_workspace(); 217 | // We ignore skip-taskbar windows in switchers, but if they are attached 218 | // to their parent, their position in the MRU list may be more appropriate 219 | // than the parent; so start with the complete list ... 220 | // ... map windows to their parent where appropriate ... 221 | return filterUnfocusableWindows( 222 | global.display.get_tab_list(Meta.TabList.NORMAL_ALL, workspace), 223 | ); 224 | } 225 | 226 | export function getWindowsOfMonitor(monitor: Monitor): Meta.Window[] { 227 | return global.workspaceManager 228 | .get_active_workspace() 229 | .list_windows() 230 | .filter( 231 | (win) => 232 | win.get_window_type() === Meta.WindowType.NORMAL && 233 | Main.layoutManager.monitors[win.get_monitor()] === monitor, 234 | ); 235 | } 236 | 237 | export function squaredEuclideanDistance( 238 | pointA: { x: number; y: number }, 239 | pointB: { x: number; y: number }, 240 | ) { 241 | return ( 242 | (pointA.x - pointB.x) * (pointA.x - pointB.x) + 243 | (pointA.y - pointB.y) * (pointA.y - pointB.y) 244 | ); 245 | } 246 | 247 | // Compatibility for GNOME 48+ where 'vertical' was deprecated in favor of 'orientation' 248 | export function widgetOrientation(vertical: boolean) { 249 | // if orientation is supported 250 | if (St.BoxLayout.prototype.get_orientation !== undefined) { 251 | return { 252 | orientation: vertical 253 | ? Clutter.Orientation.VERTICAL 254 | : Clutter.Orientation.HORIZONTAL, 255 | }; 256 | } 257 | 258 | return { vertical }; 259 | } 260 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "moduleResolution": "NodeNext", 5 | "outDir": "./dist", 6 | "sourceMap": false, 7 | "strict": true, 8 | "target": "ES2022", 9 | "lib": [ 10 | "ES2022" 11 | ], 12 | "paths": { 13 | "@*": ["./src/*"] 14 | }, 15 | "experimentalDecorators": true, 16 | }, 17 | "include": [ 18 | "ambient.d.ts", 19 | "src/**/*.ts" 20 | ], 21 | "exclude": ["node_modules"] 22 | } 23 | --------------------------------------------------------------------------------