56 Commits

Author SHA1 Message Date
Lucas Oskorep
5faf4ec3bf feat: update version in package.json
All checks were successful
Build and Test / build (push) Successful in 23s
Build and Test / release (push) Successful in 3s
2026-03-02 23:09:39 -05:00
Lucas Oskorep
add86d284b feat: initial README.md
All checks were successful
Build and Test / build (push) Successful in 24s
Build and Test / release (push) Successful in 3s
2026-03-02 23:07:28 -05:00
Lucas Oskorep
696269d8b8 feat: adding support for multi-monitor window moving
Some checks failed
Build and Test / release (push) Has been cancelled
Build and Test / build (push) Has been cancelled
Build and Test / build (pull_request) Successful in 24s
Build and Test / release (pull_request) Has been skipped
2026-03-02 23:06:47 -05:00
Lucas Oskorep
fa021b08eb feat: adding active window selection and refactoring keybindings 2026-03-02 18:46:26 -05:00
Lucas Oskorep
8ed5f104b2 fix: when new items enter the tabbed view they should be the active window
All checks were successful
Build and Test / build (push) Successful in 25s
Build and Test / release (push) Successful in 3s
2026-03-02 11:26:28 -05:00
Lucas Oskorep
cbaa802797 feat: equal-sized tabs and constantly updated tab titles
All checks were successful
Build and Test / build (push) Successful in 25s
Build and Test / release (push) Successful in 3s
2026-02-27 12:24:58 -05:00
Lucas Oskorep
e2a1792388 fix: tab bars showing when apps are fullscreen on same monitor
All checks were successful
Build and Test / build (push) Successful in 25s
Build and Test / release (push) Successful in 2s
2026-02-26 22:06:30 -05:00
Lucas Oskorep
656e448927 feat: add tabbed container layout mode with tab bar UI
All checks were successful
Build and Test / build (push) Successful in 25s
Build and Test / release (push) Successful in 3s
fix: tab bars no longer shown in overview.  Tab bars show name of app with pipe and then title of the app
2026-02-26 21:45:40 -05:00
Lucas Oskorep
93516b31fb fix: new bug from combining resize and move commands - if window has min-size set and resize goes smaller window would not move or resize causing vesktop and steam to frequently break when in arrays on a smaller monitors
All checks were successful
Build and Test / build (push) Successful in 39s
Build and Test / release (push) Successful in 10s
2026-02-26 01:54:49 -05:00
Lucas Oskorep
918c07c419 Merge pull request 'chore(deps): update dependency eslint to v10' (#16) from renovate/major-eslint-monorepo into main
All checks were successful
Build and Test / build (push) Successful in 25s
Build and Test / release (push) Successful in 2s
Reviewed-on: #16
2026-02-25 17:18:42 -05:00
Lucas Oskorep
15188b9990 Merge branch 'main' into renovate/major-eslint-monorepo
All checks were successful
Build and Test / build (pull_request) Successful in 24s
Build and Test / release (pull_request) Has been skipped
2026-02-25 17:02:44 -05:00
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
Renovate Bot
8f6e8582c9 chore(deps): update dependency eslint to v10
All checks were successful
Build and Test / build (pull_request) Successful in 24s
Build and Test / release (pull_request) Has been skipped
2026-02-06 23:13:10 +00: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
Lucas Oskorep
88623f32d7 feat: update devkit command 2025-10-16 03:44:33 -04:00
Lucas Oskorep
265ff05436 upgrade to gnome 49 2025-09-24 00:55:24 -04:00
Lucas Oskorep
2b86856a97 feat: remove crap code 2025-05-20 18:37:15 -04:00
Lucas Oskorep
a858af73f4 fix: allow windows to be moved in the overview 2025-05-20 01:59:08 -04:00
Lucas Oskorep
ecb5a568cd fix: bug with window manager resizing a window during grab ops for 1 frame when the background had to be tiled 2025-05-20 01:48:34 -04:00
Lucas Oskorep
5e9bc796ea feat: enable the window manager to be able to drag across monitors and support keybindings propperly in the extension settings 2025-05-20 01:20:48 -04:00
Lucas Oskorep
04f402c686 feat: upgrade packages and set parent for windows on add 2025-05-19 21:43:09 -04:00
Lucas Oskorep
1d3d9dc402 feat: add ability to change ordering of monitors 2025-05-16 02:58:51 -04:00
Lucas Oskorep
c7f45ecf3b feat: refactored to monitor -> workspace -> container -> window workflow 2025-05-16 02:01:11 -04:00
Lucas Oskorep
c23b9113ab feat: adding support for workspaces 2025-05-16 00:19:49 -04:00
Lucas Oskorep
50ceb02124 feat: refactoring 2025-05-04 17:17:33 -04:00
Lucas Oskorep
717c240d70 feat: fixed display signal handling on disable 2025-05-02 01:31:46 -04:00
Lucas Oskorep
822a7bd2e4 Merge branch 'feat/fix-login-issues' 2025-04-30 00:06:03 -04:00
Lucas Oskorep
4543c98de8 feat: adding fix for minimized windows still taking up space 2025-04-30 00:05:46 -04:00
Lucas Oskorep
d59a0fef6d feat: set nvm version 2025-04-19 02:18:32 +00:00
Lucas Oskorep
ed661b3fa6 feat: attempting to fix windows lost on logout 2025-04-18 17:33:02 -04:00
Lucas Oskorep
6a19b77742 feat: initial commit with multi-monitor support 2025-04-18 03:22:35 -04:00
Lucas Oskorep
7b0f37f3f9 fix 2025-04-18 01:57:45 -04:00
Lucas Oskorep
e1e240924a feat: first draft of everything working single monitor with just mouse commands 2025-04-18 01:57:29 -04:00
33 changed files with 6352 additions and 1972 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"

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules
dist
/schemas/gschemas.compiled
/aerospike.zip
/debug.log

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

View File

@@ -1 +1,30 @@
# Aerospike Gnome (Tiling Window Manager)
# Aerospike Gnome (Tiling Window Manager)
Aerospike is a simple and opinionated tiling window manager for gnome.
This project takes inspiration from previous gnome tiling window managers such
as [forge](https://github.com/forge-ext/forge)
and [cosmic-shell](https://github.com/pop-os/gnome-shell-extension-pop-cosmic) as well as
MacOS tiling WMs, mainly [Aerospace](https://github.com/nikitabobko/AeroSpace) (not affiliated).
## Current Features
- Auto-tiling
- Accordion layouts with variable sizes
- Tabbed layouts
- Tree-based (albeit set depth of 2 for now) container-window paired layout similar to Aerospace
- Diagram for this is pending
## Planned functionality
- Full Keyboard control w/window movement
- Sub-containers (more tree layers than the 1 supported for now)
- Gap size customization
## Not currently planned
- Active window borders
- See my other extension for a rainbow or static border - [PrettyBorders](https://github.com//pretty-borders)
- complicated window dragging features and uis
- Aerospace supports control + drag to combine windows while moving with the mouse, and normal window dragging.

7
ambient.d.ts vendored
View File

@@ -2,3 +2,10 @@ import "@girs/gjs";
import "@girs/gjs/dom";
import "@girs/gnome-shell/ambient";
import "@girs/gnome-shell/extensions/global";
// Extend Meta.Window with our custom property
declare namespace Meta {
interface Window {
_aerospikeData?: any;
}
}

View File

@@ -1,303 +1,76 @@
import GLib from 'gi://GLib';
import St from 'gi://St';
import Meta from 'gi://Meta';
import {Extension, ExtensionMetadata} from 'resource:///org/gnome/shell/extensions/extension.js';
import Mtk from "@girs/mtk-16";
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';
import { WindowTree, WindowNode, createWindowNode, addNodeChild, removeNode, findNodeByWindowId, calculateLayout } from './src/winGroup.js';
type Signal = {
name: string;
id: number;
}
type WinWrapper = {
window: Meta.Window | null;
signals: Signal[] | null;
}
type WorkspaceMonitorKey = `${number}-${number}`; // format: "workspace-monitor"
type DraggedWindowInfo = {
id: number;
originalMonitor: number;
originalWorkspace: number;
}
import WindowManager from './src/wm/windowManager.js'
import {Direction} from './src/wm/container.js'
import {Logger} from "./src/utils/logger.js";
export default class aerospike extends Extension {
settings: Gio.Settings;
keyBindings: Map<string, number>;
borderActor: St.Widget | null;
focusWindowSignals: any[];
lastFocusedWindow: Meta.Window | null;
_focusSignal: number | null;
_windowCreateId: number | null;
_windows: Map<number, WinWrapper>;
_windowTrees: Map<WorkspaceMonitorKey, WindowTree>;
_activeWindowId: number | null;
_windowDragBeginId: number | null;
_windowDragEndId: number | null;
_draggedWindowInfo: DraggedWindowInfo | null;
_workspaceChangedId: number | null;
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();
// Initialize instance variables
this.borderActor = null;
this.focusWindowSignals = [];
this.lastFocusedWindow = null;
this._focusSignal = null;
this._windowCreateId = null;
this._windows = new Map<number, WinWrapper>();
this._windowTrees = new Map<WorkspaceMonitorKey, WindowTree>();
this._activeWindowId = null;
this._windowDragBeginId = null;
this._windowDragEndId = null;
this._draggedWindowInfo = null;
this._workspaceChangedId = null;
this.windowManager = new WindowManager(this.settings);
}
enable() {
try {
console.log("STARTING AEROSPIKE!");
// Initialize data structures
this._windows = new Map<number, WinWrapper>();
this._windowTrees = new Map<WorkspaceMonitorKey, WindowTree>();
this._activeWindowId = null;
this._draggedWindowInfo = null;
// Connect to window creation
this._windowCreateId = global.display.connect(
'window-created',
(display, window) => {
try {
this.handleWindowCreated(window);
} catch (e) {
console.error("Error handling window creation:", e);
}
}
);
// Connect to window drag operations
this._connectDraggingSignals();
// Connect to workspace change signals
this._workspaceChangedId = global.workspace_manager.connect(
'workspace-switched',
(_workspaceManager, _oldWorkspaceIndex, _newWorkspaceIndex) => {
try {
this._refreshActiveWorkspace();
} catch (e) {
console.error("Error refreshing workspace:", e);
}
}
);
// Setup keybindings
Logger.log("STARTING AEROSPIKE!")
this.bindSettings();
// Capture existing windows - do this last
this._captureExistingWindows();
console.log("AEROSPIKE STARTED SUCCESSFULLY");
this.setupKeybindings();
this.windowManager.enable()
Logger.log("AEROSPIKE ENABLED SUCCESSFULLY")
} catch (e) {
console.error("Error enabling Aerospike:", e);
// Perform cleanup if something failed
this.disable();
Logger.error("AEROSPIKE ENABLE FAILED", e);
}
}
_connectDraggingSignals() {
// Handle window drag begin
this._windowDragBeginId = global.display.connect(
'grab-op-begin',
(_display, window, op) => {
if (window && (op === Meta.GrabOp.MOVING || op === Meta.GrabOp.KEYBOARD_MOVING)) {
this._handleDragBegin(window);
}
}
);
// Handle window drag end
this._windowDragEndId = global.display.connect(
'grab-op-end',
(_display, window, op) => {
if (window && (op === Meta.GrabOp.MOVING || op === Meta.GrabOp.KEYBOARD_MOVING)) {
this._handleDragEnd(window);
}
}
);
disable() {
this.windowManager.disable()
this.removeKeybindings()
}
_handleDragBegin(window: Meta.Window) {
try {
if (!window) {
console.error("Received null window in _handleDragBegin");
return;
}
const workspace = window.get_workspace();
if (!workspace) {
console.error("Window has no workspace in _handleDragBegin");
return;
}
const id = window.get_id();
console.log(`Drag begin for window ${id}`);
this._draggedWindowInfo = {
id: id,
originalMonitor: window.get_monitor(),
originalWorkspace: workspace.index()
};
console.log(`Original location: workspace ${this._draggedWindowInfo.originalWorkspace}, monitor ${this._draggedWindowInfo.originalMonitor}`);
} catch (e) {
console.error("Error in _handleDragBegin:", e);
this._draggedWindowInfo = null;
}
}
_handleDragEnd(window: Meta.Window) {
try {
if (!window) {
console.error("Received null window in _handleDragEnd");
this._draggedWindowInfo = null;
return;
}
if (!this._draggedWindowInfo) {
console.log("No drag info available, ignoring drag end");
return;
}
const workspace = window.get_workspace();
if (!workspace) {
console.error("Window has no workspace in _handleDragEnd");
this._draggedWindowInfo = null;
return;
}
const id = window.get_id();
const newMonitor = window.get_monitor();
const newWorkspace = workspace.index();
console.log(`Drag end for window ${id}: new location - workspace ${newWorkspace}, monitor ${newMonitor}`);
// Check if monitor or workspace changed
if (this._draggedWindowInfo.originalMonitor !== newMonitor ||
this._draggedWindowInfo.originalWorkspace !== newWorkspace) {
console.log(`Window moved from workspace ${this._draggedWindowInfo.originalWorkspace}, monitor ${this._draggedWindowInfo.originalMonitor}`);
console.log(`to workspace ${newWorkspace}, monitor ${newMonitor}`);
// Remove from old tree
const oldKey = `${this._draggedWindowInfo.originalWorkspace}-${this._draggedWindowInfo.originalMonitor}` as WorkspaceMonitorKey;
this._removeWindowFromTree(id, oldKey);
// Add to new tree
this._addWindowToTree(window, newWorkspace, newMonitor);
// Retile both affected trees with a small delay
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
try {
this._tileWindowsInTree(oldKey);
this._tileWindowsInTree(`${newWorkspace}-${newMonitor}` as WorkspaceMonitorKey);
} catch (e) {
console.error("Error retiling after drag:", e);
}
return GLib.SOURCE_REMOVE;
});
} else {
console.log("Window position unchanged after drag");
}
} catch (e) {
console.error("Error in _handleDragEnd:", e);
} finally {
this._draggedWindowInfo = null;
}
}
_refreshActiveWorkspace() {
// Refresh all trees in the current workspace
const workspace = global.workspace_manager.get_active_workspace();
const workspaceIndex = workspace.index();
// Find all trees in the current workspace
for (const key of this._windowTrees.keys()) {
const [wsIndex, _] = key.split('-').map(Number);
if (wsIndex === workspaceIndex) {
this._tileWindowsInTree(key as WorkspaceMonitorKey);
}
}
private keybindingActions(): Record<string, () => void> {
return {
'print-tree': () => { this.windowManager.printTreeStructure(); },
'toggle-orientation': () => { this.windowManager.toggleActiveContainerOrientation(); },
'reset-ratios': () => { this.windowManager.resetActiveContainerRatios(); },
'toggle-tabbed': () => { this.windowManager.toggleActiveContainerTabbed(); },
'focus-left': () => { this.windowManager.focusInDirection(Direction.LEFT); },
'focus-right': () => { this.windowManager.focusInDirection(Direction.RIGHT); },
'focus-up': () => { this.windowManager.focusInDirection(Direction.UP); },
'focus-down': () => { this.windowManager.focusInDirection(Direction.DOWN); },
'move-left': () => { this.windowManager.moveInDirection(Direction.LEFT); },
'move-right': () => { this.windowManager.moveInDirection(Direction.RIGHT); },
'move-up': () => { this.windowManager.moveInDirection(Direction.UP); },
'move-down': () => { this.windowManager.moveInDirection(Direction.DOWN); },
};
}
private bindSettings() {
// Monitor settings changes
this.settings.connect('changed::keybinding-1', () => {
log(`Keybinding 1 changed to: ${this.settings.get_strv('keybinding-1')}`);
this.refreshKeybinding('keybinding-1');
});
this.settings.connect('changed::keybinding-2', () => {
log(`Keybinding 2 changed to: ${this.settings.get_strv('keybinding-2')}`);
this.refreshKeybinding('keybinding-2');
});
this.settings.connect('changed::keybinding-3', () => {
log(`Keybinding 3 changed to: ${this.settings.get_strv('keybinding-3')}`);
this.refreshKeybinding('keybinding-3');
});
this.settings.connect('changed::keybinding-4', () => {
log(`Keybinding 4 changed to: ${this.settings.get_strv('keybinding-4')}`);
this.refreshKeybinding('keybinding-4');
});
this.settings.connect('changed::dropdown-option', () => {
log(`Dropdown option changed to: ${this.settings.get_string('dropdown-option')}`);
});
this.settings.connect('changed::color-selection', () => {
log(`Color selection changed to: ${this.settings.get_string('color-selection')}`);
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);
});
});
}
private refreshKeybinding(settingName: string) {
if (this.keyBindings.has(settingName)) {
Main.wm.removeKeybinding(settingName);
this.keyBindings.delete(settingName);
}
switch (settingName) {
case 'keybinding-1':
this.bindKeybinding('keybinding-1', () => {
log('Keybinding 1 was pressed!');
});
break;
case 'keybinding-2':
this.bindKeybinding('keybinding-2', () => {
log('Keybinding 2 was pressed!');
});
break;
case 'keybinding-3':
this.bindKeybinding('keybinding-3', () => {
log('Keybinding 3 was pressed!');
});
break;
case 'keybinding-4':
this.bindKeybinding('keybinding-4', () => {
log('Keybinding 4 was pressed!');
});
break;
}
const action = this.keybindingActions()[settingName];
if (action) this.bindKeybinding(settingName, action);
}
private removeKeybindings() {
@@ -308,21 +81,10 @@ export default class aerospike extends Extension {
}
private setupKeybindings() {
this.bindKeybinding('keybinding-1', () => {
log('Keybinding 1 was pressed!');
});
this.bindKeybinding('keybinding-2', () => {
log('Keybinding 2 was pressed!');
});
this.bindKeybinding('keybinding-3', () => {
log('Keybinding 3 was pressed!');
});
this.bindKeybinding('keybinding-4', () => {
log('Keybinding 4 was pressed!');
});
const actions = this.keybindingActions();
for (const [name, action] of Object.entries(actions)) {
this.bindKeybinding(name, action);
}
}
private bindKeybinding(settingName: string, callback: () => void) {
@@ -335,574 +97,11 @@ export default class aerospike extends Extension {
const keyBindingAction = Main.wm.addKeybinding(
settingName,
this.settings,
Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
Meta.KeyBindingFlags.NONE,
Shell.ActionMode.NORMAL,
callback
);
this.keyBindings.set(settingName, keyBindingAction);
}
handleWindowCreated(window: Meta.Window) {
try {
if (!window) {
console.error("Received null or undefined window");
return;
}
console.log("WINDOW CREATED", window);
if (!this._isWindowTileable(window)) {
console.log("Window is not tileable, ignoring");
return;
}
console.log("WINDOW IS TILABLE");
const actor = window.get_compositor_private();
if (!actor) {
console.log("Window has no compositor actor, ignoring");
return;
}
// Get workspace safely
const workspace = window.get_workspace();
if (!workspace) {
console.error("Window has no workspace, ignoring");
return;
}
// Track window for signal management
this._addWindow(window);
// Add to appropriate tree
const workspaceIndex = workspace.index();
const monitor = window.get_monitor();
console.log(`Adding window to workspace ${workspaceIndex}, monitor ${monitor}`);
this._addWindowToTree(window, workspaceIndex, monitor);
} catch (e) {
console.error("Error in handleWindowCreated:", e);
}
}
_captureExistingWindows() {
try {
console.log("CAPTURING WINDOWS");
// Get all workspaces
const workspaceCount = global.workspace_manager.get_n_workspaces();
const monitorCount = global.display.get_n_monitors();
console.log(`Found ${workspaceCount} workspaces and ${monitorCount} monitors`);
// Initialize trees for all workspace-monitor combinations
for (let wsIndex = 0; wsIndex < workspaceCount; wsIndex++) {
const workspace = global.workspace_manager.get_workspace_by_index(wsIndex);
if (!workspace) {
console.error(`Workspace at index ${wsIndex} not found`);
continue;
}
for (let monIndex = 0; monIndex < monitorCount; monIndex++) {
try {
// Create empty tree for this workspace-monitor combination
this._getWindowTree(wsIndex, monIndex);
// Get windows for this workspace
const windows = global.display.get_tab_list(Meta.TabList.NORMAL, workspace);
console.log(`Found ${windows.length} windows in workspace ${wsIndex}`);
// Add tileable windows to the appropriate tree
let addedWindows = 0;
for (const window of windows) {
try {
if (window && this._isWindowTileable(window) && window.get_monitor() === monIndex) {
// Track window for signal management
this._addWindow(window);
// Add to tree
this._addWindowToTree(window, wsIndex, monIndex);
addedWindows++;
}
} catch (e) {
console.error(`Error processing window in workspace ${wsIndex}, monitor ${monIndex}:`, e);
}
}
console.log(`Added ${addedWindows} windows to workspace ${wsIndex}, monitor ${monIndex}`);
} catch (e) {
console.error(`Error processing monitor ${monIndex} in workspace ${wsIndex}:`, e);
}
}
}
// Tile all trees with a slight delay to ensure all windows are ready
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => {
try {
for (const key of this._windowTrees.keys()) {
this._tileWindowsInTree(key as WorkspaceMonitorKey);
}
} catch (e) {
console.error("Error tiling windows:", e);
}
return GLib.SOURCE_REMOVE;
});
console.log("FINISHED CAPTURING WINDOWS");
} catch (e) {
console.error("Error in _captureExistingWindows:", e);
}
}
_getWindowTree(workspace: number, monitor: number): WindowTree {
const key: WorkspaceMonitorKey = `${workspace}-${monitor}` as WorkspaceMonitorKey;
if (!this._windowTrees.has(key)) {
this._windowTrees.set(key, {
root: null,
monitor: monitor,
workspace: workspace
});
}
return this._windowTrees.get(key)!;
}
_addWindowToTree(window: Meta.Window, workspace: number, monitor: number) {
const tree = this._getWindowTree(workspace, monitor);
const windowNode = createWindowNode(window);
if (!tree.root) {
// First window in this tree
tree.root = windowNode;
} else {
// Add to existing tree
addNodeChild(tree.root, windowNode);
}
// Update the layout
this._tileWindowsInTree(`${workspace}-${monitor}` as WorkspaceMonitorKey);
}
_removeWindowFromTree(windowId: number, key: WorkspaceMonitorKey) {
const tree = this._windowTrees.get(key);
if (!tree) return;
const node = findNodeByWindowId(tree, windowId);
if (node) {
removeNode(node, tree);
}
}
_tileWindowsInTree(key: WorkspaceMonitorKey) {
try {
console.log(`Tiling windows for ${key}`);
const tree = this._windowTrees.get(key);
if (!tree || !tree.root) {
console.log(`No tree or empty tree for ${key}`);
return;
}
// Get workspace and monitor info
const [workspaceIndex, monitorIndex] = key.split('-').map(Number);
const workspace = global.workspace_manager.get_workspace_by_index(workspaceIndex);
if (!workspace) {
console.error(`Workspace ${workspaceIndex} not found`);
return;
}
const workArea = workspace.get_work_area_for_monitor(monitorIndex);
if (!workArea) {
console.error(`WorkArea for monitor ${monitorIndex} in workspace ${workspaceIndex} not found`);
return;
}
console.log(`Work area for ${key}: ${workArea.x},${workArea.y} ${workArea.width}x${workArea.height}`);
// Calculate layout
calculateLayout(tree.root, {
x: workArea.x,
y: workArea.y,
width: workArea.width,
height: workArea.height
});
// Apply layout to all windows in the tree
this._applyLayoutToTree(tree.root);
console.log(`Finished tiling windows for ${key}`);
} catch (e) {
console.error(`Error tiling windows for ${key}:`, e);
}
}
_applyLayoutToTree(node: WindowNode) {
try {
// Apply layout to this node
if (node.window) {
// Validate window object
if (!node.window.get_compositor_private) {
console.error(`Window at node ${node.windowId} is invalid`);
return;
}
// Check for valid rect dimensions
if (node.rect.width <= 0 || node.rect.height <= 0) {
console.error(`Invalid rect dimensions for window ${node.windowId}: ${node.rect.width}x${node.rect.height}`);
return;
}
// Resize window
this.safelyResizeWindow(
node.window,
node.rect.x,
node.rect.y,
node.rect.width,
node.rect.height
);
}
// Apply layout to all children
for (const child of node.children) {
try {
this._applyLayoutToTree(child);
} catch (e) {
console.error(`Error applying layout to child node:`, e);
}
}
} catch (e) {
console.error(`Error in _applyLayoutToTree:`, e);
}
}
getUsableMonitorSpace(window: Meta.Window) {
// Get the current workspace
const workspace = window.get_workspace();
// Get the monitor index that this window is on
const monitorIndex = window.get_monitor();
// Get the work area
const workArea = workspace.get_work_area_for_monitor(monitorIndex);
return {
x: workArea.x,
y: workArea.y,
width: workArea.width,
height: workArea.height
};
}
// Function to safely resize a window after it's ready
safelyResizeWindow(win: Meta.Window, x: number, y: number, width: number, height: number): void {
const actor = win.get_compositor_private();
if (!actor) {
console.log("No actor available, can't resize safely yet");
return;
}
// Check if the window type needs special handling
const windowType = win.get_window_type();
// Try immediate resize first for most window types
if (windowType === Meta.WindowType.NORMAL) {
// Standard resizing path with safety checks
this.resizeWindow(win, x, y, width, height);
// Set up a verification check
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => {
const rect = win.get_frame_rect();
// If window didn't resize well, try again with the first-frame signal
if (Math.abs(rect.width - width) > 5 || Math.abs(rect.height - height) > 5) {
this._setupFirstFrameResize(win, actor, x, y, width, height);
}
return GLib.SOURCE_REMOVE;
});
} else {
// For non-standard windows, use the original approach
this._setupFirstFrameResize(win, actor, x, y, width, height);
}
}
_setupFirstFrameResize(win: Meta.Window, actor: any, x: number, y: number, width: number, height: number): void {
// Set a flag to track if the resize has been done
let resizeDone = false;
// Connect to the first-frame signal
const id = actor.connect('first-frame', () => {
// Disconnect the signal handler
actor.disconnect(id);
if (!resizeDone) {
resizeDone = true;
// Add a small delay
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 150, () => {
try {
this.resizeWindow(win, x, y, width, height);
} catch (e) {
console.error("Error resizing window:", e);
}
return GLib.SOURCE_REMOVE;
});
}
});
// Fallback timeout in case the first-frame signal doesn't fire
// (for windows that are already mapped)
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
if (!resizeDone) {
resizeDone = true;
try {
this.resizeWindow(win, x, y, width, height);
} catch (e) {
console.error("Error resizing window (fallback):", e);
}
}
return GLib.SOURCE_REMOVE;
});
}
resizeWindow(win: Meta.Window, x:number, y:number, width:number, height:number) {
// First, ensure window is not maximized or fullscreen
const wasMaximized = win.get_maximized();
const wasFullscreen = win.is_fullscreen();
if (wasMaximized) {
console.log("WINDOW MAXIMIZED")
win.unmaximize(Meta.MaximizeFlags.BOTH);
}
if (wasFullscreen) {
console.log("WINDOW IS FULLSCREEN")
win.unmake_fullscreen();
}
// Wait for state change to complete if needed
if (wasMaximized || wasFullscreen) {
GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
this._performResize(win, x, y, width, height);
return GLib.SOURCE_REMOVE;
});
} else {
// Immediate resize if no state change needed
this._performResize(win, x, y, width, height);
}
}
_performResize(win: Meta.Window, x:number, y:number, width:number, height:number) {
console.log("WINDOW", win.get_window_type(), win.allows_move());
console.log("MONITOR INFO", this.getUsableMonitorSpace(win));
console.log("NEW_SIZE", x, y, width, height);
// Perform the actual resize
win.move_resize_frame(false, x, y, width, height);
// Check result
const newRect = win.get_frame_rect();
console.log("RESIZED WINDOW", newRect.height, newRect.width, newRect.x, newRect.y);
// Validate the resize was successful
if (Math.abs(newRect.x - x) > 5 || Math.abs(newRect.y - y) > 5 ||
Math.abs(newRect.width - width) > 5 || Math.abs(newRect.height - height) > 5) {
console.warn(`Resize did not achieve expected dimensions for window ${win.get_id()}`);
// Try a second time if the resize didn't work well
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
win.move_resize_frame(false, x, y, width, height);
return GLib.SOURCE_REMOVE;
});
}
}
_addWindow(window: Meta.Window) {
const windowId = window.get_id();
// Connect to window signals
const signals: Signal[] = [];
console.log("ADDING WINDOW", window);
const destroyId = window.connect('unmanaging', () => {
console.log("REMOVING WINDOW", windowId);
this._handleWindowClosed(windowId);
});
signals.push({name: 'unmanaging', id: destroyId});
const focusId = window.connect('notify::has-focus', () => {
if (window.has_focus()) {
this._activeWindowId = windowId;
}
});
signals.push({name: 'notify::has-focus', id: focusId});
// Monitor change signal
const monitorChangedId = window.connect('notify::monitor', () => {
this._handleWindowMonitorChanged(window);
});
signals.push({name: 'notify::monitor', id: monitorChangedId});
// Workspace change signal
const workspaceChangedId = window.connect('workspace-changed', () => {
this._handleWindowWorkspaceChanged(window);
});
signals.push({name: 'workspace-changed', id: workspaceChangedId});
// Add window to managed windows
this._windows.set(windowId, {
window: window,
signals: signals
});
// If this is the first window, make it the active one
if (this._windows.size === 1 || window.has_focus()) {
this._activeWindowId = windowId;
}
}
_handleWindowMonitorChanged(window: Meta.Window) {
const windowId = window.get_id();
// Find which tree this window is in
for (const [key, tree] of this._windowTrees.entries()) {
const node = findNodeByWindowId(tree, windowId);
if (node) {
// Found the window - get new workspace/monitor
const newWorkspace = window.get_workspace().index();
const newMonitor = window.get_monitor();
const newKey = `${newWorkspace}-${newMonitor}` as WorkspaceMonitorKey;
// Skip if it's already in the right tree
if (key === newKey) return;
// Remove from old tree
this._removeWindowFromTree(windowId, key as WorkspaceMonitorKey);
// Add to new tree
this._addWindowToTree(window, newWorkspace, newMonitor);
// Retile both trees
this._tileWindowsInTree(key as WorkspaceMonitorKey);
this._tileWindowsInTree(newKey);
return;
}
}
}
_handleWindowWorkspaceChanged(window: Meta.Window) {
// Similar to monitor change, but for workspace changes
this._handleWindowMonitorChanged(window); // This handles both cases
}
_handleWindowClosed(windowId: number) {
print("closing window", windowId);
const windowData = this._windows.get(windowId);
if (!windowData) {
return;
}
// Disconnect signals
if (windowData.signals) {
windowData.signals.forEach(signal => {
try {
if (windowData.window != null) {
windowData.window.disconnect(signal.id);
}
} catch (e) {
// Window might already be gone
}
});
}
// Remove from managed windows
this._windows.delete(windowId);
// Remove from all trees
for (const key of this._windowTrees.keys()) {
this._removeWindowFromTree(windowId, key as WorkspaceMonitorKey);
this._tileWindowsInTree(key as WorkspaceMonitorKey);
}
// If this was the active window, find a new one
if (this._activeWindowId === windowId && this._windows.size > 0) {
this._activeWindowId = Array.from(this._windows.keys())[0];
} else if (this._windows.size === 0) {
this._activeWindowId = null;
}
}
_isWindowTileable(window: Meta.Window) {
if (!window || !window.get_compositor_private()) {
return false;
}
const windowType = window.get_window_type();
console.log("WINDOW TYPE", windowType);
// Skip certain types of windows
return !window.is_skip_taskbar() &&
windowType !== Meta.WindowType.DESKTOP &&
windowType !== Meta.WindowType.DOCK &&
windowType !== Meta.WindowType.DIALOG &&
windowType !== Meta.WindowType.MODAL_DIALOG &&
windowType !== Meta.WindowType.UTILITY &&
windowType !== Meta.WindowType.MENU;
}
disable() {
console.log("DISABLED AEROSPIKE!")
// Disconnect window creation signal
if (this._windowCreateId) {
global.display.disconnect(this._windowCreateId);
this._windowCreateId = null;
}
// Disconnect workspace signals
if (this._workspaceChangedId) {
global.workspace_manager.disconnect(this._workspaceChangedId);
this._workspaceChangedId = null;
}
// Disconnect drag signals
if (this._windowDragBeginId) {
global.display.disconnect(this._windowDragBeginId);
this._windowDragBeginId = null;
}
if (this._windowDragEndId) {
global.display.disconnect(this._windowDragEndId);
this._windowDragEndId = null;
}
// Disconnect all window signals
this._windows.forEach((windowData) => {
if (windowData.signals && windowData.window) {
windowData.signals.forEach(signal => {
try {
windowData.window!.disconnect(signal.id);
} catch (e) {
// Window might already be gone
}
});
}
});
// Clear all window data
this._windows.clear();
this._windowTrees.clear();
// Remove keybindings
this.removeKeybindings();
// Reset state
this._activeWindowId = null;
this.lastFocusedWindow = null;
this._draggedWindowInfo = null;
}
}
}

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
@@ -24,14 +24,29 @@ build-package: build
install: build
mkdir -p ~/.local/share/gnome-shell/extensions/{{NAME}}@{{DOMAIN}}
rm -rf /.local/share/gnome-shell/extensions/{{NAME}}@{{DOMAIN}}/*
rm -rf ~/.local/share/gnome-shell/extensions/{{NAME}}@{{DOMAIN}}/*
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 \

View File

@@ -4,7 +4,8 @@
"uuid": "aerospike@lucaso.io",
"settings-schema": "org.gnome.shell.extensions.aerospike",
"shell-version": [
"48"
"48",
"49"
],
"gettext-domain": "aerospike@lucaso.io",
"url": "https://gitea.chaosdev.gay/lucasoskorep/aerospike@lucaso.io"

View File

@@ -1,6 +1,6 @@
{
"name": "aerospike",
"version": "0.2.0",
"version": "0.2.1",
"description": "A TypeScript GNOME Extension for Pretty Borders",
"type": "module",
"private": true,
@@ -9,20 +9,49 @@
"url": "git+https://gitlab.com/lucasoskorep/aerospike"
},
"author": "Lucas Oskorep <lucas.oskorep@gmail.com>",
"license": "LGPL-3.0-or-later",
"license": "GPL-3.0-only",
"bugs": {
"url": "https://github.com/example/my-extension/issues"
},
"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.23",
"@girs/gnome-shell": "^48.0.2",
"eslint": "^9.24.0",
"eslint-plugin-jsdoc": "^50.6.9",
"typescript": "^5.8.3"
"@girs/gjs": "4.0.0-beta.38",
"@girs/gnome-shell": "49.1.0",
"@jest/globals": "^30.0.0",
"@types/jest": "^30.0.0",
"eslint": "^10.0.0",
"eslint-plugin-jsdoc": "^62.0.0",
"jest": "^30.0.0",
"ts-jest": "^29.1.2",
"typescript": "^5.9.2"
},
"dependencies": {
"@girs/mtk-16": "16.0.0-4.0.0-beta.23"
"@girs/mtk-17": "17.0.0-4.0.0-beta.38"
},
"pnpm": {
"overrides": {
"@girs/cairo-1.0": "1.0.0-4.0.0-beta.38",
"@girs/freetype2-2.0": "2.0.0-4.0.0-beta.38",
"@girs/gdk-4.0": "4.0.0-4.0.0-beta.38",
"@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-beta.38",
"@girs/gio-2.0": "2.86.0-4.0.0-beta.38",
"@girs/gjs": "4.0.0-beta.38",
"@girs/glib-2.0": "2.86.0-4.0.0-beta.38",
"@girs/gmodule-2.0": "2.0.0-4.0.0-beta.38",
"@girs/gobject-2.0": "2.86.0-4.0.0-beta.38",
"@girs/graphene-1.0": "1.0.0-4.0.0-beta.38",
"@girs/gsk-4.0": "4.0.0-4.0.0-beta.38",
"@girs/gtk-4.0": "4.20.1-4.0.0-beta.38",
"@girs/harfbuzz-0.0": "11.5.0-4.0.0-beta.38",
"@girs/mtk-17": "17.0.0-4.0.0-beta.38",
"@girs/pango-1.0": "1.57.0-4.0.0-beta.38",
"@girs/pangocairo-1.0": "1.0.0-4.0.0-beta.38"
}
}
}

4050
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
// This file is just a wrapper around the compiled TypeScript code
import MyExtensionPreferences from './src/prefs.js';
import AerospikeExtensions from './src/prefs/prefs.js';
export default MyExtensionPreferences;
export default AerospikeExtensions;

Binary file not shown.

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

@@ -1,40 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<schema id="org.gnome.shell.extensions.aerospike" path="/org/gnome/shell/extensions/aerospike/">
<key name="keybinding-1" type="as">
<default><![CDATA[['<Super>1']]]></default>
<summary>Keybinding for action 1</summary>
<description>Keyboard shortcut for triggering action 1</description>
<key name="focus-left" type="as">
<default><![CDATA[['<Super>h']]]></default>
<summary>Focus window to the left</summary>
<description>Move focus to the window to the left of the current window. In tabbed mode, switches to the previous tab.</description>
</key>
<key name="keybinding-2" type="as">
<default><![CDATA[['<Super>2']]]></default>
<summary>Keybinding for action 2</summary>
<description>Keyboard shortcut for triggering action 2</description>
<key name="focus-right" type="as">
<default><![CDATA[['<Super>l']]]></default>
<summary>Focus window to the right</summary>
<description>Move focus to the window to the right of the current window. In tabbed mode, switches to the next tab.</description>
</key>
<key name="keybinding-3" type="as">
<default><![CDATA[['<Super>3']]]></default>
<summary>Keybinding for action 3</summary>
<description>Keyboard shortcut for triggering action 3</description>
<key name="focus-up" type="as">
<default><![CDATA[['<Super>k']]]></default>
<summary>Focus window above</summary>
<description>Move focus to the window above the current window.</description>
</key>
<key name="keybinding-4" type="as">
<default><![CDATA[['<Super>4']]]></default>
<summary>Keybinding for action 4</summary>
<description>Keyboard shortcut for triggering action 4</description>
<key name="focus-down" type="as">
<default><![CDATA[['<Super>j']]]></default>
<summary>Focus window below</summary>
<description>Move focus to the window below the current window.</description>
</key>
<key name="dropdown-option" type="s">
<default>'option1'</default>
<summary>Dropdown selection</summary>
<description>Option selected from the dropdown menu</description>
<key name="move-left" type="as">
<default><![CDATA[['<Super><Shift>h']]]></default>
<summary>Move window to the left</summary>
<description>Move the active window one position to the left within its container</description>
</key>
<key name="color-selection" type="s">
<default>'rgb(255,0,0)'</default>
<summary>Selected color</summary>
<description>Color chosen from the color picker</description>
<key name="move-right" type="as">
<default><![CDATA[['<Super><Shift>l']]]></default>
<summary>Move window to the right</summary>
<description>Move the active window one position to the right within its container</description>
</key>
<key name="move-up" type="as">
<default><![CDATA[['<Super><Shift>k']]]></default>
<summary>Move window up</summary>
<description>Move the active window one position up within its container</description>
</key>
<key name="move-down" type="as">
<default><![CDATA[['<Super><Shift>j']]]></default>
<summary>Move window down</summary>
<description>Move the active window one position down within its container</description>
</key>
<key name="toggle-orientation" type="as">
<default><![CDATA[['<Super>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[['<Super>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="toggle-tabbed" type="as">
<default><![CDATA[['<Super>slash']]]></default>
<summary>Toggle tabbed container mode</summary>
<description>Toggles the active window's container between tabbed and accordion layout modes</description>
</key>
<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>
</schema>
</schemalist>

View File

@@ -0,0 +1,260 @@
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 Layout {
HORIZONTAL = 0,
VERTICAL = 1,
TABBED = 2,
}
const toggleOrientation = (current: Layout): Layout => {
if (current === Layout.TABBED) return Layout.HORIZONTAL;
return current === Layout.HORIZONTAL
? Layout.VERTICAL
: Layout.HORIZONTAL;
};
test('should toggle from HORIZONTAL to VERTICAL', () => {
const result = toggleOrientation(Layout.HORIZONTAL);
expect(result).toBe(Layout.VERTICAL);
});
test('should toggle from VERTICAL to HORIZONTAL', () => {
const result = toggleOrientation(Layout.VERTICAL);
expect(result).toBe(Layout.HORIZONTAL);
});
test('should toggle from TABBED to HORIZONTAL', () => {
const result = toggleOrientation(Layout.TABBED);
expect(result).toBe(Layout.HORIZONTAL);
});
test('enum reverse mapping should return string names', () => {
expect(Layout[Layout.HORIZONTAL]).toBe('HORIZONTAL');
expect(Layout[Layout.VERTICAL]).toBe('VERTICAL');
expect(Layout[Layout.TABBED]).toBe('TABBED');
});
});
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('Tabbed Bounds Calculation', () => {
const TAB_BAR_HEIGHT = 24;
test('should give all items the same content rect in tabbed mode', () => {
const workArea = { x: 100, y: 0, width: 1000, height: 500 };
const itemCount = 3;
const contentRect = {
x: workArea.x,
y: workArea.y + TAB_BAR_HEIGHT,
width: workArea.width,
height: workArea.height - TAB_BAR_HEIGHT,
};
const bounds = Array.from({ length: itemCount }, () => contentRect);
expect(bounds.length).toBe(3);
// All bounds should be identical
bounds.forEach(b => {
expect(b.x).toBe(100);
expect(b.y).toBe(TAB_BAR_HEIGHT);
expect(b.width).toBe(1000);
expect(b.height).toBe(500 - TAB_BAR_HEIGHT);
});
});
test('tab bar rect should occupy top of work area', () => {
const workArea = { x: 200, y: 50, width: 800, height: 600 };
const tabBarRect = {
x: workArea.x,
y: workArea.y,
width: workArea.width,
height: TAB_BAR_HEIGHT,
};
expect(tabBarRect.x).toBe(200);
expect(tabBarRect.y).toBe(50);
expect(tabBarRect.width).toBe(800);
expect(tabBarRect.height).toBe(TAB_BAR_HEIGHT);
});
test('active tab index should clamp after removal', () => {
let activeTabIndex = 2;
const itemCount = 2; // after removing one from 3
if (activeTabIndex >= itemCount) {
activeTabIndex = itemCount - 1;
}
expect(activeTabIndex).toBe(1);
});
test('active tab index should stay at 0 when first item removed', () => {
let activeTabIndex = 0;
const itemCount = 2; // after removing one from 3
if (activeTabIndex >= itemCount) {
activeTabIndex = itemCount - 1;
}
expect(activeTabIndex).toBe(0);
});
});
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

@@ -1,192 +0,0 @@
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import Gtk from 'gi://Gtk';
import Gdk from 'gi://Gdk';
import { ExtensionPreferences, gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
export default class MyExtensionPreferences extends ExtensionPreferences {
async fillPreferencesWindow(window: Adw.PreferencesWindow) {
// Create settings object
const settings = this.getSettings('org.gnome.shell.extensions.aerospike');
// Create a preferences page
const page = new Adw.PreferencesPage({
title: _('Settings'),
icon_name: 'preferences-system-symbolic',
});
window.add(page);
// Create keybindings group
const keybindingsGroup = new Adw.PreferencesGroup({
title: _('Keyboard Shortcuts'),
});
page.add(keybindingsGroup);
// Add keybinding rows
this.addKeybindingRow(keybindingsGroup, settings, 'keybinding-1', _('Action 1'));
this.addKeybindingRow(keybindingsGroup, settings, 'keybinding-2', _('Action 2'));
this.addKeybindingRow(keybindingsGroup, settings, 'keybinding-3', _('Action 3'));
this.addKeybindingRow(keybindingsGroup, settings, 'keybinding-4', _('Action 4'));
// Create options group
const optionsGroup = new Adw.PreferencesGroup({
title: _('Options'),
});
page.add(optionsGroup);
// Add dropdown
const dropdownRow = new Adw.ComboRow({
title: _('Select an option'),
});
optionsGroup.add(dropdownRow);
// Create dropdown model
const dropdownModel = new Gtk.StringList();
dropdownModel.append(_('Option 1'));
dropdownModel.append(_('Option 2'));
dropdownModel.append(_('Option 3'));
dropdownModel.append(_('Option 4'));
dropdownRow.set_model(dropdownModel);
// Set the active option based on settings
const currentOption = settings.get_string('dropdown-option');
switch (currentOption) {
case 'option1':
dropdownRow.set_selected(0);
break;
case 'option2':
dropdownRow.set_selected(1);
break;
case 'option3':
dropdownRow.set_selected(2);
break;
case 'option4':
dropdownRow.set_selected(3);
break;
default:
dropdownRow.set_selected(0);
}
// Connect dropdown change signal
dropdownRow.connect('notify::selected', () => {
const selected = dropdownRow.get_selected();
let optionValue: string;
switch (selected) {
case 0:
optionValue = 'option1';
break;
case 1:
optionValue = 'option2';
break;
case 2:
optionValue = 'option3';
break;
case 3:
optionValue = 'option4';
break;
default:
optionValue = 'option1';
}
settings.set_string('dropdown-option', optionValue);
});
// Add color button
const colorRow = new Adw.ActionRow({
title: _('Choose a color'),
});
optionsGroup.add(colorRow);
const colorButton = new Gtk.ColorButton();
colorRow.add_suffix(colorButton);
colorRow.set_activatable_widget(colorButton);
// Set current color from settings
const colorStr = settings.get_string('color-selection');
const rgba = new Gdk.RGBA();
rgba.parse(colorStr);
colorButton.set_rgba(rgba);
// Connect color button signal
colorButton.connect('color-set', () => {
const color = colorButton.get_rgba().to_string();
settings.set_string('color-selection', color);
});
}
private addKeybindingRow(
group: Adw.PreferencesGroup,
settings: Gio.Settings,
key: string,
title: string
) {
const shortcutsRow = new Adw.ActionRow({
title: title,
});
group.add(shortcutsRow);
// Create a button for setting shortcuts
const shortcutButton = new Gtk.Button({
valign: Gtk.Align.CENTER,
label: settings.get_strv(key)[0] || _("Disabled")
});
shortcutsRow.add_suffix(shortcutButton);
shortcutsRow.set_activatable_widget(shortcutButton);
// When clicking the button, show a dialog or start listening for keystroke
shortcutButton.connect('clicked', () => {
// Show a simple popup stating that the shortcut is being recorded
const dialog = new Gtk.MessageDialog({
modal: true,
text: _("Press a key combination to set as shortcut"),
secondary_text: _("Press Esc to cancel or Backspace to disable"),
buttons: Gtk.ButtonsType.CANCEL,
transient_for: group.get_root() as Gtk.Window
});
// Create a keypress event controller
const controller = new Gtk.EventControllerKey();
dialog.add_controller(controller);
controller.connect('key-pressed', (_controller, keyval, keycode, state) => {
// Get the key name
let keyName = Gdk.keyval_name(keyval);
// Handle special cases
if (keyName === 'Escape') {
dialog.response(Gtk.ResponseType.CANCEL);
return Gdk.EVENT_STOP;
} else if (keyName === 'BackSpace') {
// Clear the shortcut
settings.set_strv(key, []);
shortcutButton.set_label(_("Disabled"));
dialog.response(Gtk.ResponseType.OK);
return Gdk.EVENT_STOP;
}
// Convert modifier state to keybinding modifiers
let modifiers = state & Gtk.accelerator_get_default_mod_mask();
// Ignore standalone modifier keys
if (Gdk.ModifierType.SHIFT_MASK <= keyval && keyval <= Gdk.ModifierType.META_MASK)
return Gdk.EVENT_STOP;
// Create accelerator string
let accelerator = Gtk.accelerator_name(keyval, modifiers);
if (accelerator) {
settings.set_strv(key, [accelerator]);
shortcutButton.set_label(accelerator);
dialog.response(Gtk.ResponseType.OK);
}
return Gdk.EVENT_STOP;
});
dialog.present();
});
}
}

83
src/prefs/keybindings.ts Normal file
View File

@@ -0,0 +1,83 @@
// Gnome imports
import Adw from 'gi://Adw';
import Gtk from 'gi://Gtk';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import { gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
import { Logger } from '../utils/logger.js';
/**
* EntryRow class for handling text input including keybindings
*/
export class EntryRow extends Adw.EntryRow {
static {
GObject.registerClass(this);
}
constructor(params: {
title: string,
settings: Gio.Settings,
bind: string,
map?: {
from: (settings: Gio.Settings, bind: string) => string,
to: (settings: Gio.Settings, bind: string, value: string) => void
}
}) {
super({ title: params.title });
const { settings, bind, map } = params;
// When text changes, update settings
this.connect('changed', () => {
const text = this.get_text();
if (typeof text === 'string') {
if (map) {
map.to(settings, bind, text);
} else {
settings.set_string(bind, text);
}
}
});
// Set initial text from settings
const current = map ? map.from(settings, bind) : settings.get_string(bind);
this.set_text(current ?? '');
// Add reset button
this.add_suffix(
new ResetButton({
settings,
bind,
onReset: () => {
this.set_text((map ? map.from(settings, bind) : settings.get_string(bind)) ?? '');
},
})
);
}
}
/**
* Reset button for settings
*/
export class ResetButton extends Gtk.Button {
static {
GObject.registerClass(this);
}
constructor(params: {
settings?: Gio.Settings,
bind: string,
onReset?: () => void
}) {
super({
icon_name: 'edit-clear-symbolic',
tooltip_text: _('Reset'),
valign: Gtk.Align.CENTER,
});
this.connect('clicked', () => {
params.settings?.reset(params.bind);
params.onReset?.();
});
}
}

