Compare commits

..

27 Commits

Author SHA1 Message Date
Lucas Oskorep 241f90299c feat: new window gets 1/n ratio, existing windows scale proportionally
Build and Test / build (pull_request) Successful in 25s
Build and Test / release (pull_request) Has been skipped
Previously addWindow() reset all ratios to equal splits, losing any
custom sizing. Now _addRatioForNewWindow() gives the new window 1/n of
the space and scales the existing windows by (n-1)/n, preserving their
ratios relative to each other.

e.g. [1/3, 1/6, 1/2] + new → [0.25, 0.125, 0.375, 0.25]
2026-02-24 19:23:16 -05:00
Lucas Oskorep 7083482d5c fix: min-window-size setting now read live from Gio.Settings on every boundary adjust
Previously _minRatio was baked into WindowContainer at construction time
and never updated. Now adjustBoundary() takes minRatio as a parameter,
WindowManager._getMinRatio() reads it from Gio.Settings on every call,
and the settings reference is injected from extension.ts on enable().
2026-02-24 17:46:20 -05:00
Lucas Oskorep 99778f3ef2 feat: add reset-ratios keybinding and min-window-size setting to prefs UI 2026-02-24 17:33:30 -05:00
Lucas Oskorep 20bac71b45 feat: percentage-based container sizing with live boundary resize
- Add _splitRatios[] to WindowContainer — each child owns a fraction of
  the parent that always sums to 1.0; bounds are computed via prefix-sum
  so the last item absorbs rounding remainder (no pixel gaps)
- addWindow/removeWindow reset to equal splits; itemDragged swaps only
  items (ratios stay slot-based) so windows take the size of the slot
  they move into
- Add adjustBoundary() for live edge-drag resizing clamped to a
  configurable minimum (default 10%, schema key min-window-size-percent)
- Add reset-ratios keybinding (Ctrl+Z) that resets the active container
  to equal splits via WindowManager.resetActiveContainerRatios()
- Connect size-changed signal alongside position-changed so east/south
  edge drags (width-only changes) are detected and the adjacent window
  is repositioned live
- Replace LIFO pop() event queue with name-keyed Map so duplicate events
  collapse to the latest callback and timers never fight each other
- Remove redundant move_frame() before move_resize_frame(); fix retry
  condition to use symmetric pixel tolerance
- Add _isTiling re-entrancy guard around all tileWindows() call sites
  so compositor position-changed callbacks cannot recurse
- Remove double tileWindows() call in monitor.tileWindows() (move()
  already calls it internally)
