├── src ├── floating_exceptions │ ├── src │ │ ├── utils.ts │ │ ├── config.ts │ │ ├── mod.d.ts │ │ └── main.ts │ └── tsconfig.json ├── tags.ts ├── paths.ts ├── types.d.ts ├── once_cell.ts ├── result.ts ├── grab_op.ts ├── error.ts ├── context.ts ├── log.ts ├── movement.ts ├── events.ts ├── dbus_service.ts ├── dialog_add_exception.ts ├── rectangle.ts ├── node.ts ├── executor.ts ├── focus.ts ├── geom.ts ├── lib.ts ├── keybindings.ts ├── panel_settings.ts ├── xprop.ts ├── prefs.ts ├── utils.ts ├── settings.ts ├── ecs.ts ├── config.ts ├── mod.d.ts └── fork.ts ├── package.json ├── .prettierignore ├── screenshot.png ├── .prettierrc ├── .gitignore ├── .github ├── workflows │ ├── lint.yaml │ ├── build.yaml │ └── release.yaml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── ISSUE_TEMPLATE.md └── pull_request_template.md ├── metadata.json ├── keybindings ├── 10-mosaic-tile.xml ├── 10-mosaic-navigate.xml └── 10-mosaic-move.xml ├── com.github.jardon.gnome-mosaic-exceptions.desktop ├── stylesheet-dark.css ├── stylesheet-light.css ├── tsconfig.json ├── scripts ├── transpile.sh └── configure.sh ├── Makefile ├── SHORTCUTS.md ├── icons ├── gnome-mosaic-auto-on-symbolic.svg └── gnome-mosaic-auto-off-symbolic.svg ├── TESTING.md ├── README.md └── schemas └── org.gnome.shell.extensions.gnome-mosaic.gschema.xml /src/floating_exceptions/src/utils.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /src/floating_exceptions/src/config.ts: -------------------------------------------------------------------------------- 1 | ../../config.ts -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jardon/gnome-mosaic/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/tags.ts: -------------------------------------------------------------------------------- 1 | export var Tiled = 0; 2 | export var Floating = 1; 3 | export var Blocked = 2; 4 | export var ForceTile = 3; 5 | -------------------------------------------------------------------------------- /src/paths.ts: -------------------------------------------------------------------------------- 1 | export function get_current_path(): string { 2 | return import.meta.url.split('://')[1].split('/').slice(0, -1).join('/'); 3 | } 4 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | /** The ID of a monitor in the display server. */ 2 | type MonitorID = number; 3 | 4 | /** The ID of a workspace in GNOME Shell */ 5 | type WorkspaceID = number; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "semi": true, 5 | "singleQuote": true, 6 | "quoteProps": "as-needed", 7 | "trailingComma": "es5", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | *.swp 3 | _build 4 | target 5 | schemas/gschemas.compiled 6 | debian/* 7 | !debian/source 8 | !debian/changelog 9 | !debian/control 10 | !debian/copyright 11 | !debian/rules 12 | !debian/*install 13 | .confirm_shortcut_change 14 | .vscode 15 | node_modules -------------------------------------------------------------------------------- /src/once_cell.ts: -------------------------------------------------------------------------------- 1 | export class OnceCell { 2 | value: T | undefined; 3 | 4 | constructor() {} 5 | 6 | get_or_init(callback: () => T): T { 7 | if (this.value === undefined) { 8 | this.value = callback(); 9 | } 10 | 11 | return this.value; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/floating_exceptions/src/mod.d.ts: -------------------------------------------------------------------------------- 1 | declare const log: (arg: string) => void, 2 | imports: any, 3 | _: (arg: string) => string; 4 | 5 | declare module 'gi://*' { 6 | let data: any; 7 | export default data; 8 | } 9 | 10 | declare module 'gi://Gtk' { 11 | let Gtk: any; 12 | export default Gtk; 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | 6 | jobs: 7 | prettier: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-node@v4 12 | - name: Run prettier 13 | run: npx prettier . --check 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build (Make) 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | 6 | jobs: 7 | compile: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Install typescript 12 | run: sudo apt install -y node-typescript make 13 | - run: make compile 14 | -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mosaic", 3 | "description": "Window tiler extension for GNOME desktops.", 4 | "version": "main", 5 | "version-name": "main", 6 | "uuid": "gnome-mosaic@jardon.github.com", 7 | "url": "https://github.com/jardon/gnome-mosaic", 8 | "settings-schema": "org.gnome.shell.extensions.gnome-mosaic", 9 | "shell-version": ["45", "46", "47", "48", "49"] 10 | } 11 | -------------------------------------------------------------------------------- /keybindings/10-mosaic-tile.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /com.github.jardon.gnome-mosaic-exceptions.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.0 3 | Type=Application 4 | Name=GNOME Mosaic Exceptions 5 | Comment=Manage floating exceptions for GNOME Mosaic 6 | Exec=/usr/bin/gjs --module ~/.local/share/gnome-shell/extensions/gnome-mosaic\@jardon.github.com/floating_exceptions/main.js 7 | Icon=com.github.jardon.gnome-mosaic-exceptions 8 | Terminal=false 9 | Categories=Utility; 10 | StartupNotify=true 11 | NoDisplay=true 12 | -------------------------------------------------------------------------------- /stylesheet-dark.css: -------------------------------------------------------------------------------- 1 | .gnome-mosaic-active-hint { 2 | border-style: solid; 3 | border-radius: var(--active-hint-border-radius, 5px); 4 | box-shadow: inset 0 0 0 1px rgba(24, 23, 23, 0); 5 | } 6 | 7 | .gnome-mosaic-border-normal { 8 | border-width: 3px; 9 | } 10 | 11 | .gnome-mosaic-border-maximize { 12 | border-width: 3px; 13 | } 14 | 15 | .gnome-mosaic-resize-hint { 16 | padding: 12px; 17 | border-radius: 32px 32px 32px 32px; 18 | } 19 | -------------------------------------------------------------------------------- /stylesheet-light.css: -------------------------------------------------------------------------------- 1 | .gnome-mosaic-active-hint { 2 | border-style: solid; 3 | border-radius: var(--active-hint-border-radius, 5px); 4 | box-shadow: inset 0 0 0 1px rgba(200, 200, 200, 0); 5 | } 6 | 7 | .gnome-mosaic-border-normal { 8 | border-width: 3px; 9 | } 10 | 11 | .gnome-mosaic-border-maximize { 12 | border-width: 3px; 13 | } 14 | 15 | .gnome-mosaic-resize-hint { 16 | padding: 12px; 17 | border-radius: 32px 32px 32px 32px; 18 | } 19 | -------------------------------------------------------------------------------- /src/result.ts: -------------------------------------------------------------------------------- 1 | export const OK = 1; 2 | export const ERR = 2; 3 | 4 | export type Result = Ok | Err; 5 | 6 | export interface Ok { 7 | kind: 1; 8 | value: T; 9 | } 10 | 11 | export interface Err { 12 | kind: 2; 13 | value: T; 14 | } 15 | 16 | export function Ok(value: T): Result { 17 | return {kind: 1, value: value}; 18 | } 19 | 20 | export function Err(value: E): Result { 21 | return {kind: 2, value: value}; 22 | } 23 | -------------------------------------------------------------------------------- /src/floating_exceptions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "target": "es2015", 5 | // "strict": true, 6 | "outDir": "../../target/floating_exceptions", 7 | "forceConsistentCasingInFileNames": true, 8 | "downlevelIteration": true, 9 | "lib": ["es2015", "dom"], 10 | "pretty": true, 11 | "removeComments": true, 12 | "incremental": true 13 | }, 14 | "include": ["src/*.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /src/grab_op.ts: -------------------------------------------------------------------------------- 1 | import * as Movement from './movement.js'; 2 | 3 | import type {Entity} from './ecs.js'; 4 | import type {Rectangle} from './rectangle.js'; 5 | 6 | export class GrabOp { 7 | entity: Entity; 8 | rect: Rectangle; 9 | 10 | constructor(entity: Entity, rect: Rectangle) { 11 | this.entity = entity; 12 | this.rect = rect; 13 | } 14 | 15 | operation(change: Rectangle): Movement.Movement[] { 16 | return Movement.calculate(this.rect, change); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /keybindings/10-mosaic-navigate.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "main", 3 | "compileOnSave": true, 4 | "compilerOptions": { 5 | "target": "es2022", 6 | "strict": true, 7 | "outDir": "./target", 8 | "forceConsistentCasingInFileNames": true, 9 | "downlevelIteration": true, 10 | "lib": ["es2022", "dom"], 11 | "pretty": true, 12 | "removeComments": true, 13 | "incremental": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "moduleResolution": "NodeNext", 17 | "module": "NodeNext" 18 | }, 19 | "include": ["src/*.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea or enhancement 4 | title: '[Feature] ' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ## Is your feature request related to a problem? Please describe. 10 | 11 | A clear and concise description of what the problem is. 12 | 13 | ## Describe the solution you'd like 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | ## Describe alternatives you've considered 18 | 19 | Any alternative solutions or features you’ve thought about. 20 | 21 | ## Additional context 22 | 23 | Add any other context or mockups here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **(1) Issue/Bug Description:** 2 | 3 | **(2) Steps to reproduce (if you know):** 4 | 5 | **(3) Expected behavior:** 6 | 7 | **(4) Distribution (run `cat /etc/os-release`):** 8 | 9 | **(5) Gnome Shell version:** 10 | 11 | **(6) GNOME Mosaic version (run `apt policy gnome-mosaic` or provide the latest commit if building locally):** 12 | 13 | 16 | 17 | **(7) Where was GNOME Mosaic installed from:** 18 | 19 | **(8) Monitor Setup (2 x 1080p, 4K, Primary(Horizontal), Secondary(Vertical), etc):** 20 | 21 | **(9) Other Installed/Enabled Extensions:** 22 | 23 | **(10) Other Notes:** 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug or unexpected behavior 4 | title: '[Bug] ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Describe the bug 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | ## To Reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '...' 19 | 3. See error 20 | 21 | ## Expected behavior 22 | 23 | A clear and concise description of what you expected to happen. 24 | 25 | ## Screenshots (optional) 26 | 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | ## Environment (please complete the following information): 30 | 31 | - OS: [e.g. Ubuntu 22.04, Windows 11] 32 | - Browser/app: [e.g. Firefox 120, GNOME Shell 45] 33 | - Version: [e.g. v1.2.3] 34 | 35 | ## Additional context 36 | 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | export class Error { 2 | reason: string; 3 | 4 | cause: Error | null = null; 5 | 6 | constructor(reason: string) { 7 | this.reason = reason; 8 | } 9 | 10 | context(why: string): Error { 11 | let error = new Error(why); 12 | error.cause = this; 13 | return error; 14 | } 15 | 16 | *chain(): IterableIterator { 17 | let current: Error | null = this; 18 | 19 | while (current != null) { 20 | yield current; 21 | current = current.cause; 22 | } 23 | } 24 | 25 | format(): string { 26 | let causes = this.chain(); 27 | 28 | let buffer: string = causes.next().value.reason; 29 | 30 | for (const error of causes) { 31 | buffer += `\n caused by: ` + error.reason; 32 | } 33 | 34 | return buffer + `\n`; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /scripts/transpile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | pwd=$(pwd) 5 | 6 | # In goes standard JS. Out comes GJS-compatible JS 7 | transpile() { 8 | cp "${src}" "${dest}" 9 | # cat "${src}" | sed -e 's#export function#function#g' \ 10 | # -e 's#export var#var#g' \ 11 | # -e 's#export const#var#g' \ 12 | # -e 's#Object.defineProperty(exports, "__esModule", { value: true });#var exports = {};#g' \ 13 | # | sed -E 's/export class (\w+)/var \1 = class \1/g' \ 14 | # | sed -E "s/import \* as (\w+) from '(\w+)'/const \1 = Me.imports.\2/g" > "${dest}" 15 | } 16 | 17 | rm -rf _build 18 | 19 | glib-compile-schemas schemas & 20 | 21 | # Transpile to JavaScript 22 | 23 | for proj in ${PROJECTS}; do 24 | mkdir -p _build/"${proj}" 25 | tsc --p src/"${proj}" 26 | done 27 | 28 | tsc 29 | 30 | wait 31 | 32 | # Convert JS to GJS-compatible scripts 33 | 34 | cp -r metadata.json icons schemas *.css _build & 35 | 36 | for src in $(find target -name '*.js'); do 37 | dest=$(echo "$src" | sed s#target#_build#g) 38 | transpile 39 | done 40 | 41 | wait 42 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import St from 'gi://St'; 2 | 3 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 4 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 5 | 6 | export function addMenu( 7 | widget: any, 8 | request: (menu: St.Widget) => void 9 | ): St.Widget { 10 | const menu = new PopupMenu.PopupMenu(widget, 0.0, St.Side.TOP, 0); 11 | Main.uiGroup.add_child(menu.actor); 12 | menu.actor.hide(); 13 | menu.actor.add_style_class_name('panel-menu'); 14 | 15 | // Intercept right click events on the launcher app's button 16 | widget.connect('button-press-event', (_: any, event: any) => { 17 | if (event.get_button() === 3) { 18 | request(menu); 19 | } 20 | }); 21 | 22 | return menu; 23 | } 24 | 25 | export function addContext( 26 | menu: St.Widget, 27 | name: string, 28 | activate: () => void 29 | ) { 30 | const menu_item = appendMenuItem(menu, name); 31 | 32 | menu_item.connect('activate', () => activate()); 33 | } 34 | 35 | function appendMenuItem(menu: any, label: string) { 36 | let item = new PopupMenu.PopupMenuItem(label); 37 | menu.addMenuItem(item); 38 | return item; 39 | } 40 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | // simplified log4j levels 2 | export enum LOG_LEVELS { 3 | OFF, 4 | ERROR, 5 | WARN, 6 | INFO, 7 | DEBUG, 8 | } 9 | 10 | /** 11 | * parse level at runtime so we don't have to restart mosaic 12 | */ 13 | export function log_level() { 14 | // log.js is at the level of prefs.js where the mosaic Ext instance 15 | // is not yet available or visible, so we have to use the built in 16 | // ExtensionUtils to get the current settings 17 | let settings = globalThis.MosaicExtension.getSettings(); 18 | let log_level = settings.get_uint('log-level'); 19 | 20 | return log_level; 21 | } 22 | 23 | export function log(text: string) { 24 | (globalThis as any).log('gnome-mosaic: ' + text); 25 | } 26 | 27 | export function error(text: string) { 28 | if (log_level() > LOG_LEVELS.OFF) log('[ERROR] ' + text); 29 | } 30 | 31 | export function warn(text: string) { 32 | if (log_level() > LOG_LEVELS.ERROR) log('[WARN] ' + text); 33 | } 34 | 35 | export function info(text: string) { 36 | if (log_level() > LOG_LEVELS.WARN) log('[INFO] ' + text); 37 | } 38 | 39 | export function debug(text: string) { 40 | if (log_level() > LOG_LEVELS.INFO) log('[DEBUG] ' + text); 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release on Tag 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | build-and-release: 9 | name: Build and Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '20' 18 | - name: Install dependencies 19 | run: | 20 | sudo apt update 21 | sudo apt install -y make 22 | npm install typescript@latest 23 | - name: Run build and create ZIP file 24 | run: make zip-file 25 | - name: Get tag name 26 | id: get_tag 27 | run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 28 | - name: Create GitHub Release 29 | uses: softprops/action-gh-release@v2 30 | with: 31 | tag_name: ${{ steps.get_tag.outputs.tag }} 32 | name: ${{ steps.get_tag.outputs.tag }} 33 | prerelease: true 34 | files: | 35 | *.zip 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /src/movement.ts: -------------------------------------------------------------------------------- 1 | export enum Movement { 2 | NONE = 0, 3 | MOVED = 0b1, 4 | GROW = 0b10, 5 | SHRINK = 0b100, 6 | LEFT = 0b1000, 7 | UP = 0b10000, 8 | RIGHT = 0b100000, 9 | DOWN = 0b1000000, 10 | } 11 | 12 | export function calculate(from: Rectangular, change: Rectangular): Movement[] { 13 | const xchange = change.x - from.x; 14 | const ychange = change.y - from.y; 15 | const wchange = change.width - from.width; 16 | const hchange = change.height - from.height; 17 | 18 | const result: Movement[] = []; 19 | 20 | if (xchange === 0 && ychange === 0 && wchange === 0 && hchange === 0) { 21 | return [Movement.NONE]; 22 | } 23 | 24 | if (wchange !== 0) { 25 | if (wchange > 0) { 26 | result.push( 27 | Movement.GROW | (xchange < 0 ? Movement.LEFT : Movement.RIGHT) 28 | ); 29 | } else { 30 | result.push( 31 | Movement.SHRINK | (xchange > 0 ? Movement.RIGHT : Movement.LEFT) 32 | ); 33 | } 34 | } 35 | 36 | if (hchange !== 0) { 37 | if (hchange > 0) { 38 | result.push( 39 | Movement.GROW | (ychange < 0 ? Movement.UP : Movement.DOWN) 40 | ); 41 | } else { 42 | result.push( 43 | Movement.SHRINK | (ychange > 0 ? Movement.DOWN : Movement.UP) 44 | ); 45 | } 46 | } 47 | 48 | if (result.length === 0 && (xchange !== 0 || ychange !== 0)) { 49 | result.push(Movement.MOVED); 50 | } 51 | 52 | return result; 53 | } 54 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import * as Window from './window.js'; 2 | 3 | import type {Ext} from './extension.js'; 4 | 5 | /** Type representing all possible events handled by the extension's system. */ 6 | export type ExtEvent = 7 | | GenericCallback 8 | | ManagedWindow 9 | | CreateWindow 10 | | GlobalEventTag; 11 | 12 | /** Eevnt with generic callback */ 13 | export interface GenericCallback { 14 | tag: 1; 15 | callback: () => void; 16 | name?: string; 17 | } 18 | 19 | /** Event that handles a registered window */ 20 | export interface ManagedWindow { 21 | tag: 2; 22 | window: Window.ShellWindow; 23 | kind: Movement | Basic; 24 | } 25 | 26 | /** Event that registers a new window */ 27 | export interface CreateWindow { 28 | tag: 3; 29 | window: Meta.Window; 30 | } 31 | 32 | export interface GlobalEventTag { 33 | tag: 4; 34 | event: GlobalEvent; 35 | } 36 | 37 | export enum GlobalEvent { 38 | GtkShellChanged, 39 | GtkThemeChanged, 40 | MonitorsChanged, 41 | OverviewShown, 42 | OverviewHidden, 43 | } 44 | 45 | export interface Movement { 46 | tag: 1; 47 | } 48 | 49 | export interface Basic { 50 | tag: 2; 51 | event: WindowEvent; 52 | } 53 | 54 | /** The type of event triggered on a window */ 55 | export enum WindowEvent { 56 | Size, 57 | Workspace, 58 | Minimize, 59 | Maximize, 60 | Fullscreen, 61 | } 62 | 63 | export function global(event: GlobalEvent): GlobalEventTag { 64 | return {tag: 4, event}; 65 | } 66 | 67 | export function window_move( 68 | ext: Ext, 69 | window: Window.ShellWindow, 70 | rect: Rectangular 71 | ): ManagedWindow { 72 | ext.movements.insert(window.entity, rect); 73 | return {tag: 2, window, kind: {tag: 1}}; 74 | } 75 | 76 | /** Utility function for creating the an ExtEvent */ 77 | export function window_event( 78 | window: Window.ShellWindow, 79 | event: WindowEvent 80 | ): ManagedWindow { 81 | return {tag: 2, window, kind: {tag: 2, event}}; 82 | } 83 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📝 Description 2 | 3 | Please include a summary of the change and the issue it fixes. 4 | Link to the related issue if applicable (e.g., `Fixes #123`). 5 | 6 | > Example: 7 | > This PR updates the login form validation logic to handle empty username fields. 8 | > Fixes #45. 9 | 10 | --- 11 | 12 | ## 🔍 Type of Change 13 | 14 | Please delete options that are not relevant. 15 | 16 | - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) 17 | - [ ] ✨ New feature (non-breaking change that adds functionality) 18 | - [ ] 💥 Breaking change (fix or feature that would cause existing functionality to change) 19 | - [ ] 🧹 Code cleanup or refactor 20 | - [ ] 🧪 Tests 21 | - [ ] 📝 Documentation update 22 | - [ ] ⚙️ CI/CD or build system update 23 | 24 | --- 25 | 26 | ## ✅ How Has This Been Tested? 27 | 28 | Describe the tests you ran to verify your changes. 29 | Provide instructions so others can reproduce the results. 30 | 31 | > Example: 32 | > 33 | > - [ ] Unit tests added/updated 34 | > - [ ] Manual testing on Chrome, Firefox, and Safari 35 | 36 | **Test Configuration:** 37 | 38 | - OS: 39 | - Browser: 40 | - Node version (if applicable): 41 | 42 | --- 43 | 44 | ## 📸 Screenshots (if applicable) 45 | 46 | Add screenshots or GIFs to help explain your changes visually. 47 | 48 | --- 49 | 50 | ## 📋 Checklist 51 | 52 | Before submitting your PR, please check that: 53 | 54 | - [ ] Code follows the project’s style guidelines 55 | - [ ] I have self-reviewed my code 56 | - [ ] I have commented my code, particularly in hard-to-understand areas 57 | - [ ] I have made corresponding changes to documentation (if needed) 58 | - [ ] My changes generate no new warnings 59 | - [ ] I have added tests that prove my fix is effective or that my feature works 60 | - [ ] New and existing unit tests pass locally with my changes 61 | 62 | --- 63 | 64 | ## 🧠 Additional Context 65 | 66 | Add any other context about the PR here (e.g., related PRs, implementation notes, trade-offs). 67 | -------------------------------------------------------------------------------- /src/dbus_service.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | 3 | const IFACE: string = ` 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | `; 23 | 24 | export class Service { 25 | dbus: any; 26 | id: any; 27 | 28 | FocusLeft: () => void = () => {}; 29 | FocusRight: () => void = () => {}; 30 | FocusUp: () => void = () => {}; 31 | FocusDown: () => void = () => {}; 32 | WindowFocus: (window: [number, number]) => void = () => {}; 33 | WindowList: () => Array<[[number, number], string, string, string]> = 34 | () => []; 35 | WindowQuit: (window: [number, number]) => void = () => {}; 36 | 37 | constructor() { 38 | this.dbus = Gio.DBusExportedObject.wrapJSObject(IFACE, this); 39 | 40 | const onBusAcquired = (conn: any) => { 41 | this.dbus.export(conn, '/org/gnome/Shell/Extensions/Mosaic'); 42 | }; 43 | 44 | function onNameAcquired() {} 45 | 46 | function onNameLost() {} 47 | 48 | this.id = Gio.bus_own_name( 49 | Gio.BusType.SESSION, 50 | 'org.gnome.Shell.Extensions.Mosaic', 51 | Gio.BusNameOwnerFlags.NONE, 52 | onBusAcquired, 53 | onNameAcquired, 54 | onNameLost 55 | ); 56 | } 57 | 58 | destroy() { 59 | Gio.bus_unown_name(this.id); 60 | this.dbus.unexport(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /keybindings/10-mosaic-move.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/dialog_add_exception.ts: -------------------------------------------------------------------------------- 1 | import * as Lib from './lib.js'; 2 | import St from 'gi://St'; 3 | import Clutter from 'gi://Clutter'; 4 | 5 | import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js'; 6 | 7 | export class AddExceptionDialog { 8 | dialog: Shell.ModalDialog = new ModalDialog.ModalDialog({ 9 | styleClass: 'gnome-mosaic-search modal-dialog', 10 | destroyOnClose: false, 11 | shellReactive: true, 12 | shouldFadeIn: false, 13 | shouldFadeOut: false, 14 | }); 15 | 16 | constructor( 17 | cancel: () => void, 18 | this_app: () => void, 19 | current_window: () => void, 20 | on_close: () => void 21 | ) { 22 | let title = St.Label.new('Add Floating Window Exception'); 23 | title.set_x_align(Clutter.ActorAlign.CENTER); 24 | title.set_style('font-weight: bold'); 25 | 26 | let desc = St.Label.new( 27 | 'Float the selected window or all windows from the application.' 28 | ); 29 | desc.set_x_align(Clutter.ActorAlign.CENTER); 30 | 31 | let l = this.dialog.contentLayout; 32 | 33 | l.add_child(title); 34 | l.add_child(desc); 35 | 36 | this.dialog.contentLayout.width = Math.max( 37 | Lib.current_monitor().width / 4, 38 | 640 39 | ); 40 | 41 | this.dialog.addButton({ 42 | label: 'Cancel', 43 | action: () => { 44 | cancel(); 45 | on_close(); 46 | this.close(); 47 | }, 48 | key: Clutter.KEY_Escape, 49 | }); 50 | 51 | this.dialog.addButton({ 52 | label: "This App's Windows", 53 | action: () => { 54 | this_app(); 55 | on_close(); 56 | this.close(); 57 | }, 58 | }); 59 | 60 | this.dialog.addButton({ 61 | label: 'Current Window Only', 62 | action: () => { 63 | current_window(); 64 | on_close(); 65 | this.close(); 66 | }, 67 | }); 68 | } 69 | 70 | close() { 71 | this.dialog.close(global.get_current_time()); 72 | } 73 | 74 | show() { 75 | this.dialog.show(); 76 | } 77 | 78 | open() { 79 | this.dialog.open(global.get_current_time(), false); 80 | this.show(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Retrieve the UUID from ``metadata.json`` 2 | UUID = $(shell grep -E '^[ ]*"uuid":' ./metadata.json | sed 's@^[ ]*"uuid":[ ]*"\(.\+\)",[ ]*@\1@') 3 | VERSION = $(shell grep version tsconfig.json | awk -F\" '{print $$4}') 4 | 5 | ifeq ($(XDG_DATA_HOME),) 6 | XDG_DATA_HOME = $(HOME)/.local/share 7 | endif 8 | 9 | ifeq ($(strip $(DESTDIR)),) 10 | INSTALLBASE = $(XDG_DATA_HOME)/gnome-shell/extensions 11 | SCRIPTS_BASE = $(XDG_DATA_HOME)/gnome-mosaic/scripts 12 | else 13 | INSTALLBASE = $(DESTDIR)/usr/share/gnome-shell/extensions 14 | SCRIPTS_BASE = $(DESTDIR)/usr/lib/gnome-mosaic/scripts 15 | endif 16 | INSTALLNAME = $(UUID) 17 | 18 | PROJECTS = floating_exceptions 19 | 20 | ICON_NAME = com.github.jardon.gnome-mosaic-exceptions 21 | ICON_SRC = icons/mosaic-logo.svg 22 | ICON_SIZE = scalable 23 | ICON_INSTALL_DIR = $(XDG_DATA_HOME)/icons/hicolor/$(ICON_SIZE)/apps 24 | 25 | SHORTCUT_NAME = $(ICON_NAME).desktop 26 | SHORTCUT_SRC = ./$(SHORTCUT_NAME) 27 | SHORTCUT_INSTALL_DIR = $(XDG_DATA_HOME)/applications 28 | 29 | $(info UUID is "$(UUID)") 30 | 31 | .PHONY: all clean install zip-file 32 | 33 | sources = src/*.ts *.css 34 | 35 | all: depcheck compile 36 | 37 | clean: 38 | rm -rf _build target 39 | 40 | # Configure local settings on system 41 | configure: 42 | sh scripts/configure.sh 43 | 44 | compile: $(sources) clean 45 | env PROJECTS="$(PROJECTS)" ./scripts/transpile.sh 46 | 47 | # Rebuild, install, reconfigure local settings, restart shell, and listen to journalctl logs 48 | debug: depcheck compile install configure enable restart-shell listen 49 | 50 | depcheck: 51 | @echo depcheck 52 | @if ! command -v tsc >/dev/null; then \ 53 | echo \ 54 | echo 'You must install TypeScript >= 3.8 to transpile: (node-typescript on Debian systems)'; \ 55 | exit 1; \ 56 | fi 57 | 58 | enable: 59 | gnome-extensions enable "gnome-mosaic@jardon.github.com" 60 | 61 | disable: 62 | gnome-extensions disable "gnome-mosaic@jardon.github.com" 63 | 64 | listen: 65 | journalctl -o cat -n 0 -f "$$(which gnome-shell)" | grep -v warning 66 | 67 | local-install: depcheck compile install configure restart-shell enable 68 | 69 | install: install-icon install-shortcut 70 | rm -rf $(INSTALLBASE)/$(INSTALLNAME) 71 | mkdir -p $(INSTALLBASE)/$(INSTALLNAME) $(SCRIPTS_BASE) 72 | cp -r _build/* $(INSTALLBASE)/$(INSTALLNAME)/ 73 | 74 | uninstall: uninstall-icon uninstall-shortcut 75 | rm -rf $(INSTALLBASE)/$(INSTALLNAME) 76 | 77 | restart-shell: 78 | @echo "Restart shell!" 79 | ifneq ($(WAYLAND_DISPLAY),) # Don't restart if WAYLAND_DISPLAY is set 80 | @echo "WAYLAND_DISPLAY is set, not restarting shell"; 81 | else 82 | if bash -c 'xprop -root &> /dev/null'; then \ 83 | pkill -HUP gnome-shell; \ 84 | else \ 85 | gnome-session-quit --logout; \ 86 | fi 87 | sleep 3 88 | endif 89 | 90 | update-repository: 91 | git fetch origin 92 | git reset --hard origin/master 93 | git clean -fd 94 | 95 | zip-file: all 96 | cd _build && zip -qr "../$(UUID)_$(VERSION).zip" . 97 | 98 | .NOTPARALLEL: debug local-install 99 | 100 | install-icon: 101 | mkdir -p $(ICON_INSTALL_DIR) 102 | cp $(ICON_SRC) $(ICON_INSTALL_DIR)/$(ICON_NAME).svg 103 | 104 | uninstall-icon: 105 | rm -f $(ICON_INSTALL_DIR)/$(ICON_NAME).svg 106 | 107 | install-shortcut: 108 | mkdir -p $(SHORTCUT_INSTALL_DIR) 109 | cp $(SHORTCUT_SRC) $(SHORTCUT_INSTALL_DIR) 110 | 111 | uninstall-shortcut: 112 | rm -f $(SHORTCUT_INSTALL_DIR)/$(SHORTCUT_NAME) 113 | -------------------------------------------------------------------------------- /src/rectangle.ts: -------------------------------------------------------------------------------- 1 | export class Rectangle { 2 | array: [number, number, number, number]; 3 | 4 | constructor(array: [number, number, number, number]) { 5 | this.array = array; 6 | } 7 | 8 | toJSON() { 9 | return { 10 | array: this.array, 11 | }; 12 | } 13 | 14 | static fromJSON(data: any) { 15 | return new Rectangle(data.array); 16 | } 17 | 18 | static from_meta(meta: Rectangular): Rectangle { 19 | return new Rectangle([meta.x, meta.y, meta.width, meta.height]); 20 | } 21 | 22 | get x() { 23 | return this.array[0]; 24 | } 25 | 26 | set x(x: number) { 27 | this.array[0] = x; 28 | } 29 | 30 | get y() { 31 | return this.array[1]; 32 | } 33 | 34 | set y(y: number) { 35 | this.array[1] = y; 36 | } 37 | 38 | get width(): number { 39 | return this.array[2]; 40 | } 41 | 42 | set width(width: number) { 43 | this.array[2] = width; 44 | } 45 | 46 | get height() { 47 | return this.array[3]; 48 | } 49 | 50 | set height(height: number) { 51 | this.array[3] = height; 52 | } 53 | 54 | apply(other: Rectangle): this { 55 | this.x += other.x; 56 | this.y += other.y; 57 | this.width += other.width; 58 | this.height += other.height; 59 | return this; 60 | } 61 | 62 | clamp(other: Rectangular) { 63 | this.x = Math.max(other.x, this.x); 64 | this.y = Math.max(other.y, this.y); 65 | 66 | let tend = this.x + this.width, 67 | oend = other.x + other.width; 68 | if (tend > oend) { 69 | this.width = oend - this.x; 70 | } 71 | 72 | tend = this.y + this.height; 73 | oend = other.y + other.height; 74 | if (tend > oend) { 75 | this.height = oend - this.y; 76 | } 77 | } 78 | 79 | clone(): Rectangle { 80 | return new Rectangle([ 81 | this.array[0], 82 | this.array[1], 83 | this.array[2], 84 | this.array[3], 85 | ]); 86 | } 87 | 88 | contains(other: Rectangular): boolean { 89 | return ( 90 | this.x <= other.x && 91 | this.y <= other.y && 92 | this.x + this.width >= other.x + other.width && 93 | this.y + this.height >= other.y + other.height 94 | ); 95 | } 96 | 97 | diff(other: Rectangular): Rectangle { 98 | return new Rectangle([ 99 | other.x - this.x, 100 | other.y - this.y, 101 | other.width - this.width, 102 | other.height - this.height, 103 | ]); 104 | } 105 | 106 | eq(other: Rectangular): boolean { 107 | return ( 108 | this.x == other.x && 109 | this.y == other.y && 110 | this.width == other.width && 111 | this.height == other.height 112 | ); 113 | } 114 | 115 | fmt(): string { 116 | return `Rect(${[this.x, this.y, this.width, this.height]})`; 117 | } 118 | 119 | intersects(other: Rectangular): boolean { 120 | return ( 121 | this.x < other.x + other.width && 122 | this.x + this.width > other.x && 123 | this.y < other.y + other.height && 124 | this.y + this.height > other.y 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import * as Ecs from './ecs.js'; 2 | 3 | import type {Forest} from './forest.js'; 4 | import type {Entity} from './ecs.js'; 5 | import type {Ext} from './extension.js'; 6 | import type {Rectangle} from './rectangle.js'; 7 | 8 | /** A node is either a fork a window */ 9 | export enum NodeKind { 10 | FORK = 1, 11 | WINDOW = 2, 12 | } 13 | 14 | /** Fetch the string representation of this value */ 15 | function node_variant_as_string(value: NodeKind): string { 16 | return value == NodeKind.FORK ? 'NodeVariant::Fork' : 'NodeVariant::Window'; 17 | } 18 | 19 | /** Identifies this node as a fork */ 20 | export interface NodeFork { 21 | kind: 1; 22 | entity: Entity; 23 | } 24 | 25 | /** Identifies this node as a window */ 26 | export interface NodeWindow { 27 | kind: 2; 28 | entity: Entity; 29 | } 30 | 31 | export type NodeADT = NodeFork | NodeWindow; 32 | 33 | /** A tiling node may either refer to a window entity, or another fork entity */ 34 | export class Node { 35 | /** The actual data for this node */ 36 | inner: NodeADT; 37 | 38 | constructor(inner: NodeADT) { 39 | this.inner = inner; 40 | } 41 | 42 | toJSON() { 43 | return { 44 | inner: this.inner, 45 | }; 46 | } 47 | 48 | static fromJSON(data: any) { 49 | return new Node(data.inner); 50 | } 51 | 52 | /** Create a fork variant of a `Node` */ 53 | static fork(entity: Entity): Node { 54 | return new Node({kind: NodeKind.FORK, entity}); 55 | } 56 | 57 | /** Create the window variant of a `Node` */ 58 | static window(entity: Entity): Node { 59 | return new Node({kind: NodeKind.WINDOW, entity}); 60 | } 61 | 62 | /** Generates a string representation of the this value. */ 63 | display(fmt: string): string { 64 | fmt += `{\n kind: ${node_variant_as_string(this.inner.kind)},\n `; 65 | 66 | switch (this.inner.kind) { 67 | // Fork + Window 68 | case 1: 69 | case 2: 70 | fmt += `entity: (${this.inner.entity})\n }`; 71 | return fmt; 72 | } 73 | } 74 | 75 | /** Asks if this fork is the fork we are looking for */ 76 | is_fork(entity: Entity): boolean { 77 | return ( 78 | this.inner.kind === 1 && Ecs.entity_eq(this.inner.entity, entity) 79 | ); 80 | } 81 | 82 | /** Asks if this window is the window we are looking for */ 83 | is_window(entity: Entity): boolean { 84 | return ( 85 | this.inner.kind === 2 && Ecs.entity_eq(this.inner.entity, entity) 86 | ); 87 | } 88 | 89 | /** Calculates the future arrangement of windows in this node */ 90 | measure( 91 | tiler: Forest, 92 | ext: Ext, 93 | parent: Entity, 94 | area: Rectangle, 95 | record: (win: Entity, parent: Entity, area: Rectangle) => void 96 | ) { 97 | switch (this.inner.kind) { 98 | // Fork 99 | case 1: 100 | const fork = tiler.forks.get(this.inner.entity); 101 | if (fork) { 102 | record; 103 | fork.measure(tiler, ext, area, record); 104 | } 105 | 106 | break; 107 | // Window 108 | case 2: 109 | record(this.inner.entity, parent, area.clone()); 110 | break; 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/executor.ts: -------------------------------------------------------------------------------- 1 | import * as Ecs from './ecs.js'; 2 | import GLib from 'gi://GLib'; 3 | 4 | export interface Executor { 5 | wake>(system: S, event: T): void; 6 | } 7 | 8 | /** Glib-based event executor */ 9 | export class GLibExecutor implements Executor { 10 | #event_loop: SignalID | null = null; 11 | #events: Array = new Array(); 12 | 13 | /** Creates an idle_add signal that exists only for as long as there are events to process. 14 | * 15 | * - If the signal has already been created, the event will be added to the queue. 16 | * - The signal will continue executing for as long as there are events remaining in the queue. 17 | * - Events are handled within batches, yielding between each new set of events. 18 | */ 19 | wake>(system: S, event: T): void { 20 | this.#events.unshift(event); 21 | 22 | if (this.#event_loop) return; 23 | 24 | this.#event_loop = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { 25 | let event = this.#events.pop(); 26 | if (event) system.run(event); 27 | 28 | if (this.#events.length === 0) { 29 | this.#event_loop = null; 30 | return false; 31 | } 32 | 33 | return true; 34 | }); 35 | } 36 | } 37 | 38 | export class OnceExecutor> { 39 | #iterable: T; 40 | #signal: SignalID | null = null; 41 | private then_timeout: SignalID | null = null; 42 | 43 | constructor(iterable: T) { 44 | this.#iterable = iterable; 45 | } 46 | 47 | start(delay: number, apply: (v: X) => boolean, then?: () => void) { 48 | this.stop(); 49 | 50 | const iterator = this.#iterable[Symbol.iterator](); 51 | 52 | this.#signal = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { 53 | const next: X = iterator.next().value; 54 | 55 | if (typeof next === 'undefined') { 56 | if (then) { 57 | this.then_timeout = GLib.timeout_add( 58 | GLib.PRIORITY_DEFAULT, 59 | delay, 60 | () => { 61 | then(); 62 | return false; 63 | } 64 | ); 65 | } 66 | 67 | return false; 68 | } 69 | 70 | return apply(next); 71 | }); 72 | } 73 | 74 | stop() { 75 | if (this.#signal !== null) { 76 | GLib.source_remove(this.#signal); 77 | this.#signal = null; 78 | } 79 | if (this.then_timeout) { 80 | GLib.source_remove(this.then_timeout); 81 | this.then_timeout = null; 82 | } 83 | } 84 | } 85 | 86 | export class ChannelExecutor { 87 | #channel: Array = new Array(); 88 | 89 | #signal: null | number = null; 90 | 91 | clear() { 92 | this.#channel.splice(0); 93 | } 94 | 95 | get length(): number { 96 | return this.#channel.length; 97 | } 98 | 99 | send(v: X) { 100 | this.#channel.push(v); 101 | } 102 | 103 | start(delay: number, apply: (v: X) => boolean) { 104 | this.stop(); 105 | 106 | this.#signal = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { 107 | const e = this.#channel.shift(); 108 | 109 | return typeof e === 'undefined' ? true : apply(e); 110 | }); 111 | } 112 | 113 | stop() { 114 | if (this.#signal !== null) { 115 | GLib.source_remove(this.#signal); 116 | this.#signal = null; 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/focus.ts: -------------------------------------------------------------------------------- 1 | import * as Geom from './geom.js'; 2 | 3 | import type {ShellWindow} from './window.js'; 4 | import type {Ext} from './extension.js'; 5 | 6 | export enum FocusPosition { 7 | TopLeft = 'Top Left', 8 | TopRight = 'Top Right', 9 | BottomLeft = 'Bottom Left', 10 | BottomRight = 'Bottom Right', 11 | } 12 | 13 | export class FocusSelector { 14 | select( 15 | ext: Ext, 16 | direction: ( 17 | a: ShellWindow, 18 | b: Array 19 | ) => Array, 20 | window: ShellWindow | null 21 | ): ShellWindow | null { 22 | window = window ?? ext.focus_window(); 23 | if (window) { 24 | let window_list = ext.active_window_list(); 25 | return select(direction, window, window_list); 26 | } 27 | 28 | return null; 29 | } 30 | 31 | down(ext: Ext, window: ShellWindow | null): ShellWindow | null { 32 | return this.select(ext, window_down, window); 33 | } 34 | 35 | left(ext: Ext, window: ShellWindow | null): ShellWindow | null { 36 | return this.select(ext, window_left, window); 37 | } 38 | 39 | right(ext: Ext, window: ShellWindow | null): ShellWindow | null { 40 | return this.select(ext, window_right, window); 41 | } 42 | 43 | up(ext: Ext, window: ShellWindow | null): ShellWindow | null { 44 | return this.select(ext, window_up, window); 45 | } 46 | } 47 | 48 | function select( 49 | windows: (a: ShellWindow, b: Array) => Array, 50 | focused: ShellWindow, 51 | window_list: Array 52 | ): ShellWindow | null { 53 | const array = windows(focused, window_list); 54 | return array.length > 0 ? array[0] : null; 55 | } 56 | 57 | function window_down(focused: ShellWindow, windows: Array) { 58 | return windows 59 | .filter( 60 | win => 61 | !win.meta.minimized && 62 | win.meta.get_frame_rect().y > focused.meta.get_frame_rect().y 63 | ) 64 | .sort( 65 | (a, b) => 66 | Geom.downward_distance(a.meta, focused.meta) - 67 | Geom.downward_distance(b.meta, focused.meta) 68 | ); 69 | } 70 | 71 | function window_left(focused: ShellWindow, windows: Array) { 72 | return windows 73 | .filter( 74 | win => 75 | !win.meta.minimized && 76 | win.meta.get_frame_rect().x < focused.meta.get_frame_rect().x 77 | ) 78 | .sort( 79 | (a, b) => 80 | Geom.leftward_distance(a.meta, focused.meta) - 81 | Geom.leftward_distance(b.meta, focused.meta) 82 | ); 83 | } 84 | 85 | function window_right(focused: ShellWindow, windows: Array) { 86 | return windows 87 | .filter( 88 | win => 89 | !win.meta.minimized && 90 | win.meta.get_frame_rect().x > focused.meta.get_frame_rect().x 91 | ) 92 | .sort( 93 | (a, b) => 94 | Geom.rightward_distance(a.meta, focused.meta) - 95 | Geom.rightward_distance(b.meta, focused.meta) 96 | ); 97 | } 98 | 99 | function window_up(focused: ShellWindow, windows: Array) { 100 | return windows 101 | .filter( 102 | win => 103 | !win.meta.minimized && 104 | win.meta.get_frame_rect().y < focused.meta.get_frame_rect().y 105 | ) 106 | .sort( 107 | (a, b) => 108 | Geom.upward_distance(a.meta, focused.meta) - 109 | Geom.upward_distance(b.meta, focused.meta) 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/geom.ts: -------------------------------------------------------------------------------- 1 | export enum Side { 2 | LEFT, 3 | TOP, 4 | RIGHT, 5 | BOTTOM, 6 | } 7 | 8 | export function xend(rect: Rectangular): number { 9 | return rect.x + rect.width; 10 | } 11 | 12 | export function xcenter(rect: Rectangular): number { 13 | return rect.x + rect.width / 2; 14 | } 15 | 16 | export function yend(rect: Rectangular): number { 17 | return rect.y + rect.height; 18 | } 19 | 20 | export function ycenter(rect: Rectangular): number { 21 | return rect.y + rect.height / 2; 22 | } 23 | 24 | export function center(rect: Rectangular): [number, number] { 25 | return [xcenter(rect), ycenter(rect)]; 26 | } 27 | 28 | export function north(rect: Rectangular): [number, number] { 29 | return [xcenter(rect), rect.y]; 30 | } 31 | 32 | export function east(rect: Rectangular): [number, number] { 33 | return [xend(rect), ycenter(rect)]; 34 | } 35 | 36 | export function south(rect: Rectangular): [number, number] { 37 | return [xcenter(rect), yend(rect)]; 38 | } 39 | 40 | export function west(rect: Rectangular): [number, number] { 41 | return [rect.x, ycenter(rect)]; 42 | } 43 | 44 | export function distance( 45 | [ax, ay]: [number, number], 46 | [bx, by]: [number, number] 47 | ): number { 48 | return Math.sqrt(Math.pow(bx - ax, 2) + Math.pow(by - ay, 2)); 49 | } 50 | 51 | export function directional_distance( 52 | a: Rectangular, 53 | b: Rectangular, 54 | fn_a: (rect: Rectangular) => [number, number], 55 | fn_b: (rect: Rectangular) => [number, number] 56 | ) { 57 | return distance(fn_a(a), fn_b(b)); 58 | } 59 | 60 | export function window_distance(win_a: Meta.Window, win_b: Meta.Window) { 61 | return directional_distance( 62 | win_a.get_frame_rect(), 63 | win_b.get_frame_rect(), 64 | center, 65 | center 66 | ); 67 | } 68 | 69 | export function upward_distance(win_a: Meta.Window, win_b: Meta.Window) { 70 | return directional_distance( 71 | win_a.get_frame_rect(), 72 | win_b.get_frame_rect(), 73 | south, 74 | north 75 | ); 76 | } 77 | 78 | export function rightward_distance(win_a: Meta.Window, win_b: Meta.Window) { 79 | return directional_distance( 80 | win_a.get_frame_rect(), 81 | win_b.get_frame_rect(), 82 | west, 83 | east 84 | ); 85 | } 86 | 87 | export function downward_distance(win_a: Meta.Window, win_b: Meta.Window) { 88 | return directional_distance( 89 | win_a.get_frame_rect(), 90 | win_b.get_frame_rect(), 91 | north, 92 | south 93 | ); 94 | } 95 | 96 | export function leftward_distance(win_a: Meta.Window, win_b: Meta.Window) { 97 | return directional_distance( 98 | win_a.get_frame_rect(), 99 | win_b.get_frame_rect(), 100 | east, 101 | west 102 | ); 103 | } 104 | 105 | export function nearest_side( 106 | origin: [number, number], 107 | rect: Rectangular 108 | ): [number, Side] { 109 | const left = west(rect), 110 | top = north(rect), 111 | right = east(rect), 112 | bottom = south(rect); 113 | 114 | const left_distance = distance(origin, left), 115 | top_distance = distance(origin, top), 116 | right_distance = distance(origin, right), 117 | bottom_distance = distance(origin, bottom); 118 | 119 | let nearest: [number, Side] = 120 | left_distance < right_distance 121 | ? [left_distance, Side.LEFT] 122 | : [right_distance, Side.RIGHT]; 123 | 124 | if (top_distance < nearest[0]) nearest = [top_distance, Side.TOP]; 125 | if (bottom_distance < nearest[0]) nearest = [bottom_distance, Side.BOTTOM]; 126 | 127 | return nearest; 128 | } 129 | 130 | export function shortest_side( 131 | origin: [number, number], 132 | rect: Rectangular 133 | ): number { 134 | let shortest = distance(origin, west(rect)); 135 | shortest = Math.min(shortest, distance(origin, north(rect))); 136 | shortest = Math.min(shortest, distance(origin, east(rect))); 137 | return Math.min(shortest, distance(origin, south(rect))); 138 | } 139 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import * as log from './log.js'; 2 | import * as rectangle from './rectangle.js'; 3 | 4 | import type {Rectangle} from './rectangle.js'; 5 | 6 | import Meta from 'gi://Meta'; 7 | import St from 'gi://St'; 8 | 9 | export interface SizeHint { 10 | minimum: [number, number]; 11 | increment: [number, number]; 12 | base: [number, number]; 13 | } 14 | 15 | export enum Orientation { 16 | HORIZONTAL = 0, 17 | VERTICAL = 1, 18 | } 19 | 20 | export function nth_rev(array: Array, nth: number): T | null { 21 | return array[array.length - nth - 1]; 22 | } 23 | 24 | export function ok(input: T | null, func: (a: T) => X | null): X | null { 25 | return input ? func(input) : null; 26 | } 27 | 28 | export function ok_or_else( 29 | input: A | null, 30 | ok_func: (input: A) => B, 31 | or_func: () => B 32 | ): B { 33 | return input ? ok_func(input) : or_func(); 34 | } 35 | 36 | export function or_else(input: T | null, func: () => T | null): T | null { 37 | return input ? input : func(); 38 | } 39 | 40 | export function bench(name: string, callback: () => T): T { 41 | const start = new Date().getMilliseconds(); 42 | const value = callback(); 43 | const end = new Date().getMilliseconds(); 44 | 45 | log.info(`bench ${name}: ${end - start} ms elapsed`); 46 | 47 | return value; 48 | } 49 | 50 | export function current_monitor(): Rectangle { 51 | return rectangle.Rectangle.from_meta( 52 | global.display.get_monitor_geometry( 53 | global.display.get_current_monitor() 54 | ) as Rectangular 55 | ); 56 | } 57 | 58 | // Fetch rectangle that represents the cursor 59 | export function cursor_rect(): Rectangle { 60 | let [x, y] = global.get_pointer(); 61 | return new rectangle.Rectangle([x, y, 1, 1]); 62 | } 63 | 64 | export function dbg(value: T): T { 65 | log.debug(String(value)); 66 | return value; 67 | } 68 | 69 | /// Missing from the Clutter API is an Actor children iterator 70 | export function* get_children( 71 | actor: Clutter.Actor 72 | ): IterableIterator { 73 | let nth = 0; 74 | let children = actor.get_n_children(); 75 | 76 | while (nth < children) { 77 | const child = actor.get_child_at_index(nth); 78 | if (child) yield child; 79 | nth += 1; 80 | } 81 | } 82 | 83 | export function join( 84 | iterator: IterableIterator, 85 | next_func: (arg: T) => void, 86 | between_func: () => void 87 | ) { 88 | ok(iterator.next().value, first => { 89 | next_func(first); 90 | 91 | for (const item of iterator) { 92 | between_func(); 93 | next_func(item); 94 | } 95 | }); 96 | } 97 | 98 | export function is_keyboard_op(op: number): boolean { 99 | const window_flag_keyboard = 100 | Meta.GrabOp.KEYBOARD_MOVING & ~Meta.GrabOp.WINDOW_BASE; 101 | return (op & window_flag_keyboard) != 0; 102 | } 103 | 104 | export function is_resize_op(op: number): boolean { 105 | const window_dir_mask = 106 | (Meta.GrabOp.RESIZING_N | 107 | Meta.GrabOp.RESIZING_E | 108 | Meta.GrabOp.RESIZING_S | 109 | Meta.GrabOp.RESIZING_W) & 110 | ~Meta.GrabOp.WINDOW_BASE; 111 | return ( 112 | (op & window_dir_mask) != 0 || 113 | (op & Meta.GrabOp.KEYBOARD_RESIZING_UNKNOWN) == 114 | Meta.GrabOp.KEYBOARD_RESIZING_UNKNOWN 115 | ); 116 | } 117 | 118 | export function is_move_op(op: number): boolean { 119 | return !is_resize_op(op); 120 | } 121 | 122 | export function orientation_as_str(value: number): string { 123 | return value == 0 ? 'Orientation::Horizontal' : 'Orientation::Vertical'; 124 | } 125 | 126 | /// Useful in the event that you want to reuse an actor in the future 127 | export function recursive_remove_children(actor: Clutter.Actor) { 128 | for (const child of get_children(actor)) { 129 | recursive_remove_children(child); 130 | } 131 | 132 | actor.remove_all_children(); 133 | } 134 | 135 | export function round_increment(value: number, increment: number): number { 136 | return Math.round(value / increment) * increment; 137 | } 138 | 139 | export function round_to(n: number, digits: number): number { 140 | let m = Math.pow(10, digits); 141 | n = parseFloat((n * m).toFixed(11)); 142 | return Math.round(n) / m; 143 | } 144 | 145 | export function separator(): any { 146 | return new St.BoxLayout({ 147 | styleClass: 'gnome-mosaic-separator', 148 | x_expand: true, 149 | }); 150 | } 151 | -------------------------------------------------------------------------------- /src/keybindings.ts: -------------------------------------------------------------------------------- 1 | import type {Ext} from './extension.js'; 2 | 3 | import {wm} from 'resource:///org/gnome/shell/ui/main.js'; 4 | import Shell from 'gi://Shell'; 5 | import Meta from 'gi://Meta'; 6 | import {Direction} from './tiling.js'; 7 | 8 | export class Keybindings { 9 | global: Object; 10 | window_focus: Object; 11 | tiler_bindings: Object; 12 | resize_bindings: Object; 13 | 14 | constructor(ext: Ext) { 15 | this.global = { 16 | 'resize-mode': () => ext.tiler.resize_mode(), 17 | 'tile-enter': () => ext.tiler.enter(), 18 | 'resize-grow-left': () => ext.tiler.resize(Direction.Left, false), 19 | 'resize-shrink-left': () => ext.tiler.resize(Direction.Right, true), 20 | 'resize-grow-up': () => ext.tiler.resize(Direction.Up, false), 21 | 'resize-shrink-up': () => ext.tiler.resize(Direction.Down, true), 22 | 'resize-grow-right': () => ext.tiler.resize(Direction.Right, false), 23 | 'resize-shrink-right': () => ext.tiler.resize(Direction.Left, true), 24 | 'resize-grow-down': () => ext.tiler.resize(Direction.Down, false), 25 | 'resize-shrink-down': () => ext.tiler.resize(Direction.Up, true), 26 | }; 27 | 28 | this.window_focus = { 29 | 'focus-left': () => ext.focus_left(), 30 | 31 | 'focus-down': () => ext.focus_down(), 32 | 33 | 'focus-up': () => ext.focus_up(), 34 | 35 | 'focus-right': () => ext.focus_right(), 36 | 37 | 'tile-orientation': () => { 38 | const win = ext.focus_window(); 39 | if (win && ext.auto_tiler) { 40 | ext.auto_tiler.toggle_orientation(ext, win); 41 | ext.register_fn(() => win.activate(ext, true)); 42 | } 43 | }, 44 | 45 | 'toggle-floating': () => ext.auto_tiler?.toggle_floating(ext), 46 | 47 | 'toggle-tiling': () => ext.toggle_tiling(), 48 | 49 | 'tile-move-left-global': () => 50 | ext.tiler.move_left(ext.focus_window()?.entity), 51 | 52 | 'tile-move-down-global': () => 53 | ext.tiler.move_down(ext.focus_window()?.entity), 54 | 55 | 'tile-move-up-global': () => 56 | ext.tiler.move_up(ext.focus_window()?.entity), 57 | 58 | 'tile-move-right-global': () => 59 | ext.tiler.move_right(ext.focus_window()?.entity), 60 | 61 | 'mosaic-monitor-left': () => 62 | ext.move_monitor(Meta.DisplayDirection.LEFT), 63 | 64 | 'mosaic-monitor-right': () => 65 | ext.move_monitor(Meta.DisplayDirection.RIGHT), 66 | 67 | 'mosaic-monitor-up': () => 68 | ext.move_monitor(Meta.DisplayDirection.UP), 69 | 70 | 'mosaic-monitor-down': () => 71 | ext.move_monitor(Meta.DisplayDirection.DOWN), 72 | 73 | 'mosaic-workspace-up': () => 74 | ext.move_workspace(Meta.DisplayDirection.UP), 75 | 76 | 'mosaic-workspace-down': () => 77 | ext.move_workspace(Meta.DisplayDirection.DOWN), 78 | }; 79 | 80 | this.tiler_bindings = { 81 | 'management-orientation': () => ext.tiler.toggle_orientation(), 82 | 'tile-move-left': () => ext.tiler.move_left(), 83 | 'tile-move-down': () => ext.tiler.move_down(), 84 | 'tile-move-up': () => ext.tiler.move_up(), 85 | 'tile-move-right': () => ext.tiler.move_right(), 86 | 'tile-swap-left': () => ext.tiler.swap_left(), 87 | 'tile-swap-down': () => ext.tiler.swap_down(), 88 | 'tile-swap-up': () => ext.tiler.swap_up(), 89 | 'tile-swap-right': () => ext.tiler.swap_right(), 90 | 'tile-accept': () => ext.tiler.accept(), 91 | 'tile-reject': () => ext.tiler.exit(), 92 | }; 93 | 94 | this.resize_bindings = { 95 | 'tile-accept': () => ext.tiler.exit(), 96 | 'tile-reject': () => ext.tiler.exit(), 97 | }; 98 | } 99 | 100 | enable(ext: Ext, keybindings: any) { 101 | for (const name in keybindings) { 102 | wm.addKeybinding( 103 | name, 104 | ext.settings.ext, 105 | Meta.KeyBindingFlags.NONE, 106 | Shell.ActionMode.NORMAL, 107 | keybindings[name] 108 | ); 109 | } 110 | 111 | return this; 112 | } 113 | 114 | disable(keybindings: Object) { 115 | for (const name in keybindings) { 116 | wm.removeKeybinding(name); 117 | } 118 | 119 | return this; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /SHORTCUTS.md: -------------------------------------------------------------------------------- 1 | ## Direction keys 2 | 3 | Directional actions can use either the standard arrow keys or their Vim equivalents: 4 | 5 | ## Key Description 6 | 7 | `←`, `↓`, `↑`, `→` Direction keys (arrow keys) 8 | `H`, `J`, `K`, `L` Direction keys (Vim shortcuts) 9 | 10 | ## Keyboard Shortcuts 11 | 12 | ### Move, resize, and swap windows 13 | 14 | | Shortcut | Action | 15 | | -------------------------------------------- | ---------------------------------------- | 16 | | `SUPER` + `Direction keys` | Switch focus between windows | 17 | | `SUPER` + `Enter` | Enter window adjustment mode | 18 | | `SUPER` + `r` | Enter window resize mode | 19 | | `Direction keys` | Move window (while in adjustment mode) | 20 | | `Shift` + `Direction keys` | Resize window (while in adjustment mode) | 21 | | `Ctrl` + `Direction keys` | Swap windows (while in adjustment mode) | 22 | | `Enter` | Apply changes (exit adjustment mode) | 23 | | `ESC` | Cancel (exit adjustment mode) | 24 | | `SUPER` + `Left click` + `Drag` | Move window (without adjustment mode) | 25 | | `SUPER` + `Right click` + `Drag` | Resize window (without adjustment mode) | 26 | | `SUPER` + `Alt` + `Direction keys` | Resize window (grow in direction) | 27 | | `SUPER` + `Alt` + `Shift` + `Direction keys` | Resize window (shrink in direction) | 28 | 29 | ### Manipulate windows 30 | 31 | | Shortcut | Action | 32 | | ------------------------ | -------------------------------------------------- | 33 | | `SUPER` + `O` | Change window orientation (while in floating mode) | 34 | | `SUPER` + `G` | Float/un-float window (while in floating mode) | 35 | | `SUPER` + `M` | Maximize/un-maximize window | 36 | | `SUPER` + `Ctrl` + `←/→` | Snap window to left/right side of display | 37 | | `SUPER` + `Q` | Close window | 38 | 39 | ### Manage workspaces and displays 40 | 41 | | Shortcut | Action | 42 | | ---------------------------------- | --------------------------------------------------- | 43 | | `SUPER` + `Ctrl` + `↑/↓` | Navigate between workspaces | 44 | | `SUPER` + `Home/End` | Navigate to first/last workspace | 45 | | `SUPER` + `Shift` + `↑/↓` | Move active window between upper/lower workspaces | 46 | | `SUPER` + `Shift` + `←/→` | Move active window between left/right displays | 47 | | `SUPER` + `Ctrl` + `Shift` + `↑/↓` | Move the active window between upper/lower displays | 48 | | `SUPER` + `ESC` | Lock the screen | 49 | 50 | ### Switch between apps and windows 51 | 52 | | Shortcut | Action | 53 | | ------------------------- | ---------------------------------------------- | 54 | | `SUPER` + `Tab` | Switch apps | 55 | | `SUPER` + `Tab` + `Shift` | Switch apps in reverse order | 56 | | `SUPER` + `backtick` | Switch windows of current app | 57 | | `SUPER` + `\` + `Shift` | Switch windows of current app in reverse order | 58 | 59 | ### Miscellaneous OS shortcuts 60 | 61 | | Shortcut | Action | 62 | | ------------------------------ | -------------------------------------------------- | 63 | | `SUPER` + `D` | Toggle workspace menu | 64 | | `SUPER` + `A` | Toggle applications menu | 65 | | `SUPER` + `V` | Toggle notifications menu | 66 | | `SUPER` + `T` | Open a terminal | 67 | | `SUPER` + `F` | Open Files | 68 | | `SUPER` + `P` | Cycle display layout | 69 | | `SUPER` + `Space` | Cycle between configured input sources (languages) | 70 | | `Alt` + `F2` | Run command | 71 | | `Ctrl` + `Alt` + `Del` Log out | 72 | 73 | ### Accessibility shortcuts 74 | 75 | | Shortcut | Action | 76 | | ----------------------- | --------------------------------------- | 77 | | `SUPER` + `Alt` + `S` | Toggle screen reader | 78 | | `SUPER` + `Alt` + `8` | Toggle magnifier | 79 | | `SUPER` + `Alt` + `+/-` | Zoom in/out (when magnifier is enabled) | 80 | -------------------------------------------------------------------------------- /src/panel_settings.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import GObject from 'gi://GObject'; 3 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 4 | 5 | import { 6 | PopupMenuItem, 7 | PopupSeparatorMenuItem, 8 | PopupSwitchMenuItem, 9 | } from 'resource:///org/gnome/shell/ui/popupMenu.js'; 10 | import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js'; 11 | import {get_current_path} from './paths.js'; 12 | import type {Ext} from './extension.js'; 13 | 14 | const MosaicIndicator = GObject.registerClass( 15 | class MosaicIndicator extends QuickSettings.SystemIndicator { 16 | _toggle: QuickSettings.QuickMenuToggle; 17 | _icon_auto_on: any; 18 | _icon_auto_off: any; 19 | 20 | _indicator: any; 21 | 22 | constructor(ext: Ext) { 23 | super(); 24 | 25 | this._indicator = (this as any)._addIndicator(); 26 | 27 | const path = get_current_path(); 28 | const file_on = Gio.File.new_for_path( 29 | `${path}/icons/gnome-mosaic-auto-on-symbolic.svg` 30 | ); 31 | this._icon_auto_on = new Gio.FileIcon({file: file_on}); 32 | 33 | const file_off = Gio.File.new_for_path( 34 | `${path}/icons/gnome-mosaic-auto-off-symbolic.svg` 35 | ); 36 | this._icon_auto_off = new Gio.FileIcon({file: file_off}); 37 | 38 | this._toggle = new QuickSettings.QuickMenuToggle({ 39 | title: 'Mosaic', 40 | toggleMode: true, 41 | iconName: 'view-grid-symbolic', 42 | }); 43 | 44 | this._toggle.gicon = this._icon_auto_off; 45 | 46 | this._toggle.connect('clicked', () => { 47 | ext.toggle_tiling(); 48 | }); 49 | 50 | this._toggle.menu.setHeader( 51 | 'view-grid-symbolic', 52 | 'Mosaic', 53 | 'Tiled window management' 54 | ); 55 | 56 | this._toggle.menu.addMenuItem(this._createSmartGapsSwitch(ext)); 57 | this._toggle.menu.addMenuItem(this._createActiveHintSwitch(ext)); 58 | this._toggle.menu.addMenuItem(this._createMouseFollowsSwitch(ext)); 59 | 60 | this._toggle.menu.addMenuItem(new PopupSeparatorMenuItem()); 61 | this._toggle.menu.addMenuItem(this._createExceptionsItem(ext)); 62 | 63 | this._toggle.menu.addMenuItem(new PopupSeparatorMenuItem()); 64 | this._toggle.menu.addMenuItem(this._createSettingsItem(ext)); 65 | 66 | this.quickSettingsItems.push(this._toggle); 67 | 68 | Main.panel.statusArea.quickSettings.addExternalIndicator(this); 69 | } 70 | 71 | set_active(active: boolean) { 72 | this._toggle.set({checked: active}); 73 | this._toggle.gicon = active 74 | ? this._icon_auto_on 75 | : this._icon_auto_off; 76 | 77 | // Update system indicator icon 78 | this._indicator.gicon = active ? this._icon_auto_on : null; 79 | this._indicator.visible = active; 80 | } 81 | 82 | _createSmartGapsSwitch(ext: Ext) { 83 | const item = new PopupSwitchMenuItem( 84 | 'Smart Gaps', 85 | ext.settings.smart_gaps() 86 | ); 87 | item.connect('toggled', (_: any, state: boolean) => { 88 | ext.settings.set_smart_gaps(state); 89 | }); 90 | return item; 91 | } 92 | 93 | _createActiveHintSwitch(ext: Ext) { 94 | const item = new PopupSwitchMenuItem( 95 | 'Show Active Hint', 96 | ext.settings.active_hint() 97 | ); 98 | item.connect('toggled', (_: any, state: boolean) => { 99 | ext.settings.set_active_hint(state); 100 | }); 101 | return item; 102 | } 103 | 104 | _createMouseFollowsSwitch(ext: Ext) { 105 | const item = new PopupSwitchMenuItem( 106 | 'Move Pointer With Focus', 107 | ext.settings.mouse_cursor_follows_active_window() 108 | ); 109 | item.connect('toggled', (_: any, state: boolean) => { 110 | ext.settings.set_mouse_cursor_follows_active_window(state); 111 | }); 112 | return item; 113 | } 114 | 115 | _createExceptionsItem(ext: Ext) { 116 | const item = new PopupMenuItem('Floating Window Exceptions'); 117 | item.connect('activate', () => { 118 | ext.exception_dialog(); 119 | }); 120 | return item; 121 | } 122 | 123 | _createSettingsItem(ext: Ext) { 124 | const item = new PopupMenuItem('Settings'); 125 | item.connect('activate', () => { 126 | ext.open_settings(); 127 | }); 128 | return item; 129 | } 130 | 131 | destroy() { 132 | this._toggle.destroy(); 133 | super.destroy(); 134 | } 135 | } 136 | ); 137 | 138 | export {MosaicIndicator}; 139 | -------------------------------------------------------------------------------- /scripts/configure.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | shortcut_applied() { 6 | # Check if user confirmed overriding shortcuts 7 | if test -f "./.confirm_shortcut_change"; then 8 | echo "Shortcut change already confirmed" 9 | return 0 10 | fi 11 | 12 | read -p "GNOME Mosaic will override your default shortcuts. Are you sure? (y/n) " CONT 13 | if test "$CONT" = "y"; then 14 | touch "./.confirm_shortcut_change" 15 | return 1 16 | else 17 | echo "Cancelled" 18 | return 0 19 | fi 20 | } 21 | 22 | set_keybindings() { 23 | if shortcut_applied; then 24 | return 0 25 | fi 26 | 27 | left="h" 28 | down="j" 29 | up="k" 30 | right="l" 31 | 32 | KEYS_GNOME_WM=/org/gnome/desktop/wm/keybindings 33 | KEYS_GNOME_SHELL=/org/gnome/shell/keybindings 34 | KEYS_MUTTER=/org/gnome/mutter/keybindings 35 | KEYS_MEDIA=/org/gnome/settings-daemon/plugins/media-keys 36 | KEYS_MUTTER_WAYLAND_RESTORE=/org/gnome/mutter/wayland/keybindings/restore-shortcuts 37 | 38 | # Disable incompatible shortcuts 39 | # Restore the keyboard shortcuts: disable Escape 40 | dconf write ${KEYS_MUTTER_WAYLAND_RESTORE} "@as []" 41 | # Hide window: disable h 42 | dconf write ${KEYS_GNOME_WM}/minimize "@as ['comma']" 43 | # Open the application menu: disable m 44 | dconf write ${KEYS_GNOME_SHELL}/open-application-menu "@as []" 45 | # Toggle message tray: disable m 46 | dconf write ${KEYS_GNOME_SHELL}/toggle-message-tray "@as ['v']" 47 | # Show the activities overview: disable s 48 | dconf write ${KEYS_GNOME_SHELL}/toggle-overview "@as []" 49 | # Switch to workspace left: disable Left 50 | dconf write ${KEYS_GNOME_WM}/switch-to-workspace-left "@as []" 51 | # Switch to workspace right: disable Right 52 | dconf write ${KEYS_GNOME_WM}/switch-to-workspace-right "@as []" 53 | # Maximize window: disable Up 54 | dconf write ${KEYS_GNOME_WM}/maximize "@as []" 55 | # Restore window: disable Down 56 | dconf write ${KEYS_GNOME_WM}/unmaximize "@as []" 57 | # Move to monitor up: disable Up 58 | dconf write ${KEYS_GNOME_WM}/move-to-monitor-up "@as []" 59 | # Move to monitor down: disable Down 60 | dconf write ${KEYS_GNOME_WM}/move-to-monitor-down "@as []" 61 | 62 | # Super + direction keys, move window left and right monitors, or up and down workspaces 63 | # Move window one monitor to the left 64 | dconf write ${KEYS_GNOME_WM}/move-to-monitor-left "@as []" 65 | # Move window one workspace down 66 | dconf write ${KEYS_GNOME_WM}/move-to-workspace-down "@as []" 67 | # Move window one workspace up 68 | dconf write ${KEYS_GNOME_WM}/move-to-workspace-up "@as []" 69 | # Move window one workspace left 70 | dconf write ${KEYS_GNOME_WM}/move-to-workspace-left "@as []" 71 | # Move window one workspace right 72 | dconf write ${KEYS_GNOME_WM}/move-to-workspace-right "@as []" 73 | # Move window one monitor to the right 74 | dconf write ${KEYS_GNOME_WM}/move-to-monitor-right "@as []" 75 | 76 | # Super + Ctrl + direction keys, change workspaces, move focus between monitors 77 | # Move to workspace below 78 | dconf write ${KEYS_GNOME_WM}/switch-to-workspace-down "['Down','${down}']" 79 | # Move to workspace above 80 | dconf write ${KEYS_GNOME_WM}/switch-to-workspace-up "['Up','${up}']" 81 | 82 | # Disable tiling to left / right of screen 83 | dconf write ${KEYS_MUTTER}/toggle-tiled-left "@as []" 84 | dconf write ${KEYS_MUTTER}/toggle-tiled-right "@as []" 85 | 86 | # Toggle maximization state 87 | dconf write ${KEYS_GNOME_WM}/toggle-maximized "['m']" 88 | # Lock screen 89 | dconf write ${KEYS_MEDIA}/screensaver "['Escape']" 90 | # Home folder 91 | dconf write ${KEYS_MEDIA}/home "['f']" 92 | # Launch email client 93 | dconf write ${KEYS_MEDIA}/email "['e']" 94 | # Launch web browser 95 | dconf write ${KEYS_MEDIA}/www "['b']" 96 | # Launch terminal 97 | dconf write ${KEYS_MEDIA}/terminal "['t']" 98 | # Rotate Video Lock 99 | dconf write ${KEYS_MEDIA}/rotate-video-lock-static "@as []" 100 | 101 | # Close Window 102 | dconf write ${KEYS_GNOME_WM}/close "['q', 'F4']" 103 | } 104 | 105 | if ! command -v gnome-extensions >/dev/null; then 106 | echo 'You must install gnome-extensions to configure or enable via this script' 107 | '(`gnome-shell` on Debian systems, `gnome-extensions` on openSUSE systems.)' 108 | exit 1 109 | fi 110 | 111 | set_keybindings 112 | 113 | # Make sure user extensions are enabled 114 | dconf write /org/gnome/shell/disable-user-extensions false 115 | 116 | # Use a window placement behavior which works better for tiling 117 | 118 | if gnome-extensions list | grep native-window; then 119 | gnome-extensions enable $(gnome-extensions list | grep native-window) 120 | fi 121 | 122 | # Workspaces spanning displays works better with GNOME Mosaic 123 | dconf write /org/gnome/mutter/workspaces-only-on-primary false 124 | -------------------------------------------------------------------------------- /src/xprop.ts: -------------------------------------------------------------------------------- 1 | import * as lib from './lib.js'; 2 | 3 | import Gio from 'gi://Gio'; 4 | import {spawn} from 'resource:///org/gnome/shell/misc/util.js'; 5 | 6 | export var MOTIF_HINTS: string = '_MOTIF_WM_HINTS'; 7 | export var HIDE_FLAGS: string[] = ['0x2', '0x0', '0x0', '0x0', '0x0']; 8 | export var SHOW_FLAGS: string[] = ['0x2', '0x0', '0x1', '0x0', '0x0']; 9 | 10 | //export var FRAME_EXTENTS: string = "_GTK_FRAME_EXTENTS" 11 | 12 | export async function get_window_role(xid: string): Promise { 13 | let out = await xprop_cmd(xid, ['WM_WINDOW_ROLE']); 14 | 15 | if (!out) return null; 16 | 17 | return parse_string(out); 18 | } 19 | 20 | export async function get_frame_extents(xid: string): Promise { 21 | let out = await xprop_cmd(xid, ['_GTK_FRAME_EXTENTS']); 22 | 23 | if (!out) return null; 24 | 25 | return parse_string(out); 26 | } 27 | 28 | export async function get_hint( 29 | xid: string, 30 | hint: string 31 | ): Promise | null> { 32 | let out = await xprop_cmd(xid, [hint]); 33 | 34 | if (!out) return null; 35 | 36 | const array = parse_cardinal(out); 37 | 38 | return array 39 | ? array.map(value => (value.startsWith('0x') ? value : '0x' + value)) 40 | : null; 41 | } 42 | 43 | function size_params(line: string): [number, number] | null { 44 | let fields = line.split(' '); 45 | let x = lib.dbg(lib.nth_rev(fields, 2)); 46 | let y = lib.dbg(lib.nth_rev(fields, 0)); 47 | 48 | if (!x || !y) return null; 49 | 50 | let xn = parseInt(x, 10); 51 | let yn = parseInt(y, 10); 52 | 53 | return isNaN(xn) || isNaN(yn) ? null : [xn, yn]; 54 | } 55 | 56 | export async function get_size_hints( 57 | xid: string 58 | ): Promise { 59 | let out = await xprop_cmd(xid, ['WM_NORMAL_HINTS']); 60 | if (out) { 61 | let lines = out.split('\n')[Symbol.iterator](); 62 | lines.next(); 63 | 64 | let minimum: string | undefined = lines.next().value; 65 | let increment: string | undefined = lines.next().value; 66 | let base: string | undefined = lines.next().value; 67 | 68 | if (!minimum || !increment || !base) return null; 69 | 70 | let min_values = size_params(minimum); 71 | let inc_values = size_params(increment); 72 | let base_values = size_params(base); 73 | 74 | if (!min_values || !inc_values || !base_values) return null; 75 | 76 | return { 77 | minimum: min_values, 78 | increment: inc_values, 79 | base: base_values, 80 | }; 81 | } 82 | 83 | return null; 84 | } 85 | 86 | export function get_xid(meta: Meta.Window): string | null { 87 | const desc = meta.get_description(); 88 | const match = desc && desc.match(/0x[0-9a-f]+/); 89 | return match && match[0]; 90 | } 91 | 92 | export async function may_decorate(xid: string): Promise { 93 | const hints = await motif_hints(xid); 94 | return hints ? hints[2] == '0x0' || hints[2] == '0x1' : true; 95 | } 96 | 97 | export async function motif_hints(xid: string): Promise | null> { 98 | return await get_hint(xid, MOTIF_HINTS); 99 | } 100 | 101 | export function set_hint(xid: string, hint: string, value: string[]) { 102 | spawn([ 103 | 'xprop', 104 | '-id', 105 | xid, 106 | '-f', 107 | hint, 108 | '32c', 109 | '-set', 110 | hint, 111 | value.join(', '), 112 | ]); 113 | } 114 | 115 | function consume_key(string: string): number | null { 116 | const pos = string.indexOf('='); 117 | return -1 == pos ? null : pos; 118 | } 119 | 120 | function parse_cardinal(string: string): Array | null { 121 | const pos = consume_key(string); 122 | return pos 123 | ? string 124 | .slice(pos + 1) 125 | .trim() 126 | .split(', ') 127 | : null; 128 | } 129 | 130 | function parse_string(string: string): string | null { 131 | const pos = consume_key(string); 132 | return pos 133 | ? string 134 | .slice(pos + 1) 135 | .trim() 136 | .slice(1, -1) 137 | : null; 138 | } 139 | 140 | async function xprop_cmd( 141 | xid: string, 142 | args: string[], 143 | cancellable: any = null 144 | ): Promise { 145 | let cancelId = 0; 146 | let flags = 147 | Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE; 148 | 149 | const xprops = `xprop -id ${xid} ${args}`.split(' '); 150 | const proc = new Gio.Subprocess({xprops, flags}); 151 | proc.init(cancellable); 152 | 153 | if (cancellable instanceof Gio.Cancellable) 154 | cancelId = cancellable.connect(() => proc.force_exit()); 155 | 156 | try { 157 | const [stdout, stderr] = await proc.communicate_utf8_async(null, null); 158 | 159 | const status = proc.get_exit_status(); 160 | 161 | if (status !== 0) { 162 | throw new Gio.IOErrorEnum({ 163 | code: Gio.IOErrorEnum.FAILED, 164 | message: stderr 165 | ? stderr.trim() 166 | : `Command '${xprops}' failed with exit code ${status}`, 167 | }); 168 | } 169 | 170 | return stdout.trim(); 171 | } finally { 172 | if (cancelId > 0) cancellable.disconnect(cancelId); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /icons/gnome-mosaic-auto-on-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | GNOME Mosaic Symbolic Icon Theme 48 | 50 | 51 | 53 | 55 | 57 | 59 | 61 | 63 | 65 | 66 | 67 | 68 | GNOME Mosaic Symbolic Icon Theme 70 | 72 | 75 | 79 | 80 | 83 | 87 | 88 | 91 | 95 | 96 | 99 | 103 | 104 | 105 | 109 | 113 | 117 | 121 | 125 | 129 | 133 | 141 | 149 | 157 | 161 | 162 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | This document provides a guideline for testing and verifying the expected behaviors of the project. When a patch is ready for testing, the checklists may be copied and marked as they are proven to be working. 4 | 5 | ## Logs 6 | 7 | To begin watching logs, open a terminal with the following command: 8 | 9 | ```bash 10 | journalctl -o cat -n 0 -f "$(which gnome-shell)" | grep -v warning 11 | ``` 12 | 13 | Note that because the logs are from GNOME Shell, there will be messages from all installed extensions, and GNOME Shell itself. GNOME Mosaic is fairly chatty though, so the majority of the logs should be from GNOME Mosaic. GNOME Mosaic logs are usually prepended with `gnome-mosaic:`, but sometimes GNOME has internal errors and warnings surrounding those logs that could be useful for pointing to an issue that we can resolve in GNOME Mosaic. 14 | 15 | ## Checklists 16 | 17 | Tasks for a tester to verify when approving a patch. Use complex window layouts and at least two displays. Turn on active hint during testing. 18 | 19 | ## With tiling enabled 20 | 21 | ### Tiling 22 | 23 | - [ ] Super direction keys changes focus in the correct direction 24 | - [ ] Windows moved with the keyboard tile into place 25 | - [ ] Windows moved with the mouse tile into place 26 | - [ ] Windows swap with the keyboard (test with different size windows) 27 | - [ ] Windows can be resized with the keyboard (Test resizing four windows above, below, right, and left to ensure shortcut consistency) 28 | - [ ] Windows can be resized with the mouse 29 | - [ ] Minimizing a window detaches it from the tree and re-tiles remaining windows 30 | - [ ] Unminimizing a window re-tiles the window 31 | - [ ] Maximizing with the keyboard (`Super` + `M`) covers tiled windows 32 | - [ ] Unmaximizing with keyboard (`Super` + `M`) re-tiles into place 33 | - [ ] Maximizing with the mouse covers tiled windows 34 | - [ ] Unmaximizing with mouse re-tiles into place 35 | - [ ] Full-screening removes the active hint and full-screens on one display 36 | - [ ] Unfull-screening adds the active hint and re-tiles into place 37 | - [ ] Maximizing a YouTube video fills the screen and unmaximizing retiles the browser in place 38 | - [ ] VIM shortcuts work as direction keys 39 | - [ ] `Super` + `O` changes window orientation 40 | - [ ] `Super` + `G` floats and then re-tiles a window 41 | - [ ] Float a window with `Super` + `G`. It should be movable and resizeable in window management mode with keyboard keys 42 | - [ ] `Super` + `Q` Closes a window 43 | - [ ] Turn off auto-tiling. New windows launch floating. 44 | - [ ] Turn on auto-tiling. Windows automatically tile. 45 | - [ ] Disabling and enabling auto-tiling correctly handles minimized, maximized, fullscreen, floating, and non-floating windows (This test needs a better definition, steps, or to be separated out.) 46 | 47 | ### Workspaces 48 | 49 | - [ ] Windows can be moved to another workspace with the keyboard 50 | - [ ] Windows can be moved to another workspace with the mouse 51 | - [ ] Windows can be moved to workspaces between existing workspaces 52 | - [ ] Moving windows to another workspace re-tiled the previous and new workspace 53 | - [ ] Active hint is present on the new workspace and once the window is returned to its previous workspace 54 | - [ ] Floating windows move across workspaces 55 | - [ ] Remove windows from the 2nd worspace in a 3 workspace setup. The 3rd workspace becomes the 2nd workspace, and tiling is unaffected by the move. 56 | 57 | ### Displays 58 | 59 | - [ ] Windows move across displays in adjustment mode with direction keys 60 | - [ ] Windows move across displays with the mouse 61 | - [ ] Changing the primary display moves the top bar. Window heights adjust on all monitors for the new position. 62 | - [ ] Unplug a display - windows from the display retile on a new workspace on the remaining display 63 | - [ ] Plug an additional display into a laptop - windows and workspaces don't changes 64 | - [ ] NOTE: Add vertical monitor layout test 65 | 66 | ### Window Titles 67 | 68 | - [ ] Disabling window titles using global (GNOME Mosaic) option works for Shell Shortcuts, LibreOffice, etc. 69 | - [ ] Disabling window titles in Firefox works (Check debian and flatpak packages) 70 | 71 | ### Floating Exceptions 72 | 73 | - [ ] Add a window to floating exceptions-- it should float immediately. 74 | - [ ] Close and re-open the window-- it should float when opened. 75 | - [ ] Add an app to floating exceptions-- it should float immediately. 76 | - [ ] Close and re-open the app-- it should float when opened. 77 | 78 | ## With Tiling Disabled 79 | 80 | ### Tiling 81 | 82 | - [ ] Super direction keys changes focus in the correct direction 83 | - [ ] Windows can be moved with the keyboard 84 | - [ ] Windows can be moved with the mouse 85 | - [ ] Windows swap with the keyboard (test with different size windows) 86 | - [ ] Windows can be resized with the keyboard 87 | - [ ] Windows can be resized with the mouse 88 | - [ ] Windows can be half-tiled left and right with `Ctrl` + `Super` + `left`/`right` 89 | 90 | ### Displays 91 | 92 | - [ ] Windows move across displays in adjustment mode with directions keys 93 | - [ ] Windows move across displays with the mouse 94 | 95 | ### Miscellaneous 96 | 97 | - [ ] Close all windows-- no icons should be active in the GNOME launcher. 98 | - [ ] Open a window, enable tiling, stack the window, move to a different workspace, and disable tiling. The window should not become visible on the empty workspace. 99 | - [ ] With tiling still disabled, minimize the single window. The active hint should go away. 100 | - [ ] Maximize a window, then open another app with the Activities overview. The newly-opened app should be visible and focused. 101 | -------------------------------------------------------------------------------- /icons/gnome-mosaic-auto-off-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 41 | 44 | 45 | 47 | 48 | 50 | image/svg+xml 51 | 53 | GNOME Mosaic Symbolic Icon Theme 54 | 56 | 57 | 59 | 61 | 63 | 65 | 67 | 69 | 71 | 72 | 73 | 74 | GNOME Mosaic Symbolic Icon Theme 76 | 78 | 81 | 85 | 86 | 89 | 93 | 94 | 97 | 101 | 102 | 105 | 109 | 110 | 111 | 115 | 119 | 123 | 127 | 131 | 135 | 139 | 143 | 149 | 155 | 163 | 164 | -------------------------------------------------------------------------------- /src/prefs.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gtk from 'gi://Gtk'; 3 | import Gio from 'gi://Gio'; 4 | import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; 5 | 6 | import * as settings from './settings.js'; 7 | import {FocusPosition} from './focus.js'; 8 | 9 | export default class MosaicPreferences extends ExtensionPreferences { 10 | fillPreferencesWindow(window: any) { 11 | const extSettings = new settings.ExtensionSettings(); 12 | const gioSettings = extSettings.ext; 13 | 14 | const page = new Adw.PreferencesPage(); 15 | window.add(page); 16 | 17 | // Group: Appearance 18 | const appearanceGroup = new Adw.PreferencesGroup({ 19 | title: 'Appearance', 20 | }); 21 | page.add(appearanceGroup); 22 | 23 | // Show Window Titles 24 | const windowTitlesRow = new Adw.SwitchRow({ 25 | title: 'Show Window Titles', 26 | }); 27 | appearanceGroup.add(windowTitlesRow); 28 | gioSettings.bind( 29 | 'show-title', 30 | windowTitlesRow, 31 | 'active', 32 | Gio.SettingsBindFlags.DEFAULT 33 | ); 34 | 35 | // Show Indicator Panel 36 | const showIndicatorRow = new Adw.SwitchRow({ 37 | title: 'Show Indicator Panel', 38 | }); 39 | appearanceGroup.add(showIndicatorRow); 40 | gioSettings.bind( 41 | 'show-indicator', 42 | showIndicatorRow, 43 | 'active', 44 | Gio.SettingsBindFlags.DEFAULT 45 | ); 46 | 47 | // Show Minimize to Tray Windows 48 | const showSkipTaskbarRow = new Adw.SwitchRow({ 49 | title: 'Show Minimize to Tray Windows', 50 | }); 51 | appearanceGroup.add(showSkipTaskbarRow); 52 | gioSettings.bind( 53 | 'show-skip-taskbar', 54 | showSkipTaskbarRow, 55 | 'active', 56 | Gio.SettingsBindFlags.DEFAULT 57 | ); 58 | 59 | // Group: Behavior 60 | const behaviorGroup = new Adw.PreferencesGroup({ 61 | title: 'Behavior', 62 | }); 63 | page.add(behaviorGroup); 64 | 65 | // Snap to Grid 66 | const snapToGridRow = new Adw.SwitchRow({ 67 | title: 'Snap to Grid (Floating Mode)', 68 | }); 69 | behaviorGroup.add(snapToGridRow); 70 | gioSettings.bind( 71 | 'snap-to-grid', 72 | snapToGridRow, 73 | 'active', 74 | Gio.SettingsBindFlags.DEFAULT 75 | ); 76 | 77 | // Smart Gaps 78 | const smartGapsRow = new Adw.SwitchRow({ 79 | title: 'Smart Gaps', 80 | }); 81 | behaviorGroup.add(smartGapsRow); 82 | gioSettings.bind( 83 | 'smart-gaps', 84 | smartGapsRow, 85 | 'active', 86 | Gio.SettingsBindFlags.DEFAULT 87 | ); 88 | 89 | // Mouse Cursor Follows Active Window 90 | const mouseFollowsRow = new Adw.SwitchRow({ 91 | title: 'Mouse Cursor Follows Active Window', 92 | }); 93 | behaviorGroup.add(mouseFollowsRow); 94 | gioSettings.bind( 95 | 'mouse-cursor-follows-active-window', 96 | mouseFollowsRow, 97 | 'active', 98 | Gio.SettingsBindFlags.DEFAULT 99 | ); 100 | 101 | // Mouse Cursor Focus Position 102 | const focusPositionRow = new Adw.ComboRow({ 103 | title: 'Mouse Cursor Focus Position', 104 | model: new Gtk.StringList({ 105 | strings: Object.values(FocusPosition), 106 | }), 107 | }); 108 | behaviorGroup.add(focusPositionRow); 109 | gioSettings.bind( 110 | 'mouse-cursor-focus-location', 111 | focusPositionRow, 112 | 'selected', 113 | Gio.SettingsBindFlags.DEFAULT 114 | ); 115 | 116 | // Group: Layout 117 | const layoutGroup = new Adw.PreferencesGroup({ 118 | title: 'Layout', 119 | }); 120 | page.add(layoutGroup); 121 | 122 | // Active Hint Width 123 | const activeHintWidthRow = new Adw.SpinRow({ 124 | title: 'Active Hint Width', 125 | adjustment: new Gtk.Adjustment({ 126 | lower: 0, 127 | upper: 100, 128 | step_increment: 1, 129 | }), 130 | }); 131 | layoutGroup.add(activeHintWidthRow); 132 | gioSettings.bind( 133 | 'active-hint-border-width', 134 | activeHintWidthRow, 135 | 'value', 136 | Gio.SettingsBindFlags.DEFAULT 137 | ); 138 | 139 | // Gap Width 140 | const gapWidthRow = new Adw.SpinRow({ 141 | title: 'Gap Width', 142 | adjustment: new Gtk.Adjustment({ 143 | lower: 0, 144 | upper: 100, 145 | step_increment: 1, 146 | }), 147 | }); 148 | layoutGroup.add(gapWidthRow); 149 | // Bind both inner and outer gaps to this single control as per original logic 150 | // Original logic: if (inner == outer) show inner; on set, set both. 151 | // Here we bind to inner, and listen to change to set outer. 152 | gioSettings.bind( 153 | 'gap-inner', 154 | gapWidthRow, 155 | 'value', 156 | Gio.SettingsBindFlags.DEFAULT 157 | ); 158 | gapWidthRow.connect('notify::value', () => { 159 | gioSettings.set_uint('gap-outer', gapWidthRow.get_value()); 160 | }); 161 | 162 | // Group: Advanced 163 | const advancedGroup = new Adw.PreferencesGroup({ 164 | title: 'Advanced', 165 | }); 166 | page.add(advancedGroup); 167 | 168 | // Log Level 169 | // LOG_LEVELS is numeric enum 0..4 170 | // We need to map names. 171 | const logLevels = ['OFF', 'ERROR', 'WARN', 'INFO', 'DEBUG']; 172 | const logLevelRow = new Adw.ComboRow({ 173 | title: 'Log Level', 174 | model: new Gtk.StringList({ 175 | strings: logLevels, 176 | }), 177 | }); 178 | advancedGroup.add(logLevelRow); 179 | gioSettings.bind( 180 | 'log-level', 181 | logLevelRow, 182 | 'selected', 183 | Gio.SettingsBindFlags.DEFAULT 184 | ); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as result from './result.js'; 2 | import * as error from './error.js'; 3 | import * as log from './log.js'; 4 | 5 | import Gio from 'gi://Gio'; 6 | import GLib from 'gi://GLib'; 7 | import GObject from 'gi://GObject'; 8 | import Meta from 'gi://Meta'; 9 | import * as Config from 'resource:///org/gnome/shell/misc/config.js'; 10 | const {Ok, Err} = result; 11 | const {Error} = error; 12 | 13 | export function is_wayland(): boolean { 14 | return Meta.is_wayland_compositor(); 15 | } 16 | 17 | export function block_signal(object: GObject.Object, signal: SignalID) { 18 | GObject.signal_handler_block(object, signal); 19 | } 20 | 21 | export function unblock_signal(object: GObject.Object, signal: SignalID) { 22 | GObject.signal_handler_unblock(object, signal); 23 | } 24 | 25 | export function read_to_string( 26 | path: string 27 | ): result.Result { 28 | const file = Gio.File.new_for_path(path); 29 | try { 30 | const [ok, contents] = file.load_contents(null); 31 | if (ok) { 32 | return Ok(new TextDecoder().decode(contents)); 33 | } else { 34 | return Err(new Error(`failed to load contents of ${path}`)); 35 | } 36 | } catch (e) { 37 | return Err( 38 | new Error(String(e)).context(`failed to load contents of ${path}`) 39 | ); 40 | } 41 | } 42 | 43 | export function source_remove(id: SignalID): boolean { 44 | return GLib.source_remove(id) as any; 45 | } 46 | 47 | export function exists(path: string): boolean { 48 | return Gio.File.new_for_path(path).query_exists(null); 49 | } 50 | 51 | export function get_accent_colors(settings: any): [string, string] { 52 | const [major] = Config.PACKAGE_VERSION.split('.').map((s: string) => 53 | Number(s) 54 | ); 55 | var background: string; 56 | var color: string; 57 | var override_bg_color = settings.gnome_override_accent_color(); 58 | 59 | if (major > 46 && override_bg_color) { 60 | background = override_bg_color; 61 | const background_rgba = hex_to_rgba(background); 62 | color = is_dark(background_rgba) ? 'white' : 'black'; 63 | } else if (major > 46) { 64 | background = '-st-accent-color'; 65 | color = '-st-fg-accent-color'; 66 | } else { 67 | // setting for GNOME 44-46 68 | background = settings.gnome_legacy_accent_color(); 69 | const background_rgba = hex_to_rgba(background); 70 | color = is_dark(background_rgba) ? 'white' : 'black'; 71 | } 72 | 73 | return [background, color]; 74 | } 75 | 76 | /** 77 | * Parse the current background color's darkness 78 | * https://stackoverflow.com/a/41491220 - the advanced solution 79 | * @param color - the RGBA or hex string value 80 | */ 81 | export function is_dark(color: string): boolean { 82 | // 'rgba(251, 184, 108, 1)' - pop orange! 83 | let color_val = ''; 84 | let r = 255; 85 | let g = 255; 86 | let b = 255; 87 | 88 | // handle rgba(255,255,255,1.0) format 89 | if (color.indexOf('rgb') >= 0) { 90 | // starts with parsed value from Gdk.RGBA 91 | color = color 92 | .replace('rgba', 'rgb') 93 | .replace('rgb(', '') 94 | .replace(')', ''); // make it 255, 255, 255, 1 95 | // log.debug(`util color: ${color}`); 96 | let colors = color.split(','); 97 | r = parseInt(colors[0].trim()); 98 | g = parseInt(colors[1].trim()); 99 | b = parseInt(colors[2].trim()); 100 | } else if (color.charAt(0) === '#') { 101 | color_val = color.substring(1, 7); 102 | r = parseInt(color_val.substring(0, 2), 16); // hexToR 103 | g = parseInt(color_val.substring(2, 4), 16); // hexToG 104 | b = parseInt(color_val.substring(4, 6), 16); // hexToB 105 | } 106 | 107 | let uicolors = [r / 255, g / 255, b / 255]; 108 | let c = uicolors.map(col => { 109 | if (col <= 0.03928) { 110 | return col / 12.92; 111 | } 112 | return Math.pow((col + 0.055) / 1.055, 2.4); 113 | }); 114 | let L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; 115 | return L <= 0.179; 116 | } 117 | 118 | export function hex_to_rgba(hex: string, alphaOverride?: number): string { 119 | hex = hex.replace(/^#/, ''); 120 | 121 | // Parse shorthand hex like #RGB or #RGBA 122 | if (hex.length === 3 || hex.length === 4) { 123 | hex = hex 124 | .split('') 125 | .map(c => c + c) 126 | .join(''); 127 | } 128 | 129 | if (hex.length !== 6 && hex.length !== 8) { 130 | throw new Error(`Invalid hex color: ${hex}`); 131 | } 132 | 133 | const r = parseInt(hex.slice(0, 2), 16); 134 | const g = parseInt(hex.slice(2, 4), 16); 135 | const b = parseInt(hex.slice(4, 6), 16); 136 | const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1; 137 | 138 | const finalAlpha = typeof alphaOverride === 'number' ? alphaOverride : a; 139 | 140 | return `rgba(${r}, ${g}, ${b}, ${finalAlpha.toFixed(3)})`; 141 | } 142 | 143 | /** Utility function for running a process in the background and fetching its standard output as a string. */ 144 | export function async_process( 145 | argv: Array, 146 | input = null, 147 | cancellable: null | any = null 148 | ): Promise { 149 | let flags = Gio.SubprocessFlags.STDOUT_PIPE; 150 | 151 | if (input !== null) flags |= Gio.SubprocessFlags.STDIN_PIPE; 152 | 153 | let proc = new Gio.Subprocess({argv, flags}); 154 | proc.init(cancellable); 155 | 156 | proc.wait_async(null, (source: any, res: any) => { 157 | source.wait_finish(res); 158 | if (cancellable !== null) { 159 | cancellable.cancel(); 160 | } 161 | }); 162 | 163 | return new Promise((resolve, reject) => { 164 | proc.communicate_utf8_async( 165 | input, 166 | cancellable, 167 | (proc: any, res: any) => { 168 | try { 169 | let bytes = proc.communicate_utf8_finish(res)[1]; 170 | resolve(bytes.toString()); 171 | } catch (e) { 172 | reject(e); 173 | } 174 | } 175 | ); 176 | }); 177 | } 178 | 179 | export type AsyncIPC = { 180 | child: any; 181 | stdout: any; 182 | stdin: any; 183 | cancellable: any; 184 | }; 185 | 186 | export function async_process_ipc( 187 | argv: Array, 188 | callback?: () => void 189 | ): AsyncIPC | null { 190 | const {SubprocessLauncher, SubprocessFlags} = Gio; 191 | 192 | const launcher = new SubprocessLauncher({ 193 | flags: SubprocessFlags.STDIN_PIPE | SubprocessFlags.STDOUT_PIPE, 194 | }); 195 | 196 | let child: any; 197 | 198 | let cancellable = new Gio.Cancellable(); 199 | 200 | try { 201 | child = launcher.spawnv(argv); 202 | } catch (why) { 203 | log.error(`failed to spawn ${argv}: ${why}`); 204 | return null; 205 | } 206 | 207 | let stdin = new Gio.DataOutputStream({ 208 | base_stream: child.get_stdin_pipe(), 209 | close_base_stream: true, 210 | }); 211 | 212 | let stdout = new Gio.DataInputStream({ 213 | base_stream: child.get_stdout_pipe(), 214 | close_base_stream: true, 215 | }); 216 | 217 | child.wait_async(null, (source: any, res: any) => { 218 | source.wait_finish(res); 219 | cancellable.cancel(); 220 | if (callback) callback(); 221 | }); 222 | 223 | return {child, stdin, stdout, cancellable}; 224 | } 225 | 226 | export function map_eq(map1: Map, map2: Map) { 227 | if (map1.size !== map2.size) { 228 | return false; 229 | } 230 | 231 | let cmp; 232 | 233 | for (let [key, val] of map1) { 234 | cmp = map2.get(key); 235 | if (cmp !== val || (cmp === undefined && !map2.has(key))) { 236 | return false; 237 | } 238 | } 239 | 240 | return true; 241 | } 242 | 243 | export function os_release(): null | string { 244 | const [ok, contents] = GLib.file_get_contents('/etc/os-release'); 245 | if (!ok) return null; 246 | 247 | for (const line of contents.split('\n')) { 248 | if (line.startsWith('VERSION_ID')) { 249 | return line.split('"')[1]; 250 | } 251 | } 252 | 253 | return null; 254 | } 255 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | // const Me = imports.misc.extensionUtils.getCurrentExtension(); 2 | import Gio from 'gi://Gio'; 3 | import {get_current_path} from './paths.js'; 4 | 5 | const DARK = ['dark', 'adapta', 'plata', 'dracula']; 6 | 7 | interface Settings extends GObject.Object { 8 | get_boolean(key: string): boolean; 9 | set_boolean(key: string, value: boolean): void; 10 | 11 | get_uint(key: string): number; 12 | set_uint(key: string, value: number): void; 13 | 14 | get_string(key: string): string; 15 | set_string(key: string, value: string): void; 16 | 17 | bind( 18 | key: string, 19 | object: GObject.Object, 20 | property: string, 21 | flags: any 22 | ): void; 23 | } 24 | 25 | function settings_new_id(schema_id: string): Settings | null { 26 | try { 27 | return new Gio.Settings({schema_id}); 28 | } catch (why) { 29 | if (schema_id !== 'org.gnome.shell.extensions.user-theme') { 30 | // global.log(`failed to get settings for ${schema_id}: ${why}`); 31 | } 32 | 33 | return null; 34 | } 35 | } 36 | 37 | function settings_new_schema(schema: string): Settings { 38 | const GioSSS = Gio.SettingsSchemaSource; 39 | const schemaDir = 40 | Gio.File.new_for_path(get_current_path()).get_child('schemas'); 41 | 42 | let schemaSource = schemaDir.query_exists(null) 43 | ? GioSSS.new_from_directory( 44 | schemaDir.get_path(), 45 | GioSSS.get_default(), 46 | false 47 | ) 48 | : GioSSS.get_default(); 49 | 50 | const schemaObj = schemaSource.lookup(schema, true); 51 | 52 | if (!schemaObj) { 53 | throw new Error( 54 | 'Schema ' + 55 | schema + 56 | ' could not be found for extension gnome-mosaic' + 57 | '. Please check your installation.' 58 | ); 59 | } 60 | 61 | return new Gio.Settings({settings_schema: schemaObj}); 62 | } 63 | 64 | const ACTIVE_HINT = 'active-hint'; 65 | const ACTIVE_HINT_BORDER_WIDTH = 'active-hint-border-width'; 66 | const GNOME_LEGACY_ACCENT_COLOR = 'gnome-legacy-accent-color'; 67 | const GNOME_OVERRIDE_ACCENT_COLOR = 'gnome-override-accent-color'; 68 | const COLUMN_SIZE = 'column-size'; 69 | const EDGE_TILING = 'edge-tiling'; 70 | const GAP_INNER = 'gap-inner'; 71 | const GAP_OUTER = 'gap-outer'; 72 | const ROW_SIZE = 'row-size'; 73 | const SHOW_TITLE = 'show-title'; 74 | const SMART_GAPS = 'smart-gaps'; 75 | const SNAP_TO_GRID = 'snap-to-grid'; 76 | const TILE_BY_DEFAULT = 'tile-by-default'; 77 | const LOG_LEVEL = 'log-level'; 78 | const SHOW_SKIPTASKBAR = 'show-skip-taskbar'; 79 | const MOUSE_CURSOR_FOLLOWS_ACTIVE_WINDOW = 'mouse-cursor-follows-active-window'; 80 | const MOUSE_CURSOR_FOCUS_LOCATION = 'mouse-cursor-focus-location'; 81 | const MAX_WINDOW_WIDTH = 'max-window-width'; 82 | const SHOW_INDICATOR = 'show-indicator'; 83 | 84 | export class ExtensionSettings { 85 | ext: Settings = settings_new_schema( 86 | 'org.gnome.shell.extensions.gnome-mosaic' 87 | ); 88 | int: Settings | null = settings_new_id('org.gnome.desktop.interface'); 89 | mutter: Settings | null = settings_new_id('org.gnome.mutter'); 90 | shell: Settings | null = settings_new_id( 91 | 'org.gnome.shell.extensions.user-theme' 92 | ); 93 | 94 | // Getters 95 | 96 | active_hint(): boolean { 97 | return this.ext.get_boolean(ACTIVE_HINT); 98 | } 99 | 100 | active_hint_border_width(): number { 101 | return this.ext.get_uint(ACTIVE_HINT_BORDER_WIDTH); 102 | } 103 | 104 | gnome_legacy_accent_color(): string { 105 | return this.ext.get_string(GNOME_LEGACY_ACCENT_COLOR); 106 | } 107 | 108 | gnome_override_accent_color(): string { 109 | return this.ext.get_string(GNOME_OVERRIDE_ACCENT_COLOR); 110 | } 111 | 112 | column_size(): number { 113 | return this.ext.get_uint(COLUMN_SIZE); 114 | } 115 | 116 | dynamic_workspaces(): boolean { 117 | return this.mutter 118 | ? this.mutter.get_boolean('dynamic-workspaces') 119 | : false; 120 | } 121 | 122 | gap_inner(): number { 123 | return this.ext.get_uint(GAP_INNER); 124 | } 125 | 126 | gap_outer(): number { 127 | return this.ext.get_uint(GAP_OUTER); 128 | } 129 | 130 | theme(): string { 131 | return this.shell 132 | ? this.shell.get_string('name') 133 | : this.int 134 | ? this.int.get_string('gtk-theme') 135 | : 'Adwaita'; 136 | } 137 | 138 | is_dark(): boolean { 139 | const theme = this.theme().toLowerCase(); 140 | return DARK.some(dark => theme.includes(dark)); 141 | } 142 | 143 | is_high_contrast(): boolean { 144 | return this.theme().toLowerCase() === 'highcontrast'; 145 | } 146 | 147 | row_size(): number { 148 | return this.ext.get_uint(ROW_SIZE); 149 | } 150 | 151 | show_title(): boolean { 152 | return this.ext.get_boolean(SHOW_TITLE); 153 | } 154 | 155 | smart_gaps(): boolean { 156 | return this.ext.get_boolean(SMART_GAPS); 157 | } 158 | 159 | snap_to_grid(): boolean { 160 | return this.ext.get_boolean(SNAP_TO_GRID); 161 | } 162 | 163 | tile_by_default(): boolean { 164 | return this.ext.get_boolean(TILE_BY_DEFAULT); 165 | } 166 | 167 | workspaces_only_on_primary(): boolean { 168 | return this.mutter 169 | ? this.mutter.get_boolean('workspaces-only-on-primary') 170 | : false; 171 | } 172 | 173 | log_level(): number { 174 | return this.ext.get_uint(LOG_LEVEL); 175 | } 176 | 177 | show_skiptaskbar(): boolean { 178 | return this.ext.get_boolean(SHOW_SKIPTASKBAR); 179 | } 180 | 181 | mouse_cursor_follows_active_window(): boolean { 182 | return this.ext.get_boolean(MOUSE_CURSOR_FOLLOWS_ACTIVE_WINDOW); 183 | } 184 | 185 | mouse_cursor_focus_location(): number { 186 | return this.ext.get_uint(MOUSE_CURSOR_FOCUS_LOCATION); 187 | } 188 | 189 | max_window_width(): number { 190 | return this.ext.get_uint(MAX_WINDOW_WIDTH); 191 | } 192 | 193 | show_indicator(): boolean { 194 | return this.ext.get_boolean(SHOW_INDICATOR); 195 | } 196 | 197 | // Setters 198 | 199 | set_active_hint(set: boolean) { 200 | this.ext.set_boolean(ACTIVE_HINT, set); 201 | } 202 | 203 | set_active_hint_border_width(set: number) { 204 | this.ext.set_uint(ACTIVE_HINT_BORDER_WIDTH, set); 205 | } 206 | 207 | set_gnome_legacy_accent_color(color: string) { 208 | this.ext.set_string(GNOME_LEGACY_ACCENT_COLOR, color); 209 | } 210 | 211 | set_gnome_override_accent_color(color: string) { 212 | this.ext.set_string(GNOME_OVERRIDE_ACCENT_COLOR, color); 213 | } 214 | 215 | set_column_size(size: number) { 216 | this.ext.set_uint(COLUMN_SIZE, size); 217 | } 218 | 219 | set_edge_tiling(enable: boolean) { 220 | this.mutter?.set_boolean(EDGE_TILING, enable); 221 | } 222 | 223 | set_gap_inner(gap: number) { 224 | this.ext.set_uint(GAP_INNER, gap); 225 | } 226 | 227 | set_gap_outer(gap: number) { 228 | this.ext.set_uint(GAP_OUTER, gap); 229 | } 230 | 231 | set_row_size(size: number) { 232 | this.ext.set_uint(ROW_SIZE, size); 233 | } 234 | 235 | set_show_title(set: boolean) { 236 | this.ext.set_boolean(SHOW_TITLE, set); 237 | } 238 | 239 | set_smart_gaps(set: boolean) { 240 | this.ext.set_boolean(SMART_GAPS, set); 241 | } 242 | 243 | set_snap_to_grid(set: boolean) { 244 | this.ext.set_boolean(SNAP_TO_GRID, set); 245 | } 246 | 247 | set_tile_by_default(set: boolean) { 248 | this.ext.set_boolean(TILE_BY_DEFAULT, set); 249 | } 250 | 251 | set_log_level(set: number) { 252 | this.ext.set_uint(LOG_LEVEL, set); 253 | } 254 | 255 | set_show_skiptaskbar(set: boolean) { 256 | this.ext.set_boolean(SHOW_SKIPTASKBAR, set); 257 | } 258 | 259 | set_mouse_cursor_follows_active_window(set: boolean) { 260 | this.ext.set_boolean(MOUSE_CURSOR_FOLLOWS_ACTIVE_WINDOW, set); 261 | } 262 | 263 | set_mouse_cursor_focus_location(set: number) { 264 | this.ext.set_uint(MOUSE_CURSOR_FOCUS_LOCATION, set); 265 | } 266 | 267 | set_max_window_width(set: number) { 268 | this.ext.set_uint(MAX_WINDOW_WIDTH, set); 269 | } 270 | 271 | set_show_indicator(set: boolean) { 272 | this.ext.set_boolean(SHOW_INDICATOR, set); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/ecs.ts: -------------------------------------------------------------------------------- 1 | /// A generational entity ID 2 | /// 3 | /// Solves the ABA problem by tagging indexes with generations. The generation is used to 4 | /// determine if an entity is the same entity as the one which previously owned an 5 | /// assigned component at a given index. 6 | /// 7 | /// # Implementation Notes 8 | /// 9 | /// - The first 32-bit integer is the index. 10 | /// - The second 32-bit integer is the generation. 11 | 12 | import {Executor} from './executor.js'; 13 | 14 | export type Entity = [number, number]; 15 | 16 | export function entity_eq(a: Entity, b: Entity): boolean { 17 | return a[0] == b[0] && b[1] == b[1]; 18 | } 19 | 20 | export function entity_new(pos: number, gen: number): Entity { 21 | return [pos, gen]; 22 | } 23 | 24 | /// Storages hold components of a specific type, and define these associations on entities 25 | /// 26 | /// # Implementation Notes 27 | /// 28 | /// Consists of an `Array` which uses the entity ID as the index into that array. Each 29 | /// value in the array is an array which contains the entity's generation, and the 30 | /// component which was assigned to it. The generation is used to determine if an 31 | /// assigned component is stale on component lookup. 32 | export class Storage { 33 | private store: Array<[number, T] | null>; 34 | 35 | constructor() { 36 | this.store = new Array(); 37 | } 38 | 39 | toJSON() { 40 | return { 41 | store: this.store, 42 | }; 43 | } 44 | 45 | static fromJSON( 46 | store: Array<[number, any]>, 47 | revive: (obj: any) => T 48 | ): Storage { 49 | const storage = new Storage(); 50 | for (let i = 0; i < store.length; i++) { 51 | let entry = store[i]; 52 | if (entry !== null) { 53 | const [gen, component] = entry; 54 | storage.insert([i, gen], revive(component)); 55 | } else { 56 | storage.store[i] = null; 57 | } 58 | } 59 | return storage; 60 | } 61 | 62 | toString(): string { 63 | return this.store.toString(); 64 | } 65 | 66 | /// Private method for iterating across allocated slots 67 | *_iter(): IterableIterator<[number, [number, T]]> { 68 | let idx = 0; 69 | for (const slot of this.store) { 70 | if (slot) yield [idx, slot]; 71 | idx += 1; 72 | } 73 | } 74 | 75 | /// Iterates across each stored component, and their entities 76 | *iter(): IterableIterator<[Entity, T]> { 77 | for (const [idx, [gen, value]] of this._iter()) { 78 | yield [entity_new(idx, gen), value]; 79 | } 80 | } 81 | 82 | /// Finds values with the matching component 83 | *find(func: (value: T) => boolean): IterableIterator { 84 | for (const [idx, [gen, value]] of this._iter()) { 85 | if (func(value)) yield entity_new(idx, gen); 86 | } 87 | } 88 | 89 | /// Iterates across each stored component 90 | *values(): IterableIterator { 91 | for (const [, [, value]] of this._iter()) { 92 | yield value; 93 | } 94 | } 95 | 96 | /** 97 | * Checks if the component associated with this entity exists 98 | * 99 | * @param {Entity} entity 100 | */ 101 | contains(entity: Entity): boolean { 102 | return this.get(entity) != null; 103 | } 104 | 105 | /// Fetches the component for this entity, if it exists 106 | get(entity: Entity): T | null { 107 | let [id, gen] = entity; 108 | const val = this.store[id]; 109 | return val && val[0] == gen ? val[1] : null; 110 | } 111 | 112 | /// Fetches the component, and initializing it if it is missing 113 | get_or(entity: Entity, init: () => T): T { 114 | let value = this.get(entity); 115 | 116 | if (!value) { 117 | value = init(); 118 | this.insert(entity, value); 119 | } 120 | 121 | return value; 122 | } 123 | 124 | /// Assigns component to an entity 125 | insert(entity: Entity, component: T) { 126 | let [id, gen] = entity; 127 | 128 | let length = this.store.length; 129 | if (length >= id) { 130 | this.store.fill(null, length, id); 131 | } 132 | 133 | this.store[id] = [gen, component]; 134 | } 135 | 136 | /** Check if the storage is empty */ 137 | is_empty(): boolean { 138 | for (const slot of this.store) if (slot) return false; 139 | return true; 140 | } 141 | 142 | /// Removes the component for this entity, if it exists 143 | remove(entity: Entity): T | null { 144 | const comp = this.get(entity); 145 | if (comp) { 146 | this.store[entity[0]] = null; 147 | } 148 | return comp; 149 | } 150 | 151 | /** 152 | * Takes the component associated with the `entity`, and passes it into the `func` callback 153 | * 154 | * @param {Entity} entity 155 | * @param {function} func 156 | */ 157 | take_with(entity: Entity, func: (component: T) => X): X | null { 158 | const component = this.remove(entity); 159 | return component ? func(component) : null; 160 | } 161 | 162 | /// Apply a function to the component when it exists 163 | with(entity: Entity, func: (component: T) => X): X | null { 164 | const component = this.get(entity); 165 | return component ? func(component) : null; 166 | } 167 | } 168 | 169 | /// The world maintains all of the entities, which have their components associated in storages 170 | /// 171 | /// # Implementation Notes 172 | /// 173 | /// This implementation consists of: 174 | /// 175 | /// - An array for storing entities 176 | /// - An array for storing a list of registered storages 177 | /// - An array for containing a list of free slots to allocate 178 | /// - An array for storing tags associated with an entity 179 | export class World { 180 | entities_: Array; 181 | storages: Array>; 182 | tags_: Array; 183 | free_slots: Array; 184 | 185 | constructor( 186 | entities?: Array, 187 | storages?: Array>, 188 | tags?: Array, 189 | free_slots?: Array 190 | ) { 191 | this.entities_ = entities ?? new Array(); 192 | this.storages = storages ?? new Array(); 193 | this.tags_ = tags ?? new Array(); 194 | this.free_slots = free_slots ?? new Array(); 195 | } 196 | 197 | /// The total capacity of the entity array 198 | get capacity(): number { 199 | return this.entities_.length; 200 | } 201 | 202 | /// The number of unallocated entity slots 203 | get free(): number { 204 | return this.free_slots.length; 205 | } 206 | 207 | /// The number of allocated entities 208 | get length(): number { 209 | return this.capacity - this.free; 210 | } 211 | 212 | /// Fetches tags associated with an entity 213 | /// 214 | /// Tags are essentially a dense set of small components 215 | tags(entity: Entity): any { 216 | return this.tags_[entity[0]]; 217 | } 218 | 219 | /// Iterates across entities in the world 220 | *entities(): IterableIterator { 221 | for (const entity of this.entities_.values()) { 222 | if (!(this.free_slots.indexOf(entity[0]) > -1)) yield entity; 223 | } 224 | } 225 | 226 | /// Create a new entity in the world 227 | /// 228 | /// Find the first available slot, and increment the generation. 229 | create_entity(): Entity { 230 | let slot = this.free_slots.pop(); 231 | 232 | if (slot) { 233 | var entity = this.entities_[slot]; 234 | entity[1] += 1; 235 | } else { 236 | var entity = entity_new(this.capacity, 0); 237 | this.entities_.push(entity); 238 | this.tags_.push(new Set()); 239 | } 240 | 241 | return entity; 242 | } 243 | 244 | /// Deletes an entity from the world 245 | /// 246 | /// Sets the `id` of the entity to `null`, thus marking its slot as unused. 247 | delete_entity(entity: Entity) { 248 | this.tags(entity).clear(); 249 | for (const storage of this.storages) { 250 | storage.remove(entity); 251 | } 252 | 253 | this.free_slots.push(entity[0]); 254 | } 255 | 256 | /// Adds a new tag to the given entity 257 | add_tag(entity: Entity, tag: any) { 258 | this.tags(entity).add(tag); 259 | } 260 | 261 | /// Returns `true` if this tag exists for the given entity 262 | contains_tag(entity: Entity, tag: any): boolean { 263 | return this.tags(entity).has(tag); 264 | } 265 | 266 | /// Deletes a tag from the given entity 267 | delete_tag(entity: Entity, tag: any) { 268 | this.tags(entity).delete(tag); 269 | } 270 | 271 | /// Registers a new component storage for our world 272 | /// 273 | /// This will be used to easily remove components when deleting an entity. 274 | register_storage(): Storage { 275 | let storage = new Storage(); 276 | this.storages.push(storage); 277 | return storage; 278 | } 279 | 280 | /// Unregisters an old component storage from our world 281 | unregister_storage(storage: Storage) { 282 | let matched = this.storages.indexOf(storage); 283 | if (matched) { 284 | swap_remove(this.storages, matched); 285 | } 286 | } 287 | } 288 | 289 | function swap_remove(array: Array, index: number): T | undefined { 290 | array[index] = array[array.length - 1]; 291 | return array.pop(); 292 | } 293 | 294 | /** A system registers events, and handles their execution. 295 | * 296 | * An executor must be provided for registering events onto. 297 | * 298 | */ 299 | export class System extends World { 300 | #executor: Executor; 301 | 302 | constructor(executor: Executor) { 303 | super(); 304 | 305 | this.#executor = executor; 306 | } 307 | 308 | /** Registers an event to be executed in the event loop */ 309 | register(event: T): void { 310 | this.#executor.wake(this, event); 311 | } 312 | 313 | /** Executs an event on the system */ 314 | run(_event: T): void {} 315 | } 316 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 |
7 | 8 | 9 |
10 | 11 |

12 | Mosaic 13 |
14 | Mosaic 15 |

16 | 17 | Originally forked from [pop-shell](https://github.com/pop-os/shell), Mosaic is a keyboard-driven layer for GNOME Shell that was which allows for quick and sensible navigation and management of windows. It is an opinionated GNOME extension that aims for simple and intuitive window management as well as minimal configuration and sane defaults. 18 | 19 | With pop-shell as a starting point, this project is intended to implement mosaic-style window management as defined in GNOME's [Rethinking Window Management blog post](https://blogs.gnome.org/tbernard/2023/07/26/rethinking-window-management/). These changes are not yet implemented, but are being actively developed. 20 | 21 | [![](./screenshot.png)](https://raw.githubusercontent.com/jardon/gnome-mosaic/main/screenshot.png) 22 | 23 | ## Installation 24 | 25 | GNU Make and TypeScript are also required to build the project. 26 | 27 | Proper functionality of the shell requires modifying GNOME's default keyboard shortcuts. For a local installation, run `make local-install`. 28 | 29 | If you want to uninstall the extension, you may invoke `make uninstall`, and then open the "Keyboard Shortcuts" panel in GNOME Settings to select the "Reset All.." button in the header bar. 30 | 31 | > [!Important] 32 | > If you are packaging for your Linux distribution, many features in Mosaic will not work out of the box because they require changes to GNOME's default keyboard shortcuts. A local install is necessary if you aren't packaging your GNOME session with these default keyboard shortcuts unset or changed. 33 | 34 | ## Shared Features 35 | 36 | Features that are shared between floating and auto-tiling modes. 37 | 38 | ### Directional Keys 39 | 40 | These are key to many of the shortcuts utilized by tiling window managers. This document will henceforth refer to these keys as ``, which default to the following keys: 41 | 42 | | Direction | Keys | 43 | | --------- | ------------------- | 44 | | Left | `Left Arrow` / `h` | 45 | | Down | `Down Arrow` / `j` | 46 | | Up | `Up Arrow` / `k` | 47 | | Right | `Right Arrow` / `l` | 48 | 49 | ### Overridden GNOME Shortcuts 50 | 51 | | Shortcut | Action | 52 | | --------------- | --------------------------- | 53 | | `Super` + `q` | Close window | 54 | | `Super` + `m` | Maximize the focused window | 55 | | `Super` + `,` | Minimize the focused window | 56 | | `Super` + `Esc` | Lock screen | 57 | | `Super` + `f` | Files | 58 | | `Super` + `e` | Email | 59 | | `Super` + `b` | Web Browser | 60 | | `Super` + `t` | Terminal | 61 | 62 | ### Window Management Mode 63 | 64 | This mode is activated with `Super` + `Return`. 65 | 66 | Window management mode activates additional keyboard control over the size and location of the currently-focused window. The behavior of this mode changes slightly based on whether you are in auto-tile mode, or in the default floating mode. In the default mode, an overlay is displayed snapped to a grid, which represents a possible future location and size of your focused window. This behavior changes slightly in auto-tiling mode, where resizes are performed immediately and overlays are only shown when swapping windows. 67 | 68 | Activating this enables the following behaviors: 69 | 70 | | Shortcut | Action | 71 | | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | 72 | | `` | In default mode, this will move the displayed overlay around based on a grid.
In auto-tile mode, this will resize the window. | 73 | | `Shift` + `` | In default mode, this will resize the overlay.
In auto-tile mode, this will do nothing. | 74 | | `Ctrl` + `` | Selects a window in the given direction of the overlay. When `Return` is pressed, window positions will be swapped. | 75 | | `Shift` + `Ctrl` + `` | In auto-tile mode, this resizes in the opposite direction. | 76 | | `O` | Toggles between horizontal and vertical tiling in auto-tile mode. | 77 | | `~` | Toggles between floating and tiling in auto-tile mode. | 78 | | `Return` | Applies the changes that have been requested. | 79 | | `Esc` | Cancels any changes that were requested. | 80 | 81 | ### Window Focus Switching 82 | 83 | When not in window management mode, pressing `Super` + `` will shift window focus to a window in the given direction. This is calculated based on the distance between the center of the side of the focused window that the window is being shifted from, and the opposite side of windows surrounding it. 84 | 85 | Switching focus to the left will calculate from the center of the east side of the focused window to the center of the west side of all other windows. The window with the least distance is the window we pick. 86 | 87 | ### Inner and Outer Gaps 88 | 89 | Gaps improve the aesthetics of tiled windows and make it easier to grab the edge of a specific window. We've decided to add support for inner and outer gaps, and made these settings configurable in the extension's popup menu. 90 | 91 | ### Hiding Window Title Bars 92 | 93 | Windows with server-side decorations may have their title bars completely hidden, resulting in additional screen real estate for your applications, and a visually cleaner environment. This feature can be toggled in the extension's popup menu. Windows can be moved with the mouse by holding `Super` when clicking and dragging a window to another location, or using the keyboard shortcuts native to gnome-mosaic. Windows may be closed by pressing `Super` + `Q`, and maximized with `Super` + `M`. 94 | 95 | ## Floating Mode 96 | 97 | This is the default mode of Mosaic, which combines traditional floating window management, with optional tiling window management features. 98 | 99 | ### Display Grid 100 | 101 | In this mode, displays are split into a grid of columns and rows. When entering tile mode, windows are snapped to this grid as they are placed. The number of rows and columns are configurable in the extension's popup menu in the panel. 102 | 103 | ### Snap-to-Grid 104 | 105 | An optional feature to improve your tiling experience is the ability to snap windows to the grid when using your mouse to move and resize them. This provides the same precision as entering window management mode to position a window with your keyboard, but with the convenience and familiarity of a mouse. This feature can be enabled through the extension's popup menu. 106 | 107 | ## Tiling Mode 108 | 109 | Disabled by default, this mode manages windows using a tree-based tiling window manager. Similar to i3, each node of the tree represents two branches. A branch may be a window, a fork containing more branches, or a stack that contains many windows. Each branch represents a rectangular area of space on the screen, and can be subdivided by creating more branches inside of a branch. As windows are created, they are assigned to the window or stack that is actively focused, which creates a new fork on a window, or attaches the window to the focused stack. As windows are destroyed, the opposite is performed to compress the tree and rearrange windows to their new dimensions. 110 | 111 | ### Keyboard Shortcuts 112 | 113 | | Shortcut | Action | 114 | | ------------- | ------------------------------------------------------ | 115 | | `Super` + `O` | Toggles the orientation of a fork's tiling orientation | 116 | | `Super` + `G` | Toggles a window between floating and tiling. | 117 | | `Super` + `R` | Enter window resizing mode | 118 | 119 | ### Customizing the Floating Window List 120 | 121 | There is file `$XDG_CONFIG_HOME/gnome-mosaic/config.json` where you can add the following structure: 122 | 123 | ```json 124 | { 125 | "class": "", 126 | "title": "" 127 | } 128 | ``` 129 | 130 | For example, doing `xprop` on GNOME Settings (or GNOME Control Center), the WM_CLASS values are `gnome-control-center` and `Gnome-control-center`. Use the second value (Gnome-control-center), which gnome-mosaic will read. The `title` field is optional. 131 | 132 | After applying changes in `config.json`, you can reload the tiling if it doesn't work the first time. 133 | 134 | ## Developers 135 | 136 | Due to the risky nature of plain JavaScript, this GNOME Shell extension is written in [TypeScript](https://www.typescriptlang.org/). In addition to supplying static type-checking and self-documenting classes and interfaces, it allows us to write modern JavaScript syntax whilst supporting the generation of code for older targets. 137 | 138 | Please install the following as dependencies when developing: 139 | 140 | - [`Node.js`](https://nodejs.org/en/) LTS+ (v12+) 141 | - Latest `npm` (comes with NodeJS) 142 | - `npm install typescript@latest` 143 | 144 | While working on the shell, you can recompile, reconfigure, reinstall, and restart GNOME Shell with logging with `make debug`. Note that this only works reliably in X11 sessions, since Wayland will exit to the login screen on restarting the shell. 145 | 146 | ## License 147 | 148 | Licensed under the GNU General Public License, Version 3.0, ([LICENSE](LICENSE) or https://www.gnu.org/licenses/gpl-3.0.en.html) 149 | 150 | ### Contribution 151 | 152 | Any contribution intentionally submitted for inclusion in the work by you shall be licensed under the GNU GPLv3. 153 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib'; 2 | import Gio from 'gi://Gio'; 3 | 4 | const CONF_DIR: string = GLib.get_user_config_dir() + '/gnome-mosaic'; 5 | export const WM_CLASS_ID = 'gnome-mosaic-exceptions'; 6 | export var CONF_FILE: string = CONF_DIR + '/config.json'; 7 | 8 | export interface FloatRule { 9 | class?: string; 10 | title?: string; 11 | disabled?: boolean; 12 | } 13 | 14 | interface Ok { 15 | tag: 0; 16 | value: T; 17 | } 18 | 19 | interface Error { 20 | tag: 1; 21 | why: string; 22 | } 23 | 24 | type Result = Ok | Error; 25 | 26 | export const DEFAULT_FLOAT_RULES: Array = [ 27 | {class: 'Authy Desktop'}, 28 | {class: 'Com.github.amezin.ddterm'}, 29 | {class: 'Com.github.donadigo.eddy'}, 30 | {class: 'Conky'}, 31 | {title: 'Discord Updater'}, 32 | {class: 'Enpass', title: 'Enpass Assistant'}, 33 | {class: WM_CLASS_ID}, 34 | {class: 'Gjs', title: 'Settings'}, 35 | {class: 'Gnome-initial-setup'}, 36 | {class: 'Gnome-terminal', title: 'Preferences – General'}, 37 | {class: 'Guake'}, 38 | {class: 'Io.elementary.sideload'}, 39 | {title: 'JavaEmbeddedFrame'}, 40 | {class: 'KotatogramDesktop', title: 'Media viewer'}, 41 | {class: 'Mozilla VPN'}, 42 | {class: 'update-manager', title: 'Software Updater'}, 43 | {class: 'Solaar'}, 44 | {class: 'Steam', title: '^((?!Steam).)*$'}, 45 | {class: 'Steam', title: '^.*(Guard|Login).*'}, 46 | {class: 'TelegramDesktop', title: 'Media viewer'}, 47 | {class: 'Zotero', title: 'Quick Format Citation'}, 48 | {class: 'firefox', title: '^(?!.*Mozilla Firefox).*$'}, 49 | {class: 'gnome-screenshot'}, 50 | {class: 'ibus-.*'}, 51 | {class: 'jetbrains-toolbox'}, 52 | {class: 'jetbrains-webstorm', title: 'Customize WebStorm'}, 53 | {class: 'jetbrains-webstorm', title: 'License Activation'}, 54 | {class: 'jetbrains-webstorm', title: 'Welcome to WebStorm'}, 55 | {class: 'krunner'}, 56 | {class: 'pritunl'}, 57 | {class: 're.sonny.Junction'}, 58 | {class: 'system76-driver'}, 59 | {class: 'tilda'}, 60 | {class: 'zoom'}, 61 | {class: '^.*action=join.*$'}, 62 | {class: 'gjs'}, 63 | ]; 64 | 65 | export interface WindowRule { 66 | class?: string; 67 | title?: string; 68 | disabled?: boolean; 69 | } 70 | 71 | /** 72 | * These windows will skip showing in Overview, Thumbnails or SwitcherList 73 | * And any rule here should be added on the DEFAULT_RULES above 74 | */ 75 | export const SKIPTASKBAR_EXCEPTIONS: Array = [ 76 | {class: 'Conky'}, 77 | {class: 'gjs'}, 78 | {class: 'Guake'}, 79 | {class: 'Com.github.amezin.ddterm'}, 80 | {class: 'plank'}, 81 | ]; 82 | 83 | export interface FloatRule { 84 | class?: string; 85 | title?: string; 86 | } 87 | 88 | export class Config { 89 | /** List of windows that should float, regardless of their WM hints */ 90 | float: Array = []; 91 | 92 | /** 93 | * List of Windows with skip taskbar true but still hidden in Overview, 94 | * Switchers, Workspace Thumbnails 95 | */ 96 | skiptaskbarhidden: Array = []; 97 | 98 | /** Logs window details on focus of window */ 99 | log_on_focus: boolean = false; 100 | 101 | /** Add a floating exception which matches by wm_class */ 102 | add_app_exception(wmclass: string) { 103 | for (const r of this.float) { 104 | if (r.class === wmclass && r.title === undefined) return; 105 | } 106 | 107 | this.float.push({class: wmclass}); 108 | this.sync_to_disk(); 109 | } 110 | 111 | /** Add a floating exception which matches by wm_title */ 112 | add_window_exception(wmclass: string, title: string) { 113 | for (const r of this.float) { 114 | if (r.class === wmclass && r.title === title) return; 115 | } 116 | 117 | this.float.push({class: wmclass, title}); 118 | this.sync_to_disk(); 119 | } 120 | 121 | window_shall_float(wclass: string, title: string): boolean { 122 | for (const rule of this.float.concat(DEFAULT_FLOAT_RULES)) { 123 | if (rule.class) { 124 | if (!new RegExp(rule.class, 'i').test(wclass)) { 125 | continue; 126 | } 127 | } 128 | 129 | if (rule.title) { 130 | if (!new RegExp(rule.title, 'i').test(title)) { 131 | continue; 132 | } 133 | } 134 | 135 | return rule.disabled ? false : true; 136 | } 137 | 138 | return false; 139 | } 140 | 141 | skiptaskbar_shall_hide(meta_window: any) { 142 | let wmclass = meta_window.get_wm_class(); 143 | let wmtitle = meta_window.get_title(); 144 | 145 | if (!meta_window.is_skip_taskbar()) return false; 146 | 147 | for (const rule of this.skiptaskbarhidden.concat( 148 | SKIPTASKBAR_EXCEPTIONS 149 | )) { 150 | if (rule.class) { 151 | if (!new RegExp(rule.class, 'i').test(wmclass)) { 152 | continue; 153 | } 154 | } 155 | 156 | if (rule.title) { 157 | if (!new RegExp(rule.title, 'i').test(wmtitle)) { 158 | continue; 159 | } 160 | } 161 | 162 | return rule.disabled ? false : true; 163 | } 164 | 165 | return false; 166 | } 167 | 168 | reload() { 169 | const conf = Config.from_config(); 170 | 171 | if (conf.tag === 0) { 172 | let c = conf.value; 173 | this.float = c.float; 174 | this.log_on_focus = c.log_on_focus; 175 | } else { 176 | log(`error loading conf: ${conf.why}`); 177 | } 178 | } 179 | 180 | rule_disabled(rule: FloatRule): boolean { 181 | for (const value of this.float.values()) { 182 | if ( 183 | value.disabled && 184 | rule.class === value.class && 185 | value.title === rule.title 186 | ) { 187 | return true; 188 | } 189 | } 190 | 191 | return false; 192 | } 193 | 194 | to_json(): string { 195 | return JSON.stringify(this, set_to_json, 2); 196 | } 197 | 198 | toggle_system_exception( 199 | wmclass: string | undefined, 200 | wmtitle: string | undefined, 201 | disabled: boolean 202 | ) { 203 | if (disabled) { 204 | for (const value of DEFAULT_FLOAT_RULES) { 205 | if (value.class === wmclass && value.title === wmtitle) { 206 | value.disabled = disabled; 207 | this.float.push(value); 208 | this.sync_to_disk(); 209 | return; 210 | } 211 | } 212 | } 213 | 214 | let index = 0; 215 | let found = false; 216 | for (const value of this.float) { 217 | if (value.class === wmclass && value.title === wmtitle) { 218 | found = true; 219 | break; 220 | } 221 | index += 1; 222 | } 223 | 224 | if (found) swap_remove(this.float, index); 225 | 226 | this.sync_to_disk(); 227 | } 228 | 229 | remove_user_exception( 230 | wmclass: string | undefined, 231 | wmtitle: string | undefined 232 | ) { 233 | let index = 0; 234 | let found = new Array(); 235 | for (const value of this.float.values()) { 236 | if (value.class === wmclass && value.title === wmtitle) { 237 | found.push(index); 238 | } 239 | 240 | index += 1; 241 | } 242 | 243 | if (found.length !== 0) { 244 | for (const idx of found) swap_remove(this.float, idx); 245 | 246 | this.sync_to_disk(); 247 | } 248 | } 249 | 250 | static from_json(json: string): Config { 251 | try { 252 | return JSON.parse(json); 253 | } catch (error) { 254 | return new Config(); 255 | } 256 | } 257 | 258 | private static from_config(): Result { 259 | const stream = Config.read(); 260 | if (stream.tag === 1) return stream; 261 | let value = Config.from_json(stream.value); 262 | return {tag: 0, value}; 263 | } 264 | 265 | private static gio_file(): Result { 266 | try { 267 | const conf = Gio.File.new_for_path(CONF_FILE); 268 | 269 | if (!conf.query_exists(null)) { 270 | const dir = Gio.File.new_for_path(CONF_DIR); 271 | if (!dir.query_exists(null) && !dir.make_directory(null)) { 272 | return { 273 | tag: 1, 274 | why: 'failed to create gnome-mosaic config directory', 275 | }; 276 | } 277 | 278 | const example = new Config(); 279 | example.float.push({ 280 | class: 'gnome-mosaic-example', 281 | title: 'gnome-mosaic-example', 282 | }); 283 | 284 | conf.create(Gio.FileCreateFlags.NONE, null).write_all( 285 | JSON.stringify(example, undefined, 2), 286 | null 287 | ); 288 | } 289 | 290 | return {tag: 0, value: conf}; 291 | } catch (why) { 292 | return {tag: 1, why: `Gio.File I/O error: ${why}`}; 293 | } 294 | } 295 | 296 | private static read(): Result { 297 | try { 298 | const file = Config.gio_file(); 299 | if (file.tag === 1) return file; 300 | 301 | const [, buffer] = file.value.load_contents(null); 302 | 303 | return {tag: 0, value: new TextDecoder().decode(buffer)}; 304 | } catch (why) { 305 | return {tag: 1, why: `failed to read gnome-mosaic config: ${why}`}; 306 | } 307 | } 308 | 309 | private static write(data: string): Result { 310 | try { 311 | const file = Config.gio_file(); 312 | if (file.tag === 1) return file; 313 | 314 | file.value.replace_contents( 315 | data, 316 | null, 317 | false, 318 | Gio.FileCreateFlags.NONE, 319 | null 320 | ); 321 | 322 | return {tag: 0, value: file.value}; 323 | } catch (why) { 324 | return {tag: 1, why: `failed to write to config: ${why}`}; 325 | } 326 | } 327 | 328 | sync_to_disk() { 329 | Config.write(this.to_json()); 330 | } 331 | } 332 | 333 | function set_to_json(_key: string, value: any) { 334 | if (typeof value === 'object' && value instanceof Set) { 335 | return [...value]; 336 | } 337 | return value; 338 | } 339 | 340 | function swap_remove(array: Array, index: number): T | undefined { 341 | array[index] = array[array.length - 1]; 342 | return array.pop(); 343 | } 344 | -------------------------------------------------------------------------------- /schemas/org.gnome.shell.extensions.gnome-mosaic.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | Show a hint around the active window 8 | 9 | 10 | 11 | 6 12 | 13 | Border width for active window hint, in pixels 14 | 15 | 16 | 17 | '#1c71d8' 18 | Color to use for accents on pre-Gnome 47 19 | 20 | 21 | 22 | '' 23 | Color to override for accents on Gnome 47+ 24 | 25 | 26 | 27 | 6 28 | Gap between tiled windows, in pixels 29 | 30 | 31 | 32 | 6 33 | Gap surrounding tiled windows, in pixels 34 | 35 | 36 | 37 | true 38 | Show title bars on windows with server-side decorations 39 | 40 | 41 | 42 | true 43 | Handle minimized to tray windows 44 | 45 | 46 | 47 | true 48 | Move cursor to active window when navigating with keyboard shortcuts or touchpad gestures 49 | 50 | 51 | 52 | 0 53 | The location the mouse cursor focuses when selecting a window 54 | 55 | 56 | 57 | true 58 | Hide the indicator settings panel 59 | 60 | 61 | 62 | 63 | 64 64 | Size of a column in the display grid 65 | 66 | 67 | 68 | 64 69 | Size of a row in the display grid 70 | 71 | 72 | 73 | false 74 | Hide the outer gap when a tree contains only one window 75 | 76 | 77 | 78 | false 79 | Snaps windows to the tiling grid on drop 80 | 81 | 82 | 83 | false 84 | Tile launched windows by default 85 | 86 | 87 | 88 | 0 89 | Maximum width of tiled windows, in pixels (0 to disable) 90 | 91 | 92 | 93 | 94 | Left','KP_Left','h']]]> 95 | Focus left window 96 | 97 | 98 | 99 | Down','KP_Down','j']]]> 100 | Focus down window 101 | 102 | 103 | 104 | Up','KP_Up','k']]]> 105 | Focus up window 106 | 107 | 108 | 109 | Right','KP_Right','l']]]> 110 | Focus right window 111 | 112 | 113 | 114 | 115 | Toggle tiling orientation 116 | 117 | 118 | 119 | Return','KP_Enter']]]> 120 | Enter tiling mode 121 | 122 | 123 | 124 | 125 | Accept tiling changes 126 | 127 | 128 | 129 | 130 | Reject tiling changes 131 | 132 | 133 | 134 | g']]]> 135 | Toggles a window between floating and tiling 136 | 137 | 138 | 139 | 140 | y']]]> 141 | Toggles auto-tiling on and off 142 | 143 | 144 | 145 | 146 | 147 | Move window left 148 | 149 | 150 | 151 | 152 | Move window down 153 | 154 | 155 | 156 | 157 | Move window up 158 | 159 | 160 | 161 | 162 | Move window right 163 | 164 | 165 | 166 | 167 | Move window left 168 | 169 | 170 | 171 | 172 | Move window down 173 | 174 | 175 | 176 | 177 | Move window up 178 | 179 | 180 | 181 | 182 | Move window right 183 | 184 | 185 | 186 | o']]]> 187 | Toggle tiling orientation 188 | 189 | 190 | 191 | 192 | R']]]> 193 | Toggle resize mode 194 | 195 | 196 | 197 | KP_Left','Left','h']]]> 198 | Resize window (grow left) 199 | 200 | 201 | 202 | KP_Right', 'Right','l']]]> 203 | Resize window (shrink left) 204 | 205 | 206 | 207 | KP_Up','Up','k']]]> 208 | Resize window (grow up) 209 | 210 | 211 | 212 | KP_Down', 'Down','j']]]> 213 | Resize window (shrink up) 214 | 215 | 216 | 217 | KP_Right','Right','l']]]> 218 | Resize window (grow right) 219 | 220 | 221 | 222 | KP_Left', 'Left','h']]]> 223 | Resize window (shrink right) 224 | 225 | 226 | 227 | KP_Down','Down','j']]]> 228 | Resize window (grow down) 229 | 230 | 231 | 232 | KP_Up', 'Up','k']]]> 233 | Resize window (shrink down) 234 | 235 | 236 | 237 | 238 | Left','KP_Left','h']]]> 239 | Swap window left 240 | 241 | 242 | 243 | Down','KP_Down','j']]]> 244 | Swap window down 245 | 246 | 247 | 248 | Up','KP_Up','k']]]> 249 | Swap window up 250 | 251 | 252 | 253 | Right','KP_Right','l']]]> 254 | Swap window right 255 | 256 | 257 | 258 | 259 | 260 | Down','KP_Down','j']]]> 261 | Move window to the lower workspace 262 | 263 | 264 | 265 | Up','KP_Up','k']]]> 266 | Move window to the upper workspace 267 | 268 | 269 | 270 | Down','KP_Down','j']]]> 271 | Move window to the lower monitor 272 | 273 | 274 | 275 | Up','KP_Up','k']]]> 276 | Move window to the upper monitor 277 | 278 | 279 | 280 | Left','KP_Left','h']]]> 281 | Move window to the leftward monitor 282 | 283 | 284 | 285 | Right','KP_Right','l']]]> 286 | Move window to the rightward monitor 287 | 288 | 289 | 290 | '' 291 | Cached state used to reload data during lock and unlock 292 | 293 | 294 | 295 | 296 | 0 297 | 298 | Derive some log4j level/order 299 | 0 - OFF 300 | 1 - ERROR 301 | 2 - WARN 302 | 3 - INFO 303 | 4 - DEBUG 304 | 305 | 306 | 307 | 308 | 309 | -------------------------------------------------------------------------------- /src/floating_exceptions/src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/gjs --module 2 | 3 | import Adw from 'gi://Adw'; 4 | import Gio from 'gi://Gio'; 5 | import GioUnix from 'gi://GioUnix'; 6 | import GLib from 'gi://GLib'; 7 | import Gtk from 'gi://Gtk'; 8 | import Pango from 'gi://Pango'; 9 | import * as config from './config.js'; 10 | 11 | let app; 12 | let instance = null; 13 | const SYS_EXEMPTION_TITLE = 'System Exceptions'; 14 | const SYS_EXCEPTION_DESC = 'Updated based on validated user reports.'; 15 | const APPLICATION_ID = 'com.github.jardon.gnome-mosaic-exceptions'; 16 | 17 | interface SelectWindow { 18 | tag: 0; 19 | } 20 | 21 | enum ViewNum { 22 | MainView = 0, 23 | Exceptions = 1, 24 | } 25 | 26 | interface SwitchTo { 27 | tag: 1; 28 | view: ViewNum; 29 | } 30 | 31 | interface ToggleException { 32 | tag: 2; 33 | wmclass: string | undefined; 34 | wmtitle: string | undefined; 35 | enable: boolean; 36 | } 37 | 38 | interface RemoveException { 39 | tag: 3; 40 | wmclass: string | undefined; 41 | wmtitle: string | undefined; 42 | } 43 | 44 | type Event = SelectWindow | SwitchTo | ToggleException | RemoveException; 45 | 46 | interface View { 47 | widget: any; 48 | 49 | callback: (event: Event) => void; 50 | } 51 | 52 | export class MainView implements View { 53 | widget: any; 54 | 55 | callback: (event: Event) => void = () => {}; 56 | 57 | private list: any; 58 | 59 | constructor() { 60 | let exceptions = this.exceptions_button(); 61 | 62 | this.list = Gtk.ListBox.new(); 63 | this.list.set_selection_mode(Gtk.SelectionMode.NONE); 64 | this.list.set_header_func(list_header_func); 65 | this.list.set_activate_on_single_click(true); 66 | this.list.append(exceptions); 67 | 68 | this.list.connect('row-activated', (_: any, row: any) => { 69 | if (row.get_child() === exceptions) { 70 | this.callback({tag: 1, view: ViewNum.Exceptions}); 71 | } 72 | }); 73 | 74 | let scroller = new Gtk.ScrolledWindow(); 75 | scroller.hscrollbar_policy = Gtk.PolicyType.NEVER; 76 | scroller.set_propagate_natural_width(true); 77 | scroller.set_propagate_natural_height(true); 78 | scroller.set_child(this.list); 79 | 80 | let list_frame = Gtk.Frame.new(null); 81 | list_frame.set_child(scroller); 82 | 83 | let desc = new Gtk.Label({ 84 | label: 'Add exceptions by selecting currently running applications and windows.', 85 | wrap: true, 86 | }); 87 | desc.set_halign(Gtk.Align.CENTER); 88 | desc.set_justify(Gtk.Justification.CENTER); 89 | desc.set_max_width_chars(55); 90 | desc.set_margin_top(12); 91 | 92 | this.widget = Gtk.Box.new(Gtk.Orientation.VERTICAL, 24); 93 | this.widget.append(desc); 94 | this.widget.append(list_frame); 95 | } 96 | 97 | add_rule(wmclass: string | undefined, wmtitle: string | undefined) { 98 | let label = Gtk.Label.new( 99 | wmtitle === undefined ? wmclass : `${wmclass} / ${wmtitle}` 100 | ); 101 | label.set_xalign(0); 102 | label.set_hexpand(true); 103 | label.set_ellipsize(Pango.EllipsizeMode.END); 104 | 105 | let button = Gtk.Button.new_from_icon_name('user-trash-symbolic'); 106 | button.set_valign(Gtk.Align.CENTER); 107 | 108 | let widget = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 24); 109 | widget.append(label); 110 | widget.append(button); 111 | widget.set_margin_top(12); 112 | widget.set_margin_bottom(12); 113 | widget.set_margin_start(12); 114 | widget.set_margin_end(12); 115 | 116 | widget.set_margin_start(12); 117 | 118 | button.connect('clicked', () => { 119 | this.list.remove(widget); 120 | widget.set_visible(false); 121 | this.callback({tag: 3, wmclass, wmtitle}); 122 | }); 123 | 124 | this.list.append(widget); 125 | } 126 | 127 | exceptions_button(): any { 128 | let label = Gtk.Label.new(SYS_EXEMPTION_TITLE); 129 | label.set_xalign(0); 130 | label.set_hexpand(true); 131 | label.set_ellipsize(Pango.EllipsizeMode.END); 132 | 133 | let description = Gtk.Label.new(SYS_EXCEPTION_DESC); 134 | description.set_xalign(0); 135 | description.get_style_context().add_class('dim-label'); 136 | 137 | let button = Gtk.Button.new_from_icon_name('go-next-symbolic'); 138 | button.set_valign(Gtk.Align.CENTER); 139 | 140 | let widget = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 24); 141 | widget.append(label); 142 | widget.append(description); 143 | widget.append(button); 144 | widget.set_margin_top(12); 145 | widget.set_margin_bottom(12); 146 | widget.set_margin_start(12); 147 | widget.set_margin_end(12); 148 | 149 | widget.set_margin_start(12); 150 | 151 | button.connect('clicked', () => 152 | this.callback({tag: 1, view: ViewNum.Exceptions}) 153 | ); 154 | 155 | return widget; 156 | } 157 | } 158 | 159 | export class ExceptionsView implements View { 160 | widget: any; 161 | callback: (event: Event) => void = () => {}; 162 | 163 | exceptions: any = Gtk.ListBox.new(); 164 | 165 | constructor() { 166 | let desc_title = Gtk.Label.new(`${SYS_EXEMPTION_TITLE}`); 167 | desc_title.set_use_markup(true); 168 | desc_title.set_xalign(0); 169 | 170 | let desc_desc = Gtk.Label.new(SYS_EXCEPTION_DESC); 171 | desc_desc.set_xalign(0); 172 | desc_desc.get_style_context().add_class('dim-label'); 173 | desc_desc.set_margin_bottom(6); 174 | 175 | let scroller = new Gtk.ScrolledWindow(); 176 | scroller.hscrollbar_policy = Gtk.PolicyType.NEVER; 177 | scroller.set_propagate_natural_width(true); 178 | scroller.set_propagate_natural_height(true); 179 | scroller.set_child(this.exceptions); 180 | 181 | let exceptions_frame = Gtk.Frame.new(null); 182 | exceptions_frame.set_child(scroller); 183 | 184 | this.exceptions.set_selection_mode(Gtk.SelectionMode.NONE); 185 | this.exceptions.set_header_func(list_header_func); 186 | 187 | this.widget = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6); 188 | this.widget.append(desc_title); 189 | this.widget.append(desc_desc); 190 | this.widget.append(exceptions_frame); 191 | } 192 | 193 | add_rule( 194 | wmclass: string | undefined, 195 | wmtitle: string | undefined, 196 | enabled: boolean 197 | ) { 198 | let label = Gtk.Label.new( 199 | wmtitle === undefined ? wmclass : `${wmclass} / ${wmtitle}` 200 | ); 201 | label.set_xalign(0); 202 | label.set_hexpand(true); 203 | label.set_ellipsize(Pango.EllipsizeMode.END); 204 | 205 | let button = Gtk.Switch.new(); 206 | button.set_valign(Gtk.Align.CENTER); 207 | button.set_state(enabled); 208 | button.set_active(true); 209 | button.connect('notify::state', () => { 210 | this.callback({ 211 | tag: 2, 212 | wmclass, 213 | wmtitle, 214 | enable: button.get_state(), 215 | }); 216 | }); 217 | 218 | let widget = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 24); 219 | widget.append(label); 220 | widget.append(button); 221 | widget.set_margin_top(12); 222 | widget.set_margin_bottom(12); 223 | widget.set_margin_start(12); 224 | widget.set_margin_end(12); 225 | 226 | this.exceptions.append(widget); 227 | } 228 | } 229 | 230 | class App { 231 | main_view: MainView = new MainView(); 232 | exceptions_view: ExceptionsView = new ExceptionsView(); 233 | 234 | stack: any = Gtk.Stack.new(); 235 | window: any; 236 | config: config.Config = new config.Config(); 237 | 238 | constructor() { 239 | this.stack.set_margin_top(16); 240 | this.stack.set_margin_bottom(16); 241 | this.stack.set_margin_start(16); 242 | this.stack.set_margin_end(16); 243 | this.stack.add_child(this.main_view.widget); 244 | this.stack.add_child(this.exceptions_view.widget); 245 | 246 | let header = new Adw.HeaderBar(); 247 | 248 | let add_exception = Gtk.Button.new_from_icon_name('list-add-symbolic'); 249 | add_exception.set_valign(Gtk.Align.CENTER); 250 | add_exception.set_halign(Gtk.Align.START); 251 | 252 | let back = Gtk.Button.new_from_icon_name('go-previous-symbolic'); 253 | back.set_valign(Gtk.Align.CENTER); 254 | back.set_halign(Gtk.Align.START); 255 | 256 | const TITLE = 'Floating Window Exceptions'; 257 | 258 | let win = new Adw.Window(); 259 | this.window = win; 260 | header.pack_start(add_exception); 261 | header.pack_start(back); 262 | win.set_deletable(true); 263 | win.set_title(TITLE); 264 | 265 | Adw.Window.set_default_icon_name('application-default'); 266 | 267 | const vbox = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL}); 268 | vbox.append(header); 269 | vbox.append(this.stack); 270 | 271 | win.default_width = 550; 272 | win.default_height = 700; 273 | win.set_content(vbox); 274 | 275 | back.hide(); 276 | 277 | this.config.reload(); 278 | 279 | for (const value of config.DEFAULT_FLOAT_RULES.values()) { 280 | let wmtitle = value.title ?? undefined; 281 | let wmclass = value.class ?? undefined; 282 | 283 | let disabled = this.config.rule_disabled({ 284 | class: wmclass, 285 | title: wmtitle, 286 | }); 287 | this.exceptions_view.add_rule(wmclass, wmtitle, !disabled); 288 | } 289 | 290 | for (const value of Array.from(this.config.float)) { 291 | let wmtitle = value.title ?? undefined; 292 | let wmclass = value.class ?? undefined; 293 | if (!value.disabled) this.main_view.add_rule(wmclass, wmtitle); 294 | } 295 | 296 | let event_handler = (event: Event) => { 297 | switch (event.tag) { 298 | // SelectWindow 299 | case 0: 300 | println('SELECT'); 301 | app.quit(); 302 | break; 303 | 304 | // SwitchTo 305 | case 1: 306 | switch (event.view) { 307 | case ViewNum.MainView: 308 | this.stack.set_visible_child(this.main_view.widget); 309 | back.hide(); 310 | add_exception.show(); 311 | break; 312 | case ViewNum.Exceptions: 313 | this.stack.set_visible_child( 314 | this.exceptions_view.widget 315 | ); 316 | back.show(); 317 | add_exception.hide(); 318 | break; 319 | } 320 | 321 | break; 322 | 323 | // ToggleException 324 | case 2: 325 | log(`toggling exception ${event.enable}`); 326 | this.config.toggle_system_exception( 327 | event.wmclass, 328 | event.wmtitle, 329 | !event.enable 330 | ); 331 | println('MODIFIED'); 332 | break; 333 | 334 | // RemoveException 335 | case 3: 336 | log(`removing exception`); 337 | this.config.remove_user_exception( 338 | event.wmclass, 339 | event.wmtitle 340 | ); 341 | println('MODIFIED'); 342 | break; 343 | } 344 | }; 345 | 346 | this.main_view.callback = event_handler; 347 | this.exceptions_view.callback = event_handler; 348 | back.connect('clicked', () => 349 | event_handler({tag: 1, view: ViewNum.MainView}) 350 | ); 351 | add_exception.connect('clicked', () => event_handler({tag: 0})); 352 | } 353 | } 354 | 355 | function list_header_func(row: any, before: null | any) { 356 | if (before) { 357 | row.set_header(Gtk.Separator.new(Gtk.Orientation.HORIZONTAL)); 358 | } 359 | } 360 | 361 | const STDOUT = new Gio.DataOutputStream({ 362 | base_stream: new GioUnix.OutputStream({fd: 1}), 363 | }); 364 | 365 | function println(message: string) { 366 | STDOUT.put_string(message + '\n', null); 367 | } 368 | 369 | function main() { 370 | app = new Adw.Application({ 371 | application_id: APPLICATION_ID, 372 | flags: Gio.ApplicationFlags.FLAGS_NONE, 373 | }); 374 | 375 | app.connect('activate', () => { 376 | GLib.set_prgname(config.WM_CLASS_ID); 377 | GLib.set_application_name('GNOME Mosaic Floating Window Exceptions'); 378 | 379 | if (instance !== null) { 380 | instance.window.present(); 381 | return; 382 | } 383 | 384 | instance = new App(); 385 | instance.window.set_application(app); 386 | instance.window.set_icon_name(APPLICATION_ID); 387 | instance.window.present(); 388 | }); 389 | 390 | app.connect('window-removed', () => { 391 | instance = null; 392 | }); 393 | 394 | app.run([]); 395 | } 396 | 397 | main(); 398 | -------------------------------------------------------------------------------- /src/mod.d.ts: -------------------------------------------------------------------------------- 1 | declare const global: Global, 2 | imports: any, 3 | log: any, 4 | _: (arg: string) => string; 5 | 6 | interface Global { 7 | get_current_time(): number; 8 | get_pointer(): [number, number]; 9 | get_window_actors(): Array; 10 | log(msg: string): void; 11 | logError(error: any): void; 12 | 13 | display: Meta.Display; 14 | run_at_leisure(func: () => void): void; 15 | session_mode: string; 16 | stage: Clutter.Actor; 17 | window_group: Clutter.Actor; 18 | window_manager: Meta.WindowManager; 19 | workspace_manager: Meta.WorkspaceManager; 20 | } 21 | 22 | interface ImportMeta { 23 | url: string; 24 | } 25 | 26 | interface Rectangular { 27 | x: number; 28 | y: number; 29 | width: number; 30 | height: number; 31 | } 32 | 33 | interface DialogButtonAction { 34 | label: string; 35 | action: () => void; 36 | key?: number; 37 | default?: boolean; 38 | } 39 | 40 | declare type ProcessResult = [boolean, any, any, number]; 41 | declare type SignalID = number; 42 | 43 | declare module 'resource://*'; 44 | 45 | declare module 'gi://Gio' { 46 | let Gio: any; 47 | export default Gio; 48 | } 49 | 50 | declare module 'gi://St' { 51 | let St: any; 52 | export default St; 53 | } 54 | 55 | declare module 'gi://Clutter' { 56 | let Clutter: any; 57 | export default Clutter; 58 | } 59 | 60 | declare module 'gi://Shell' { 61 | let Shell: any; 62 | export default Shell; 63 | } 64 | 65 | declare module 'gi://Meta' { 66 | let Meta: any; 67 | export default Meta; 68 | } 69 | 70 | declare module 'gi://Mtk' { 71 | let Mtk: any; 72 | export default Mtk; 73 | } 74 | 75 | declare module 'gi://Gtk' { 76 | let Gtk: any; 77 | export default Gtk; 78 | } 79 | 80 | declare module 'gi://Gdk' { 81 | let Gdk: any; 82 | export default Gdk; 83 | } 84 | 85 | declare module 'gi://GObject' { 86 | let GObject: any; 87 | export default GObject; 88 | } 89 | 90 | declare module 'gi://Adw' { 91 | let Adw: any; 92 | export default Adw; 93 | } 94 | 95 | declare module 'resource:///org/gnome/shell/ui/quickSettings.js' { 96 | export class QuickMenuToggle { 97 | constructor(params: any); 98 | menu: any; 99 | set(params: any): void; 100 | connect(signal: string, callback: () => void): number; 101 | checked: boolean; 102 | gicon: any; 103 | destroy(): void; 104 | } 105 | 106 | export class SystemIndicator { 107 | constructor(); 108 | quickSettingsItems: any[]; 109 | gicon: any; 110 | visible: boolean; 111 | destroy(): void; 112 | } 113 | } 114 | 115 | declare module 'gi://Pango' { 116 | let Pango: any; 117 | export default Pango; 118 | } 119 | 120 | declare module 'gi://GLib' { 121 | class GLib { 122 | PRIORITY_DEFAULT_IDLE: number; 123 | PRIORITY_DEFAULT: number; 124 | PRIORITY_LOW: number; 125 | SOURCE_REMOVE: boolean; 126 | SOURCE_CONTINUE: boolean; 127 | 128 | find_program_in_path(prog: string): string | null; 129 | get_current_dir(): string; 130 | get_monotonic_time(): number; 131 | 132 | idle_add(priority: any, callback: () => boolean): number; 133 | 134 | signal_handler_block(object: GObject.Object, signal: SignalID): void; 135 | signal_handler_unblock(object: GObject.Object, signal: SignalID): void; 136 | 137 | source_remove(id: SignalID): void; 138 | spawn_command_line_sync(cmd: string): ProcessResult; 139 | spawn_command_line_async(cmd: string): boolean; 140 | 141 | timeout_add( 142 | priority: number, 143 | ms: number, 144 | callback: () => boolean 145 | ): number; 146 | 147 | get_user_config_dir(): string; 148 | file_get_contents(filename: string): string; 149 | } 150 | let gLib: GLib; 151 | export default gLib; 152 | } 153 | 154 | declare interface GLib { 155 | PRIORITY_DEFAULT: number; 156 | PRIORITY_LOW: number; 157 | SOURCE_REMOVE: boolean; 158 | SOURCE_CONTINUE: boolean; 159 | 160 | find_program_in_path(prog: string): string | null; 161 | get_current_dir(): string; 162 | get_monotonic_time(): number; 163 | 164 | idle_add(priority: any, callback: () => boolean): number; 165 | 166 | signal_handler_block(object: GObject.Object, signal: SignalID): void; 167 | signal_handler_unblock(object: GObject.Object, signal: SignalID): void; 168 | 169 | source_remove(id: SignalID): void; 170 | spawn_command_line_sync(cmd: string): ProcessResult; 171 | spawn_command_line_async(cmd: string): boolean; 172 | 173 | timeout_add(priority: number, ms: number, callback: () => boolean): number; 174 | } 175 | 176 | declare namespace GObject { 177 | class Object { 178 | connect( 179 | signal: string, 180 | callback: (...args: any) => boolean | void 181 | ): SignalID; 182 | disconnect(id: SignalID): void; 183 | 184 | ref(): this; 185 | static registerClass(meta: any, klass: any): any; 186 | static registerClass(klass: any): any; 187 | } 188 | } 189 | 190 | declare namespace Gtk { 191 | export enum Orientation { 192 | HORIZONTAL, 193 | VERTICAL, 194 | } 195 | 196 | export class Box extends Container { 197 | constructor(orientation: Orientation, spacing: number); 198 | } 199 | 200 | export class Container extends Widget { 201 | constructor(); 202 | add(widget: Widget): void; 203 | set_border_width(border_width: number): void; 204 | } 205 | 206 | export class Widget { 207 | constructor(); 208 | show_all?: () => void; 209 | show(): void; 210 | } 211 | } 212 | 213 | declare namespace Clutter { 214 | enum ActorAlign { 215 | FILL = 0, 216 | START = 1, 217 | CENTER = 3, 218 | END = 3, 219 | } 220 | 221 | enum AnimationMode { 222 | EASE_IN_QUAD = 2, 223 | EASE_OUT_QUAD = 3, 224 | } 225 | 226 | interface Actor extends Rectangular, GObject.Object { 227 | visible: boolean; 228 | x_align: ActorAlign; 229 | y_align: ActorAlign; 230 | opacity: number; 231 | 232 | add(child: Actor): void; 233 | add_child(child: Actor): void; 234 | destroy(): void; 235 | destroy_all_children(): void; 236 | ease(params: Object): void; 237 | hide(): void; 238 | get_child_at_index(nth: number): Clutter.Actor | null; 239 | get_n_children(): number; 240 | get_parent(): Clutter.Actor | null; 241 | get_stage(): Clutter.Actor | null; 242 | get_transition(param: string): any | null; 243 | grab_key_focus(): void; 244 | is_visible(): boolean; 245 | queue_redraw(): void; 246 | remove_all_children(): void; 247 | remove_all_transitions(): void; 248 | remove_child(child: Actor): void; 249 | set_child_above_sibling(child: Actor, sibling: Actor | null): void; 250 | set_child_below_sibling(child: Actor, sibling: Actor | null): void; 251 | set_easing_duration(msecs: number | null): void; 252 | set_opacity(value: number): void; 253 | set_size(width: number, height: number): void; 254 | set_y_align(align: ActorAlign): void; 255 | set_position(x: number, y: number): void; 256 | set_size(width: number, height: number): void; 257 | show(): void; 258 | get_context(): Clutter.Context; 259 | } 260 | 261 | interface ActorBox { 262 | new (x: number, y: number, width: number, height: number): ActorBox; 263 | } 264 | 265 | interface Text extends Actor { 266 | get_text(): Readonly; 267 | set_text(text: string | null): void; 268 | } 269 | 270 | interface Seat extends GObject.Object { 271 | warp_pointer(x: number, y: number): void; 272 | } 273 | 274 | interface Backend extends GObject.Object { 275 | get_default_seat(): Seat; 276 | } 277 | 278 | interface Context extends GObject.Object { 279 | get_backend(): Backend; 280 | } 281 | } 282 | 283 | declare namespace Meta { 284 | enum DisplayDirection { 285 | UP, 286 | DOWN, 287 | LEFT, 288 | RIGHT, 289 | } 290 | 291 | enum MaximizeFlags { 292 | HORIZONTAL = 1, 293 | VERTICAL = 2, 294 | BOTH = 3, 295 | } 296 | 297 | enum MotionDirection { 298 | UP, 299 | DOWN, 300 | LEFT, 301 | RIGHT, 302 | } 303 | 304 | interface Display extends GObject.Object { 305 | get_current_monitor(): number; 306 | get_focus_window(): null | Meta.Window; 307 | get_monitor_index_for_rect(rect: Rectangular): number; 308 | get_monitor_geometry(monitor: number): null | Rectangular; 309 | get_monitor_neighbor_index( 310 | monitor: number, 311 | direction: DisplayDirection 312 | ): number; 313 | get_n_monitors(): number; 314 | get_primary_monitor(): number; 315 | get_tab_list( 316 | list: number, 317 | workspace: Meta.Workspace | null 318 | ): Array; 319 | get_workspace_manager(): WorkspaceManager; 320 | } 321 | 322 | interface Window extends Clutter.Actor { 323 | appears_focused: Readonly; 324 | minimized: Readonly; 325 | window_type: Readonly; 326 | decorated: Readonly; 327 | 328 | activate(time: number): void; 329 | change_workspace_by_index(workspace: number, append: boolean): void; 330 | delete(timestamp: number): void; 331 | get_buffer_rect(): Rectangular; 332 | get_compositor_private(): Clutter.Actor | null; 333 | get_display(): Meta.Display | null; 334 | get_description(): string; 335 | get_frame_rect(): Rectangular; 336 | get_id(): number; 337 | get_maximize_flags(): MaximizeFlags; 338 | get_maximized(): number; 339 | get_monitor(): number; 340 | get_pid(): number; 341 | get_role(): null | string; 342 | get_stable_sequence(): number; 343 | get_title(): null | string; 344 | get_transient_for(): Window | null; 345 | get_user_time(): number; 346 | get_wm_class(): string | null; 347 | get_wm_class_instance(): string | null; 348 | get_work_area_for_monitor(monitor: number): null | Rectangular; 349 | get_workspace(): Workspace; 350 | has_focus(): boolean; 351 | is_above(): boolean; 352 | is_attached_dialog(): boolean; 353 | is_fullscreen(): boolean; 354 | is_maximized(): boolean; 355 | is_on_all_workspaces(): boolean; 356 | is_override_redirect(): boolean; 357 | is_skip_taskbar(): boolean; 358 | make_above(): void; 359 | make_fullscreen(): void; 360 | set_maximize_flags(flags: MaximizeFlags): boolean; 361 | maximize(flags: MaximizeFlags): void; 362 | move_frame(user_op: boolean, x: number, y: number): void; 363 | move_resize_frame( 364 | user_op: boolean, 365 | x: number, 366 | y: number, 367 | w: number, 368 | h: number 369 | ): boolean; 370 | raise(): void; 371 | skip_taskbar: boolean; 372 | set_unmaximize_flags(flags: MaximizeFlags): boolean; 373 | unmaximize(flags: MaximizeFlags): void; 374 | unminimize(): void; 375 | } 376 | 377 | interface WindowActor extends Clutter.Actor { 378 | get_meta_window(): Meta.Window; 379 | } 380 | 381 | interface WindowManager extends GObject.Object {} 382 | 383 | interface Workspace extends GObject.Object { 384 | n_windows: number; 385 | 386 | activate(time: number): boolean; 387 | activate_with_focus(window: Meta.Window, timestamp: number): void; 388 | get_neighbor(direction: Meta.MotionDirection): null | Workspace; 389 | get_work_area_for_monitor(monitor: number): null | Rectangular; 390 | index(): number; 391 | list_windows(): Array; 392 | } 393 | 394 | interface WorkspaceManager extends GObject.Object { 395 | append_new_workspace(activate: boolean, timestamp: number): Workspace; 396 | get_active_workspace(): Workspace; 397 | get_active_workspace_index(): number; 398 | get_n_workspaces(): number; 399 | get_workspace_by_index(index: number): null | Workspace; 400 | remove_workspace(workspace: Workspace, timestamp: number): void; 401 | reorder_workspace(workspace: Workspace, new_index: number): void; 402 | } 403 | } 404 | 405 | declare namespace Shell { 406 | interface Dialog extends St.Widget { 407 | _dialog: St.Widget; 408 | contentLayout: St.Widget; 409 | } 410 | 411 | interface ModalDialog extends St.Widget { 412 | contentLayout: St.Widget; 413 | dialogLayout: Dialog; 414 | 415 | addButton(action: DialogButtonAction): void; 416 | 417 | close(timestamp: number): void; 418 | open(timestamp: number, on_primary: boolean): void; 419 | 420 | setInitialKeyFocus(actor: Clutter.Actor): void; 421 | } 422 | } 423 | 424 | declare namespace St { 425 | interface Button extends Widget { 426 | set_label(label: string): void; 427 | } 428 | 429 | interface Widget extends Clutter.Actor { 430 | add_style_class_name(name: string): void; 431 | add_style_pseudo_class(name: string): void; 432 | add(child: St.Widget): void; 433 | get_theme_node(): any; 434 | hide(): void; 435 | remove_style_class_name(name: string): void; 436 | remove_style_pseudo_class(name: string): void; 437 | set_style(inlinecss: string): boolean; 438 | set_style_class_name(name: string): void; 439 | set_style_pseudo_class(name: string): void; 440 | show_all(): void; 441 | show(): void; 442 | } 443 | 444 | interface Bin extends Widget { 445 | // empty for now 446 | } 447 | 448 | interface Entry extends Widget { 449 | clutter_text: any; 450 | 451 | get_clutter_text(): Clutter.Text; 452 | set_hint_text(hint: string): void; 453 | } 454 | 455 | interface Icon extends Widget { 456 | icon_name: string; 457 | } 458 | } 459 | -------------------------------------------------------------------------------- /src/fork.ts: -------------------------------------------------------------------------------- 1 | import type {Forest} from './forest.js'; 2 | import type {Entity} from './ecs.js'; 3 | import type {Ext} from './extension.js'; 4 | import {Rectangle} from './rectangle.js'; 5 | import {Node} from './node.js'; 6 | 7 | import * as Ecs from './ecs.js'; 8 | import * as Lib from './lib.js'; 9 | import * as Rect from './rectangle.js'; 10 | import {ShellWindow} from './window.js'; 11 | 12 | const XPOS = 0; 13 | const YPOS = 1; 14 | const WIDTH = 2; 15 | const HEIGHT = 3; 16 | 17 | /** A tiling fork contains two children nodes. 18 | * 19 | * These nodes may either be windows, or sub-forks. 20 | */ 21 | export class Fork { 22 | left: Node; 23 | right: Node | null; 24 | area: Rectangle; 25 | entity: Entity; 26 | on_primary_display: boolean; 27 | workspace: number; 28 | length_left: number; 29 | prev_length_left: number; 30 | prev_ratio: number = 0.5; 31 | monitor: number; 32 | minimum_ratio: number = 0.1; 33 | orientation: Lib.Orientation = Lib.Orientation.HORIZONTAL; 34 | 35 | orientation_changed: boolean = false; 36 | is_toplevel: boolean = false; 37 | 38 | smart_gapped: boolean = false; 39 | 40 | /** Tracks toggle count so that we may swap branches when toggled twice */ 41 | private n_toggled: number = 0; 42 | 43 | constructor( 44 | entity: Entity, 45 | left: Node, 46 | right: Node | null, 47 | area: Rectangle, 48 | workspace: WorkspaceID, 49 | monitor: MonitorID, 50 | orient: Lib.Orientation 51 | ) { 52 | this.on_primary_display = 53 | global.display.get_primary_monitor() === monitor; 54 | this.area = area; 55 | this.left = left; 56 | this.right = right; 57 | this.workspace = workspace; 58 | this.length_left = 59 | orient === Lib.Orientation.HORIZONTAL 60 | ? this.area.width / 2 61 | : this.area.height / 2; 62 | this.prev_length_left = this.length_left; 63 | this.entity = entity; 64 | this.orientation = orient; 65 | this.monitor = monitor; 66 | } 67 | 68 | toJSON() { 69 | return { 70 | left: this.left, 71 | right: this.right, 72 | area: this.area, 73 | entity: this.entity, 74 | on_primary_display: this.on_primary_display, 75 | workspace: this.workspace, 76 | length_left: this.length_left, 77 | prev_length_left: this.prev_length_left, 78 | prev_ratio: this.prev_ratio, 79 | monitor: this.monitor, 80 | minimum_ratio: this.minimum_ratio, 81 | orientation: this.orientation, 82 | orientation_changed: this.orientation_changed, 83 | is_toplevel: this.is_toplevel, 84 | smart_gapped: this.smart_gapped, 85 | }; 86 | } 87 | 88 | static fromJSON(data: any) { 89 | let fork = new Fork( 90 | data.entity, 91 | Node.fromJSON(data.left), 92 | data.right ? Node.fromJSON(data.right) : null, 93 | Rectangle.fromJSON(data.area), 94 | data.workspace, 95 | data.monitor, 96 | data.orientation 97 | ? Lib.Orientation.VERTICAL 98 | : Lib.Orientation.HORIZONTAL 99 | ); 100 | fork.on_primary_display = data.on_primary_display; 101 | fork.length_left = data.length_left; 102 | fork.prev_length_left = data.prev_length_left; 103 | fork.prev_ratio = data.prev_ratio; 104 | fork.orientation_changed = data.orientation_changed; 105 | fork.is_toplevel = data.is_toplevel; 106 | fork.smart_gapped = data.smart_gapped; 107 | return fork; 108 | } 109 | 110 | /** The calculated left area of this fork */ 111 | area_of_left(ext: Ext): Rect.Rectangle { 112 | return new Rect.Rectangle( 113 | this.is_horizontal() 114 | ? [ 115 | this.area.x, 116 | this.area.y, 117 | this.length_left - ext.gap_inner_half, 118 | this.area.height, 119 | ] 120 | : [ 121 | this.area.x, 122 | this.area.y, 123 | this.area.width, 124 | this.length_left - ext.gap_inner_half, 125 | ] 126 | ); 127 | } 128 | 129 | /** The calculated right area of this fork */ 130 | area_of_right(ext: Ext): Rect.Rectangle { 131 | let area: [number, number, number, number]; 132 | 133 | if (this.is_horizontal()) { 134 | const width = this.area.width - this.length_left + ext.gap_inner; 135 | area = [ 136 | width, 137 | this.area.y, 138 | this.area.width - width, 139 | this.area.height, 140 | ]; 141 | } else { 142 | const height = this.area.height - this.length_left + ext.gap_inner; 143 | area = [ 144 | this.area.x, 145 | height, 146 | this.area.width, 147 | this.area.height - height, 148 | ]; 149 | } 150 | 151 | return new Rect.Rectangle(area); 152 | } 153 | 154 | depth(): number { 155 | return this.is_horizontal() ? this.area.height : this.area.width; 156 | } 157 | 158 | find_branch(entity: Entity): Node | null { 159 | const locate = (branch: Node): Node | null => { 160 | switch (branch.inner.kind) { 161 | case 2: 162 | if (Ecs.entity_eq(branch.inner.entity, entity)) { 163 | return branch; 164 | } 165 | 166 | break; 167 | } 168 | 169 | return null; 170 | }; 171 | 172 | const node = locate(this.left); 173 | if (node) return node; 174 | 175 | return this.right ? locate(this.right) : null; 176 | } 177 | 178 | /** If this fork has a horizontal orientation */ 179 | is_horizontal(): boolean { 180 | return Lib.Orientation.HORIZONTAL == this.orientation; 181 | } 182 | 183 | length(): number { 184 | return this.is_horizontal() ? this.area.width : this.area.height; 185 | } 186 | 187 | /** Replaces the association of a window in a fork with another */ 188 | replace_window(a: ShellWindow, b: ShellWindow): null | (() => void) { 189 | let closure = null; 190 | 191 | let check_right = () => { 192 | if (this.right) { 193 | const inner = this.right.inner; 194 | if (inner.kind === 2) { 195 | closure = () => { 196 | inner.entity = b.entity; 197 | }; 198 | } 199 | } 200 | }; 201 | 202 | switch (this.left.inner.kind) { 203 | case 1: 204 | check_right(); 205 | break; 206 | case 2: 207 | const inner = this.left.inner; 208 | if (Ecs.entity_eq(inner.entity, a.entity)) { 209 | closure = () => { 210 | inner.entity = b.entity; 211 | }; 212 | } else { 213 | check_right(); 214 | } 215 | 216 | break; 217 | } 218 | 219 | return closure; 220 | } 221 | 222 | /** Sets a new area for this fork */ 223 | set_area(area: Rectangle): Rectangle { 224 | this.area = area; 225 | return this.area; 226 | } 227 | 228 | /** Sets the ratio of this fork 229 | * 230 | * Ensures that the ratio is never smaller or larger than the constraints. 231 | */ 232 | set_ratio(left_length: number): Fork { 233 | const fork_len = this.is_horizontal() 234 | ? this.area.width 235 | : this.area.height; 236 | const clamped = Math.round( 237 | Math.max(256, Math.min(fork_len - 256, left_length)) 238 | ); 239 | this.prev_length_left = clamped; 240 | this.length_left = clamped; 241 | return this; 242 | } 243 | 244 | /** Defines this fork as a top level fork, and records it in the forest */ 245 | set_toplevel( 246 | tiler: Forest, 247 | entity: Entity, 248 | string: string, 249 | id: [number, number] 250 | ): Fork { 251 | this.is_toplevel = true; 252 | tiler.toplevel.set(string, [entity, id]); 253 | return this; 254 | } 255 | 256 | /** Calculates the future arrangement of windows in this fork */ 257 | measure( 258 | tiler: Forest, 259 | ext: Ext, 260 | area: Rectangle, 261 | record: (win: Entity, parent: Entity, area: Rectangle) => void 262 | ) { 263 | let ratio = null; 264 | 265 | let manually_moved = ext.grab_op !== null || ext.tiler.resizing_window; 266 | 267 | if (!this.is_toplevel) { 268 | if (this.orientation_changed) { 269 | this.orientation_changed = false; 270 | ratio = this.length_left / this.depth(); 271 | } else { 272 | ratio = this.length_left / this.length(); 273 | } 274 | 275 | this.area = this.set_area(area.clone()); 276 | } else if (this.orientation_changed) { 277 | this.orientation_changed = false; 278 | ratio = this.length_left / this.depth(); 279 | } 280 | 281 | if (ratio) { 282 | this.length_left = Math.round(ratio * this.length()); 283 | if (manually_moved) this.prev_ratio = ratio; 284 | } else if (manually_moved) { 285 | this.prev_ratio = this.length_left / this.length(); 286 | } 287 | 288 | if (this.right) { 289 | const [l, p, startpos] = this.is_horizontal() 290 | ? [WIDTH, XPOS, this.area.x] 291 | : [HEIGHT, YPOS, this.area.y]; 292 | 293 | let region = this.area.clone(); 294 | 295 | const half = ~~(this.area.array[l] / 32) * 16; 296 | 297 | let length; 298 | if (this.length_left > half - 32 && this.length_left < half + 32) { 299 | length = half; 300 | } else { 301 | const diff = (startpos + this.length_left) % 32; 302 | length = this.length_left - diff + (diff > 16 ? 32 : 0); 303 | if (length == 0) length = 32; 304 | } 305 | 306 | region.array[l] = length - ext.gap_inner_half; 307 | 308 | this.left.measure(tiler, ext, this.entity, region, record); 309 | 310 | region.array[p] = region.array[p] + length + ext.gap_inner_half; 311 | region.array[l] = this.area.array[l] - length - ext.gap_inner_half; 312 | 313 | this.right.measure(tiler, ext, this.entity, region, record); 314 | } else { 315 | this.left.measure(tiler, ext, this.entity, this.area, record); 316 | } 317 | } 318 | 319 | migrate( 320 | ext: Ext, 321 | forest: Forest, 322 | area: Rectangle, 323 | monitor: number, 324 | workspace: number 325 | ) { 326 | if (ext.auto_tiler && this.is_toplevel) { 327 | const primary = global.display.get_primary_monitor() === monitor; 328 | 329 | this.monitor = monitor; 330 | this.workspace = workspace; 331 | this.on_primary_display = primary; 332 | 333 | let blocked = new Array(); 334 | 335 | forest.toplevel.set(forest.string_reps.get(this.entity) as string, [ 336 | this.entity, 337 | [monitor, workspace], 338 | ]); 339 | 340 | for (const child of forest.iter(this.entity)) { 341 | switch (child.inner.kind) { 342 | case 1: 343 | const cfork = forest.forks.get(child.inner.entity); 344 | if (!cfork) continue; 345 | cfork.workspace = workspace; 346 | cfork.monitor = monitor; 347 | cfork.on_primary_display = primary; 348 | break; 349 | case 2: 350 | let window = ext.windows.get(child.inner.entity); 351 | if (window) { 352 | ext.size_signals_block(window); 353 | window.reassignment = false; 354 | window.known_workspace = workspace; 355 | window.meta.change_workspace_by_index( 356 | workspace, 357 | true 358 | ); 359 | ext.monitors.insert(window.entity, [ 360 | monitor, 361 | workspace, 362 | ]); 363 | blocked.push(window); 364 | } 365 | break; 366 | } 367 | } 368 | 369 | area.x += ext.gap_outer; 370 | area.y += ext.gap_outer; 371 | area.width -= ext.gap_outer * 2; 372 | area.height -= ext.gap_outer * 2; 373 | 374 | this.set_area(area.clone()); 375 | this.measure(forest, ext, area, forest.on_record()); 376 | forest.arrange(ext, workspace, true); 377 | 378 | for (const window of blocked) { 379 | ext.size_signals_unblock(window); 380 | } 381 | } 382 | } 383 | 384 | rebalance_orientation() { 385 | this.set_orientation( 386 | this.area.height > this.area.width 387 | ? Lib.Orientation.VERTICAL 388 | : Lib.Orientation.HORIZONTAL 389 | ); 390 | } 391 | 392 | set_orientation(o: Lib.Orientation) { 393 | if (o !== this.orientation) { 394 | this.orientation = o; 395 | this.orientation_changed = true; 396 | } 397 | } 398 | 399 | /** Swaps the left branch with the right branch, if there is a right branch */ 400 | swap_branches() { 401 | if (this.right) { 402 | const temp = this.left; 403 | this.left = this.right; 404 | this.right = temp; 405 | } 406 | } 407 | 408 | /** Toggles the orientation of this fork */ 409 | toggle_orientation() { 410 | this.orientation = 411 | Lib.Orientation.HORIZONTAL === this.orientation 412 | ? Lib.Orientation.VERTICAL 413 | : Lib.Orientation.HORIZONTAL; 414 | 415 | this.orientation_changed = true; 416 | if (this.n_toggled === 1) { 417 | if (this.right) { 418 | const tmp = this.right; 419 | this.right = this.left; 420 | this.left = tmp; 421 | } 422 | this.n_toggled = 0; 423 | } else { 424 | this.n_toggled += 1; 425 | } 426 | } 427 | } 428 | --------------------------------------------------------------------------------