191
src/prefs/prefs.ts Normal file
View File

@@ -0,0 +1,191 @@
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import Gtk from 'gi://Gtk';
import Gdk from 'gi://Gdk';
import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
import {Logger} from "../utils/logger.js";
import {EntryRow} from "./keybindings.js";
export default class AerospikeExtensions extends ExtensionPreferences {
async fillPreferencesWindow(window: Adw.PreferencesWindow) {
// Create settings object
const settings = this.getSettings('org.gnome.shell.extensions.aerospike');
// Create keybindings page (top-level)
const keybindingsPage = new Adw.PreferencesPage({
title: _('Keybindings'),
icon_name: 'input-keyboard-symbolic',
});
window.add(keybindingsPage);
const keybindingMap = this.createKeybindingMap();
// Top-level Keybindings header group with syntax help
const keybindingsHeader = new Adw.PreferencesGroup({
title: _('Keybindings'),
description: `${_("Syntax")}: <Super>h, <Shift>g, <Super><Shift>h
${_("Legend")}: <Super> - ${_("Windows key")}, <Primary> - ${_("Control key")}
${_("Delete text to unset. Press Return key to accept.")}`,
});
keybindingsPage.add(keybindingsHeader);
// --- Focus group ---
const focusGroup = new Adw.PreferencesGroup({
title: _('Focus'),
});
keybindingsPage.add(focusGroup);
focusGroup.add(
new EntryRow({
title: _('Focus Left'),
settings: settings,
bind: 'focus-left',
map: keybindingMap
})
);
focusGroup.add(
new EntryRow({
title: _('Focus Right'),
settings: settings,
bind: 'focus-right',
map: keybindingMap
})
);
focusGroup.add(
new EntryRow({
title: _('Focus Up'),
settings: settings,
bind: 'focus-up',
map: keybindingMap
})
);
focusGroup.add(
new EntryRow({
title: _('Focus Down'),
settings: settings,
bind: 'focus-down',
map: keybindingMap
})
);
// --- Move group ---
const moveGroup = new Adw.PreferencesGroup({
title: _('Move'),
});
keybindingsPage.add(moveGroup);
moveGroup.add(
new EntryRow({
title: _('Move Left'),
settings: settings,
bind: 'move-left',
map: keybindingMap
})
);
moveGroup.add(
new EntryRow({
title: _('Move Right'),
settings: settings,
bind: 'move-right',
map: keybindingMap
})
);
moveGroup.add(
new EntryRow({
title: _('Move Up'),
settings: settings,
bind: 'move-up',
map: keybindingMap
})
);
moveGroup.add(
new EntryRow({
title: _('Move Down'),
settings: settings,
bind: 'move-down',
map: keybindingMap
})
);
// --- Container Interactions group ---
const containerGroup = new Adw.PreferencesGroup({
title: _('Container Interactions'),
});
keybindingsPage.add(containerGroup);
containerGroup.add(
new EntryRow({
title: _('Toggle Orientation'),
settings: settings,
bind: 'toggle-orientation',
map: keybindingMap
})
);
containerGroup.add(
new EntryRow({
title: _('Reset Container Ratios to Equal'),
settings: settings,
bind: 'reset-ratios',
map: keybindingMap
})
);
containerGroup.add(
new EntryRow({
title: _('Toggle Tabbed Mode'),
settings: settings,
bind: 'toggle-tabbed',
map: keybindingMap
})
);
// --- Debugging group ---
const debuggingGroup = new Adw.PreferencesGroup({
title: _('Debugging'),
});
keybindingsPage.add(debuggingGroup);
debuggingGroup.add(
new EntryRow({
title: _('Print Tree Structure'),
settings: settings,
bind: 'print-tree',
map: keybindingMap
})
);
}
// Helper function to create a keybinding mapping object
private createKeybindingMap() {
return {
from(settings: Gio.Settings, bind: string) {
return settings.get_strv(bind).join(',');
},
to(settings: Gio.Settings, bind: string, value: string) {
if (!!value) {
const mappings = value.split(',').map((x) => {
const [, key, mods] = Gtk.accelerator_parse(x);
return Gtk.accelerator_valid(key, mods) && Gtk.accelerator_name(key, mods);
});
// Filter out any false values to ensure we only have strings
const stringMappings = mappings.filter((x): x is string => typeof x === 'string');
if (stringMappings.length > 0) {
Logger.debug("setting", bind, "to", stringMappings);
settings.set_strv(bind, stringMappings);
}
} else {
// If value deleted, unset the mapping
settings.set_strv(bind, []);
}
},
};
}
}

