25 Commits

Author SHA1 Message Date
Lucas Oskorep
19696298d0 Merge pull request 'feat/percentage-based-container-sizing' (#18) from feat/percentage-based-container-sizing into main
All checks were successful
Build and Test / build (push) Successful in 23s
Build and Test / release (push) Successful in 11s
Reviewed-on: #18
2026-02-25 11:36:39 -05:00
Lucas Oskorep
4be7602316 feat: adding support for changing the size of windows during window resize events to aerospike. Also cleaning up imports and simplifying some of the settings logic.
All checks were successful
Build and Test / build (pull_request) Successful in 23s
Build and Test / release (pull_request) Has been skipped
2026-02-25 11:35:59 -05:00
Lucas Oskorep
3d2da0a4bc Update .gitea/workflows/build.yaml
All checks were successful
Build and Test / build (push) Successful in 24s
Build and Test / release (push) Successful in 11s
2026-01-29 02:47:15 -05:00
Lucas Oskorep
a17441cda3 Merge pull request 'chore(deps): update all-dependencies' (#12) from renovate/all into main
Some checks failed
Build and Test / build (push) Successful in 24s
Build and Test / release (push) Has been cancelled
Reviewed-on: #12
2026-01-29 02:46:04 -05:00
Lucas Oskorep
c4f5835424 fix: renovate updating past v3 for upload when v4+ is unsupported in gitea
Some checks failed
Build and Test / build (pull_request) Successful in 33s
Build and Test / release (pull_request) Has been cancelled
2026-01-29 02:45:04 -05:00
Lucas Oskorep
7773c3b808 feat: update code to work with new libs
Some checks failed
Build and Test / build (pull_request) Failing after 24s
Build and Test / release (pull_request) Has been cancelled
2026-01-29 02:41:59 -05:00
Renovate Bot
b8e861cf9e chore(deps): update all-dependencies
Some checks failed
Build and Test / build (pull_request) Failing after 24s
Build and Test / release (pull_request) Has been cancelled
2026-01-29 02:38:21 -05:00
Lucas Oskorep
318ce6c064 Update .gitea/workflows/build.yaml
Some checks failed
Build and Test / build (push) Failing after 39s
Build and Test / release (push) Has been cancelled
2026-01-29 02:36:39 -05:00
Lucas Oskorep
1a4d8253f7 Merge pull request 'chore(deps): update all-dependencies' (#10) from renovate/all into main
Some checks failed
Build and Test / build (push) Has been cancelled
Build and Test / release (push) Has been cancelled
Reviewed-on: #10
2026-01-29 01:57:18 -05:00
Renovate Bot
86786ba831 chore(deps): update all-dependencies
Some checks failed
renovate/artifacts Artifact file update failure
Build and Test / build (pull_request) Has been cancelled
Build and Test / release (pull_request) Has been cancelled
2026-01-29 06:55:32 +00:00
Lucas Oskorep
bfd027f6c3 Merge pull request 'chore: Configure Renovate' (#8) from renovate/configure into main
Some checks failed
Build and Test / build (push) Has been cancelled
Build and Test / release (push) Has been cancelled
Reviewed-on: #8
Reviewed-by: Lucas Oskorep <lucas.oskorep@gmail.com>
2026-01-29 01:46:16 -05:00
Renovate Bot
b46810bc0f Add renovate.json
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Build and Test / release (pull_request) Has been cancelled
2026-01-29 06:45:35 +00:00
Lucas Oskorep
c4abd1e8e5 revert df9bc3e1eb
Some checks failed
Build and Test / build (push) Has been cancelled
Build and Test / release (push) Has been cancelled
revert Merge pull request 'chore: Configure Renovate' (#1) from renovate/configure into main

Reviewed-on: #1
2026-01-29 01:36:37 -05:00
Lucas Oskorep
df9bc3e1eb Merge pull request 'chore: Configure Renovate' (#1) from renovate/configure into main
Some checks failed
Build and Test / build (push) Has been cancelled
Build and Test / release (push) Has been cancelled
Reviewed-on: #1
2026-01-29 01:05:11 -05:00
Renovate Bot
90a4e19751 Add renovate.json
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Build and Test / release (pull_request) Has been cancelled
2026-01-29 05:44:36 +00:00
Lucas Oskorep
5da4001a32 feat: gitea pipeline fix for upload
All checks were successful
Build and Test / build (push) Successful in 27s
Build and Test / release (push) Successful in 11s
2025-10-17 02:50:21 -04:00
Lucas Oskorep
9011cd48f2 feat: gitea pipeline fix for upload
All checks were successful
Build and Test / build (push) Successful in 36s
2025-10-17 02:46:56 -04:00
Lucas Oskorep
e065acd37d feat: gitea pipeline
Some checks failed
Build and Test / build (push) Failing after 1m2s
2025-10-17 02:42:12 -04:00
Lucas Oskorep
b4afd6d4dc feat: gitea pipeline
Some checks failed
Build and Test / build (push) Has been cancelled
2025-10-17 02:38:04 -04:00
Lucas Oskorep
1ae379868b feat: adding basic unit tests and framework for them 2025-10-17 02:06:16 -04:00
Lucas Oskorep
8d4e51284d bugfixes 2025-10-17 01:56:09 -04:00
Lucas Oskorep
9f46347179 feat: cleanup 2025-10-17 01:26:57 -04:00
Lucas Oskorep
5a6c3ccd72 Finish adding debugging command and ability to swap the orientation of a container 2025-10-17 01:25:47 -04:00
Lucas Oskorep
c977c61714 adding debug command 2025-10-17 01:09:13 -04:00
Lucas Oskorep
e615efceb9 feat: removing prettyborders zip 2025-10-17 00:08:57 -04:00
20 changed files with 3781 additions and 994 deletions

View File

@@ -0,0 +1,99 @@
name: Build and Test
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: archlinux
container:
image: node:25
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm and TypeScript
run: npm install -g pnpm typescript@5.8.3
- name: Install just
run: |
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
- name: Install system dependencies
run: |
apt-get update
apt-get install -y libglib2.0-dev glib-networking zip
- name: Install project dependencies
run: pnpm install
- name: Run unit tests
run: just test
- name: Build and package extension
run: just build-package
- name: Upload extension package
uses: actions/upload-artifact@v3
with:
name: aerospike-extension
path: aerospike.zip
retention-days: 30
release:
needs: build
runs-on: archlinux
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
container:
image: node:25
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: aerospike-extension
- name: Get version from package.json
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=v${VERSION}" >> $GITHUB_OUTPUT
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
curl -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "${{ steps.get_version.outputs.version }}",
"name": "Release ${{ steps.get_version.outputs.version }}",
"body": "Automated release of aerospike GNOME extension ${{ steps.get_version.outputs.version }}\n\n## Installation\nDownload aerospike.zip and install it as a GNOME extension."
}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases"
- name: Upload Release Asset
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
RELEASE_ID=$(curl -H "Authorization: token ${GITEA_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${{ steps.get_version.outputs.version }}" | \
grep -Po '"id":\s*\K[0-9]+' | head -1)
curl -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: multipart/form-data" \
-F "attachment=@aerospike.zip" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=aerospike.zip"

2
.nvmrc
View File

@@ -1 +1 @@
v20
24

View File

@@ -1 +1 @@
# Aerospike Gnome (Tiling Window Manager)
# Aerospike Gnome (Tiling Window Manager)

View File

@@ -1,5 +1,5 @@
import Meta from 'gi://Meta';
import {Extension, ExtensionMetadata} from 'resource:///org/gnome/shell/extensions/extension.js';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import Gio from 'gi://Gio';
import Shell from 'gi://Shell';
@@ -11,11 +11,11 @@ export default class aerospike extends Extension {
keyBindings: Map<string, number>;
windowManager: WindowManager;
constructor(metadata: ExtensionMetadata) {
constructor(metadata: ConstructorParameters<typeof Extension>[0]) {
super(metadata);
this.settings = this.getSettings('org.gnome.shell.extensions.aerospike');
this.keyBindings = new Map();
this.windowManager = new WindowManager();
this.windowManager = new WindowManager(this.settings);
}
enable() {
@@ -30,32 +30,25 @@ export default class aerospike extends Extension {
this.removeKeybindings()
}
private keybindingActions(): Record<string, () => void> {
return {
'move-left': () => { Logger.info('Keybinding 1 was pressed!'); },
'move-right': () => { Logger.info('Keybinding 2 was pressed!'); },
'join-with-left': () => { Logger.info('Keybinding 3 was pressed!'); },
'join-with-right': () => { Logger.info('Keybinding 4 was pressed!'); },
'print-tree': () => { this.windowManager.printTreeStructure(); },
'toggle-orientation': () => { this.windowManager.toggleActiveContainerOrientation(); },
'reset-ratios': () => { this.windowManager.resetActiveContainerRatios(); },
};
}
private bindSettings() {
// Monitor settings changes
this.settings.connect('changed::move-left', () => {
log(`Keybinding 1 changed to: ${this.settings.get_strv('move-left')}`);
this.refreshKeybinding('move-left');
});
this.settings.connect('changed::move-right', () => {
log(`Keybinding 2 changed to: ${this.settings.get_strv('move-right')}`);
this.refreshKeybinding('move-right');
});
this.settings.connect('changed::join-with-left', () => {
log(`Keybinding 3 changed to: ${this.settings.get_strv('join-with-left')}`);
this.refreshKeybinding('join-with-left');
});
this.settings.connect('changed::join-with-right', () => {
log(`Keybinding 4 changed to: ${this.settings.get_strv('join-with-right')}`);
this.refreshKeybinding('join-with-right');
});
this.settings.connect('changed::remove-all-dividers', () => {
log(`Keybinding remove-all-dividers changed to: ${this.settings.get_strv('remove-all-dividers')}`);
this.refreshKeybinding('remove-all-dividers');
const keybindings = Object.keys(this.keybindingActions());
keybindings.forEach(name => {
this.settings.connect(`changed::${name}`, () => {
log(`${name} keybinding changed to: ${this.settings.get_strv(name)}`);
this.refreshKeybinding(name);
});
});
this.settings.connect('changed::dropdown-option', () => {
@@ -66,40 +59,15 @@ export default class aerospike extends Extension {
log(`Color selection changed to: ${this.settings.get_string('color-selection')}`);
});
}
private refreshKeybinding(settingName: string) {
if (this.keyBindings.has(settingName)) {
Main.wm.removeKeybinding(settingName);
this.keyBindings.delete(settingName);
}
switch (settingName) {
case 'move-left':
this.bindKeybinding('move-left', () => {
Logger.info('Keybinding 1 was pressed!');
});
break;
case 'move-right':
this.bindKeybinding('move-right', () => {
Logger.info('Keybinding 2 was pressed!');
});
break;
case 'join-with-left':
this.bindKeybinding('join-with-left', () => {
Logger.info('Keybinding 3 was pressed!');
});
break;
case 'join-with-right':
this.bindKeybinding('join-with-right', () => {
Logger.info('Keybinding 4 was pressed!');
});
break;
case 'remove-all-dividers':
this.bindKeybinding('remove-all-dividers', () => {
Logger.info('Remove all dividers keybinding pressed!');
this.windowManager.removeAllDividersFromActiveContainer();
});
break;
}
const action = this.keybindingActions()[settingName];
if (action) this.bindKeybinding(settingName, action);
}
private removeKeybindings() {
@@ -110,26 +78,10 @@ export default class aerospike extends Extension {
}
private setupKeybindings() {
this.bindKeybinding('move-left', () => {
Logger.info('Keybinding 1 was pressed!');
});
this.bindKeybinding('move-right', () => {
Logger.info('Keybinding 2 was pressed!');
});
this.bindKeybinding('join-with-left', () => {
Logger.info('Keybinding 3 was pressed!');
});
this.bindKeybinding('join-with-right', () => {
Logger.info('Keybinding 4 was pressed!');
});
this.bindKeybinding('remove-all-dividers', () => {
Logger.info('Remove all dividers keybinding pressed!');
this.windowManager.removeAllDividersFromActiveContainer();
});
const actions = this.keybindingActions();
for (const [name, action] of Object.entries(actions)) {
this.bindKeybinding(name, action);
}
}
private bindKeybinding(settingName: string, callback: () => void) {
@@ -149,7 +101,4 @@ export default class aerospike extends Extension {
this.keyBindings.set(settingName, keyBindingAction);
}
}
}

23
jest.config.js Normal file
View File

@@ -0,0 +1,23 @@
export default {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.ts$': [
'ts-jest',
{
useESM: true,
},
],
},
testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/__tests__/**',
],
modulePathIgnorePatterns: ['<rootDir>/dist/', '<rootDir>/node_modules/'],
};

View File

@@ -8,7 +8,7 @@ packages:
build: packages && build-schemas
rm -rf dist/*
tsc
pnpm exec tsc
cp metadata.json dist/
cp stylesheet.css dist/
mkdir -p dist/schemas
@@ -28,13 +28,25 @@ install: build
cp -r dist/* ~/.local/share/gnome-shell/extensions/{{NAME}}@{{DOMAIN}}/
run:
env MUTTER_DEBUG_DUMMY_MODE_SPECS=1280x720 dbus-run-session -- gnome-shell --nested --wayland
env MUTTER_DEBUG_DUMMY_MODE_SPECS=1280x720 dbus-run-session -- gnome-shell --devkit --wayland
install-and-run: install run
live-debug:
journalctl /usr/bin/gnome-shell -f -o cat | tee debug.log
test:
pnpm test
test-watch:
pnpm test:watch
test-coverage:
pnpm test:coverage
ci-local:
act -W .gitea/workflows/build.yaml
#pack: build
# gnome-extensions pack dist \
# --force \
@@ -42,4 +54,4 @@ live-debug:
# --schema ../schemas/org.gnome.shell.extensions.aerospike.gschema.xml
#
#install-pack: pack
# gnome-extensions install ./{{FULL_NAME}}.shell-extension.zip --force
# gnome-extensions install ./{{FULL_NAME}}.shell-extension.zip --force

View File

@@ -15,11 +15,20 @@
},
"homepage": "https://github.com/example/my-extension#readme",
"sideEffects": false,
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"devDependencies": {
"@girs/gjs": "4.0.0-beta.38",
"@girs/gnome-shell": "49.0.1",
"@girs/gnome-shell": "49.1.0",
"@jest/globals": "^30.0.0",
"@types/jest": "^30.0.0",
"eslint": "^9.36.0",
"eslint-plugin-jsdoc": "^50.8.0",
"eslint-plugin-jsdoc": "^62.0.0",
"jest": "^30.0.0",
"ts-jest": "^29.1.2",
"typescript": "^5.9.2"
},
"dependencies": {

2964
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

12
renovate.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"matchPackageNames": ["actions/upload-artifact", "actions/download-artifact"],
"allowedVersions": "<4"
}
]
}

View File

@@ -37,10 +37,22 @@
<description>Keyboard shortcut for triggering action 4</description>
</key>
<key name="remove-all-dividers" type="as">
<default><![CDATA[['<Alt>z']]]></default>
<summary>Remove all dividers from active container</summary>
<description>Keyboard shortcut for removing all dividers from the container with the active window</description>
<key name="print-tree" type="as">
<default><![CDATA[['<Super><Shift>x']]]></default>
<summary>Print window tree structure</summary>
<description>Prints the current tree of containers and windows per monitor to logs</description>
</key>
<key name="toggle-orientation" type="as">
<default><![CDATA[['<Super><Shift>comma']]]></default>
<summary>Toggle active container orientation</summary>
<description>Toggles the orientation of the container holding the active window between horizontal and vertical</description>
</key>
<key name="reset-ratios" type="as">
<default><![CDATA[['<Primary>z']]]></default>
<summary>Reset container ratios to equal splits</summary>
<description>Resets all window size ratios in the active window's container to equal splits</description>
</key>
</schema>

View File

@@ -0,0 +1,182 @@
import { describe, test, expect, jest, beforeEach } from '@jest/globals';
// Mock the dependencies
jest.mock('../utils/logger.js', () => ({
Logger: {
log: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('../utils/events.js', () => ({
default: jest.fn(),
}));
// Since we can't import the actual WindowContainer that depends on GNOME APIs,
// we'll test the logic patterns used in the container
describe('Container Logic Tests', () => {
describe('Orientation Toggle Logic', () => {
enum Orientation {
HORIZONTAL = 0,
VERTICAL = 1,
}
const toggleOrientation = (current: Orientation): Orientation => {
return current === Orientation.HORIZONTAL
? Orientation.VERTICAL
: Orientation.HORIZONTAL;
};
test('should toggle from HORIZONTAL to VERTICAL', () => {
const result = toggleOrientation(Orientation.HORIZONTAL);
expect(result).toBe(Orientation.VERTICAL);
});
test('should toggle from VERTICAL to HORIZONTAL', () => {
const result = toggleOrientation(Orientation.VERTICAL);
expect(result).toBe(Orientation.HORIZONTAL);
});
});
describe('Window Bounds Calculation', () => {
test('should calculate horizontal bounds correctly', () => {
const workArea = { x: 0, y: 0, width: 1000, height: 500 };
const itemCount = 3;
const windowWidth = Math.floor(workArea.width / itemCount);
const bounds = Array.from({ length: itemCount }, (_, index) => ({
x: workArea.x + (index * windowWidth),
y: workArea.y,
width: windowWidth,
height: workArea.height,
}));
expect(bounds.length).toBe(3);
expect(bounds[0].x).toBe(0);
expect(bounds[1].x).toBe(333);
expect(bounds[2].x).toBe(666);
expect(bounds[0].width).toBe(333);
});
test('should calculate vertical bounds correctly', () => {
const workArea = { x: 0, y: 0, width: 1000, height: 900 };
const itemCount = 3;
const windowHeight = Math.floor(workArea.height / itemCount);
const bounds = Array.from({ length: itemCount }, (_, index) => ({
x: workArea.x,
y: workArea.y + (index * windowHeight),
width: workArea.width,
height: windowHeight,
}));
expect(bounds.length).toBe(3);
expect(bounds[0].y).toBe(0);
expect(bounds[1].y).toBe(300);
expect(bounds[2].y).toBe(600);
expect(bounds[0].height).toBe(300);
});
test('should handle single window bounds', () => {
const workArea = { x: 100, y: 50, width: 800, height: 600 };
const itemCount = 1;
const windowWidth = Math.floor(workArea.width / itemCount);
const bounds = [{
x: workArea.x,
y: workArea.y,
width: windowWidth,
height: workArea.height,
}];
expect(bounds[0].x).toBe(100);
expect(bounds[0].y).toBe(50);
expect(bounds[0].width).toBe(800);
expect(bounds[0].height).toBe(600);
});
});
describe('Window Index Finding', () => {
test('should find window index in array', () => {
const windows = [
{ id: 1, title: 'Window 1' },
{ id: 2, title: 'Window 2' },
{ id: 3, title: 'Window 3' },
];
const findIndex = (id: number) => {
for (let i = 0; i < windows.length; i++) {
if (windows[i].id === id) {
return i;
}
}
return -1;
};
expect(findIndex(2)).toBe(1);
expect(findIndex(3)).toBe(2);
expect(findIndex(999)).toBe(-1);
});
test('should safely remove window by index', () => {
const windows = [
{ id: 1, title: 'Window 1' },
{ id: 2, title: 'Window 2' },
{ id: 3, title: 'Window 3' },
];
const removeWindow = (id: number) => {
const index = windows.findIndex(w => w.id === id);
if (index !== -1) {
windows.splice(index, 1);
return true;
}
return false;
};
const removed = removeWindow(2);
expect(removed).toBe(true);
expect(windows.length).toBe(2);
expect(windows.find(w => w.id === 2)).toBeUndefined();
});
});
describe('Container Item Reordering', () => {
test('should reorder items correctly', () => {
const items = ['A', 'B', 'C', 'D'];
const originalIndex = 1; // 'B'
const newIndex = 3;
// Remove from original position and insert at new position
const [item] = items.splice(originalIndex, 1);
items.splice(newIndex, 0, item);
expect(items).toEqual(['A', 'C', 'D', 'B']);
});
test('should handle reordering to same position', () => {
const items = ['A', 'B', 'C'];
const originalIndex = 1;
const newIndex = 1;
if (originalIndex !== newIndex) {
const [item] = items.splice(originalIndex, 1);
items.splice(newIndex, 0, item);
}
expect(items).toEqual(['A', 'B', 'C']);
});
test('should handle moving first item to last', () => {
const items = ['A', 'B', 'C'];
const [item] = items.splice(0, 1);
items.splice(2, 0, item);
expect(items).toEqual(['B', 'C', 'A']);
});
});
});

View File

@@ -0,0 +1,75 @@
import { describe, test, expect } from '@jest/globals';
import type { Rect } from '../utils/rect.js';
describe('Rect Type Tests', () => {
test('should create a valid Rect object', () => {
const rect: Rect = {
x: 10,
y: 20,
width: 100,
height: 200,
};
expect(rect.x).toBe(10);
expect(rect.y).toBe(20);
expect(rect.width).toBe(100);
expect(rect.height).toBe(200);
});
test('should handle zero dimensions', () => {
const rect: Rect = {
x: 0,
y: 0,
width: 0,
height: 0,
};
expect(rect.width).toBe(0);
expect(rect.height).toBe(0);
});
test('should handle negative coordinates', () => {
const rect: Rect = {
x: -50,
y: -100,
width: 200,
height: 300,
};
expect(rect.x).toBe(-50);
expect(rect.y).toBe(-100);
});
test('should calculate rect area correctly', () => {
const rect: Rect = {
x: 0,
y: 0,
width: 100,
height: 50,
};
const area = rect.width * rect.height;
expect(area).toBe(5000);
});
test('should determine if point is inside rect', () => {
const rect: Rect = {
x: 10,
y: 10,
width: 100,
height: 100,
};
const pointInside = { x: 50, y: 50 };
const pointOutside = { x: 200, y: 200 };
const isInside = (point: { x: number; y: number }, r: Rect) =>
point.x >= r.x &&
point.x <= r.x + r.width &&
point.y >= r.y &&
point.y <= r.y + r.height;
expect(isInside(pointInside, rect)).toBe(true);
expect(isInside(pointOutside, rect)).toBe(false);
});
});

View File

@@ -155,6 +155,32 @@ export default class AerospikeExtensions extends ExtensionPreferences {
})
);
keybindingsGroup.add(
new EntryRow({
title: _('Print Tree Structure'),
settings: settings,
bind: 'print-tree',
map: keybindingMap
})
);
keybindingsGroup.add(
new EntryRow({
title: _('Toggle Orientation'),
settings: settings,
bind: 'toggle-orientation',
map: keybindingMap
})
);
keybindingsGroup.add(
new EntryRow({
title: _('Reset Container Ratios to Equal'),
settings: settings,
bind: 'reset-ratios',
map: keybindingMap
})
);
}

View File

@@ -1,20 +1,21 @@
import GLib from "gi://GLib";
export type QueuedEvent = {
name: string;
callback: () => void;
}
const queuedEvents: QueuedEvent[] = [];
const pendingEvents: Map<string, QueuedEvent> = new Map();
export default function queueEvent(event: QueuedEvent, interval = 200) {
queuedEvents.push(event);
pendingEvents.set(event.name, event);
GLib.timeout_add(GLib.PRIORITY_DEFAULT, interval, () => {
const e = queuedEvents.pop()
if (e) {
const e = pendingEvents.get(event.name);
if (e && e === event) {
pendingEvents.delete(event.name);
e.callback();
}
return queuedEvents.length !== 0;
return GLib.SOURCE_REMOVE;
});
}
}

View File

@@ -1,49 +1,87 @@
import {WindowWrapper} from "./window.js";
import {Logger} from "../utils/logger.js";
import Meta from "gi://Meta";
import queueEvent from "../utils/events.js";
import {Rect} from "../utils/rect.js";
import {Divider} from "./divider.js";
enum Orientation {
HORIZONTAL = 0,
VERTICAL = 1,
}
type ContainerItem = WindowWrapper | WindowContainer | Divider;
// Returns equal ratios summing exactly to 1.0, with float drift absorbed by the last slot.
function equalRatios(n: number): number[] {
if (n <= 0) return [];
const base = 1 / n;
const ratios = Array(n).fill(base);
const sumExceptLast = ratios.slice(0, -1).reduce((a, b) => a + b, 0);
ratios[n - 1] = 1 - sumExceptLast;
return ratios;
}
export default class WindowContainer {
_tiledItems: ContainerItem[];
_tiledItems: (WindowWrapper | WindowContainer)[];
_tiledWindowLookup: Map<number, WindowWrapper>;
_orientation: Orientation = Orientation.HORIZONTAL;
_workArea: Rect;
_splitRatios: number[];
constructor(workspaceArea: Rect,) {
// this._id = monitorId;
constructor(workspaceArea: Rect) {
this._tiledItems = [];
this._tiledWindowLookup = new Map<number, WindowWrapper>();
this._workArea = workspaceArea;
this._splitRatios = [];
}
// ─── Helpers ────────────────────────────────────────────────────────────────
private _resetRatios(): void {
this._splitRatios = equalRatios(this._tiledItems.length);
}
private _addRatioForNewWindow(): void {
const n = this._tiledItems.length;
if (n <= 1) {
this._splitRatios = [1.0];
return;
}
const newRatio = 1 / n;
const scale = 1 - newRatio;
const scaled = this._splitRatios.map(r => r * scale);
const partialSum = scaled.reduce((a, b) => a + b, 0) + newRatio;
scaled[scaled.length - 1] += (1.0 - partialSum);
this._splitRatios = [...scaled, newRatio];
}
private _totalDimension(): number {
return this._orientation === Orientation.HORIZONTAL
? this._workArea.width
: this._workArea.height;
}
// ─── Public API ─────────────────────────────────────────────────────────────
move(rect: Rect): void {
this._workArea = rect;
this.tileWindows();
}
toggleOrientation(): void {
this._orientation = this._orientation === Orientation.HORIZONTAL
? Orientation.VERTICAL
: Orientation.HORIZONTAL;
Logger.info(`Container orientation toggled to ${this._orientation === Orientation.HORIZONTAL ? 'HORIZONTAL' : 'VERTICAL'}`);
this.tileWindows();
}
addWindow(winWrap: WindowWrapper): void {
// Add window to managed windows
this._tiledItems.push(winWrap);
this._tiledWindowLookup.set(winWrap.getWindowId(), winWrap);
// winWrap.setParent(this);
this._addRatioForNewWindow();
queueEvent({
name: "tiling-windows",
callback: () => {
this.tileWindows();
}
}, 100)
callback: () => this.tileWindows(),
}, 100);
}
getWindow(win_id: number): WindowWrapper | undefined {
@@ -51,36 +89,35 @@ export default class WindowContainer {
return this._tiledWindowLookup.get(win_id);
}
for (const item of this._tiledItems) {
if (Divider.isDivider(item)) {
continue; // Skip dividers
} else if (item instanceof WindowContainer) {
if (item instanceof WindowContainer) {
const win = item.getWindow(win_id);
if (win) {
return win;
}
} else if (item instanceof WindowWrapper && item.getWindowId() === win_id) {
if (win) return win;
} else if (item.getWindowId() === win_id) {
return item;
}
}
return undefined
return undefined;
}
_getIndexOfWindow(win_id: number) {
_getIndexOfWindow(win_id: number): number {
for (let i = 0; i < this._tiledItems.length; i++) {
const item = this._tiledItems[i];
if (item instanceof WindowWrapper && item.getWindowId() === win_id) {
return i;
}
}
return -1
return -1;
}
removeWindow(win_id: number): void {
if (this._tiledWindowLookup.has(win_id)) {
// Get index before deleting from lookup to avoid race condition
const index = this._getIndexOfWindow(win_id);
this._tiledWindowLookup.delete(win_id);
const index = this._getIndexOfWindow(win_id)
this._tiledItems.splice(index, 1);
this._cleanupInvalidDividers();
if (index !== -1) {
this._tiledItems.splice(index, 1);
}
this._resetRatios();
} else {
for (const item of this._tiledItems) {
if (item instanceof WindowContainer) {
@@ -88,272 +125,121 @@ export default class WindowContainer {
}
}
}
this.tileWindows()
}
/**
* Removes invalid dividers from the items list.
* Invalid dividers are:
* - Dividers at the start or end of the list (no window on one side)
* - Consecutive dividers (two dividers in a row)
*/
_cleanupInvalidDividers(): void {
let i = 0;
while (i < this._tiledItems.length) {
const item = this._tiledItems[i];
if (Divider.isDivider(item)) {
// Check if divider is at start or end
const isAtStart = i === 0;
const isAtEnd = i === this._tiledItems.length - 1;
// Check if next item is also a divider
const nextIsDivider = i < this._tiledItems.length - 1 &&
Divider.isDivider(this._tiledItems[i + 1]);
if (isAtStart || isAtEnd || nextIsDivider) {
Logger.log(`Removing invalid divider at index ${i}`);
this._tiledItems.splice(i, 1);
continue; // Don't increment i, check the same position again
}
}
i++;
}
this.tileWindows();
}
disconnectSignals(): void {
this._tiledItems.forEach((item) => {
if (Divider.isDivider(item)) {
// Skip dividers - they don't have signals
return;
} else if (item instanceof WindowContainer) {
item.disconnectSignals()
} else if (item instanceof WindowWrapper) {
item.disconnectWindowSignals();
}
if (item instanceof WindowContainer) {
item.disconnectSignals();
} else {
item.disconnectWindowSignals();
}
)
});
}
removeAllWindows(): void {
this._tiledItems = []
this._tiledWindowLookup.clear()
this._tiledItems = [];
this._tiledWindowLookup.clear();
this._splitRatios = [];
}
tileWindows() {
Logger.log("TILING WINDOWS IN CONTAINER")
tileWindows(): void {
Logger.log("TILING WINDOWS IN CONTAINER");
Logger.log("WorkArea", this._workArea);
// Get all windows for current workspaceArea
this._tileItems()
return true
this._tileItems();
}
_tileItems() {
if (this._tiledItems.length === 0) {
return;
}
if (this._tiledItems.length === 0) return;
const bounds = this.getBounds();
// Apply bounds to non-divider items
let boundsIndex = 0;
this._tiledItems.forEach((item) => {
if (Divider.isDivider(item)) {
return; // Skip dividers
}
const rect = bounds[boundsIndex];
Logger.info(`_tileItems: ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}] bounds=[${bounds.map(b => `(${b.x},${b.y},${b.width},${b.height})`).join(', ')}]`);
this._tiledItems.forEach((item, index) => {
const rect = bounds[index];
if (item instanceof WindowContainer) {
item.move(rect);
} else if (item instanceof WindowWrapper) {
} else {
Logger.info(`_tileItems: window[${index}] id=${item.getWindowId()} dragging=${item._dragging} → rect=(${rect.x},${rect.y},${rect.width},${rect.height})`);
item.safelyResizeWindow(rect);
}
boundsIndex++;
})
});
}
// ─── Bounds Calculation ──────────────────────────────────────────────────────
getBounds(): Rect[] {
if (this._orientation === Orientation.HORIZONTAL) {
return this.getHorizontalBounds();
}
return this.getVerticalBounds();
return this._orientation === Orientation.HORIZONTAL
? this._computeBounds('horizontal')
: this._computeBounds('vertical');
}
getVerticalBounds(): Rect[] {
// Filter out dividers to get only windows/containers
const nonDividerItems = this._tiledItems.filter(item => !Divider.isDivider(item));
private _computeBounds(axis: 'horizontal' | 'vertical'): Rect[] {
const isHorizontal = axis === 'horizontal';
const total = isHorizontal ? this._workArea.width : this._workArea.height;
let used = 0;
if (nonDividerItems.length === 0) {
return [];
}
return this._tiledItems.map((_, index) => {
const offset = used;
const size = index === this._tiledItems.length - 1
? total - used
: Math.floor(this._splitRatios[index] * total);
used += size;
// If no dividers, use equal distribution
const hasDividers = this._tiledItems.some(item => Divider.isDivider(item));
if (!hasDividers) {
const containerHeight = Math.floor(this._workArea.height / nonDividerItems.length);
return nonDividerItems.map((_, index) => {
const y = this._workArea.y + (index * containerHeight);
return {
x: this._workArea.x,
y: y,
width: this._workArea.width,
height: containerHeight
} as Rect;
});
}
// Calculate bounds based on divider positions
const bounds: Rect[] = [];
let currentY = this._workArea.y;
let itemIndex = 0;
for (let i = 0; i < this._tiledItems.length; i++) {
const item = this._tiledItems[i];
if (Divider.isDivider(item)) {
// Next segment starts at divider position
currentY = this._workArea.y + Math.floor(item.getPosition() * this._workArea.height);
} else {
// Find the end position for this item
let endY: number = this._workArea.y + this._workArea.height;
// Look ahead to find next divider or end of container
for (let j = i + 1; j < this._tiledItems.length; j++) {
if (Divider.isDivider(this._tiledItems[j])) {
const divider = this._tiledItems[j] as Divider;
endY = this._workArea.y + Math.floor(divider.getPosition() * this._workArea.height);
break;
}
}
// Count non-divider items until next divider
let itemCount = 0;
let itemsInSegment: number[] = [];
for (let j = i; j < this._tiledItems.length; j++) {
if (Divider.isDivider(this._tiledItems[j])) {
break;
}
itemsInSegment.push(j);
itemCount++;
}
// Divide space equally among items in this segment
const segmentHeight = endY - currentY;
const itemHeight = Math.floor(segmentHeight / itemCount);
for (let k = 0; k < itemsInSegment.length; k++) {
const itemY = currentY + (k * itemHeight);
bounds.push({
x: this._workArea.x,
y: itemY,
width: this._workArea.width,
height: itemHeight
} as Rect);
}
// Skip the items we just processed
i += itemCount - 1;
currentY = endY;
}
}
return bounds;
return isHorizontal
? { x: this._workArea.x + offset, y: this._workArea.y, width: size, height: this._workArea.height }
: { x: this._workArea.x, y: this._workArea.y + offset, width: this._workArea.width, height: size };
});
}
getHorizontalBounds(): Rect[] {
// Filter out dividers to get only windows/containers
const nonDividerItems = this._tiledItems.filter(item => !Divider.isDivider(item));
// ─── Boundary Adjustment ─────────────────────────────────────────────────────
if (nonDividerItems.length === 0) {
return [];
adjustBoundary(boundaryIndex: number, deltaPixels: number): boolean {
if (boundaryIndex < 0 || boundaryIndex >= this._tiledItems.length - 1) {
Logger.warn(`adjustBoundary: invalid boundaryIndex ${boundaryIndex}`);
return false;
}
// If no dividers, use equal distribution
const hasDividers = this._tiledItems.some(item => Divider.isDivider(item));
if (!hasDividers) {
const windowWidth = Math.floor(this._workArea.width / nonDividerItems.length);
return nonDividerItems.map((_, index) => {
const x = this._workArea.x + (index * windowWidth);
return {
x: x,
y: this._workArea.y,
width: windowWidth,
height: this._workArea.height
} as Rect;
});
const totalDim = this._totalDimension();
if (totalDim === 0) return false;
const ratioDelta = deltaPixels / totalDim;
const newLeft = this._splitRatios[boundaryIndex] + ratioDelta;
const newRight = this._splitRatios[boundaryIndex + 1] - ratioDelta;
if (newLeft <= 0 || newRight <= 0) {
Logger.log(`adjustBoundary: clamped — newLeft=${newLeft.toFixed(3)}, newRight=${newRight.toFixed(3)}`);
return false;
}
// Calculate bounds based on divider positions
const bounds: Rect[] = [];
let currentX = this._workArea.x;
this._splitRatios[boundaryIndex] = newLeft;
this._splitRatios[boundaryIndex + 1] = newRight;
for (let i = 0; i < this._tiledItems.length; i++) {
const item = this._tiledItems[i];
Logger.info(`adjustBoundary: boundary=${boundaryIndex} ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}]`);
return true;
}
if (Divider.isDivider(item)) {
// Next segment starts at divider position
currentX = this._workArea.x + Math.floor(item.getPosition() * this._workArea.width);
} else {
// Find the end position for this item
let endX: number = this._workArea.x + this._workArea.width;
// ─── Container Lookup ────────────────────────────────────────────────────────
// Look ahead to find next divider or end of container
for (let j = i + 1; j < this._tiledItems.length; j++) {
if (Divider.isDivider(this._tiledItems[j])) {
const divider = this._tiledItems[j] as Divider;
endX = this._workArea.x + Math.floor(divider.getPosition() * this._workArea.width);
break;
}
}
// Count non-divider items until next divider
let itemCount = 0;
let itemsInSegment: number[] = [];
for (let j = i; j < this._tiledItems.length; j++) {
if (Divider.isDivider(this._tiledItems[j])) {
break;
}
itemsInSegment.push(j);
itemCount++;
}
// Divide space equally among items in this segment
const segmentWidth = endX - currentX;
const itemWidth = Math.floor(segmentWidth / itemCount);
for (let k = 0; k < itemsInSegment.length; k++) {
const itemX = currentX + (k * itemWidth);
bounds.push({
x: itemX,
y: this._workArea.y,
width: itemWidth,
height: this._workArea.height
} as Rect);
}
// Skip the items we just processed
i += itemCount - 1;
currentX = endX;
getContainerForWindow(win_id: number): WindowContainer | null {
for (const item of this._tiledItems) {
if (item instanceof WindowWrapper && item.getWindowId() === win_id) {
return this;
}
if (item instanceof WindowContainer) {
const found = item.getContainerForWindow(win_id);
if (found !== null) return found;
}
}
return bounds;
return null;
}
getIndexOfItemNested(item: WindowWrapper): number {
for (let i = 0; i < this._tiledItems.length; i++) {
const container = this._tiledItems[i];
if (Divider.isDivider(container)) {
continue; // Skip dividers
} else if (container instanceof WindowContainer) {
const index = container.getIndexOfItemNested(item);
if (index !== -1) {
return i;
}
} else if (container instanceof WindowWrapper && container.getWindowId() === item.getWindowId()) {
if (container instanceof WindowContainer) {
if (container.getIndexOfItemNested(item) !== -1) return i;
} else if (container.getWindowId() === item.getWindowId()) {
return i;
}
}
@@ -362,272 +248,30 @@ export default class WindowContainer {
// TODO: update this to work with nested containers - all other logic should already be working
itemDragged(item: WindowWrapper, x: number, y: number): void {
// Find the actual index in _tiledItems (including dividers)
const original_actual_index = this._getIndexOfWindow(item.getWindowId());
const original_index = this.getIndexOfItemNested(item);
if (original_actual_index === -1) {
if (original_index === -1) {
Logger.error("Item not found in container during drag op", item.getWindowId());
return;
}
// Find which visual slot (non-divider index) we're moving to
let new_visual_index = this.getIndexOfItemNested(item);
const bounds = this.getBounds();
bounds.forEach((rect, index) => {
let new_index = original_index;
this.getBounds().forEach((rect, index) => {
if (rect.x < x && rect.x + rect.width > x && rect.y < y && rect.y + rect.height > y) {
new_visual_index = index;
new_index = index;
}
})
});
// Get current visual index (counting only non-dividers before this item)
let original_visual_index = 0;
for (let i = 0; i < original_actual_index; i++) {
if (!Divider.isDivider(this._tiledItems[i])) {
original_visual_index++;
}
if (original_index !== new_index) {
Logger.info(`itemDragged: swapped slots ${original_index}<->${new_index}, ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}]`);
[this._tiledItems[original_index], this._tiledItems[new_index]] =
[this._tiledItems[new_index], this._tiledItems[original_index]];
this.tileWindows();
}
}
if (original_visual_index === new_visual_index) {
return; // No movement needed
}
Logger.log(`Swapping window from visual index ${original_visual_index} to ${new_visual_index}`);
// Find the target window at the new visual index
let target_actual_index = -1;
let visual_count = 0;
for (let i = 0; i < this._tiledItems.length; i++) {
if (!Divider.isDivider(this._tiledItems[i])) {
if (visual_count === new_visual_index) {
target_actual_index = i;
break;
}
visual_count++;
}
}
if (target_actual_index === -1) {
Logger.warn("Could not find target position for drag");
return;
}
// Simply swap the two windows in place, leaving dividers where they are
const temp = this._tiledItems[original_actual_index];
this._tiledItems[original_actual_index] = this._tiledItems[target_actual_index];
this._tiledItems[target_actual_index] = temp;
resetRatios(): void {
this._resetRatios();
this.tileWindows();
}
/**
* Handles window resize operations. Creates or updates dividers based on resize direction.
* @param item - The window being resized
* @param resizeEdge - The edge being resized (N, S, E, W, etc.)
* @param newRect - The new rectangle after resize
*/
handleWindowResize(item: WindowWrapper, resizeEdge: Meta.GrabOp, newRect: Rect): void {
const itemIndex = this._getIndexOfWindow(item.getWindowId());
if (itemIndex === -1) {
Logger.warn("Window not found in container during resize", item.getWindowId());
return;
}
// Determine if this is a valid resize for this container orientation
const isHorizontalResize = this._isHorizontalResizeOp(resizeEdge);
const isVerticalResize = this._isVerticalResizeOp(resizeEdge);
// Only allow horizontal resizes in horizontal containers
// Only allow vertical resizes in vertical containers
if (this._orientation === Orientation.HORIZONTAL && !isHorizontalResize) {
Logger.log("Ignoring vertical resize in horizontal container");
return;
}
if (this._orientation === Orientation.VERTICAL && !isVerticalResize) {
Logger.log("Ignoring horizontal resize in vertical container");
return;
}
// Determine which edge is being resized and find adjacent window
let adjacentIndex = -1;
let dividerPosition = 0;
if (this._orientation === Orientation.HORIZONTAL) {
// East/West resize
if (this._isEastResizeOp(resizeEdge)) {
// Resizing east edge - divider goes after this window
adjacentIndex = itemIndex + 1;
// Calculate divider position as ratio of container width
const rightEdge = newRect.x + newRect.width;
dividerPosition = (rightEdge - this._workArea.x) / this._workArea.width;
} else if (this._isWestResizeOp(resizeEdge)) {
// Resizing west edge - divider goes before this window
adjacentIndex = itemIndex - 1;
dividerPosition = (newRect.x - this._workArea.x) / this._workArea.width;
}
} else {
// Vertical orientation - North/South resize
if (this._isSouthResizeOp(resizeEdge)) {
// Resizing south edge - divider goes after this window
adjacentIndex = itemIndex + 1;
const bottomEdge = newRect.y + newRect.height;
dividerPosition = (bottomEdge - this._workArea.y) / this._workArea.height;
} else if (this._isNorthResizeOp(resizeEdge)) {
// Resizing north edge - divider goes before this window
adjacentIndex = itemIndex - 1;
dividerPosition = (newRect.y - this._workArea.y) / this._workArea.height;
}
}
// Make sure there's an adjacent item
if (adjacentIndex < 0 || adjacentIndex >= this._tiledItems.length) {
Logger.log("No adjacent window for resize operation");
return;
}
// Skip if adjacent item is already a divider
if (Divider.isDivider(this._tiledItems[adjacentIndex])) {
// Update existing divider
const divider = this._tiledItems[adjacentIndex] as Divider;
divider.setPosition(dividerPosition);
Logger.log(`Updated divider at index ${adjacentIndex} to position ${dividerPosition}`);
} else {
// Insert new divider between items
const dividerIndex = Math.max(itemIndex, adjacentIndex);
const newDivider = new Divider(dividerPosition, this._orientation);
this._tiledItems.splice(dividerIndex, 0, newDivider);
Logger.log(`Inserted new divider at index ${dividerIndex} with position ${dividerPosition}`);
}
this.tileWindows();
}
private _isHorizontalResizeOp(op: Meta.GrabOp): boolean {
return op === Meta.GrabOp.RESIZING_E ||
op === Meta.GrabOp.RESIZING_W ||
op === Meta.GrabOp.RESIZING_NE ||
op === Meta.GrabOp.RESIZING_NW ||
op === Meta.GrabOp.RESIZING_SE ||
op === Meta.GrabOp.RESIZING_SW;
}
private _isVerticalResizeOp(op: Meta.GrabOp): boolean {
return op === Meta.GrabOp.RESIZING_N ||
op === Meta.GrabOp.RESIZING_S ||
op === Meta.GrabOp.RESIZING_NE ||
op === Meta.GrabOp.RESIZING_NW ||
op === Meta.GrabOp.RESIZING_SE ||
op === Meta.GrabOp.RESIZING_SW;
}
private _isEastResizeOp(op: Meta.GrabOp): boolean {
return op === Meta.GrabOp.RESIZING_E ||
op === Meta.GrabOp.RESIZING_NE ||
op === Meta.GrabOp.RESIZING_SE;
}
private _isWestResizeOp(op: Meta.GrabOp): boolean {
return op === Meta.GrabOp.RESIZING_W ||
op === Meta.GrabOp.RESIZING_NW ||
op === Meta.GrabOp.RESIZING_SW;
}
private _isSouthResizeOp(op: Meta.GrabOp): boolean {
return op === Meta.GrabOp.RESIZING_S ||
op === Meta.GrabOp.RESIZING_SE ||
op === Meta.GrabOp.RESIZING_SW;
}
private _isNorthResizeOp(op: Meta.GrabOp): boolean {
return op === Meta.GrabOp.RESIZING_N ||
op === Meta.GrabOp.RESIZING_NE ||
op === Meta.GrabOp.RESIZING_NW;
}
/**
* Removes all dividers from this container, reverting to equal space distribution
*/
removeAllDividers(): void {
Logger.log("Removing all dividers from container");
this._tiledItems = this._tiledItems.filter(item => !Divider.isDivider(item));
this.tileWindows();
}
/**
* Updates divider position during a live resize operation (or creates if doesn't exist)
* This is called repeatedly during resize for live feedback
*/
updateDividerDuringResize(item: WindowWrapper, resizeEdge: Meta.GrabOp, newRect: Rect): void {
const itemIndex = this._getIndexOfWindow(item.getWindowId());
if (itemIndex === -1) {
return;
}
// Determine if this is a valid resize for this container orientation
const isHorizontalResize = this._isHorizontalResizeOp(resizeEdge);
const isVerticalResize = this._isVerticalResizeOp(resizeEdge);
if (this._orientation === Orientation.HORIZONTAL && !isHorizontalResize) {
return;
}
if (this._orientation === Orientation.VERTICAL && !isVerticalResize) {
return;
}
// Determine which edge is being resized and find adjacent window
let adjacentIndex = -1;
let dividerPosition = 0;
if (this._orientation === Orientation.HORIZONTAL) {
if (this._isEastResizeOp(resizeEdge)) {
adjacentIndex = itemIndex + 1;
const rightEdge = newRect.x + newRect.width;
dividerPosition = (rightEdge - this._workArea.x) / this._workArea.width;
} else if (this._isWestResizeOp(resizeEdge)) {
adjacentIndex = itemIndex - 1;
dividerPosition = (newRect.x - this._workArea.x) / this._workArea.width;
}
} else {
if (this._isSouthResizeOp(resizeEdge)) {
adjacentIndex = itemIndex + 1;
const bottomEdge = newRect.y + newRect.height;
dividerPosition = (bottomEdge - this._workArea.y) / this._workArea.height;
} else if (this._isNorthResizeOp(resizeEdge)) {
adjacentIndex = itemIndex - 1;
dividerPosition = (newRect.y - this._workArea.y) / this._workArea.height;
}
}
// Make sure there's an adjacent item (window or container, not out of bounds)
if (adjacentIndex < 0 || adjacentIndex >= this._tiledItems.length) {
Logger.log(`No adjacent item at index ${adjacentIndex}`);
return;
}
// Determine where divider should be inserted/updated
// For East/South resizes: divider between current (itemIndex) and next (itemIndex+1)
// For West/North resizes: divider between previous (itemIndex-1) and current (itemIndex)
let dividerIndex: number;
if (this._orientation === Orientation.HORIZONTAL) {
dividerIndex = this._isEastResizeOp(resizeEdge) ? itemIndex + 1 : itemIndex;
} else {
dividerIndex = this._isSouthResizeOp(resizeEdge) ? itemIndex + 1 : itemIndex;
}
// Check if there's already a divider at this position
if (dividerIndex < this._tiledItems.length && Divider.isDivider(this._tiledItems[dividerIndex])) {
// Update existing divider
const divider = this._tiledItems[dividerIndex] as Divider;
divider.setPosition(dividerPosition);
} else {
// Insert new divider
const newDivider = new Divider(dividerPosition, this._orientation);
this._tiledItems.splice(dividerIndex, 0, newDivider);
}
// Retile to show live updates
this.tileWindows();
}
}
}

View File

@@ -1,45 +0,0 @@
import {Logger} from "../utils/logger.js";
enum Orientation {
HORIZONTAL = 0,
VERTICAL = 1,
}
/**
* Represents a divider between windows in a container.
* Dividers track the split position as a ratio (0-1) of the container's size.
*/
export class Divider {
private _position: number; // Position as ratio 0-1
private _orientation: Orientation;
/**
* Creates a new divider
* @param position - Position as ratio between 0 and 1
* @param orientation - Orientation of the divider (HORIZONTAL or VERTICAL)
*/
constructor(position: number, orientation: Orientation) {
this._position = Math.max(0, Math.min(1, position)); // Clamp between 0 and 1
this._orientation = orientation;
}
getPosition(): number {
return this._position;
}
setPosition(position: number): void {
this._position = Math.max(0, Math.min(1, position)); // Clamp between 0 and 1
Logger.log(`Divider position updated to ${this._position}`);
}
getOrientation(): Orientation {
return this._orientation;
}
/**
* Check if this is a divider instance
*/
static isDivider(item: any): item is Divider {
return item instanceof Divider;
}
}

View File

@@ -1,12 +1,9 @@
import {WindowWrapper} from "./window.js";
import {Rect} from "../utils/rect.js";
import queueEvent from "../utils/events.js";
import {Logger} from "../utils/logger.js";
import Meta from "gi://Meta";
import Mtk from "@girs/mtk-17";
import WindowContainer from "./container.js";
import Window = Meta.Window;
export default class Monitor {
@@ -20,7 +17,7 @@ export default class Monitor {
this._workArea = workspace.get_work_area_for_monitor(this._id);
Logger.log("CREATING MONITOR", monitorId);
Logger.log("WorkArea", this._workArea.x, this._workArea.y, this._workArea.width, this._workArea.height);
const workspaceCount = global.workspace_manager.get_n_workspaces()
const workspaceCount = global.workspace_manager.get_n_workspaces();
Logger.log("Workspace Count", workspaceCount);
for (let i = 0; i < workspaceCount; i++) {
this._workspaces.push(new WindowContainer(this._workArea));
@@ -42,9 +39,7 @@ export default class Monitor {
getWindow(windowId: number): WindowWrapper | undefined {
for (const container of this._workspaces) {
const win = container.getWindow(windowId);
if (win) {
return win;
}
if (win) return win;
}
return undefined;
}
@@ -52,8 +47,7 @@ export default class Monitor {
removeWindow(winWrap: WindowWrapper) {
const windowId = winWrap.getWindowId();
for (const container of this._workspaces) {
const win = container.getWindow(windowId);
if (win) {
if (container.getWindow(windowId)) {
container.removeWindow(windowId);
}
}
@@ -65,10 +59,10 @@ export default class Monitor {
}
tileWindows(): void {
this._workArea = global.workspace_manager.get_active_workspace().get_work_area_for_monitor(this._id);
const activeWorkspace = global.workspace_manager.get_active_workspace();
this._workArea = activeWorkspace.get_work_area_for_monitor(this._id);
// move() calls tileWindows() internally
this._workspaces[activeWorkspace.index()].move(this._workArea);
this._workspaces[activeWorkspace.index()].tileWindows()
}
removeWorkspace(workspaceId: number): void {
@@ -82,18 +76,4 @@ export default class Monitor {
itemDragged(item: WindowWrapper, x: number, y: number): void {
this._workspaces[item.getWorkspace()].itemDragged(item, x, y);
}
handleWindowResize(item: WindowWrapper, resizeEdge: Meta.GrabOp, newRect: Rect): void {
this._workspaces[item.getWorkspace()].handleWindowResize(item, resizeEdge, newRect);
}
updateDividerDuringResize(item: WindowWrapper, resizeEdge: Meta.GrabOp, newRect: Rect): void {
this._workspaces[item.getWorkspace()].updateDividerDuringResize(item, resizeEdge, newRect);
}
removeAllDividersFromActiveContainer(): void {
const activeWorkspace = global.workspace_manager.get_active_workspace();
this._workspaces[activeWorkspace.index()].removeAllDividers();
}
}
}

View File

@@ -11,12 +11,13 @@ type WindowMinimizedHandler = (window: WindowWrapper) => void;
type WindowWorkspaceChangedHandler = (window: WindowWrapper) => void;
export class WindowWrapper {
private static readonly RESIZE_TOLERANCE = 2;
readonly _window: Meta.Window;
readonly _windowMinimizedHandler: WindowMinimizedHandler;
readonly _signals: number[] = [];
_parent: WindowContainer | null = null;
_dragging: boolean = false;
_resizing: boolean = false;
constructor(
window: Meta.Window,
@@ -49,53 +50,26 @@ export class WindowWrapper {
startDragging(): void {
this._dragging = true;
}
stopDragging(): void {
Logger.log("STOPPED DRAGGING")
this._dragging = false;
}
startResizing(): void {
this._resizing = true;
}
stopResizing(): void {
this._resizing = false;
}
// setParent(parent: WindowContainer): void {
// this._parent = parent;
// }
//
// getParent(): WindowContainer | null {
// if (this._parent == null) {
// Logger.warn(`Attempting to get parent for window without parent ${JSON.stringify(this)}`);
// }
// return this._parent
// }
connectWindowSignals(
windowManager: IWindowManager,
): void {
const windowId = this._window.get_id()
// Handle window destruction
connectWindowSignals(windowManager: IWindowManager): void {
const windowId = this._window.get_id();
this._signals.push(
this._window.connect('unmanaging', window => {
this._window.connect('unmanaging', () => {
Logger.log("REMOVING WINDOW", windowId);
windowManager.handleWindowClosed(this)
windowManager.handleWindowClosed(this);
}),
this._window.connect('notify::minimized', (we) => {
this._window.connect('notify::minimized', () => {
if (this._window.minimized) {
Logger.log(`Window minimized: ${windowId}`);
windowManager.handleWindowMinimized(this);
} else if (!this._window.minimized) {
} else {
Logger.log(`Window unminimized: ${windowId}`);
windowManager.handleWindowUnminimized(this);
}
}),
this._window.connect('notify::has-focus', () => {
if (this._window.has_focus()) {
windowManager._activeWindowId = windowId;
}
}),
this._window.connect('notify::maximized-horizontally', () => {
@@ -105,18 +79,20 @@ export class WindowWrapper {
Logger.log(`Window unmaximized: ${windowId}`);
}
}),
this._window.connect("workspace-changed", (_metaWindow) => {
this._window.connect("workspace-changed", () => {
Logger.log("WORKSPACE CHANGED FOR WINDOW", this._window.get_id());
windowManager.handleWindowChangedWorkspace(this);
}),
this._window.connect("position-changed", (_metaWindow) => {
this._window.connect("position-changed", () => {
windowManager.handleWindowPositionChanged(this);
}),
this._window.connect("size-changed", () => {
windowManager.handleWindowPositionChanged(this);
}),
);
}
disconnectWindowSignals(): void {
if (this._signals) {
this._signals.forEach(signal => {
try {
@@ -130,43 +106,36 @@ export class WindowWrapper {
}
}
safelyResizeWindow(rect: Rect, _retry: number = 2): void {
// Keep minimal logging
if (this._dragging && !this._resizing) {
// During drag operations (not resize), skip this entirely
safelyResizeWindow(rect: Rect, _retry: number = 3): void {
if (this._dragging) {
Logger.info("STOPPED RESIZE BECAUSE ITEM IS BEING DRAGGED");
return;
}
// Logger.log("SAFELY RESIZE", rect.x, rect.y, rect.width, rect.height);
const actor = this._window.get_compositor_private();
const actor = this._window.get_compositor_private() as Clutter.Actor | null;
if (!actor) {
Logger.log("No actor available, can't resize safely yet");
return;
}
let windowActor = this._window.get_compositor_private() as Clutter.Actor;
if (!windowActor) return;
windowActor.remove_all_transitions();
// Logger.info("MOVING")
this._window.move_frame(true, rect.x, rect.y);
// Logger.info("RESIZING MOVING")
actor.remove_all_transitions();
this._window.move_resize_frame(true, rect.x, rect.y, rect.width, rect.height);
// Don't retry during live resize operations - it causes spam and isn't needed
if (this._resizing) {
return;
}
const new_rect = this._window.get_frame_rect();
const mismatch =
Math.abs(new_rect.x - rect.x) > WindowWrapper.RESIZE_TOLERANCE ||
Math.abs(new_rect.y - rect.y) > WindowWrapper.RESIZE_TOLERANCE ||
Math.abs(new_rect.width - rect.width) > WindowWrapper.RESIZE_TOLERANCE ||
Math.abs(new_rect.height - rect.height) > WindowWrapper.RESIZE_TOLERANCE;
let new_rect = this._window.get_frame_rect();
if ( _retry > 0 && (new_rect.x != rect.x || rect.y != new_rect.y || rect.width < new_rect.width || rect.height < new_rect.height)) {
Logger.warn("RESIZING FAILED AS SMALLER", new_rect.x, new_rect.y, new_rect.width, new_rect.height, rect.x, rect.y, rect.width, rect.height);
if (_retry > 0 && mismatch) {
Logger.warn("RESIZE MISMATCH, retrying",
`want(${rect.x},${rect.y},${rect.width},${rect.height})`,
`got(${new_rect.x},${new_rect.y},${new_rect.width},${new_rect.height})`);
queueEvent({
name: "attempting_delayed_resize",
callback: () => {
this.safelyResizeWindow(rect, _retry-1);
}
})
name: `delayed_resize_${this.getWindowId()}`,
callback: () => this.safelyResizeWindow(rect, _retry - 1),
}, 50);
}
}
}

View File

@@ -1,19 +1,17 @@
import Meta from "gi://Meta";
// import Gio from "gi://Gio";
// import GLib from "gi://GLib";
import Gio from "gi://Gio";
import {WindowWrapper} from './window.js';
import * as Main from "resource:///org/gnome/shell/ui/main.js";
// import Mtk from "@girs/mtk-16";
import {Logger} from "../utils/logger.js";
import Monitor from "./monitor.js";
import WindowContainer from "./container.js";
import {Rect} from "../utils/rect.js";
export interface IWindowManager {
_activeWindowId: number | null;
// addWindow(window: Meta.Window): void;
handleWindowClosed(winWrap: WindowWrapper): void;
handleWindowMinimized(winWrap: WindowWrapper): void;
@@ -28,8 +26,8 @@ export interface IWindowManager {
}
const _UNUSED_MONITOR_ID = -1
const _UNUSED_WINDOW_ID = -1
const _UNUSED_MONITOR_ID = -1;
const _UNUSED_WINDOW_ID = -1;
export default class WindowManager implements IWindowManager {
_displaySignals: number[] = [];
@@ -39,24 +37,29 @@ export default class WindowManager implements IWindowManager {
_activeWindowId: number | null = null;
_monitors: Map<number, Monitor> = new Map<number, Monitor>();
_minimizedItems: Map<number, WindowWrapper> = new Map<number, WindowWrapper>();
_grabbedWindowMonitor: number = _UNUSED_MONITOR_ID;
_grabbedWindowId: number = _UNUSED_WINDOW_ID;
_grabbedOp: Meta.GrabOp | null = null;
_changingGrabbedMonitor: boolean = false;
_showingOverview: boolean = false;
constructor() {
// ── Resize-drag tracking ──────────────────────────────────────────────────
_isResizeDrag: boolean = false;
_resizeDragWindowId: number = _UNUSED_WINDOW_ID;
_resizeDragOp: Meta.GrabOp = Meta.GrabOp.NONE;
_resizeDragLastMouseX: number = 0;
_resizeDragLastMouseY: number = 0;
_isTiling: boolean = false;
private readonly _settings: Gio.Settings;
constructor(settings: Gio.Settings) {
this._settings = settings;
}
public enable(): void {
Logger.log("Starting Aerospike Window Manager");
// Connect window signals
this.instantiateDisplaySignals();
const mon_count = global.display.get_n_monitors();
@@ -65,6 +68,7 @@ export default class WindowManager implements IWindowManager {
}
this.captureExistingWindows();
this.syncActiveWindow();
}
instantiateDisplaySignals(): void {
@@ -87,7 +91,9 @@ export default class WindowManager implements IWindowManager {
global.display.connect('window-created', (display, window) => {
this.handleWindowCreated(display, window);
}),
global.display.connect('notify::focus-window', () => {
this.syncActiveWindow();
}),
global.display.connect("showing-desktop-changed", () => {
Logger.log("SHOWING DESKTOP CHANGED");
}),
@@ -98,13 +104,7 @@ export default class WindowManager implements IWindowManager {
global.display.connect("in-fullscreen-changed", () => {
Logger.log("IN FULL SCREEN CHANGED");
}),
)
// this._windowManagerSignals = [
// global.window_manager.connect("show-tile-preview", (_, _metaWindow, _rect, _num) => {
// Logger.log("SHOW TITLE PREVIEW!")
// }),
// ];
);
this._workspaceManagerSignals = [
global.workspace_manager.connect("showing-desktop-changed", () => {
@@ -129,42 +129,31 @@ export default class WindowManager implements IWindowManager {
this._overviewSignals = [
Main.overview.connect("hiding", () => {
// this.fromOverview = true;
Logger.log("HIDING OVERVIEW")
this._showingOverview = false;
this._tileMonitors();
// const eventObj = {
// name: "focus-after-overview",
// callback: () => {
// Logger.log("FOCUSING AFTER OVERVIEW");
// },
// };
// this.queueEvent(eventObj);
}),
Main.overview.connect("showing", () => {
this._showingOverview = true;
Logger.log("SHOWING OVERVIEW");
}),
];
}
public disable(): void {
Logger.log("DISABLED AEROSPIKE WINDOW MANAGER!")
// Disconnect the focus signal and remove any existing borders
this.disconnectSignals();
this.removeAllWindows();
}
removeAllWindows(): void {
this.disconnectMinimizedSignals();
this._minimizedItems.clear();
this._monitors.forEach((monitor: Monitor) => {
monitor.removeAllWindows();
})
this._minimizedItems.clear();
}
disconnectSignals(): void {
this.disconnectDisplaySignals();
this.disconnectMonitorSignals();
@@ -198,75 +187,67 @@ export default class WindowManager implements IWindowManager {
})
}
handleGrabOpBegin(display: Meta.Display, window: Meta.Window, op: Meta.GrabOp): void {
if (op === Meta.GrabOp.MOVING_UNCONSTRAINED){
}
Logger.log("Grab Op Start", op);
Logger.log(display, window, op)
Logger.log(window.get_monitor())
const winWrap = this._getWrappedWindow(window);
if (this._isResizeOp(op)) {
winWrap?.startResizing();
} else {
winWrap?.startDragging();
}
this._grabbedWindowMonitor = window.get_monitor();
this._grabbedWindowId = window.get_id();
this._grabbedOp = op;
}
handleGrabOpEnd(display: Meta.Display, window: Meta.Window, op: Meta.GrabOp): void {
Logger.log("Grab Op End ", op);
Logger.log("primary display", display.get_primary_monitor())
// Handle resize operations
if (this._isResizeOp(op)) {
const winWrap = this._getWrappedWindow(window);
if (winWrap && this._grabbedOp) {
const newRect = window.get_frame_rect();
const monitorId = window.get_monitor();
Logger.log(`Handling resize operation: ${op}, new rect:`, newRect);
this._monitors.get(monitorId)?.handleWindowResize(winWrap, this._grabbedOp, newRect);
winWrap.stopResizing();
}
} else {
this._getWrappedWindow(window)?.stopDragging();
}
this._grabbedWindowId = _UNUSED_WINDOW_ID;
this._grabbedOp = null;
this._tileMonitors();
Logger.info("monitor_start and monitor_end", this._grabbedWindowMonitor, window.get_monitor());
}
private _isResizeOp(op: Meta.GrabOp): boolean {
return op === Meta.GrabOp.RESIZING_E ||
op === Meta.GrabOp.RESIZING_W ||
op === Meta.GrabOp.RESIZING_N ||
op === Meta.GrabOp.RESIZING_S ||
_isResizeOp(op: Meta.GrabOp): boolean {
return op === Meta.GrabOp.RESIZING_E ||
op === Meta.GrabOp.RESIZING_W ||
op === Meta.GrabOp.RESIZING_N ||
op === Meta.GrabOp.RESIZING_S ||
op === Meta.GrabOp.RESIZING_NE ||
op === Meta.GrabOp.RESIZING_NW ||
op === Meta.GrabOp.RESIZING_SE ||
op === Meta.GrabOp.RESIZING_SW;
}
handleGrabOpBegin(display: Meta.Display, window: Meta.Window, op: Meta.GrabOp): void {
Logger.log("Grab Op Start", op);
if (this._isResizeOp(op)) {
Logger.log("Resize drag begin, op=", op);
this._isResizeDrag = true;
this._resizeDragWindowId = window.get_id();
this._resizeDragOp = op;
const [startMouseX, startMouseY] = global.get_pointer();
this._resizeDragLastMouseX = startMouseX;
this._resizeDragLastMouseY = startMouseY;
this._getWrappedWindow(window)?.startDragging();
} else {
this._getWrappedWindow(window)?.startDragging();
this._grabbedWindowMonitor = window.get_monitor();
this._grabbedWindowId = window.get_id();
}
}
handleGrabOpEnd(display: Meta.Display, window: Meta.Window, op: Meta.GrabOp): void {
Logger.log("Grab Op End ", op);
if (this._isResizeDrag) {
Logger.log("Resize drag end, op=", op);
this._isResizeDrag = false;
this._resizeDragWindowId = _UNUSED_WINDOW_ID;
this._resizeDragLastMouseX = 0;
this._resizeDragLastMouseY = 0;
this._resizeDragOp = Meta.GrabOp.NONE;
this._getWrappedWindow(window)?.stopDragging();
this._tileMonitors();
} else {
this._grabbedWindowId = _UNUSED_WINDOW_ID;
this._getWrappedWindow(window)?.stopDragging();
this._tileMonitors();
Logger.info("monitor_start and monitor_end", this._grabbedWindowMonitor, window.get_monitor());
}
}
_getWrappedWindow(window: Meta.Window): WindowWrapper | undefined {
let wrapped = undefined;
let wrapped: WindowWrapper | undefined = undefined;
for (const monitor of this._monitors.values()) {
wrapped = monitor.getWindow(window.get_id());
if (wrapped !== undefined) {
break;
}
if (wrapped !== undefined) break;
}
return wrapped;
}
_getAndRemoveWrappedWindow(window: Meta.Window): WindowWrapper | undefined {
let wrapped = undefined;
let wrapped: WindowWrapper | undefined = undefined;
for (const monitor of this._monitors.values()) {
wrapped = monitor.getWindow(window.get_id());
if (wrapped !== undefined) {
@@ -281,7 +262,7 @@ export default class WindowManager implements IWindowManager {
let wrapped = this._getAndRemoveWrappedWindow(window);
if (wrapped === undefined) {
Logger.error("WINDOW NOT DEFINED")
wrapped = new WindowWrapper(window, this.handleWindowMinimized);
wrapped = new WindowWrapper(window, (winWrap) => this.handleWindowMinimized(winWrap));
wrapped.connectWindowSignals(this);
}
let new_mon = this._monitors.get(monitorId);
@@ -290,16 +271,10 @@ export default class WindowManager implements IWindowManager {
}
public handleWindowPositionChanged(winWrap: WindowWrapper): void {
if (this._changingGrabbedMonitor) {
return;
}
if (this._isTiling || this._changingGrabbedMonitor) return;
// Handle resize operations - update dividers in real-time
if (this._grabbedOp && this._isResizeOp(this._grabbedOp)) {
const window = winWrap.getWindow();
const newRect = window.get_frame_rect();
const monitorId = window.get_monitor();
this._monitors.get(monitorId)?.updateDividerDuringResize(winWrap, this._grabbedOp, newRect);
if (this._isResizeDrag && winWrap.getWindowId() === this._resizeDragWindowId) {
this._handleResizeDragUpdate(winWrap);
return;
}
@@ -315,19 +290,82 @@ export default class WindowManager implements IWindowManager {
break;
}
}
if (monitorIndex === -1) {
return
}
if (monitorIndex === -1) return;
if (monitorIndex !== this._grabbedWindowMonitor) {
this._changingGrabbedMonitor = true;
this._moveWindowToMonitor(winWrap.getWindow(), monitorIndex);
this._changingGrabbedMonitor = false
this._changingGrabbedMonitor = false;
}
this._isTiling = true;
try {
this._monitors.get(monitorIndex)?.itemDragged(winWrap, mouseX, mouseY);
} finally {
this._isTiling = false;
}
this._monitors.get(monitorIndex)?.itemDragged(winWrap, mouseX, mouseY);
}
}
private _handleResizeDragUpdate(winWrap: WindowWrapper): void {
const op = this._resizeDragOp;
const winId = winWrap.getWindowId();
const [mouseX, mouseY] = global.get_pointer();
const dx = mouseX - this._resizeDragLastMouseX;
const dy = mouseY - this._resizeDragLastMouseY;
if (dx === 0 && dy === 0) return;
this._resizeDragLastMouseX = mouseX;
this._resizeDragLastMouseY = mouseY;
const container = this._findContainerForWindowAcrossMonitors(winId);
if (!container) {
Logger.warn("_handleResizeDragUpdate: no container found for window", winId);
return;
}
const itemIndex = container._getIndexOfWindow(winId);
if (itemIndex === -1) return;
const isHorizontal = container._orientation === 0;
// E/S edge → boundary after the item; W/N edge → boundary before it.
let adjusted = false;
if (isHorizontal) {
if (op === Meta.GrabOp.RESIZING_E || op === Meta.GrabOp.RESIZING_NE || op === Meta.GrabOp.RESIZING_SE) {
adjusted = container.adjustBoundary(itemIndex, dx);
} else if (op === Meta.GrabOp.RESIZING_W || op === Meta.GrabOp.RESIZING_NW || op === Meta.GrabOp.RESIZING_SW) {
adjusted = container.adjustBoundary(itemIndex - 1, dx);
}
} else {
if (op === Meta.GrabOp.RESIZING_S || op === Meta.GrabOp.RESIZING_SE || op === Meta.GrabOp.RESIZING_SW) {
adjusted = container.adjustBoundary(itemIndex, dy);
} else if (op === Meta.GrabOp.RESIZING_N || op === Meta.GrabOp.RESIZING_NE || op === Meta.GrabOp.RESIZING_NW) {
adjusted = container.adjustBoundary(itemIndex - 1, dy);
}
}
if (adjusted) {
this._isTiling = true;
try {
container.tileWindows();
} finally {
this._isTiling = false;
}
}
}
private _findContainerForWindowAcrossMonitors(winId: number): WindowContainer | null {
const activeWorkspaceIndex = global.workspace_manager.get_active_workspace().index();
for (const monitor of this._monitors.values()) {
if (activeWorkspaceIndex >= monitor._workspaces.length) continue;
const container = monitor._workspaces[activeWorkspaceIndex].getContainerForWindow(winId);
if (container !== null) return container;
}
return null;
}
public handleWindowMinimized(winWrap: WindowWrapper): void {
const monitor_id = winWrap.getWindow().get_monitor()
@@ -342,7 +380,6 @@ export default class WindowManager implements IWindowManager {
this._tileMonitors()
}
public handleWindowChangedWorkspace(winWrap: WindowWrapper): void {
const monitor = winWrap.getWindow().get_monitor();
this._monitors.get(monitor)?.removeWindow(winWrap);
@@ -361,41 +398,26 @@ export default class WindowManager implements IWindowManager {
this._tileMonitors();
}
handleWindowCreated(display: Meta.Display, window: Meta.Window) {
Logger.log("WINDOW CREATED ON DISPLAY", window, display);
if (!this._isWindowTileable(window)) {
return;
}
if (!this._isWindowTileable(window)) return;
Logger.log("WINDOW IS TILABLE");
this.addWindowToMonitor(window);
}
/**
* Handle window closed event
*/
handleWindowClosed(window: WindowWrapper): void {
const mon_id = window._window.get_monitor();
this._monitors.get(mon_id)?.removeWindow(window);
window.disconnectWindowSignals()
// Remove from managed windows
this.syncActiveWindow();
// Retile remaining windows
this._tileMonitors();
}
public addWindowToMonitor(window: Meta.Window) {
Logger.log("ADDING WINDOW TO MONITOR", window, window);
var wrapper = new WindowWrapper(window, this.handleWindowMinimized)
var wrapper = new WindowWrapper(window, (winWrap) => this.handleWindowMinimized(winWrap))
wrapper.connectWindowSignals(this);
this._addWindowWrapperToMonitor(wrapper);
}
_addWindowWrapperToMonitor(winWrap: WindowWrapper) {
@@ -407,9 +429,13 @@ export default class WindowManager implements IWindowManager {
}
_tileMonitors(): void {
for (const monitor of this._monitors.values()) {
monitor.tileWindows()
this._isTiling = true;
try {
for (const monitor of this._monitors.values()) {
monitor.tileWindows();
}
} finally {
this._isTiling = false;
}
}
@@ -417,7 +443,7 @@ export default class WindowManager implements IWindowManager {
"org.gnome.Shell.Extensions",
]
_isWindowTilingBlocked(window: Meta.Window) : boolean {
_isWindowTilingBlocked(window: Meta.Window): boolean {
Logger.info("title", window.get_title());
Logger.info("description", window.get_description());
Logger.info("class", window.get_wm_class());
@@ -432,17 +458,12 @@ export default class WindowManager implements IWindowManager {
}
_isWindowTileable(window: Meta.Window) {
if (!window || !window.get_compositor_private()) return false;
if (this._isWindowTilingBlocked(window)) return false;
if (!window || !window.get_compositor_private()) {
return false;
}
if (this._isWindowTilingBlocked(window)) {
return false;
}
const windowType = window.get_window_type();
Logger.log("WINDOW TILING CHECK",);
// Skip certain types of windows
return !window.is_skip_taskbar() &&
windowType !== Meta.WindowType.DESKTOP &&
windowType !== Meta.WindowType.DOCK &&
@@ -452,38 +473,92 @@ export default class WindowManager implements IWindowManager {
windowType !== Meta.WindowType.MENU;
}
/**
* Synchronizes the active window with GNOME's currently active window
*
* This function queries GNOME Shell for the current focused window and
* updates the extension's active window tracking to match.
*
* @returns The window ID of the active window, or null if no window is active
*/
public syncActiveWindow(): number | null {
return null;
const focusWindow = global.display.focus_window;
if (focusWindow) {
this._activeWindowId = focusWindow.get_id();
Logger.debug(`Active window changed to: ${this._activeWindowId} (${focusWindow.get_title()})`);
} else {
this._activeWindowId = null;
Logger.debug('No active window');
}
return this._activeWindowId;
}
/**
* Removes all dividers from the container with the currently active window
*/
public removeAllDividersFromActiveContainer(): void {
const activeWindow = global.display.focus_window;
if (!activeWindow) {
Logger.log("No active window, cannot remove dividers");
public toggleActiveContainerOrientation(): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot toggle container orientation");
return;
}
const monitorId = activeWindow.get_monitor();
const monitor = this._monitors.get(monitorId);
if (monitor) {
Logger.log(`Removing all dividers from monitor ${monitorId}`);
monitor.removeAllDividersFromActiveContainer();
const container = this._findContainerForWindowAcrossMonitors(this._activeWindowId);
if (container) {
container.toggleOrientation();
} else {
Logger.warn(`Monitor ${monitorId} not found`);
Logger.warn("Could not find container for active window");
}
}
public resetActiveContainerRatios(): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot reset container ratios");
return;
}
const container = this._findContainerForWindowAcrossMonitors(this._activeWindowId);
if (container) {
Logger.info("Resetting container ratios to equal splits");
container.resetRatios();
} else {
Logger.warn("Could not find container for active window");
}
}
public printTreeStructure(): void {
Logger.info("=".repeat(80));
Logger.info("WINDOW TREE STRUCTURE");
Logger.info("=".repeat(80));
Logger.info(`Active Window ID: ${this._activeWindowId ?? 'none'}`);
Logger.info("=".repeat(80));
const activeWorkspaceIndex = global.workspace_manager.get_active_workspace().index();
this._monitors.forEach((monitor: Monitor, monitorId: number) => {
const isActiveMonitor = this._activeWindowId !== null &&
monitor.getWindow(this._activeWindowId) !== undefined;
Logger.info(`Monitor ${monitorId}${isActiveMonitor ? ' *' : ''}:`);
Logger.info(` Work Area: x=${monitor._workArea.x}, y=${monitor._workArea.y}, w=${monitor._workArea.width}, h=${monitor._workArea.height}`);
monitor._workspaces.forEach((workspace, workspaceIndex) => {
const isActiveWorkspace = workspaceIndex === activeWorkspaceIndex;
Logger.info(` Workspace ${workspaceIndex}${isActiveWorkspace && isActiveMonitor ? ' *' : ''}:`);
Logger.info(` Orientation: ${workspace._orientation === 0 ? 'HORIZONTAL' : 'VERTICAL'}`);
Logger.info(` Items: ${workspace._tiledItems.length}`);
this._printContainerTree(workspace, 4);
});
});
Logger.info("=".repeat(80));
}
private _printContainerTree(container: WindowContainer, indentLevel: number): void {
const indent = " ".repeat(indentLevel);
container._tiledItems.forEach((item, index) => {
if (item instanceof WindowContainer) {
const containsActive = this._activeWindowId !== null &&
item.getWindow(this._activeWindowId) !== undefined;
Logger.info(`${indent}[${index}] Container (${item._orientation === 0 ? 'HORIZONTAL' : 'VERTICAL'})${containsActive ? ' *' : ''}:`);
Logger.info(`${indent} Items: ${item._tiledItems.length}`);
Logger.info(`${indent} Work Area: x=${item._workArea.x}, y=${item._workArea.y}, w=${item._workArea.width}, h=${item._workArea.height}`);
this._printContainerTree(item, indentLevel + 4);
} else {
const window = item.getWindow();
Logger.info(`${indent}[${index}] Window ID: ${item.getWindowId()}${this._activeWindowId === item.getWindowId() ? ' *' : ''}`);
Logger.info(`${indent} Title: "${window.get_title()}"`);
Logger.info(`${indent} Class: ${window.get_wm_class()}`);
const rect = item.getRect();
Logger.info(`${indent} Rect: x=${rect.x}, y=${rect.y}, w=${rect.width}, h=${rect.height}`);
}
});
}
}

View File

@@ -5,6 +5,8 @@
"outDir": "./dist",
"sourceMap": false,
"strict": true,
"noImplicitAny": false,
"skipLibCheck": true,
"target": "ES2022",
"lib": [
"ES2022"