2026-02-24 17:26:27 -05:00
Lucas Oskorep 3d2da0a4bc Update .gitea/workflows/build.yaml
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Build and Test / build (push) Successful in 36s
2025-10-17 02:46:56 -04:00
Lucas Oskorep e065acd37d feat: gitea pipeline
Build and Test / build (push) Failing after 1m2s
2025-10-17 02:42:12 -04:00
Lucas Oskorep b4afd6d4dc feat: gitea pipeline
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 4038 additions and 790 deletions
+99
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"
+1 -1
View File
@@ -1 +1 @@
v20 24
+1 -1
View File
@@ -1 +1 @@
# Aerospike Gnome (Tiling Window Manager) # Aerospike Gnome (Tiling Window Manager)
+39 -12
View File
@@ -1,5 +1,5 @@
import Meta from 'gi://Meta'; 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 * as Main from 'resource:///org/gnome/shell/ui/main.js';
import Gio from 'gi://Gio'; import Gio from 'gi://Gio';
import Shell from 'gi://Shell'; import Shell from 'gi://Shell';
@@ -11,7 +11,7 @@ export default class aerospike extends Extension {
keyBindings: Map<string, number>; keyBindings: Map<string, number>;
windowManager: WindowManager; windowManager: WindowManager;
constructor(metadata: ExtensionMetadata) { constructor(metadata: ConstructorParameters<typeof Extension>[0]) {
super(metadata); super(metadata);
this.settings = this.getSettings('org.gnome.shell.extensions.aerospike'); this.settings = this.getSettings('org.gnome.shell.extensions.aerospike');
this.keyBindings = new Map(); this.keyBindings = new Map();
@@ -20,6 +20,7 @@ export default class aerospike extends Extension {
enable() { enable() {
Logger.log("STARTING AEROSPIKE!") Logger.log("STARTING AEROSPIKE!")
this.windowManager._settings = this.settings;
this.bindSettings(); this.bindSettings();
this.setupKeybindings(); this.setupKeybindings();
this.windowManager.enable() this.windowManager.enable()
@@ -53,9 +54,19 @@ export default class aerospike extends Extension {
this.refreshKeybinding('join-with-right'); this.refreshKeybinding('join-with-right');
}); });
this.settings.connect('changed::remove-all-dividers', () => { this.settings.connect('changed::print-tree', () => {
log(`Keybinding remove-all-dividers changed to: ${this.settings.get_strv('remove-all-dividers')}`); log(`Print tree keybinding changed to: ${this.settings.get_strv('print-tree')}`);
this.refreshKeybinding('remove-all-dividers'); this.refreshKeybinding('print-tree');
});
this.settings.connect('changed::toggle-orientation', () => {
log(`Toggle orientation keybinding changed to: ${this.settings.get_strv('toggle-orientation')}`);
this.refreshKeybinding('toggle-orientation');
});
this.settings.connect('changed::reset-ratios', () => {
log(`Reset ratios keybinding changed to: ${this.settings.get_strv('reset-ratios')}`);
this.refreshKeybinding('reset-ratios');
}); });
this.settings.connect('changed::dropdown-option', () => { this.settings.connect('changed::dropdown-option', () => {
@@ -93,10 +104,19 @@ export default class aerospike extends Extension {
Logger.info('Keybinding 4 was pressed!'); Logger.info('Keybinding 4 was pressed!');
}); });
break; break;
case 'remove-all-dividers': case 'print-tree':
this.bindKeybinding('remove-all-dividers', () => { this.bindKeybinding('print-tree', () => {
Logger.info('Remove all dividers keybinding pressed!'); this.windowManager.printTreeStructure();
this.windowManager.removeAllDividersFromActiveContainer(); });
break;
case 'toggle-orientation':
this.bindKeybinding('toggle-orientation', () => {
this.windowManager.toggleActiveContainerOrientation();
});
break;
case 'reset-ratios':
this.bindKeybinding('reset-ratios', () => {
this.windowManager.resetActiveContainerRatios();
}); });
break; break;
} }
@@ -126,9 +146,16 @@ export default class aerospike extends Extension {
Logger.info('Keybinding 4 was pressed!'); Logger.info('Keybinding 4 was pressed!');
}); });
this.bindKeybinding('remove-all-dividers', () => { this.bindKeybinding('print-tree', () => {
Logger.info('Remove all dividers keybinding pressed!'); this.windowManager.printTreeStructure();
this.windowManager.removeAllDividersFromActiveContainer(); });
this.bindKeybinding('toggle-orientation', () => {
this.windowManager.toggleActiveContainerOrientation();
});
this.bindKeybinding('reset-ratios', () => {
this.windowManager.resetActiveContainerRatios();
}); });
} }
+23
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/'],
};
+15 -3
View File
@@ -8,7 +8,7 @@ packages:
build: packages && build-schemas build: packages && build-schemas
rm -rf dist/* rm -rf dist/*
tsc pnpm exec tsc
cp metadata.json dist/ cp metadata.json dist/
cp stylesheet.css dist/ cp stylesheet.css dist/
mkdir -p dist/schemas mkdir -p dist/schemas
@@ -28,13 +28,25 @@ install: build
cp -r dist/* ~/.local/share/gnome-shell/extensions/{{NAME}}@{{DOMAIN}}/ cp -r dist/* ~/.local/share/gnome-shell/extensions/{{NAME}}@{{DOMAIN}}/
run: 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 install-and-run: install run
live-debug: live-debug:
journalctl /usr/bin/gnome-shell -f -o cat | tee debug.log 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 #pack: build
# gnome-extensions pack dist \ # gnome-extensions pack dist \
# --force \ # --force \
@@ -42,4 +54,4 @@ live-debug:
# --schema ../schemas/org.gnome.shell.extensions.aerospike.gschema.xml # --schema ../schemas/org.gnome.shell.extensions.aerospike.gschema.xml
# #
#install-pack: pack #install-pack: pack
# gnome-extensions install ./{{FULL_NAME}}.shell-extension.zip --force # gnome-extensions install ./{{FULL_NAME}}.shell-extension.zip --force
+11 -2
View File
@@ -15,11 +15,20 @@
}, },
"homepage": "https://github.com/example/my-extension#readme", "homepage": "https://github.com/example/my-extension#readme",
"sideEffects": false, "sideEffects": false,
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"devDependencies": { "devDependencies": {
"@girs/gjs": "4.0.0-beta.38", "@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": "^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" "typescript": "^5.9.2"
}, },
"dependencies": { "dependencies": {
+2863 -101
View File
File diff suppressed because it is too large Load Diff
+12
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"
}
]
}
@@ -37,10 +37,29 @@
<description>Keyboard shortcut for triggering action 4</description> <description>Keyboard shortcut for triggering action 4</description>
</key> </key>
<key name="remove-all-dividers" type="as"> <key name="print-tree" type="as">
<default><![CDATA[['<Alt>z']]]></default> <default><![CDATA[['<Super><Shift>x']]]></default>
<summary>Remove all dividers from active container</summary> <summary>Print window tree structure</summary>
<description>Keyboard shortcut for removing all dividers from the container with the active window</description> <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>
<key name="min-window-size-percent" type="d">
<default>0.10</default>
<range min="0.01" max="0.49"/>
<summary>Minimum window size percentage</summary>
<description>Minimum fraction of a container that any single window may occupy when resizing boundaries</description>
</key> </key>
</schema> </schema>
+182
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']);
});
});
});
+75
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);
});
});
+61
View File
@@ -155,6 +155,67 @@ 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
})
);
// Create sizing group
const sizingGroup = new Adw.PreferencesGroup({
title: _('Window Sizing'),
});
page.add(sizingGroup);
// Minimum window size percentage spinner
const minSizeRow = new Adw.ActionRow({
title: _('Minimum Window Size'),
subtitle: _('Smallest fraction of a container any window may occupy when resizing (e.g. 0.10 = 10%)'),
});
sizingGroup.add(minSizeRow);
const minSizeSpin = new Gtk.SpinButton({
adjustment: new Gtk.Adjustment({
lower: 0.01,
upper: 0.49,
step_increment: 0.01,
page_increment: 0.05,
value: settings.get_double('min-window-size-percent'),
}),
digits: 2,
valign: Gtk.Align.CENTER,
});
minSizeRow.add_suffix(minSizeSpin);
minSizeRow.set_activatable_widget(minSizeSpin);
minSizeSpin.connect('value-changed', () => {
settings.set_double('min-window-size-percent', minSizeSpin.get_value());
});
settings.connect('changed::min-window-size-percent', () => {
minSizeSpin.set_value(settings.get_double('min-window-size-percent'));
});
} }
+14 -6
View File
@@ -6,15 +6,23 @@ export type QueuedEvent = {
callback: () => void; callback: () => void;
} }
const queuedEvents: QueuedEvent[] = []; // Pending events indexed by name so that duplicate events collapse into one.
// Only the most-recently-queued callback for a given name is kept.
const pendingEvents: Map<string, QueuedEvent> = new Map();
export default function queueEvent(event: QueuedEvent, interval = 200) { export default function queueEvent(event: QueuedEvent, interval = 200) {
queuedEvents.push(event); // Overwrite any earlier pending event with the same name — the latest
// callback is always the most up-to-date one.
pendingEvents.set(event.name, event);
GLib.timeout_add(GLib.PRIORITY_DEFAULT, interval, () => { GLib.timeout_add(GLib.PRIORITY_DEFAULT, interval, () => {
const e = queuedEvents.pop() const e = pendingEvents.get(event.name);
if (e) { if (e && e === event) {
// Only fire if this is still the current callback for this name
// (a newer call may have replaced it).
pendingEvents.delete(event.name);
e.callback(); e.callback();
} }
return queuedEvents.length !== 0; return GLib.SOURCE_REMOVE;
}); });
} }
+233 -483
View File
@@ -3,47 +3,113 @@ import {Logger} from "../utils/logger.js";
import Meta from "gi://Meta"; import Meta from "gi://Meta";
import queueEvent from "../utils/events.js"; import queueEvent from "../utils/events.js";
import {Rect} from "../utils/rect.js"; import {Rect} from "../utils/rect.js";
import {Divider} from "./divider.js";
enum Orientation { enum Orientation {
HORIZONTAL = 0, HORIZONTAL = 0,
VERTICAL = 1, VERTICAL = 1,
} }
type ContainerItem = WindowWrapper | WindowContainer | Divider; /**
* Build a split-ratio array of length `n` where every element equals 1/n,
* with the last slot absorbing any floating-point remainder so the array
* always sums to exactly 1.0.
*/
function equalRatios(n: number): number[] {
if (n <= 0) return [];
const base = 1 / n;
const ratios = Array(n).fill(base);
// Fix floating-point drift: make last slot exact
const sumExceptLast = ratios.slice(0, -1).reduce((a, b) => a + b, 0);
ratios[n - 1] = 1 - sumExceptLast;
return ratios;
}
export default class WindowContainer { export default class WindowContainer {
_tiledItems: ContainerItem[]; _tiledItems: (WindowWrapper | WindowContainer)[];
_tiledWindowLookup: Map<number, WindowWrapper>; _tiledWindowLookup: Map<number, WindowWrapper>;
_orientation: Orientation = Orientation.HORIZONTAL; _orientation: Orientation = Orientation.HORIZONTAL;
_workArea: Rect; _workArea: Rect;
constructor(workspaceArea: Rect,) { /**
// this._id = monitorId; * Per-child split ratios. Always satisfies:
* _splitRatios.length === _tiledItems.length
* _splitRatios.reduce((a,b) => a+b, 0) === 1.0 (within floating-point epsilon)
* every element >= MIN_RATIO
*/
_splitRatios: number[];
constructor(workspaceArea: Rect) {
this._tiledItems = []; this._tiledItems = [];
this._tiledWindowLookup = new Map<number, WindowWrapper>(); this._tiledWindowLookup = new Map<number, WindowWrapper>();
this._workArea = workspaceArea; this._workArea = workspaceArea;
this._splitRatios = [];
} }
// ─── Helpers ────────────────────────────────────────────────────────────────
/** Rebuild _splitRatios as equal fractions after any structural change. */
private _resetRatios(): void {
this._splitRatios = equalRatios(this._tiledItems.length);
}
/**
* Called after a new item has been pushed onto _tiledItems.
* The new window (last slot) gets 1/n of the space; existing windows
* are scaled down proportionally so their ratios relative to each other
* are preserved and the total remains 1.0.
*
* e.g. [0.33, 0.166, 0.5] + new → new=0.25, existing scaled by 0.75
* → [0.2475, 0.1245, 0.375, 0.25]
*/
private _addRatioForNewWindow(): void {
const n = this._tiledItems.length;
if (n <= 1) {
this._splitRatios = [1.0];
return;
}
const newRatio = 1 / n;
const scale = 1 - newRatio; // existing windows share this fraction
const scaled = this._splitRatios.map(r => r * scale);
// Absorb all floating-point drift into the last slot so sum is exactly 1.0
const partialSum = scaled.reduce((a, b) => a + b, 0) + newRatio;
scaled[scaled.length - 1] += (1.0 - partialSum);
this._splitRatios = [...scaled, newRatio];
}
/** Total dimension for the active orientation (width for H, height for V). */
private _totalDimension(): number {
return this._orientation === Orientation.HORIZONTAL
? this._workArea.width
: this._workArea.height;
}
// ─── Public API ─────────────────────────────────────────────────────────────
move(rect: Rect): void { move(rect: Rect): void {
this._workArea = rect; this._workArea = rect;
this.tileWindows(); 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 { addWindow(winWrap: WindowWrapper): void {
// Add window to managed windows
this._tiledItems.push(winWrap); this._tiledItems.push(winWrap);
this._tiledWindowLookup.set(winWrap.getWindowId(), winWrap); this._tiledWindowLookup.set(winWrap.getWindowId(), winWrap);
// winWrap.setParent(this); this._addRatioForNewWindow();
queueEvent({ queueEvent({
name: "tiling-windows", name: "tiling-windows",
callback: () => { callback: () => {
this.tileWindows(); this.tileWindows();
} }
}, 100) }, 100);
} }
getWindow(win_id: number): WindowWrapper | undefined { getWindow(win_id: number): WindowWrapper | undefined {
@@ -51,36 +117,36 @@ export default class WindowContainer {
return this._tiledWindowLookup.get(win_id); return this._tiledWindowLookup.get(win_id);
} }
for (const item of this._tiledItems) { for (const item of this._tiledItems) {
if (Divider.isDivider(item)) { if (item instanceof WindowContainer) {
continue; // Skip dividers
} else if (item instanceof WindowContainer) {
const win = item.getWindow(win_id); const win = item.getWindow(win_id);
if (win) { if (win) {
return win; return win;
} }
} else if (item instanceof WindowWrapper && item.getWindowId() === win_id) { } else if (item.getWindowId() === win_id) {
return item; return item;
} }
} }
return undefined return undefined;
} }
_getIndexOfWindow(win_id: number) { _getIndexOfWindow(win_id: number): number {
for (let i = 0; i < this._tiledItems.length; i++) { for (let i = 0; i < this._tiledItems.length; i++) {
const item = this._tiledItems[i]; const item = this._tiledItems[i];
if (item instanceof WindowWrapper && item.getWindowId() === win_id) { if (item instanceof WindowWrapper && item.getWindowId() === win_id) {
return i; return i;
} }
} }
return -1 return -1;
} }
removeWindow(win_id: number): void { removeWindow(win_id: number): void {
if (this._tiledWindowLookup.has(win_id)) { if (this._tiledWindowLookup.has(win_id)) {
const index = this._getIndexOfWindow(win_id);
this._tiledWindowLookup.delete(win_id); this._tiledWindowLookup.delete(win_id);
const index = this._getIndexOfWindow(win_id) if (index !== -1) {
this._tiledItems.splice(index, 1); this._tiledItems.splice(index, 1);
this._cleanupInvalidDividers(); }
this._resetRatios();
} else { } else {
for (const item of this._tiledItems) { for (const item of this._tiledItems) {
if (item instanceof WindowContainer) { if (item instanceof WindowContainer) {
@@ -88,67 +154,30 @@ export default class WindowContainer {
} }
} }
} }
this.tileWindows() 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++;
}
} }
disconnectSignals(): void { disconnectSignals(): void {
this._tiledItems.forEach((item) => { this._tiledItems.forEach((item) => {
if (Divider.isDivider(item)) { if (item instanceof WindowContainer) {
// Skip dividers - they don't have signals item.disconnectSignals();
return; } else {
} else if (item instanceof WindowContainer) { item.disconnectWindowSignals();
item.disconnectSignals()
} else if (item instanceof WindowWrapper) {
item.disconnectWindowSignals();
}
} }
) });
} }
removeAllWindows(): void { removeAllWindows(): void {
this._tiledItems = [] this._tiledItems = [];
this._tiledWindowLookup.clear() this._tiledWindowLookup.clear();
this._splitRatios = [];
} }
tileWindows() { tileWindows() {
Logger.log("TILING WINDOWS IN CONTAINER") Logger.log("TILING WINDOWS IN CONTAINER");
Logger.log("WorkArea", this._workArea); Logger.log("WorkArea", this._workArea);
this._tileItems();
// Get all windows for current workspaceArea return true;
this._tileItems()
return true
} }
_tileItems() { _tileItems() {
@@ -156,24 +185,19 @@ export default class WindowContainer {
return; return;
} }
const bounds = this.getBounds(); const bounds = this.getBounds();
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(', ')}]`);
// Apply bounds to non-divider items this._tiledItems.forEach((item, index) => {
let boundsIndex = 0; const rect = bounds[index];
this._tiledItems.forEach((item) => {
if (Divider.isDivider(item)) {
return; // Skip dividers
}
const rect = bounds[boundsIndex];
if (item instanceof WindowContainer) { if (item instanceof WindowContainer) {
item.move(rect); 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); item.safelyResizeWindow(rect);
} }
boundsIndex++; });
})
} }
// ─── Bounds Calculation ──────────────────────────────────────────────────────
getBounds(): Rect[] { getBounds(): Rect[] {
if (this._orientation === Orientation.HORIZONTAL) { if (this._orientation === Orientation.HORIZONTAL) {
@@ -183,177 +207,139 @@ export default class WindowContainer {
} }
getVerticalBounds(): Rect[] { getVerticalBounds(): Rect[] {
// Filter out dividers to get only windows/containers const items = this._tiledItems;
const nonDividerItems = this._tiledItems.filter(item => !Divider.isDivider(item)); const totalHeight = this._workArea.height;
let usedHeight = 0;
if (nonDividerItems.length === 0) { return items.map((_, index) => {
return []; const y = this._workArea.y + usedHeight;
} let height: number;
if (index === items.length - 1) {
// If no dividers, use equal distribution // Last item gets the remainder to avoid pixel gaps from rounding
const hasDividers = this._tiledItems.some(item => Divider.isDivider(item)); height = totalHeight - usedHeight;
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 { } else {
// Find the end position for this item height = Math.floor(this._splitRatios[index] * totalHeight);
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;
} }
} usedHeight += height;
return {
return bounds; x: this._workArea.x,
y: y,
width: this._workArea.width,
height: height,
} as Rect;
});
} }
getHorizontalBounds(): Rect[] { getHorizontalBounds(): Rect[] {
// Filter out dividers to get only windows/containers const totalWidth = this._workArea.width;
const nonDividerItems = this._tiledItems.filter(item => !Divider.isDivider(item)); let usedWidth = 0;
if (nonDividerItems.length === 0) { return this._tiledItems.map((_, index) => {
return []; const x = this._workArea.x + usedWidth;
} let width: number;
if (index === this._tiledItems.length - 1) {
// If no dividers, use equal distribution // Last item gets the remainder to avoid pixel gaps from rounding
const hasDividers = this._tiledItems.some(item => Divider.isDivider(item)); width = totalWidth - usedWidth;
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;
});
}
// Calculate bounds based on divider positions
const bounds: Rect[] = [];
let currentX = this._workArea.x;
for (let i = 0; i < this._tiledItems.length; i++) {
const item = this._tiledItems[i];
if (Divider.isDivider(item)) {
// Next segment starts at divider position
currentX = this._workArea.x + Math.floor(item.getPosition() * this._workArea.width);
} else { } else {
// Find the end position for this item width = Math.floor(this._splitRatios[index] * totalWidth);
let endX: number = this._workArea.x + this._workArea.width; }
usedWidth += width;
return {
x: x,
y: this._workArea.y,
width: width,
height: this._workArea.height,
} as Rect;
});
}
// Look ahead to find next divider or end of container // ─── Boundary / Ratio Adjustment ─────────────────────────────────────────────
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; * Adjust the boundary between item[boundaryIndex] and item[boundaryIndex+1]
let itemsInSegment: number[] = []; * by deltaPixels (positive = move right/down, negative = move left/up).
for (let j = i; j < this._tiledItems.length; j++) { *
if (Divider.isDivider(this._tiledItems[j])) { * Both affected ratios are clamped to [_minRatio, 1 - _minRatio] so no
break; * window can be squashed below the configured minimum.
} *
itemsInSegment.push(j); * Returns true if the adjustment was applied, false if it was rejected
itemCount++; * (e.g. out of bounds index or clamping would violate minimum).
} */
adjustBoundary(boundaryIndex: number, deltaPixels: number, minRatio: number = 0.10): boolean {
if (boundaryIndex < 0 || boundaryIndex >= this._tiledItems.length - 1) {
Logger.warn(`adjustBoundary: invalid boundaryIndex ${boundaryIndex}`);
return false;
}
// Divide space equally among items in this segment const totalDim = this._totalDimension();
const segmentWidth = endX - currentX; if (totalDim === 0) return false;
const itemWidth = Math.floor(segmentWidth / itemCount);
for (let k = 0; k < itemsInSegment.length; k++) { const ratioDelta = deltaPixels / totalDim;
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 const newLeft = this._splitRatios[boundaryIndex] + ratioDelta;
i += itemCount - 1; const newRight = this._splitRatios[boundaryIndex + 1] - ratioDelta;
currentX = endX;
if (newLeft < minRatio || newRight < minRatio) {
Logger.log(`adjustBoundary: clamped — newLeft=${newLeft.toFixed(3)}, newRight=${newRight.toFixed(3)}, min=${minRatio}`);
return false;
}
this._splitRatios[boundaryIndex] = newLeft;
this._splitRatios[boundaryIndex + 1] = newRight;
Logger.info(`adjustBoundary: boundary=${boundaryIndex} ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}]`);
return true;
}
/**
* Adjust boundaries on BOTH axes simultaneously for corner resize ops.
* horizontalDelta applies to this container if HORIZONTAL, verticalDelta if VERTICAL.
* For nested containers the perpendicular delta is forwarded to the child container.
*
* boundaryIndex: the slot index whose right/bottom edge is being dragged.
*/
adjustBoundaryBothAxes(
boundaryIndex: number,
horizontalDelta: number,
verticalDelta: number,
): void {
if (this._orientation === Orientation.HORIZONTAL) {
this.adjustBoundary(boundaryIndex, horizontalDelta);
} else {
this.adjustBoundary(boundaryIndex, verticalDelta);
}
}
// ─── Container Lookup ────────────────────────────────────────────────────────
/**
* Returns the direct-parent WindowContainer that contains win_id as an
* immediate child (not recursed further). Returns null if not found.
*/
getContainerForWindow(win_id: number): WindowContainer | null {
for (const item of this._tiledItems) {
if (item instanceof WindowWrapper && item.getWindowId() === win_id) {
return this;
} }
} }
for (const item of this._tiledItems) {
return bounds; if (item instanceof WindowContainer) {
const found = item.getContainerForWindow(win_id);
if (found !== null) return found;
}
}
return null;
} }
getIndexOfItemNested(item: WindowWrapper): number { getIndexOfItemNested(item: WindowWrapper): number {
for (let i = 0; i < this._tiledItems.length; i++) { for (let i = 0; i < this._tiledItems.length; i++) {
const container = this._tiledItems[i]; const container = this._tiledItems[i];
if (Divider.isDivider(container)) { if (container instanceof WindowContainer) {
continue; // Skip dividers
} else if (container instanceof WindowContainer) {
const index = container.getIndexOfItemNested(item); const index = container.getIndexOfItemNested(item);
if (index !== -1) { if (index !== -1) {
return i; return i;
} }
} else if (container instanceof WindowWrapper && container.getWindowId() === item.getWindowId()) { } else if (container.getWindowId() === item.getWindowId()) {
return i; return i;
} }
} }
@@ -362,272 +348,36 @@ export default class WindowContainer {
// TODO: update this to work with nested containers - all other logic should already be working // TODO: update this to work with nested containers - all other logic should already be working
itemDragged(item: WindowWrapper, x: number, y: number): void { itemDragged(item: WindowWrapper, x: number, y: number): void {
// Find the actual index in _tiledItems (including dividers) let original_index = this.getIndexOfItemNested(item);
const original_actual_index = this._getIndexOfWindow(item.getWindowId());
if (original_actual_index === -1) { if (original_index === -1) {
Logger.error("Item not found in container during drag op", item.getWindowId()); Logger.error("Item not found in container during drag op", item.getWindowId());
return; return;
} }
let new_index = original_index;
// Find which visual slot (non-divider index) we're moving to this.getBounds().forEach((rect, index) => {
let new_visual_index = this.getIndexOfItemNested(item);
const bounds = this.getBounds();
bounds.forEach((rect, index) => {
if (rect.x < x && rect.x + rect.width > x && rect.y < y && rect.y + rect.height > y) { 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) {
// Swap only the items — ratios stay with their slots.
// e.g. slot 0 = 40%, slot 1 = 60%: when the window in slot 1 drags
// into slot 0, it takes slot 0's 40% size. The window it displaces
// moves to slot 1 and takes the 60% size. The slot ratios are unchanged.
[this._tiledItems[original_index], this._tiledItems[new_index]] =
[this._tiledItems[new_index], this._tiledItems[original_index]];
Logger.info(`itemDragged: swapped slots ${original_index}<->${new_index}, ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}]`);
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;
this.tileWindows();
} }
/** /**
* Handles window resize operations. Creates or updates dividers based on resize direction. * Reset all split ratios in this container to equal fractions.
* @param item - The window being resized * Called when the user explicitly requests an equal-split reset (e.g. Ctrl+Z).
* @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 { resetRatios(): void {
const itemIndex = this._getIndexOfWindow(item.getWindowId()); this._resetRatios();
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(); 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();
}
}
-45
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;
}
}
+1 -14
View File
@@ -67,8 +67,8 @@ export default class Monitor {
tileWindows(): void { tileWindows(): void {
this._workArea = global.workspace_manager.get_active_workspace().get_work_area_for_monitor(this._id); this._workArea = global.workspace_manager.get_active_workspace().get_work_area_for_monitor(this._id);
const activeWorkspace = global.workspace_manager.get_active_workspace(); const activeWorkspace = global.workspace_manager.get_active_workspace();
// move() already calls tileWindows() internally — don't call it again
this._workspaces[activeWorkspace.index()].move(this._workArea); this._workspaces[activeWorkspace.index()].move(this._workArea);
this._workspaces[activeWorkspace.index()].tileWindows()
} }
removeWorkspace(workspaceId: number): void { removeWorkspace(workspaceId: number): void {
@@ -83,17 +83,4 @@ export default class Monitor {
this._workspaces[item.getWorkspace()].itemDragged(item, x, y); 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();
}
} }
+25 -35
View File
@@ -16,7 +16,6 @@ export class WindowWrapper {
readonly _signals: number[] = []; readonly _signals: number[] = [];
_parent: WindowContainer | null = null; _parent: WindowContainer | null = null;
_dragging: boolean = false; _dragging: boolean = false;
_resizing: boolean = false;
constructor( constructor(
window: Meta.Window, window: Meta.Window,
@@ -54,13 +53,6 @@ export class WindowWrapper {
this._dragging = false; this._dragging = false;
} }
startResizing(): void {
this._resizing = true;
}
stopResizing(): void {
this._resizing = false;
}
// setParent(parent: WindowContainer): void { // setParent(parent: WindowContainer): void {
// this._parent = parent; // this._parent = parent;
// } // }
@@ -93,11 +85,6 @@ export class WindowWrapper {
} }
}), }),
this._window.connect('notify::has-focus', () => {
if (this._window.has_focus()) {
windowManager._activeWindowId = windowId;
}
}),
this._window.connect('notify::maximized-horizontally', () => { this._window.connect('notify::maximized-horizontally', () => {
if (this._window.is_maximized()) { if (this._window.is_maximized()) {
Logger.log(`Window maximized: ${windowId}`); Logger.log(`Window maximized: ${windowId}`);
@@ -112,6 +99,9 @@ export class WindowWrapper {
this._window.connect("position-changed", (_metaWindow) => { this._window.connect("position-changed", (_metaWindow) => {
windowManager.handleWindowPositionChanged(this); windowManager.handleWindowPositionChanged(this);
}), }),
this._window.connect("size-changed", (_metaWindow) => {
windowManager.handleWindowPositionChanged(this);
}),
); );
} }
@@ -130,41 +120,41 @@ export class WindowWrapper {
} }
} }
safelyResizeWindow(rect: Rect, _retry: number = 2): void { safelyResizeWindow(rect: Rect, _retry: number = 3): void {
// Keep minimal logging if (this._dragging) {
if (this._dragging && !this._resizing) { Logger.info("STOPPED RESIZE BECAUSE ITEM IS BEING DRAGGED");
// During drag operations (not resize), skip this entirely
return; 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) { if (!actor) {
Logger.log("No actor available, can't resize safely yet"); Logger.log("No actor available, can't resize safely yet");
return; return;
} }
let windowActor = this._window.get_compositor_private() as Clutter.Actor;
if (!windowActor) return; actor.remove_all_transitions();
windowActor.remove_all_transitions();
// Logger.info("MOVING") // Single call: move + resize atomically
this._window.move_frame(true, rect.x, rect.y);
// Logger.info("RESIZING MOVING")
this._window.move_resize_frame(true, rect.x, rect.y, rect.width, rect.height); 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 const new_rect = this._window.get_frame_rect();
if (this._resizing) { const TOLERANCE = 2; // pixels — allow compositor rounding
return; const mismatch =
} Math.abs(new_rect.x - rect.x) > TOLERANCE ||
Math.abs(new_rect.y - rect.y) > TOLERANCE ||
Math.abs(new_rect.width - rect.width) > TOLERANCE ||
Math.abs(new_rect.height - rect.height) > TOLERANCE;
let new_rect = this._window.get_frame_rect(); if (_retry > 0 && mismatch) {
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("RESIZE MISMATCH, retrying",
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); `want(${rect.x},${rect.y},${rect.width},${rect.height})`,
`got(${new_rect.x},${new_rect.y},${new_rect.width},${new_rect.height})`);
queueEvent({ queueEvent({
name: "attempting_delayed_resize", name: `delayed_resize_${this.getWindowId()}`,
callback: () => { callback: () => {
this.safelyResizeWindow(rect, _retry-1); this.safelyResizeWindow(rect, _retry - 1);
} }
}) }, 50);
} }
} }
+358 -83
View File
@@ -1,5 +1,5 @@
import Meta from "gi://Meta"; import Meta from "gi://Meta";
// import Gio from "gi://Gio"; import Gio from "gi://Gio";
// import GLib from "gi://GLib"; // import GLib from "gi://GLib";
import {WindowWrapper} from './window.js'; import {WindowWrapper} from './window.js';
@@ -7,6 +7,8 @@ import * as Main from "resource:///org/gnome/shell/ui/main.js";
// import Mtk from "@girs/mtk-16"; // import Mtk from "@girs/mtk-16";
import {Logger} from "../utils/logger.js"; import {Logger} from "../utils/logger.js";
import Monitor from "./monitor.js"; import Monitor from "./monitor.js";
import WindowContainer from "./container.js";
import {Rect} from "../utils/rect.js";
export interface IWindowManager { export interface IWindowManager {
@@ -44,14 +46,27 @@ export default class WindowManager implements IWindowManager {
_grabbedWindowMonitor: number = _UNUSED_MONITOR_ID; _grabbedWindowMonitor: number = _UNUSED_MONITOR_ID;
_grabbedWindowId: number = _UNUSED_WINDOW_ID; _grabbedWindowId: number = _UNUSED_WINDOW_ID;
_grabbedOp: Meta.GrabOp | null = null;
_changingGrabbedMonitor: boolean = false; _changingGrabbedMonitor: boolean = false;
_showingOverview: boolean = false; _showingOverview: boolean = false;
constructor() { // ── Resize-drag tracking ──────────────────────────────────────────────────
_isResizeDrag: boolean = false;
_resizeDragWindowId: number = _UNUSED_WINDOW_ID;
_resizeDragOp: Meta.GrabOp = Meta.GrabOp.NONE;
/** Mouse position at the start of each incremental resize step. */
_resizeDragLastMouseX: number = 0;
_resizeDragLastMouseY: number = 0;
/** Re-entrancy guard: true while tileWindows is propagating position-changed events. */
_isTiling: boolean = false;
_settings: Gio.Settings | null = null;
constructor() {}
/** Returns the live min-ratio value from settings, falling back to 0.10. */
private _getMinRatio(): number {
return this._settings?.get_double('min-window-size-percent') ?? 0.10;
} }
public enable(): void { public enable(): void {
@@ -65,6 +80,9 @@ export default class WindowManager implements IWindowManager {
} }
this.captureExistingWindows(); this.captureExistingWindows();
// Sync the initially focused window
this.syncActiveWindow();
} }
instantiateDisplaySignals(): void { instantiateDisplaySignals(): void {
@@ -87,6 +105,9 @@ export default class WindowManager implements IWindowManager {
global.display.connect('window-created', (display, window) => { global.display.connect('window-created', (display, window) => {
this.handleWindowCreated(display, window); this.handleWindowCreated(display, window);
}), }),
global.display.connect('notify::focus-window', () => {
this.syncActiveWindow();
}),
global.display.connect("showing-desktop-changed", () => { global.display.connect("showing-desktop-changed", () => {
Logger.log("SHOWING DESKTOP CHANGED"); Logger.log("SHOWING DESKTOP CHANGED");
@@ -158,10 +179,13 @@ export default class WindowManager implements IWindowManager {
} }
removeAllWindows(): void { removeAllWindows(): void {
// Disconnect signals from minimized windows before clearing
this.disconnectMinimizedSignals();
this._minimizedItems.clear();
this._monitors.forEach((monitor: Monitor) => { this._monitors.forEach((monitor: Monitor) => {
monitor.removeAllWindows(); monitor.removeAllWindows();
}) })
this._minimizedItems.clear();
} }
@@ -199,63 +223,69 @@ export default class WindowManager implements IWindowManager {
} }
handleGrabOpBegin(display: Meta.Display, window: Meta.Window, op: Meta.GrabOp): void { /**
if (op === Meta.GrabOp.MOVING_UNCONSTRAINED){ * Returns true if the grab op is a resize operation (any edge or corner).
*/
} _isResizeOp(op: Meta.GrabOp): boolean {
Logger.log("Grab Op Start", op); return op === Meta.GrabOp.RESIZING_E ||
Logger.log(display, window, op) op === Meta.GrabOp.RESIZING_W ||
Logger.log(window.get_monitor()) op === Meta.GrabOp.RESIZING_N ||
op === Meta.GrabOp.RESIZING_S ||
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 ||
op === Meta.GrabOp.RESIZING_NE || op === Meta.GrabOp.RESIZING_NE ||
op === Meta.GrabOp.RESIZING_NW || op === Meta.GrabOp.RESIZING_NW ||
op === Meta.GrabOp.RESIZING_SE || op === Meta.GrabOp.RESIZING_SE ||
op === Meta.GrabOp.RESIZING_SW; 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)) {
// ── Resize drag ──────────────────────────────────────────────────
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;
// Mark the window as dragging so safelyResizeWindow skips it while
// we tile the other windows in response to ratio changes.
this._getWrappedWindow(window)?.startDragging();
} else {
// ── Move drag (existing behaviour) ───────────────────────────────
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) {
// ── Resize drag end ──────────────────────────────────────────────
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;
// Stop suppressing the window, then snap everything to computed ratios
this._getWrappedWindow(window)?.stopDragging();
this._tileMonitors();
} else {
// ── Move drag end (existing behaviour) ───────────────────────────
Logger.log("primary display", display.get_primary_monitor())
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 { _getWrappedWindow(window: Meta.Window): WindowWrapper | undefined {
let wrapped = undefined; let wrapped: WindowWrapper | undefined = undefined;
for (const monitor of this._monitors.values()) { for (const monitor of this._monitors.values()) {
wrapped = monitor.getWindow(window.get_id()); wrapped = monitor.getWindow(window.get_id());
if (wrapped !== undefined) { if (wrapped !== undefined) {
@@ -266,7 +296,7 @@ export default class WindowManager implements IWindowManager {
} }
_getAndRemoveWrappedWindow(window: Meta.Window): WindowWrapper | undefined { _getAndRemoveWrappedWindow(window: Meta.Window): WindowWrapper | undefined {
let wrapped = undefined; let wrapped: WindowWrapper | undefined = undefined;
for (const monitor of this._monitors.values()) { for (const monitor of this._monitors.values()) {
wrapped = monitor.getWindow(window.get_id()); wrapped = monitor.getWindow(window.get_id());
if (wrapped !== undefined) { if (wrapped !== undefined) {
@@ -281,7 +311,7 @@ export default class WindowManager implements IWindowManager {
let wrapped = this._getAndRemoveWrappedWindow(window); let wrapped = this._getAndRemoveWrappedWindow(window);
if (wrapped === undefined) { if (wrapped === undefined) {
Logger.error("WINDOW NOT DEFINED") Logger.error("WINDOW NOT DEFINED")
wrapped = new WindowWrapper(window, this.handleWindowMinimized); wrapped = new WindowWrapper(window, (winWrap) => this.handleWindowMinimized(winWrap));
wrapped.connectWindowSignals(this); wrapped.connectWindowSignals(this);
} }
let new_mon = this._monitors.get(monitorId); let new_mon = this._monitors.get(monitorId);
@@ -290,19 +320,21 @@ export default class WindowManager implements IWindowManager {
} }
public handleWindowPositionChanged(winWrap: WindowWrapper): void { public handleWindowPositionChanged(winWrap: WindowWrapper): void {
// Ignore position changes that we triggered ourselves via tileWindows
if (this._isTiling) {
return;
}
if (this._changingGrabbedMonitor) { if (this._changingGrabbedMonitor) {
return; return;
} }
// Handle resize operations - update dividers in real-time // ── Live resize-drag handling ─────────────────────────────────────────
if (this._grabbedOp && this._isResizeOp(this._grabbedOp)) { if (this._isResizeDrag && winWrap.getWindowId() === this._resizeDragWindowId) {
const window = winWrap.getWindow(); this._handleResizeDragUpdate(winWrap);
const newRect = window.get_frame_rect();
const monitorId = window.get_monitor();
this._monitors.get(monitorId)?.updateDividerDuringResize(winWrap, this._grabbedOp, newRect);
return; return;
} }
// ── Move-drag handling (existing behaviour) ───────────────────────────
if (winWrap.getWindowId() === this._grabbedWindowId) { if (winWrap.getWindowId() === this._grabbedWindowId) {
const [mouseX, mouseY, _] = global.get_pointer(); const [mouseX, mouseY, _] = global.get_pointer();
@@ -316,18 +348,109 @@ export default class WindowManager implements IWindowManager {
} }
} }
if (monitorIndex === -1) { if (monitorIndex === -1) {
return return;
} }
if (monitorIndex !== this._grabbedWindowMonitor) { if (monitorIndex !== this._grabbedWindowMonitor) {
this._changingGrabbedMonitor = true; this._changingGrabbedMonitor = true;
this._moveWindowToMonitor(winWrap.getWindow(), monitorIndex); this._moveWindowToMonitor(winWrap.getWindow(), monitorIndex);
this._changingGrabbedMonitor = false this._changingGrabbedMonitor = false;
}
// Guard _isTiling so that tileWindows() calls triggered by itemDragged
// (which repositions the displaced window) don't re-enter this handler.
this._isTiling = true;
try {
this._monitors.get(monitorIndex)?.itemDragged(winWrap, mouseX, mouseY);
} finally {
this._isTiling = false;
} }
this._monitors.get(monitorIndex)?.itemDragged(winWrap, mouseX, mouseY);
} }
} }
/**
* Called on every position-changed event while a resize drag is in progress.
* Computes the pixel delta from the drag-start rect, maps it to the correct
* container boundary, and calls adjustBoundary() for live feedback.
*/
private _handleResizeDragUpdate(winWrap: WindowWrapper): void {
const op = this._resizeDragOp;
const winId = winWrap.getWindowId();
// Read the current mouse position — this is unclamped by the compositor
// and always reflects the true user intent, unlike the window's frame rect
// which gets clamped when adjacent windows block expansion.
const [mouseX, mouseY] = global.get_pointer();
const dx = mouseX - this._resizeDragLastMouseX;
const dy = mouseY - this._resizeDragLastMouseY;
if (dx === 0 && dy === 0) return;
// Update last position first so even if we return early the baseline advances
this._resizeDragLastMouseX = mouseX;
this._resizeDragLastMouseY = mouseY;
// Find the container that directly holds this window
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; // Orientation.HORIZONTAL
// Map the mouse delta to the correct boundary.
//
// East/South edge → boundary AFTER the item (boundaryIndex = itemIndex)
// positive dx/dy grows this item, shrinks the next one.
// West/North edge → boundary BEFORE the item (boundaryIndex = itemIndex - 1)
// positive dx/dy moves the left edge right, growing the left neighbour
// and shrinking this item — so we negate the delta.
const minRatio = this._getMinRatio();
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, minRatio);
} else if (op === Meta.GrabOp.RESIZING_W || op === Meta.GrabOp.RESIZING_NW || op === Meta.GrabOp.RESIZING_SW) {
adjusted = container.adjustBoundary(itemIndex - 1, dx, minRatio);
}
} else {
if (op === Meta.GrabOp.RESIZING_S || op === Meta.GrabOp.RESIZING_SE || op === Meta.GrabOp.RESIZING_SW) {
adjusted = container.adjustBoundary(itemIndex, dy, minRatio);
} else if (op === Meta.GrabOp.RESIZING_N || op === Meta.GrabOp.RESIZING_NE || op === Meta.GrabOp.RESIZING_NW) {
adjusted = container.adjustBoundary(itemIndex - 1, dy, minRatio);
}
}
// Tile all windows with the updated ratios, guarded so the resulting
// position-changed events don't re-enter this handler.
if (adjusted) {
this._isTiling = true;
try {
container.tileWindows();
} finally {
this._isTiling = false;
}
}
}
/**
* Searches all monitors for the WindowContainer that directly holds win_id.
*/
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 workspace = monitor._workspaces[activeWorkspaceIndex];
const container = workspace.getContainerForWindow(winId);
if (container !== null) return container;
}
return null;
}
public handleWindowMinimized(winWrap: WindowWrapper): void { public handleWindowMinimized(winWrap: WindowWrapper): void {
const monitor_id = winWrap.getWindow().get_monitor() const monitor_id = winWrap.getWindow().get_monitor()
@@ -392,7 +515,7 @@ export default class WindowManager implements IWindowManager {
public addWindowToMonitor(window: Meta.Window) { public addWindowToMonitor(window: Meta.Window) {
Logger.log("ADDING WINDOW TO MONITOR", window, 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); wrapper.connectWindowSignals(this);
this._addWindowWrapperToMonitor(wrapper); this._addWindowWrapperToMonitor(wrapper);
@@ -407,9 +530,13 @@ export default class WindowManager implements IWindowManager {
} }
_tileMonitors(): void { _tileMonitors(): void {
this._isTiling = true;
for (const monitor of this._monitors.values()) { try {
monitor.tileWindows() for (const monitor of this._monitors.values()) {
monitor.tileWindows();
}
} finally {
this._isTiling = false;
} }
} }
@@ -461,28 +588,176 @@ export default class WindowManager implements IWindowManager {
* @returns The window ID of the active window, or null if no window is active * @returns The window ID of the active window, or null if no window is active
*/ */
public syncActiveWindow(): number | null { public syncActiveWindow(): number | 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;
}
/**
* Toggles the orientation of the active container (the container holding the active window)
*/
public toggleActiveContainerOrientation(): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot toggle container orientation");
return;
}
// Find the active window's container
const activeContainer = this._findActiveContainer();
if (activeContainer) {
activeContainer.toggleOrientation();
} else {
Logger.warn("Could not find container for active window");
}
}
/**
* Resets all split ratios in the active window's container to equal fractions.
* Bound to Ctrl+Z by default.
*/
public resetActiveContainerRatios(): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot reset container ratios");
return;
}
const activeContainer = this._findActiveContainer();
if (activeContainer) {
Logger.info("Resetting container ratios to equal splits");
activeContainer.resetRatios();
} else {
Logger.warn("Could not find container for active window");
}
}
/**
* Finds the container that directly contains the active window
* @returns The container holding the active window, or null if not found
*/
private _findActiveContainer(): WindowContainer | null {
if (this._activeWindowId === null) {
return null;
}
for (const monitor of this._monitors.values()) {
const activeWorkspaceIndex = global.workspace_manager.get_active_workspace().index();
// Bounds check to prevent accessing invalid workspace
if (activeWorkspaceIndex >= monitor._workspaces.length || activeWorkspaceIndex < 0) {
Logger.warn(`Active workspace index ${activeWorkspaceIndex} out of bounds for monitor with ${monitor._workspaces.length} workspaces`);
continue;
}
const workspace = monitor._workspaces[activeWorkspaceIndex];
// Check if the window is directly in the workspace container
const windowWrapper = workspace.getWindow(this._activeWindowId);
if (windowWrapper) {
// Try to find the parent container
const container = this._findContainerHoldingWindow(workspace, this._activeWindowId);
return container;
}
}
return null; return null;
} }
/** /**
* Removes all dividers from the container with the currently active window * Recursively finds the container that directly contains a specific window
* @param container The container to search
* @param windowId The window ID to find
* @returns The container that directly contains the window, or null if not found
*/ */
public removeAllDividersFromActiveContainer(): void { private _findContainerHoldingWindow(container: WindowContainer, windowId: number): WindowContainer | null {
const activeWindow = global.display.focus_window; // Check if this container directly contains the window
if (!activeWindow) { for (const item of container._tiledItems) {
Logger.log("No active window, cannot remove dividers"); if (item instanceof WindowContainer) {
return; // Recursively search nested containers
const result = this._findContainerHoldingWindow(item, windowId);
if (result) {
return result;
}
} else if (item.getWindowId() === windowId) {
// Found it! Return this container as it directly holds the window
return container;
}
} }
const monitorId = activeWindow.get_monitor(); return null;
const monitor = this._monitors.get(monitorId); }
if (monitor) { /**
Logger.log(`Removing all dividers from monitor ${monitorId}`); * Prints the tree structure of all monitors, workspaces, containers, and windows to the logs
monitor.removeAllDividersFromActiveContainer(); */
} else { public printTreeStructure(): void {
Logger.warn(`Monitor ${monitorId} not found`); 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;
const monitorMarker = isActiveMonitor ? ' *' : '';
Logger.info(`Monitor ${monitorId}${monitorMarker}:`);
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;
const workspaceMarker = isActiveWorkspace && isActiveMonitor ? ' *' : '';
Logger.info(` Workspace ${workspaceIndex}${workspaceMarker}:`);
Logger.info(` Orientation: ${workspace._orientation === 0 ? 'HORIZONTAL' : 'VERTICAL'}`);
Logger.info(` Items: ${workspace._tiledItems.length}`);
this._printContainerTree(workspace, 4);
});
});
Logger.info("=".repeat(80));
}
/**
* Recursively prints the container tree structure
* @param container The container to print
* @param indentLevel The indentation level (number of spaces)
*/
private _printContainerTree(container: any, indentLevel: number): void {
const indent = " ".repeat(indentLevel);
container._tiledItems.forEach((item: any, index: number) => {
if (item instanceof WindowContainer) {
// Check if this container contains the active window
const containsActiveWindow = this._activeWindowId !== null &&
item.getWindow(this._activeWindowId) !== undefined;
const containerMarker = containsActiveWindow ? ' *' : '';
Logger.info(`${indent}[${index}] Container (${item._orientation === 0 ? 'HORIZONTAL' : 'VERTICAL'})${containerMarker}:`);
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();
const isActiveWindow = this._activeWindowId === item.getWindowId();
const windowMarker = isActiveWindow ? ' *' : '';
Logger.info(`${indent}[${index}] Window ID: ${item.getWindowId()}${windowMarker}`);
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}`);
}
});
} }
+2
View File
@@ -5,6 +5,8 @@
"outDir": "./dist", "outDir": "./dist",
"sourceMap": false, "sourceMap": false,
"strict": true, "strict": true,
"noImplicitAny": false,
"skipLibCheck": true,
"target": "ES2022", "target": "ES2022",
"lib": [ "lib": [
"ES2022" "ES2022"