View File

@@ -1,20 +0,0 @@
// Utility functions and type definitions
/**
* Interface for the extension settings
*/
export interface ExtensionSettings {
keybinding1: string[];
keybinding2: string[];
keybinding3: string[];
keybinding4: string[];
dropdownOption: string;
colorSelection: string;
}
/**
* Log a message with the extension name prefix
*/
export function log(message: string): void {
console.log(`[MyExtension] ${message}`);
}

21
src/utils/events.ts Normal file
View File

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

30
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,30 @@
export class Logger {
static fatal(...args: any[]) {
console.log(`[Aerospike] [FATAL]`, ...args);
}
static error(...args: any[]) {
console.log(`[Aerospike] [ERROR]`, ...args);
}
static warn(...args: any[]) {
console.log(`[Aerospike] [WARN]`, ...args);
}
static info(...args: any[]) {
console.log(`[Aerospike] [INFO]`, ...args);
}
static debug(...args: any[]) {
console.log(`[Aerospike] [DEBUG]`, ...args);
}
static trace(...args: any[]) {
console.log(`[Aerospike] [TRACE]`, ...args);
}
static log(...args: any[]) {
console.log(`[Aerospike] [LOG]`, ...args);
}
}

6
src/utils/rect.ts Normal file
View File

@@ -0,0 +1,6 @@
export type Rect = {
x: number;
y: number;
width: number;
height: number;
}

View File

@@ -1,164 +0,0 @@
import Meta from 'gi://Meta';
import Mtk from '@girs/mtk-16';
export interface WindowTree {
root: WindowNode | null;
monitor: number;
workspace: number;
}
export interface WindowNode {
window: Meta.Window | null;
windowId: number | null;
children: WindowNode[];
parent: WindowNode | null;
splitRatio: number;
splitDirection: 'horizontal' | 'vertical';
rect: {
x: number;
y: number;
width: number;
height: number;
};
}
export function createWindowNode(window: Meta.Window | null = null): WindowNode {
return {
window: window,
windowId: window ? window.get_id() : null,
children: [],
parent: null,
splitRatio: 1.0,
splitDirection: 'horizontal',
rect: { x: 0, y: 0, width: 0, height: 0 }
};
}
export function addNodeChild(parent: WindowNode, child: WindowNode): void {
child.parent = parent;
parent.children.push(child);
// Update split ratios to be equal
const childCount = parent.children.length;
parent.children.forEach(node => {
node.splitRatio = 1.0 / childCount;
});
}
export function removeNode(node: WindowNode, tree: WindowTree): WindowNode | null {
if (!node.parent) {
// This is the root node
if (node.children.length > 0) {
// Promote first child to root
const newRoot = node.children[0];
newRoot.parent = null;
// Transfer any other children to the new root
for (let i = 1; i < node.children.length; i++) {
addNodeChild(newRoot, node.children[i]);
}
tree.root = newRoot;
return newRoot;
} else {
// No children, tree is now empty
tree.root = null;
return null;
}
} else {
// Remove from parent's children
const parent = node.parent;
const index = parent.children.indexOf(node);
if (index !== -1) {
parent.children.splice(index, 1);
}
// Update split ratios of remaining siblings
if (parent.children.length > 0) {
const ratio = 1.0 / parent.children.length;
parent.children.forEach(child => {
child.splitRatio = ratio;
});
}
// Transfer any children to the parent
node.children.forEach(child => {
addNodeChild(parent, child);
});
return parent;
}
}
export function findNodeByWindowId(tree: WindowTree, windowId: number): WindowNode | null {
if (!tree.root) return null;
function search(node: WindowNode): WindowNode | null {
if (node.windowId === windowId) return node;
for (const child of node.children) {
const result = search(child);
if (result) return result;
}
return null;
}
return search(tree.root);
}
export function calculateLayout(node: WindowNode, rect: {x: number, y: number, width: number, height: number}): void {
// Update node's rect
node.rect = {...rect};
// Process children recursively
if (node.children.length > 0) {
if (node.splitDirection === 'horizontal') {
// Divide width with remainder handling
let currentX = rect.x;
let remainingWidth = rect.width;
const lastChildIndex = node.children.length - 1;
node.children.forEach((child, index) => {
const isLastChild = index === lastChildIndex;
// Last child gets remainder to avoid gaps
const childWidth = isLastChild ?
remainingWidth :
Math.floor(rect.width * child.splitRatio);
calculateLayout(child, {
x: currentX,
y: rect.y,
width: childWidth,
height: rect.height
});
currentX += childWidth;
remainingWidth -= childWidth;
});
} else {
// Divide height with remainder handling
let currentY = rect.y;
let remainingHeight = rect.height;
const lastChildIndex = node.children.length - 1;
node.children.forEach((child, index) => {
const isLastChild = index === lastChildIndex;
// Last child gets remainder to avoid gaps
const childHeight = isLastChild ?
remainingHeight :
Math.floor(rect.height * child.splitRatio);
calculateLayout(child, {
x: rect.x,
y: currentY,
width: rect.width,
height: childHeight
});
currentY += childHeight;
remainingHeight -= childHeight;
});
}
}
}

704
src/wm/container.ts Normal file
View File

@@ -0,0 +1,704 @@
import {WindowWrapper} from "./window.js";
import {Logger} from "../utils/logger.js";
import queueEvent from "../utils/events.js";
import {Rect} from "../utils/rect.js";
import {TabBar, TAB_BAR_HEIGHT} from "./tabBar.js";
export enum Layout {
ACC_HORIZONTAL = 0,
ACC_VERTICAL = 1,
TABBED = 2,
}
export enum Direction {
LEFT = 'left',
RIGHT = 'right',
UP = 'up',
DOWN = 'down',
}
// 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: (WindowWrapper | WindowContainer)[];
_tiledWindowLookup: Map<number, WindowWrapper>;
_orientation: Layout = Layout.ACC_HORIZONTAL;
_workArea: Rect;
// -- Accordion Mode States
_splitRatios: number[];
// -- Tabbed mode state -----------------------------------------------------
_activeTabIndex: number = 0;
_tabBar: TabBar | null = null;
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);
}
/**
* Proportionally shrink existing ratios to carve out space for a new item
* at the given index. If no index is supplied the ratio is appended at the end.
*/
private _addRatioForNewWindow(index?: number): 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);
const insertAt = index ?? scaled.length;
scaled.splice(insertAt, 0, newRatio);
this._splitRatios = scaled;
}
private _totalDimension(): number {
return this._orientation === Layout.ACC_HORIZONTAL
? this._workArea.width
: this._workArea.height;
}
isTabbed(): boolean {
return this._orientation === Layout.TABBED;
}
// --- Public API -------------------------------------------------------------
move(rect: Rect): void {
this._workArea = rect;
this.drawWindows();
}
toggleOrientation(): void {
if (this._orientation === Layout.TABBED) {
// Tabbed → Horizontal: restore accordion mode
this.setAccordion(Layout.ACC_HORIZONTAL);
} else {
this._orientation = this._orientation === Layout.ACC_HORIZONTAL
? Layout.ACC_VERTICAL
: Layout.ACC_HORIZONTAL;
Logger.info(`Container orientation toggled to ${Layout[this._orientation]}`);
this.drawWindows();
}
}
/**
* Switch this container to tabbed mode.
*/
setTabbed(): void {
if (this._orientation === Layout.TABBED) return;
Logger.info("Container switching to TABBED mode");
this._orientation = Layout.TABBED;
// Clamp active tab index
if (this._activeTabIndex < 0 || this._activeTabIndex >= this._tiledItems.length) {
this._activeTabIndex = 0;
}
// Create tab bar
this._tabBar = new TabBar((index) => {
this.setActiveTab(index);
});
this.drawWindows();
}
/**
* Switch this container back to accordion (H or V) mode.
*/
setAccordion(orientation: Layout.ACC_HORIZONTAL | Layout.ACC_VERTICAL): void {
if (this._orientation !== Layout.TABBED) {
// Already accordion — just set the orientation
this._orientation = orientation;
this.drawWindows();
return;
}
Logger.info(`Container switching from TABBED to ${Layout[orientation]}`);
this._orientation = orientation;
// Destroy tab bar
if (this._tabBar) {
this._tabBar.destroy();
this._tabBar = null;
}
// Show all windows (they may have been hidden in tabbed mode)
this._showAllWindows();
this.drawWindows();
}
/**
* Set the active tab by index. Shows that window, hides others, updates tab bar.
*/
setActiveTab(index: number): void {
if (!this.isTabbed()) return;
if (index < 0 || index >= this._tiledItems.length) return;
this._activeTabIndex = index;
Logger.info(`Active tab set to ${index}`);
this._applyTabVisibility();
this._updateTabBar();
// Tile to resize the active window to the content area
this.drawWindows();
}
getActiveTabIndex(): number {
return this._activeTabIndex;
}
/**
* If the given window is a tab in this container, make it the active tab.
* Returns true if the window was found and activated.
*/
focusWindowTab(windowId: number): boolean {
if (!this.isTabbed()) return false;
const index = this._getIndexOfWindow(windowId);
if (index !== -1 && index !== this._activeTabIndex) {
this.setActiveTab(index);
return true;
}
return index !== -1;
}
hideTabBar(): void {
this._tabBar?.hide();
}
showTabBar(): void {
if (this.isTabbed() && this._tabBar) {
this._tabBar.show();
}
}
/**
* Add a window to this container.
* If `index` is omitted the window is appended at the end.
* A negative index (e.g. -1) is treated as "append at end".
*/
addWindow(winWrap: WindowWrapper, index?: number): void {
const insertAt = (index === undefined || index < 0)
? this._tiledItems.length
: Math.min(index, this._tiledItems.length);
this._tiledItems.splice(insertAt, 0, winWrap);
this._tiledWindowLookup.set(winWrap.getWindowId(), winWrap);
this._addRatioForNewWindow(insertAt);
if (this.isTabbed()) {
// TODO: make it so that when tabs are added they are made the current active tab
this._applyTabVisibility();
this._updateTabBar();
}
queueEvent({
name: "tiling-windows",
callback: () => this.drawWindows(),
}, 100);
}
getWindow(win_id: number): WindowWrapper | undefined {
if (this._tiledWindowLookup.has(win_id)) {
return this._tiledWindowLookup.get(win_id);
}
for (const item of this._tiledItems) {
if (item instanceof WindowContainer) {
const win = item.getWindow(win_id);
if (win) return win;
} else if (item.getWindowId() === win_id) {
return item;
}
}
return undefined;
}
_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;
}
removeWindow(win_id: number): void {
if (this._tiledWindowLookup.has(win_id)) {
const index = this._getIndexOfWindow(win_id);
this._tiledWindowLookup.delete(win_id);
if (index !== -1) {
// If removing the window that was hidden in tabbed mode,
// make sure to show it first so it doesn't stay invisible
const item = this._tiledItems[index];
if (item instanceof WindowWrapper) {
item.showWindow();
}
this._tiledItems.splice(index, 1);
}
this._resetRatios();
if (this.isTabbed()) {
if (this._tiledItems.length === 0) {
this._activeTabIndex = 0;
} else if (this._activeTabIndex >= this._tiledItems.length) {
this._activeTabIndex = this._tiledItems.length - 1;
}
this._applyTabVisibility();
this._updateTabBar();
}
} else {
for (const item of this._tiledItems) {
if (item instanceof WindowContainer) {
item.removeWindow(win_id);
}
}
}
this.drawWindows();
}
disconnectSignals(): void {
this._tiledItems.forEach((item) => {
if (item instanceof WindowContainer) {
item.disconnectSignals();
} else {
item.disconnectWindowSignals();
}
});
}
removeAllWindows(): void {
// tabbed mode hides all windows - this ensures they are available before removal
this._showAllWindows();
if (this._tabBar) {
this._tabBar.destroy();
this._tabBar = null;
}
this._tiledItems = [];
this._tiledWindowLookup.clear();
this._splitRatios = [];
this._activeTabIndex = 0;
}
drawWindows(): void {
Logger.log("TILING WINDOWS IN CONTAINER");
Logger.log("WorkArea", this._workArea);
if (this.isTabbed()) {
this._tileTab();
} else {
this._tileAccordion();
}
}
_tileAccordion() {
if (this._tiledItems.length === 0) return;
const bounds = this.getBounds();
Logger.info(`_tileAccordion: 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 {
Logger.info(`_tileAccordion: window[${index}] id=${item.getWindowId()} dragging=${item._dragging} → rect=(${rect.x},${rect.y},${rect.width},${rect.height})`);
item.safelyResizeWindow(rect);
}
});
}
private _tileTab(): void {
if (this._tiledItems.length === 0) return;
const tabBarRect: Rect = {
x: this._workArea.x,
y: this._workArea.y,
width: this._workArea.width,
height: TAB_BAR_HEIGHT,
};
const contentRect: Rect = {
x: this._workArea.x,
y: this._workArea.y + TAB_BAR_HEIGHT,
width: this._workArea.width,
height: this._workArea.height - TAB_BAR_HEIGHT,
};
// Position and show the tab bar
if (this._tabBar) {
this._tabBar.setPosition(tabBarRect);
if (!this._tabBar.isVisible()) {
this._rebuildAndShowTabBar();
}
}
this._applyTabVisibility();
const activeItem = this._tiledItems[this._activeTabIndex];
if (activeItem) {
if (activeItem instanceof WindowContainer) {
activeItem.move(contentRect);
} else {
Logger.info(`_tileTabbed: active tab[${this._activeTabIndex}] id=${activeItem.getWindowId()} → rect=(${contentRect.x},${contentRect.y},${contentRect.width},${contentRect.height})`);
activeItem.safelyResizeWindow(contentRect);
}
}
}
/**
* Show the active tab window, hide all others.
*/
private _applyTabVisibility(): void {
this._tiledItems.forEach((item, index) => {
if (item instanceof WindowWrapper) {
if (index === this._activeTabIndex) {
item.showWindow();
} else {
item.hideWindow();
}
}
});
}
/**
* Show all windows (used when leaving tabbed mode).
*/
private _showAllWindows(): void {
this._tiledItems.forEach((item) => {
if (item instanceof WindowWrapper) {
item.showWindow();
}
});
}
/**
* Rebuild the tab bar buttons and show it.
*/
private _rebuildAndShowTabBar(): void {
if (!this._tabBar) return;
const windowItems = this._tiledItems.filter(
(item): item is WindowWrapper => item instanceof WindowWrapper
);
this._tabBar.rebuild(windowItems, this._activeTabIndex);
this._tabBar.show();
}
/**
* Public entry point to refresh tab titles (e.g. when a window title changes).
*/
refreshTabTitles(): void {
this._updateTabBar();
}
/**
* Update tab bar state (active highlight, titles) without a full rebuild.
*/
private _updateTabBar(): void {
if (!this._tabBar) return;
// Rebuild is cheap — just recreate buttons from the current items
const windowItems = this._tiledItems.filter(
(item): item is WindowWrapper => item instanceof WindowWrapper
);
this._tabBar.rebuild(windowItems, this._activeTabIndex);
}
getBounds(): Rect[] {
if (this._orientation === Layout.TABBED) {
// In tabbed mode, all items share the same content rect
const contentRect: Rect = {
x: this._workArea.x,
y: this._workArea.y + TAB_BAR_HEIGHT,
width: this._workArea.width,
height: this._workArea.height - TAB_BAR_HEIGHT,
};
return this._tiledItems.map(() => contentRect);
}
return this._orientation === Layout.ACC_HORIZONTAL
? this._computeBounds('horizontal')
: this._computeBounds('vertical');
}
private _computeBounds(axis: 'horizontal' | 'vertical'): Rect[] {
const isHorizontal = axis === 'horizontal';
const total = isHorizontal ? this._workArea.width : this._workArea.height;
let used = 0;
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;
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};
});
}
adjustBoundary(boundaryIndex: number, deltaPixels: number): boolean {
// No boundary adjustment in tabbed mode
if (this.isTabbed()) return false;
if (boundaryIndex < 0 || boundaryIndex >= this._tiledItems.length - 1) {
Logger.warn(`adjustBoundary: invalid boundaryIndex ${boundaryIndex}`);
return false;
}
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;
}
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;
}
// --- Container Lookup --------------------------------------------------------
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 null;
}
getIndexOfItemNested(item: WindowWrapper): number {
for (let i = 0; i < this._tiledItems.length; i++) {
const container = this._tiledItems[i];
if (container instanceof WindowContainer) {
if (container.getIndexOfItemNested(item) !== -1) return i;
} else if (container.getWindowId() === item.getWindowId()) {
return i;
}
}
return -1;
}
// TODO: update this to work with nested containers - all other logic should already be working
itemDragged(item: WindowWrapper, x: number, y: number): void {
// In tabbed mode, dragging reorders tabs but doesn't change layout
if (this.isTabbed()) {
// Don't reorder during tabbed mode — tabs have a fixed visual layout
return;
}
const original_index = this.getIndexOfItemNested(item);
if (original_index === -1) {
Logger.error("Item not found in container during drag op", item.getWindowId());
return;
}
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_index = 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.drawWindows();
}
}
resetRatios(): void {
this._resetRatios();
this.drawWindows();
}
// --- Directional Move (swap) ------------------------------------------------
/**
* Swap the window at `windowId` with its neighbour in the given direction.
* Returns true if the swap occurred, false if the window is already at the edge
* or the direction is perpendicular to the container axis.
*/
swapWindowInDirection(windowId: number, direction: Direction): boolean {
const currentIndex = this._getIndexOfWindow(windowId);
if (currentIndex === -1) return false;
if (this.isTabbed()) {
// Tabbed: left/up = swap toward start, right/down = swap toward end
const delta = (direction === Direction.LEFT || direction === Direction.UP) ? -1 : 1;
const newIndex = currentIndex + delta;
if (newIndex < 0 || newIndex >= this._tiledItems.length) return false;
this._swapItems(currentIndex, newIndex);
this._activeTabIndex = newIndex;
this._updateTabBar();
this.drawWindows();
return true;
}
// Accordion mode — only swap along the container's axis
const isAlongAxis =
(this._orientation === Layout.ACC_HORIZONTAL && (direction === Direction.LEFT || direction === Direction.RIGHT)) ||
(this._orientation === Layout.ACC_VERTICAL && (direction === Direction.UP || direction === Direction.DOWN));
if (!isAlongAxis) return false;
const delta = (direction === Direction.LEFT || direction === Direction.UP) ? -1 : 1;
const newIndex = currentIndex + delta;
if (newIndex < 0 || newIndex >= this._tiledItems.length) return false;
this._swapItems(currentIndex, newIndex);
this.drawWindows();
return true;
}
/**
* Swap two items in `_tiledItems` and their corresponding split ratios.
*/
private _swapItems(indexA: number, indexB: number): void {
[this._tiledItems[indexA], this._tiledItems[indexB]] =
[this._tiledItems[indexB], this._tiledItems[indexA]];
[this._splitRatios[indexA], this._splitRatios[indexB]] =
[this._splitRatios[indexB], this._splitRatios[indexA]];
}
// --- Directional Navigation ------------------------------------------------
/**
* Given a window inside this container and a direction, return the window ID
* that should receive focus, or null if the edge of the container is reached.
*
* Behaviour by layout mode:
* - ACC_HORIZONTAL: left/right moves to the prev/next item; up/down → null
* - ACC_VERTICAL: up/down moves to the prev/next item; left/right → null
* - TABBED: left/right moves to the prev/next tab; up/down → null
*/
getAdjacentWindowId(windowId: number, direction: Direction): number | null {
const currentIndex = this._getIndexOfWindow(windowId);
if (currentIndex === -1) return null;
if (this.isTabbed()) {
// Tabbed: left/right cycle through tabs
if (direction === Direction.LEFT || direction === Direction.UP) {
const newIndex = currentIndex - 1;
if (newIndex < 0) return null;
return this._windowIdAtIndex(newIndex);
}
if (direction === Direction.RIGHT || direction === Direction.DOWN) {
const newIndex = currentIndex + 1;
if (newIndex >= this._tiledItems.length) return null;
return this._windowIdAtIndex(newIndex);
}
return null;
}
// Accordion mode only navigate along the container's axis
const isAlongAxis =
(this._orientation === Layout.ACC_HORIZONTAL && (direction === Direction.LEFT || direction === Direction.RIGHT)) ||
(this._orientation === Layout.ACC_VERTICAL && (direction === Direction.UP || direction === Direction.DOWN));
if (!isAlongAxis) return null;
const delta = (direction === Direction.LEFT || direction === Direction.UP) ? -1 : 1;
const newIndex = currentIndex + delta;
if (newIndex < 0 || newIndex >= this._tiledItems.length) return null;
return this._windowIdAtIndex(newIndex);
}
/**
* Return the "representative" window ID for the item at `index`.
* If the item is a WindowWrapper, return its ID directly.
* If it's a nested WindowContainer, return the first (or last) leaf window.
*/
private _windowIdAtIndex(index: number): number | null {
const item = this._tiledItems[index];
if (!item) return null;
if (item instanceof WindowWrapper) {
return item.getWindowId();
}
if (item instanceof WindowContainer) {
return item._firstLeafWindowId();
}
return null;
}
/**
* Return the window ID of the first leaf window in this container (depth-first).
*/
_firstLeafWindowId(): number | null {
for (const item of this._tiledItems) {
if (item instanceof WindowWrapper) return item.getWindowId();
if (item instanceof WindowContainer) {
const id = item._firstLeafWindowId();
if (id !== null) return id;
}
}
return null;
}
/**
* Return the window ID of the last leaf window in this container (depth-first from end).
*/
_lastLeafWindowId(): number | null {
for (let i = this._tiledItems.length - 1; i >= 0; i--) {
const item = this._tiledItems[i];
if (item instanceof WindowWrapper) return item.getWindowId();
if (item instanceof WindowContainer) {
const id = item._lastLeafWindowId();
if (id !== null) return id;
}
}
return null;
}
}

103
src/wm/monitor.ts Normal file
View File

@@ -0,0 +1,103 @@
import {WindowWrapper} from "./window.js";
import {Rect} from "../utils/rect.js";
import {Logger} from "../utils/logger.js";
import WindowContainer from "./container.js";
export default class Monitor {
_id: number;
_workArea: Rect;
_workspaces: WindowContainer[] = [];
constructor(monitorId: number) {
this._id = monitorId;
const workspace = global.workspace_manager.get_active_workspace();
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();
Logger.log("Workspace Count", workspaceCount);
for (let i = 0; i < workspaceCount; i++) {
this._workspaces.push(new WindowContainer(this._workArea));
}
}
disconnectSignals() {
for (const container of this._workspaces) {
container.disconnectSignals();
}
}
removeAllWindows(): void {
for (const container of this._workspaces) {
container.removeAllWindows();
}
}
getWindow(windowId: number): WindowWrapper | undefined {
for (const container of this._workspaces) {
const win = container.getWindow(windowId);
if (win) return win;
}
return undefined;
}
removeWindow(winWrap: WindowWrapper) {
const windowId = winWrap.getWindowId();
for (const container of this._workspaces) {
if (container.getWindow(windowId)) {
container.removeWindow(windowId);
}
}
}
addWindow(winWrap: WindowWrapper, index?: number) {
const window_workspace = winWrap.getWindow().get_workspace().index();
this._workspaces[window_workspace].addWindow(winWrap, index);
}
tileWindows(): void {
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);
}
removeWorkspace(workspaceId: number): void {
this._workspaces.splice(workspaceId, 1);
}
addWorkspace(): void {
this._workspaces.push(new WindowContainer(this._workArea));
}
focusWindowTab(windowId: number): void {
for (const container of this._workspaces) {
if (container.focusWindowTab(windowId)) return;
}
}
refreshTabTitlesForWindow(winWrap: WindowWrapper): void {
const wsId = winWrap.getWorkspace();
if (wsId >= 0 && wsId < this._workspaces.length) {
this._workspaces[wsId].refreshTabTitles();
}
}
hideTabBars(): void {
for (const container of this._workspaces) {
container.hideTabBar();
}
}
showTabBars(): void {
for (const container of this._workspaces) {
container.showTabBar();
}
}
itemDragged(item: WindowWrapper, x: number, y: number): void {
this._workspaces[item.getWorkspace()].itemDragged(item, x, y);
}
}

131
src/wm/tabBar.ts Normal file
View File

@@ -0,0 +1,131 @@
import Clutter from 'gi://Clutter';
import Pango from 'gi://Pango';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {Logger} from "../utils/logger.js";
import {WindowWrapper} from "./window.js";
import {Rect} from "../utils/rect.js";
export const TAB_BAR_HEIGHT = 24;
type TabClickedCallback = (index: number) => void;
export class TabBar {
private _bar: St.BoxLayout;
private _buttons: St.Button[] = [];
private _activeIndex: number = 0;
private _onTabClicked: TabClickedCallback;
private _visible: boolean = false;
constructor(onTabClicked: TabClickedCallback) {
this._onTabClicked = onTabClicked;
this._bar = new St.BoxLayout({
style_class: 'aerospike-tab-bar',
vertical: false,
reactive: true,
can_focus: false,
track_hover: false,
});
// Force all tabs to equal width regardless of text length
(this._bar.layout_manager as Clutter.BoxLayout).homogeneous = true;
}
/**
* Rebuild all tab buttons from the current list of window items.
*/
rebuild(items: WindowWrapper[], activeIndex: number): void {
// Remove old buttons
this._bar.destroy_all_children();
this._buttons = [];
items.forEach((item, index) => {
const label = new St.Label({
text: item.getTabLabel(),
style_class: 'aerospike-tab-label',
y_align: Clutter.ActorAlign.CENTER,
x_align: Clutter.ActorAlign.CENTER,
x_expand: true,
});
label.clutter_text.ellipsize = Pango.EllipsizeMode.END;
const button = new St.Button({
style_class: 'aerospike-tab',
reactive: true,
can_focus: false,
track_hover: true,
x_expand: true,
child: label,
});
button.connect('clicked', () => {
this._onTabClicked(index);
});
this._bar.add_child(button);
this._buttons.push(button);
});
this.setActive(activeIndex);
}
/**
* Update just the title text of a single tab (e.g. when a window title changes).
*/
updateTabTitle(index: number, title: string): void {
if (index < 0 || index >= this._buttons.length) return;
const label = this._buttons[index].get_child() as St.Label;
if (label) label.set_text(title);
}
/**
* Highlight the active tab and dim the rest.
*/
setActive(index: number): void {
this._activeIndex = index;
this._buttons.forEach((btn, i) => {
if (i === index) {
btn.add_style_class_name('aerospike-tab-active');
} else {
btn.remove_style_class_name('aerospike-tab-active');
}
});
}
/**
* Position and size the tab bar at the given screen rect.
*/
setPosition(rect: Rect): void {
this._bar.set_position(rect.x, rect.y);
this._bar.set_size(rect.width, rect.height);
}
show(): void {
if (this._visible) return;
this._visible = true;
Main.layoutManager.uiGroup.add_child(this._bar);
this._bar.show();
Logger.log("TabBar shown");
}
hide(): void {
if (!this._visible) return;
this._visible = false;
this._bar.hide();
if (this._bar.get_parent()) {
Main.layoutManager.uiGroup.remove_child(this._bar);
}
Logger.log("TabBar hidden");
}
destroy(): void {
this.hide();
this._bar.destroy_all_children();
this._buttons = [];
this._bar.destroy();
Logger.log("TabBar destroyed");
}
isVisible(): boolean {
return this._visible;
}
}

187
src/wm/window.ts Normal file
View File

@@ -0,0 +1,187 @@
import Meta from 'gi://Meta';
import Clutter from "gi://Clutter";
import {IWindowManager} from "./windowManager.js";
import {Logger} from "../utils/logger.js";
import {Rect} from "../utils/rect.js";
import WindowContainer from "./container.js";
import queueEvent from "../utils/events.js";
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;
constructor(
window: Meta.Window,
winMinimized: WindowMinimizedHandler
) {
this._window = window;
this._windowMinimizedHandler = winMinimized;
}
getWindow(): Meta.Window {
return this._window;
}
getWindowId(): number {
return this._window.get_id();
}
getWorkspace(): number {
return this._window.get_workspace().index();
}
getMonitor(): number {
return this._window.get_monitor();
}
getRect(): Rect {
return this._window.get_frame_rect();
}
getTabLabel(): string {
const rawAppName = this._window.get_wm_class() ?? '';
// Strip reverse-domain prefix (e.g. "org.gnome.Nautilus" -> "Nautilus")
const lastName = rawAppName.includes('.')
? (rawAppName.split('.').pop() ?? rawAppName)
: rawAppName;
// Capitalize first letter
const appName = lastName.charAt(0).toUpperCase() + lastName.slice(1);
const title = this._window.get_title() ?? 'Untitled';
if (appName && appName.toLowerCase() !== title.toLowerCase()) {
return `${appName} | ${title}`;
}
return title;
}
hideWindow(): void {
const actor = this._window.get_compositor_private() as Clutter.Actor | null;
if (actor) actor.hide();
}
showWindow(): void {
const actor = this._window.get_compositor_private() as Clutter.Actor | null;
if (actor) actor.show();
}
startDragging(): void {
this._dragging = true;
}
stopDragging(): void {
Logger.log("STOPPED DRAGGING")
this._dragging = false;
}
connectWindowSignals(windowManager: IWindowManager): void {
const windowId = this._window.get_id();
this._signals.push(
this._window.connect('unmanaging', () => {
Logger.log("REMOVING WINDOW", windowId);
windowManager.handleWindowClosed(this);
}),
this._window.connect('notify::minimized', () => {
if (this._window.minimized) {
Logger.log(`Window minimized: ${windowId}`);
windowManager.handleWindowMinimized(this);
} else {
Logger.log(`Window unminimized: ${windowId}`);
windowManager.handleWindowUnminimized(this);
}
}),
this._window.connect('notify::maximized-horizontally', () => {
if (this._window.is_maximized()) {
Logger.log(`Window maximized: ${windowId}`);
} else {
Logger.log(`Window unmaximized: ${windowId}`);
}
}),
this._window.connect("workspace-changed", () => {
Logger.log("WORKSPACE CHANGED FOR WINDOW", this._window.get_id());
windowManager.handleWindowChangedWorkspace(this);
}),
this._window.connect("position-changed", () => {
windowManager.handleWindowPositionChanged(this);
}),
this._window.connect("size-changed", () => {
windowManager.handleWindowPositionChanged(this);
}),
this._window.connect('notify::title', () => {
windowManager.handleWindowTitleChanged(this);
}),
);
}
disconnectWindowSignals(): void {
if (this._signals) {
this._signals.forEach(signal => {
try {
if (this._window != null) {
this._window.disconnect(signal);
}
} catch (e) {
Logger.warn("error disconnecting signal", signal, e);
}
});
}
}
safelyResizeWindow(rect: Rect, _retry: number = 3): void {
if (this._dragging) {
Logger.info("STOPPED RESIZE BECAUSE ITEM IS BEING DRAGGED");
return;
}
const actor = this._window.get_compositor_private() as Clutter.Actor | null;
if (!actor) {
Logger.log("No actor available, can't resize safely yet");
return;
}
actor.remove_all_transitions();
// Move first to guarantee the window reaches the correct position even
// if the subsequent resize is clamped by minimum-size hints.
this._window.move_frame(true, rect.x, rect.y);
this._window.move_resize_frame(true, rect.x, rect.y, rect.width, rect.height);
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;
if (_retry > 0 && mismatch) {
// If the window's actual size is larger than requested, it has a
// minimum-size constraint — retrying won't help. Just make sure
// it's at the correct position with its actual size.
const sizeConstrained =
new_rect.width > rect.width + WindowWrapper.RESIZE_TOLERANCE ||
new_rect.height > rect.height + WindowWrapper.RESIZE_TOLERANCE;
if (sizeConstrained) {
Logger.info("Window has min-size constraint, accepting actual size",
`want(${rect.x},${rect.y},${rect.width},${rect.height})`,
`actual(${new_rect.x},${new_rect.y},${new_rect.width},${new_rect.height})`);
this._window.move_frame(true, rect.x, rect.y);
} else {
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: `delayed_resize_${this.getWindowId()}`,
callback: () => this.safelyResizeWindow(rect, _retry - 1),
}, 50);
}
}
}
}

817
src/wm/windowManager.ts Normal file
View File

@@ -0,0 +1,817 @@
import Meta from "gi://Meta";
import Gio from "gi://Gio";
import {WindowWrapper} from './window.js';
import * as Main from "resource:///org/gnome/shell/ui/main.js";
import {Logger} from "../utils/logger.js";
import Monitor from "./monitor.js";
import WindowContainer, {Direction, Layout} from "./container.js";
import {Rect} from "../utils/rect.js";
export interface IWindowManager {
_activeWindowId: number | null;
handleWindowClosed(winWrap: WindowWrapper): void;
handleWindowMinimized(winWrap: WindowWrapper): void;
handleWindowUnminimized(winWrap: WindowWrapper): void;
handleWindowChangedWorkspace(winWrap: WindowWrapper): void;
handleWindowPositionChanged(winWrap: WindowWrapper): void;
handleWindowTitleChanged(winWrap: WindowWrapper): void;
syncActiveWindow(): number | null;
}
const _UNUSED_MONITOR_ID = -1;
const _UNUSED_WINDOW_ID = -1;
export default class WindowManager implements IWindowManager {
_displaySignals: number[] = [];
_windowManagerSignals: number[] = [];
_workspaceManagerSignals: number[] = [];
_overviewSignals: number[] = [];
_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;
_changingGrabbedMonitor: boolean = false;
_showingOverview: boolean = false;
// -- 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");
this.instantiateDisplaySignals();
const mon_count = global.display.get_n_monitors();
for (let i = 0; i < mon_count; i++) {
this._monitors.set(i, new Monitor(i));
}
this.captureExistingWindows();
this.syncActiveWindow();
}
instantiateDisplaySignals(): void {
this._displaySignals.push(
global.display.connect("grab-op-begin", (display, window, op) => {
this.handleGrabOpBegin(display, window, op)
}),
global.display.connect("grab-op-end", (display, window, op) => {
this.handleGrabOpEnd(display, window, op)
}),
global.display.connect("window-entered-monitor", (display, monitor, window) => {
Logger.log("WINDOW HAS ENTERED NEW MONITOR!")
if (this._showingOverview) {
if (this._getWrappedWindow(window) !== undefined) {
Logger.log("OVERVIEW - MOVING")
this._moveWindowToMonitor(window, monitor)
}
}
}),
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");
}),
global.display.connect("workareas-changed", (display) => {
Logger.log("WORK AREAS CHANGED",);
console.log(display.get_workspace_manager().get_active_workspace_index())
}),
global.display.connect("in-fullscreen-changed", () => {
Logger.log("IN FULL SCREEN CHANGED");
this._syncFullscreenTabBars();
}),
);
this._workspaceManagerSignals = [
global.workspace_manager.connect("showing-desktop-changed", () => {
Logger.log("SHOWING DESKTOP CHANGED AT WORKSPACE LEVEL");
}),
global.workspace_manager.connect("workspace-added", (_, wsIndex) => {
Logger.log("WORKSPACE ADDED", wsIndex);
this._monitors.forEach((monitor: Monitor) => {
monitor.addWorkspace();
})
}),
global.workspace_manager.connect("workspace-removed", (_, wsIndex) => {
Logger.log("WORKSPACE REMOVED", wsIndex);
this._monitors.forEach((monitor: Monitor) => {
monitor.removeWorkspace(wsIndex);
})
}),
global.workspace_manager.connect("active-workspace-changed", (source) => {
Logger.log("Active workspace-changed", source.get_active_workspace().index());
}),
];
this._overviewSignals = [
Main.overview.connect("hiding", () => {
Logger.log("HIDING OVERVIEW")
this._showingOverview = false;
this._tileMonitors();
for (const monitor of this._monitors.values()) {
monitor.showTabBars();
}
}),
Main.overview.connect("showing", () => {
this._showingOverview = true;
Logger.log("SHOWING OVERVIEW");
for (const monitor of this._monitors.values()) {
monitor.hideTabBars();
}
}),
];
}
public disable(): void {
Logger.log("DISABLED AEROSPIKE WINDOW MANAGER!")
this.disconnectSignals();
this.removeAllWindows();
}
removeAllWindows(): void {
this.disconnectMinimizedSignals();
this._minimizedItems.clear();
this._monitors.forEach((monitor: Monitor) => {
monitor.removeAllWindows();
})
}
disconnectSignals(): void {
this.disconnectDisplaySignals();
this.disconnectMonitorSignals();
this.disconnectMinimizedSignals();
}
disconnectMonitorSignals(): void {
this._monitors.forEach((monitor: Monitor) => {
monitor.disconnectSignals();
})
}
disconnectDisplaySignals(): void {
this._displaySignals.forEach((signal) => {
global.display.disconnect(signal)
})
this._windowManagerSignals.forEach((signal) => {
global.window_manager.disconnect(signal)
})
this._workspaceManagerSignals.forEach((signal) => {
global.workspace_manager.disconnect(signal)
})
this._overviewSignals.forEach((signal) => {
Main.overview.disconnect(signal)
})
}
disconnectMinimizedSignals(): void {
this._minimizedItems.forEach((item) => {
item.disconnectWindowSignals();
})
}
_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: WindowWrapper | undefined = undefined;
for (const monitor of this._monitors.values()) {
wrapped = monitor.getWindow(window.get_id());
if (wrapped !== undefined) break;
}
return wrapped;
}
_getAndRemoveWrappedWindow(window: Meta.Window): WindowWrapper | undefined {
let wrapped: WindowWrapper | undefined = undefined;
for (const monitor of this._monitors.values()) {
wrapped = monitor.getWindow(window.get_id());
if (wrapped !== undefined) {
monitor.removeWindow(wrapped);
break;
}
}
return wrapped;
}
_moveWindowToMonitor(window: Meta.Window, monitorId: number): void {
let wrapped = this._getAndRemoveWrappedWindow(window);
if (wrapped === undefined) {
Logger.error("WINDOW NOT DEFINED")
wrapped = new WindowWrapper(window, (winWrap) => this.handleWindowMinimized(winWrap));
wrapped.connectWindowSignals(this);
}
let new_mon = this._monitors.get(monitorId);
new_mon?.addWindow(wrapped)
this._grabbedWindowMonitor = monitorId;
}
public handleWindowPositionChanged(winWrap: WindowWrapper): void {
if (this._isTiling || this._changingGrabbedMonitor) return;
if (this._isResizeDrag && winWrap.getWindowId() === this._resizeDragWindowId) {
this._handleResizeDragUpdate(winWrap);
return;
}
if (winWrap.getWindowId() === this._grabbedWindowId) {
const [mouseX, mouseY, _] = global.get_pointer();
let monitorIndex = -1;
for (let i = 0; i < global.display.get_n_monitors(); i++) {
const workArea = global.workspace_manager.get_active_workspace().get_work_area_for_monitor(i);
if (mouseX >= workArea.x && mouseX < workArea.x + workArea.width &&
mouseY >= workArea.y && mouseY < workArea.y + workArea.height) {
monitorIndex = i;
break;
}
}
if (monitorIndex === -1) return;
if (monitorIndex !== this._grabbedWindowMonitor) {
this._changingGrabbedMonitor = true;
this._moveWindowToMonitor(winWrap.getWindow(), monitorIndex);
this._changingGrabbedMonitor = false;
}
this._isTiling = true;
try {
this._monitors.get(monitorIndex)?.itemDragged(winWrap, mouseX, mouseY);
} finally {
this._isTiling = false;
}
}
}
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 === Layout.ACC_HORIZONTAL;
// 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.drawWindows();
} 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()
this._minimizedItems.set(winWrap.getWindowId(), winWrap);
this._monitors.get(monitor_id)?.removeWindow(winWrap);
this._tileMonitors()
}
public handleWindowUnminimized(winWrap: WindowWrapper): void {
this._minimizedItems.delete(winWrap.getWindowId());
this._addWindowWrapperToMonitor(winWrap);
this._tileMonitors()
}
public handleWindowChangedWorkspace(winWrap: WindowWrapper): void {
const monitor = winWrap.getWindow().get_monitor();
this._monitors.get(monitor)?.removeWindow(winWrap);
this._monitors.get(monitor)?.addWindow(winWrap);
}
public captureExistingWindows() {
const workspace = global.workspace_manager.get_active_workspace();
const windows = global.display.get_tab_list(Meta.TabList.NORMAL, workspace);
windows.forEach(window => {
if (this._isWindowTileable(window)) {
this.addWindowToMonitor(window);
}
});
this._tileMonitors();
}
handleWindowCreated(display: Meta.Display, window: Meta.Window) {
Logger.log("WINDOW CREATED ON DISPLAY", window, display);
if (!this._isWindowTileable(window)) return;
Logger.log("WINDOW IS TILABLE");
this.addWindowToMonitor(window);
}
handleWindowClosed(window: WindowWrapper): void {
const mon_id = window._window.get_monitor();
this._monitors.get(mon_id)?.removeWindow(window);
window.disconnectWindowSignals()
this.syncActiveWindow();
this._tileMonitors();
}
handleWindowTitleChanged(window: WindowWrapper): void {
const mon_id = window._window.get_monitor();
this._monitors.get(mon_id)?.refreshTabTitlesForWindow(window);
}
public addWindowToMonitor(window: Meta.Window) {
Logger.log("ADDING WINDOW TO MONITOR", window, window);
var wrapper = new WindowWrapper(window, (winWrap) => this.handleWindowMinimized(winWrap))
wrapper.connectWindowSignals(this);
this._addWindowWrapperToMonitor(wrapper);
}
_addWindowWrapperToMonitor(winWrap: WindowWrapper) {
if (winWrap.getWindow().minimized) {
this._minimizedItems.set(winWrap.getWindow().get_id(), winWrap);
} else {
this._monitors.get(winWrap.getWindow().get_monitor())?.addWindow(winWrap)
}
}
private _syncFullscreenTabBars(): void {
for (const [monitorId, monitor] of this._monitors.entries()) {
if (global.display.get_monitor_in_fullscreen(monitorId)) {
monitor.hideTabBars();
} else if (!this._showingOverview) {
monitor.showTabBars();
}
}
}
_tileMonitors(): void {
this._isTiling = true;
try {
for (const monitor of this._monitors.values()) {
monitor.tileWindows();
}
} catch (e) {
Logger.error("_tileMonitors FAILED", e);
} finally {
this._isTiling = false;
}
}
block_titles = [
"org.gnome.Shell.Extensions",
]
_isWindowTilingBlocked(window: Meta.Window): boolean {
Logger.info("title", window.get_title());
Logger.info("description", window.get_description());
Logger.info("class", window.get_wm_class());
Logger.info("class", window.get_wm_class_instance());
return this.block_titles.some((title) => {
if (window.get_wm_class() === title) {
Logger.log("WINDOW BLOCKED FROM TILING", window.get_title());
return true;
}
return false;
});
}
_isWindowTileable(window: Meta.Window) {
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",);
return !window.is_skip_taskbar() &&
windowType !== Meta.WindowType.DESKTOP &&
windowType !== Meta.WindowType.DOCK &&
windowType !== Meta.WindowType.DIALOG &&
windowType !== Meta.WindowType.MODAL_DIALOG &&
windowType !== Meta.WindowType.UTILITY &&
windowType !== Meta.WindowType.MENU;
}
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()})`);
// If the focused window is inside a tabbed container, make it the active tab
const monId = focusWindow.get_monitor();
this._monitors.get(monId)?.focusWindowTab(this._activeWindowId);
} else {
this._activeWindowId = null;
Logger.debug('No active window');
}
return this._activeWindowId;
}
public toggleActiveContainerOrientation(): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot toggle container orientation");
return;
}
const container = this._findContainerForWindowAcrossMonitors(this._activeWindowId);
if (container) {
container.toggleOrientation();
} else {
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 toggleActiveContainerTabbed(): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot toggle tabbed mode");
return;
}
const container = this._findContainerForWindowAcrossMonitors(this._activeWindowId);
if (container) {
if (container.isTabbed()) {
container.setAccordion(Layout.ACC_HORIZONTAL);
} else {
// Set the active tab to the focused window
const activeIndex = container._getIndexOfWindow(this._activeWindowId);
if (activeIndex !== -1) {
container._activeTabIndex = activeIndex;
}
container.setTabbed();
}
this._tileMonitors();
} else {
Logger.warn("Could not find container for active window");
}
}
/**
* Move the active window in the given direction.
*
* 1. Find the container holding the active window.
* 2. Try to swap within the container (adjacent neighbour).
* 3. If already at the container edge, move the window to the
* nearest monitor in that direction instead.
* 4. Re-tile to apply the new layout.
*/
public moveInDirection(direction: Direction): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot move in direction");
return;
}
const container = this._findContainerForWindowAcrossMonitors(this._activeWindowId);
if (!container) {
Logger.warn("Could not find container for active window");
return;
}
const swapped = container.swapWindowInDirection(this._activeWindowId, direction);
if (swapped) {
Logger.info(`Moved window ${this._activeWindowId} ${direction}`);
this._tileMonitors();
return;
}
this._moveWindowCrossMonitor(this._activeWindowId, direction);
}
/**
* Move focus to the adjacent window in the given direction.
*
* 1. Find the container holding the active window.
* 2. Ask the container for the adjacent window in that direction.
* 3. If the container returns null (at the edge), try cross-monitor navigation.
* 4. Activate (focus) the target window.
*/
public focusInDirection(direction: Direction): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot focus in direction");
return;
}
const container = this._findContainerForWindowAcrossMonitors(this._activeWindowId);
if (!container) {
Logger.warn("Could not find container for active window");
return;
}
const targetId = container.getAdjacentWindowId(this._activeWindowId, direction);
if (targetId !== null) {
this._activateWindowById(targetId);
return;
}
// At the edge of the container — try cross-monitor navigation
const crossMonitorId = this._findCrossMonitorWindow(direction);
if (crossMonitorId !== null) {
this._activateWindowById(crossMonitorId);
}
}
/**
* Focus a window by its ID. Finds the Meta.Window and calls activate().
*/
private _activateWindowById(windowId: number): void {
for (const monitor of this._monitors.values()) {
const wrapped = monitor.getWindow(windowId);
if (wrapped) {
const metaWindow = wrapped.getWindow();
metaWindow.activate(global.get_current_time());
return;
}
}
Logger.warn(`_activateWindowById: window ${windowId} not found in any monitor`);
}
/**
* Find the adjacent monitor in the given direction from a current monitor.
* Returns the monitor ID or null if none exists in that direction.
*/
private _findAdjacentMonitorId(currentMonitorId: number, direction: Direction): number | null {
const currentMonitor = this._monitors.get(currentMonitorId)!;
const currentArea = currentMonitor._workArea;
const currentCenterX = currentArea.x + currentArea.width / 2;
const currentCenterY = currentArea.y + currentArea.height / 2;
let bestMonitorId: number | null = null;
let bestDistance = Infinity;
for (const [monId, monitor] of this._monitors.entries()) {
if (monId === currentMonitorId) continue;
const area = monitor._workArea;
const centerX = area.x + area.width / 2;
const centerY = area.y + area.height / 2;
let isInDirection = false;
let distance = Infinity;
switch (direction) {
case Direction.LEFT:
isInDirection = centerX < currentCenterX;
distance = currentCenterX - centerX;
break;
case Direction.RIGHT:
isInDirection = centerX > currentCenterX;
distance = centerX - currentCenterX;
break;
case Direction.UP:
isInDirection = centerY < currentCenterY;
distance = currentCenterY - centerY;
break;
case Direction.DOWN:
isInDirection = centerY > currentCenterY;
distance = centerY - currentCenterY;
break;
}
if (isInDirection && distance < bestDistance) {
bestDistance = distance;
bestMonitorId = monId;
}
}
return bestMonitorId;
}
/**
* Return the monitor ID that contains the given window, or null.
*/
private _findMonitorIdForWindow(windowId: number): number | null {
for (const [monId, monitor] of this._monitors.entries()) {
if (monitor.getWindow(windowId) !== undefined) return monId;
}
return null;
}
/**
* When at the edge of a container, find the nearest window on the adjacent
* monitor in the given direction.
*
* On the target monitor, picks the edge-most window:
* - Navigating LEFT/UP → last (far-edge) leaf window
* - Navigating RIGHT/DOWN → first (near-edge) leaf window
*/
private _findCrossMonitorWindow(direction: Direction): number | null {
if (this._activeWindowId === null) return null;
const currentMonitorId = this._findMonitorIdForWindow(this._activeWindowId);
if (currentMonitorId === null) return null;
const targetMonitorId = this._findAdjacentMonitorId(currentMonitorId, direction);
if (targetMonitorId === null) return null;
const targetMonitor = this._monitors.get(targetMonitorId)!;
const activeWorkspaceIndex = global.workspace_manager.get_active_workspace().index();
if (activeWorkspaceIndex >= targetMonitor._workspaces.length) return null;
const targetContainer = targetMonitor._workspaces[activeWorkspaceIndex];
if (targetContainer._tiledItems.length === 0) return null;
return (direction === Direction.LEFT || direction === Direction.UP)
? targetContainer._lastLeafWindowId()
: targetContainer._firstLeafWindowId();
}
/**
* Move a window to the adjacent monitor in the given direction.
*
* The window is inserted at the "entry edge" of the target container:
* - Moving RIGHT/DOWN → position 0 (near edge)
* - Moving LEFT/UP → end of the container (far edge)
*/
private _moveWindowCrossMonitor(windowId: number, direction: Direction): void {
const currentMonitorId = this._findMonitorIdForWindow(windowId);
if (currentMonitorId === null) return;
const targetMonitorId = this._findAdjacentMonitorId(currentMonitorId, direction);
if (targetMonitorId === null) return;
const currentMonitor = this._monitors.get(currentMonitorId)!;
const wrapped = currentMonitor.getWindow(windowId);
if (!wrapped) return;
const targetMonitor = this._monitors.get(targetMonitorId)!;
const insertIndex = (direction === Direction.RIGHT || direction === Direction.DOWN) ? 0 : undefined;
currentMonitor.removeWindow(wrapped);
targetMonitor.addWindow(wrapped, insertIndex);
this._tileMonitors();
Logger.info(`Moved window ${windowId} to monitor ${targetMonitorId} (${direction})`);
}
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: ${Layout[workspace._orientation]}`);
Logger.info(` Items: ${workspace._tiledItems.length}`);
if (workspace.isTabbed()) {
Logger.info(` Active Tab: ${workspace._activeTabIndex}`);
}
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 (${Layout[item._orientation]})${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

@@ -1,37 +1,33 @@
/* Add your custom extension styling here */
.active-window-border {
/*border: 2px solid rgba(191, 0, 255, 0.8);*/
/*border-radius: 3px;*/
/* border-image-source: linear-gradient(to left, #743ad5, #d53a9d);*/
/* !*border: 4px solid transparent;*!*/
/* !*border-radius: 5px;*!*/
/* !*!* Gradient border using border-image *!*/
/* border-image: linear-gradient(45deg, red, orange, yellow, green, blue, indigo, violet) 1;*/
.aerospike-tab-bar {
background-color: rgba(30, 30, 30, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
spacing: 1px;
padding: 2px 2px 0 2px;
}
/*.border-gradient-purple {*/
/* border-image-source: linear-gradient(to left, #743ad5, #d53a9d);*/
/*}*/
.aerospike-tab {
background-color: rgba(50, 50, 50, 0.8);
border-radius: 6px 6px 0 0;
padding: 2px 12px;
margin: 0 1px;
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
font-weight: 400;
min-width: 0;
}
/*@keyframes rainbow-border {*/
/* 0% {*/
/* border-image: linear-gradient(0deg, red, orange, yellow, green, blue, indigo, violet, red) 1;*/
/* }*/
/* 100% {*/
/* border-image: linear-gradient(360deg, red, orange, yellow, green, blue, indigo, violet, red) 1;*/
/* }*/
/*}*/
.aerospike-tab:hover {
background-color: rgba(70, 70, 70, 0.9);
color: rgba(255, 255, 255, 0.8);
}
/*.active-window-border {*/
/* border: 4px solid transparent;*/
/* border-radius: 5px;*/
.aerospike-tab-active {
background-color: rgba(80, 80, 80, 1);
color: rgba(255, 255, 255, 1);
font-weight: 500;
}
/* !* Initial gradient border *!*/
/* border-image: linear-gradient(0deg, red, orange, yellow, green, blue, indigo, violet, red) 1;*/
/* !* Apply animation *!*/
/* animation: rainbow-border 5s linear infinite;*/
/*}*/
.aerospike-tab-label {
font-size: 11px;
min-width: 0;
}

View File

@@ -5,6 +5,8 @@
"outDir": "./dist",
"sourceMap": false,
"strict": true,
"noImplicitAny": false,
"skipLibCheck": true,
"target": "ES2022",
"lib": [
"ES2022"
@@ -16,7 +18,6 @@
"src/**/*"
],
"files": [
"extension.ts",
"src/winGroup.ts"
"extension.ts"
],
}

0
winGroup.ts Normal file
View File