6 Commits

Author SHA1 Message Date
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
11 changed files with 982 additions and 462 deletions

View File

@@ -15,14 +15,19 @@ export default class aerospike extends Extension {
super(metadata);
this.settings = this.getSettings('org.gnome.shell.extensions.aerospike');
this.keyBindings = new Map();
this.windowManager = new WindowManager();
this.windowManager = new WindowManager(this.settings);
}
enable() {
try {
Logger.log("STARTING AEROSPIKE!")
this.bindSettings();
this.setupKeybindings();
this.windowManager.enable()
Logger.log("AEROSPIKE ENABLED SUCCESSFULLY")
} catch (e) {
Logger.error("AEROSPIKE ENABLE FAILED", e);
}
}
disable() {
@@ -30,37 +35,26 @@ export default class aerospike extends Extension {
this.removeKeybindings()
}
private keybindingActions(): Record<string, () => void> {
return {
'move-left': () => { Logger.info('Keybinding 1 was pressed!'); },
'move-right': () => { Logger.info('Keybinding 2 was pressed!'); },
'join-with-left': () => { Logger.info('Keybinding 3 was pressed!'); },
'join-with-right': () => { Logger.info('Keybinding 4 was pressed!'); },
'print-tree': () => { this.windowManager.printTreeStructure(); },
'toggle-orientation': () => { this.windowManager.toggleActiveContainerOrientation(); },
'reset-ratios': () => { this.windowManager.resetActiveContainerRatios(); },
'toggle-tabbed': () => { this.windowManager.toggleActiveContainerTabbed(); },
};
}
private bindSettings() {
// Monitor settings changes
this.settings.connect('changed::move-left', () => {
log(`Keybinding 1 changed to: ${this.settings.get_strv('move-left')}`);
this.refreshKeybinding('move-left');
const keybindings = Object.keys(this.keybindingActions());
keybindings.forEach(name => {
this.settings.connect(`changed::${name}`, () => {
log(`${name} keybinding changed to: ${this.settings.get_strv(name)}`);
this.refreshKeybinding(name);
});
this.settings.connect('changed::move-right', () => {
log(`Keybinding 2 changed to: ${this.settings.get_strv('move-right')}`);
this.refreshKeybinding('move-right');
});
this.settings.connect('changed::join-with-left', () => {
log(`Keybinding 3 changed to: ${this.settings.get_strv('join-with-left')}`);
this.refreshKeybinding('join-with-left');
});
this.settings.connect('changed::join-with-right', () => {
log(`Keybinding 4 changed to: ${this.settings.get_strv('join-with-right')}`);
this.refreshKeybinding('join-with-right');
});
this.settings.connect('changed::print-tree', () => {
log(`Print tree keybinding changed to: ${this.settings.get_strv('print-tree')}`);
this.refreshKeybinding('print-tree');
});
this.settings.connect('changed::toggle-orientation', () => {
log(`Toggle orientation keybinding changed to: ${this.settings.get_strv('toggle-orientation')}`);
this.refreshKeybinding('toggle-orientation');
});
this.settings.connect('changed::dropdown-option', () => {
@@ -71,44 +65,15 @@ export default class aerospike extends Extension {
log(`Color selection changed to: ${this.settings.get_string('color-selection')}`);
});
}
private refreshKeybinding(settingName: string) {
if (this.keyBindings.has(settingName)) {
Main.wm.removeKeybinding(settingName);
this.keyBindings.delete(settingName);
}
switch (settingName) {
case 'move-left':
this.bindKeybinding('move-left', () => {
Logger.info('Keybinding 1 was pressed!');
});
break;
case 'move-right':
this.bindKeybinding('move-right', () => {
Logger.info('Keybinding 2 was pressed!');
});
break;
case 'join-with-left':
this.bindKeybinding('join-with-left', () => {
Logger.info('Keybinding 3 was pressed!');
});
break;
case 'join-with-right':
this.bindKeybinding('join-with-right', () => {
Logger.info('Keybinding 4 was pressed!');
});
break;
case 'print-tree':
this.bindKeybinding('print-tree', () => {
this.windowManager.printTreeStructure();
});
break;
case 'toggle-orientation':
this.bindKeybinding('toggle-orientation', () => {
this.windowManager.toggleActiveContainerOrientation();
});
break;
}
const action = this.keybindingActions()[settingName];
if (action) this.bindKeybinding(settingName, action);
}
private removeKeybindings() {
@@ -119,29 +84,10 @@ export default class aerospike extends Extension {
}
private setupKeybindings() {
this.bindKeybinding('move-left', () => {
Logger.info('Keybinding 1 was pressed!');
});
this.bindKeybinding('move-right', () => {
Logger.info('Keybinding 2 was pressed!');
});
this.bindKeybinding('join-with-left', () => {
Logger.info('Keybinding 3 was pressed!');
});
this.bindKeybinding('join-with-right', () => {
Logger.info('Keybinding 4 was pressed!');
});
this.bindKeybinding('print-tree', () => {
this.windowManager.printTreeStructure();
});
this.bindKeybinding('toggle-orientation', () => {
this.windowManager.toggleActiveContainerOrientation();
});
const actions = this.keybindingActions();
for (const [name, action] of Object.entries(actions)) {
this.bindKeybinding(name, action);
}
}
private bindKeybinding(settingName: string, callback: () => void) {
@@ -161,7 +107,4 @@ export default class aerospike extends Extension {
this.keyBindings.set(settingName, keyBindingAction);
}
}

View File

@@ -44,10 +44,22 @@
</key>
<key name="toggle-orientation" type="as">
<default><![CDATA[['<Super><Shift>comma']]]></default>
<default><![CDATA[['<Primary>comma']]]></default>
<summary>Toggle active container orientation</summary>
<description>Toggles the orientation of the container holding the active window between horizontal and vertical</description>
</key>
<key name="reset-ratios" type="as">
<default><![CDATA[['<Primary>z']]]></default>
<summary>Reset container ratios to equal splits</summary>
<description>Resets all window size ratios in the active window's container to equal splits</description>
</key>
<key name="toggle-tabbed" type="as">
<default><![CDATA[['<Primary>slash']]]></default>
<summary>Toggle tabbed container mode</summary>
<description>Toggles the active window's container between tabbed and accordion layout modes</description>
</key>
</schema>
</schemalist>

View File

@@ -20,25 +20,38 @@ jest.mock('../utils/events.js', () => ({
describe('Container Logic Tests', () => {
describe('Orientation Toggle Logic', () => {
enum Orientation {
enum Layout {
HORIZONTAL = 0,
VERTICAL = 1,
TABBED = 2,
}
const toggleOrientation = (current: Orientation): Orientation => {
return current === Orientation.HORIZONTAL
? Orientation.VERTICAL
: Orientation.HORIZONTAL;
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(Orientation.HORIZONTAL);
expect(result).toBe(Orientation.VERTICAL);
const result = toggleOrientation(Layout.HORIZONTAL);
expect(result).toBe(Layout.VERTICAL);
});
test('should toggle from VERTICAL to HORIZONTAL', () => {
const result = toggleOrientation(Orientation.VERTICAL);
expect(result).toBe(Orientation.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');
});
});
@@ -100,6 +113,71 @@ describe('Container Logic Tests', () => {
});
});
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 = [

View File

@@ -173,6 +173,23 @@ export default class AerospikeExtensions extends ExtensionPreferences {
})
);
keybindingsGroup.add(
new EntryRow({
title: _('Reset Container Ratios to Equal'),
settings: settings,
bind: 'reset-ratios',
map: keybindingMap
})
);
keybindingsGroup.add(
new EntryRow({
title: _('Toggle Tabbed Mode'),
settings: settings,
bind: 'toggle-tabbed',
map: keybindingMap
})
);
}

View File

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

View File

@@ -1,52 +1,191 @@
import {WindowWrapper} from "./window.js";
import {Logger} from "../utils/logger.js";
import Meta from "gi://Meta";
import queueEvent from "../utils/events.js";
import {Rect} from "../utils/rect.js";
import {TabBar, TAB_BAR_HEIGHT} from "./tabBar.js";
enum Orientation {
HORIZONTAL = 0,
VERTICAL = 1,
export enum Layout {
ACC_HORIZONTAL = 0,
ACC_VERTICAL = 1,
TABBED = 2,
}
// 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: Orientation = Orientation.HORIZONTAL;
_orientation: Layout = Layout.ACC_HORIZONTAL;
_workArea: Rect;
constructor(workspaceArea: 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);
}
private _addRatioForNewWindow(): void {
const n = this._tiledItems.length;
if (n <= 1) {
this._splitRatios = [1.0];
return;
}
const newRatio = 1 / n;
const scale = 1 - newRatio;
const scaled = this._splitRatios.map(r => r * scale);
const partialSum = scaled.reduce((a, b) => a + b, 0) + newRatio;
scaled[scaled.length - 1] += (1.0 - partialSum);
this._splitRatios = [...scaled, newRatio];
}
private _totalDimension(): number {
return this._orientation === 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.tileWindows();
this.drawWindows();
}
toggleOrientation(): void {
this._orientation = this._orientation === Orientation.HORIZONTAL
? Orientation.VERTICAL
: Orientation.HORIZONTAL;
Logger.info(`Container orientation toggled to ${this._orientation === Orientation.HORIZONTAL ? 'HORIZONTAL' : 'VERTICAL'}`);
this.tileWindows();
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;
}
hideTabBar(): void {
this._tabBar?.hide();
}
showTabBar(): void {
if (this.isTabbed() && this._tabBar) {
this._tabBar.show();
}
}
addWindow(winWrap: WindowWrapper): void {
this._tiledItems.push(winWrap);
this._tiledWindowLookup.set(winWrap.getWindowId(), winWrap);
this._addRatioForNewWindow();
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.tileWindows();
}
}, 100)
callback: () => this.drawWindows(),
}, 100);
}
getWindow(win_id: number): WindowWrapper | undefined {
@@ -56,34 +195,48 @@ export default class WindowContainer {
for (const item of this._tiledItems) {
if (item instanceof WindowContainer) {
const win = item.getWindow(win_id);
if (win) {
return win;
}
if (win) return win;
} else if (item.getWindowId() === win_id) {
return item;
}
}
return undefined
return undefined;
}
_getIndexOfWindow(win_id: number) {
_getIndexOfWindow(win_id: number): number {
for (let i = 0; i < this._tiledItems.length; i++) {
const item = this._tiledItems[i];
if (item instanceof WindowWrapper && item.getWindowId() === win_id) {
return i;
}
}
return -1
return -1;
}
removeWindow(win_id: number): void {
if (this._tiledWindowLookup.has(win_id)) {
// Get index before deleting from lookup to avoid race condition
const index = this._getIndexOfWindow(win_id);
this._tiledWindowLookup.delete(win_id);
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) {
@@ -91,94 +244,236 @@ export default class WindowContainer {
}
}
}
this.tileWindows()
this.drawWindows();
}
disconnectSignals(): void {
this._tiledItems.forEach((item) => {
if (item instanceof WindowContainer) {
item.disconnectSignals()
item.disconnectSignals();
} else {
item.disconnectWindowSignals();
}
}
)
});
}
removeAllWindows(): void {
this._tiledItems = []
this._tiledWindowLookup.clear()
// tabbed mode hides all windows - this ensures they are available before removal
this._showAllWindows();
if (this._tabBar) {
this._tabBar.destroy();
this._tabBar = null;
}
tileWindows() {
Logger.log("TILING WINDOWS IN CONTAINER")
this._tiledItems = [];
this._tiledWindowLookup.clear();
this._splitRatios = [];
this._activeTabIndex = 0;
}
drawWindows(): void {
Logger.log("TILING WINDOWS IN CONTAINER");
Logger.log("WorkArea", this._workArea);
this._tileItems()
return true
if (this.isTabbed()) {
this._tileTab();
} else {
this._tileAccordion();
}
}
_tileItems() {
if (this._tiledItems.length === 0) {
return;
}
_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();
}
/**
* 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 === Orientation.HORIZONTAL) {
return this.getHorizontalBounds();
}
return this.getVerticalBounds();
}
getVerticalBounds(): Rect[] {
const items = this._tiledItems
const containerHeight = Math.floor(this._workArea.height / items.length);
return items.map((_, index) => {
const y = this._workArea.y + (index * containerHeight);
return {
if (this._orientation === Layout.TABBED) {
// In tabbed mode, all items share the same content rect
const contentRect: Rect = {
x: this._workArea.x,
y: y,
y: this._workArea.y + TAB_BAR_HEIGHT,
width: this._workArea.width,
height: containerHeight
} as Rect;
});
height: this._workArea.height - TAB_BAR_HEIGHT,
};
return this._tiledItems.map(() => contentRect);
}
getHorizontalBounds(): Rect[] {
const windowWidth = Math.floor(this._workArea.width / this._tiledItems.length);
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 x = this._workArea.x + (index * windowWidth);
return {
x: x,
y: this._workArea.y,
width: windowWidth,
height: this._workArea.height
} as Rect;
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) {
const index = container.getIndexOfItemNested(item);
if (index !== -1) {
return i;
}
if (container.getIndexOfItemNested(item) !== -1) return i;
} else if (container.getWindowId() === item.getWindowId()) {
return i;
}
@@ -188,25 +483,36 @@ export default class WindowContainer {
// TODO: update this to work with nested containers - all other logic should already be working
itemDragged(item: WindowWrapper, x: number, y: number): void {
let original_index = this.getIndexOfItemNested(item);
// 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 = this.getIndexOfItemNested(item);
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) {
this._tiledItems.splice(original_index, 1);
this._tiledItems.splice(new_index, 0, item);
this.tileWindows()
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();
}
}

View File

@@ -1,12 +1,8 @@
import {WindowWrapper} from "./window.js";
import {Rect} from "../utils/rect.js";
import queueEvent from "../utils/events.js";
import {Logger} from "../utils/logger.js";
import Meta from "gi://Meta";
import Mtk from "@girs/mtk-17";
import WindowContainer from "./container.js";
import Window = Meta.Window;
export default class Monitor {
@@ -20,7 +16,7 @@ export default class Monitor {
this._workArea = workspace.get_work_area_for_monitor(this._id);
Logger.log("CREATING MONITOR", monitorId);
Logger.log("WorkArea", this._workArea.x, this._workArea.y, this._workArea.width, this._workArea.height);
const workspaceCount = global.workspace_manager.get_n_workspaces()
const workspaceCount = global.workspace_manager.get_n_workspaces();
Logger.log("Workspace Count", workspaceCount);
for (let i = 0; i < workspaceCount; i++) {
this._workspaces.push(new WindowContainer(this._workArea));
@@ -42,9 +38,7 @@ export default class Monitor {
getWindow(windowId: number): WindowWrapper | undefined {
for (const container of this._workspaces) {
const win = container.getWindow(windowId);
if (win) {
return win;
}
if (win) return win;
}
return undefined;
}
@@ -52,8 +46,7 @@ export default class Monitor {
removeWindow(winWrap: WindowWrapper) {
const windowId = winWrap.getWindowId();
for (const container of this._workspaces) {
const win = container.getWindow(windowId);
if (win) {
if (container.getWindow(windowId)) {
container.removeWindow(windowId);
}
}
@@ -65,10 +58,10 @@ export default class Monitor {
}
tileWindows(): void {
this._workArea = global.workspace_manager.get_active_workspace().get_work_area_for_monitor(this._id);
const activeWorkspace = global.workspace_manager.get_active_workspace();
this._workArea = activeWorkspace.get_work_area_for_monitor(this._id);
// move() calls tileWindows() internally
this._workspaces[activeWorkspace.index()].move(this._workArea);
this._workspaces[activeWorkspace.index()].tileWindows()
}
removeWorkspace(workspaceId: number): void {
@@ -79,8 +72,19 @@ export default class Monitor {
this._workspaces.push(new WindowContainer(this._workArea));
}
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);
}
}

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

@@ -0,0 +1,125 @@
import Clutter from 'gi://Clutter';
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,
});
}
/**
* 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 button = new St.Button({
style_class: 'aerospike-tab',
reactive: true,
can_focus: false,
track_hover: true,
x_expand: true,
child: new St.Label({
text: item.getTabLabel(),
style_class: 'aerospike-tab-label',
y_align: Clutter.ActorAlign.CENTER,
x_align: Clutter.ActorAlign.CENTER,
x_expand: true,
}),
});
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;
}
}

View File

@@ -11,6 +11,8 @@ 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[] = [];
@@ -45,44 +47,48 @@ export class WindowWrapper {
return this._window.get_frame_rect();
}
getTabLabel(): string {
const appName = this._window.get_wm_class() ?? '';
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;
}
// setParent(parent: WindowContainer): void {
// this._parent = parent;
// }
//
// getParent(): WindowContainer | null {
// if (this._parent == null) {
// Logger.warn(`Attempting to get parent for window without parent ${JSON.stringify(this)}`);
// }
// return this._parent
// }
connectWindowSignals(
windowManager: IWindowManager,
): void {
const windowId = this._window.get_id()
// Handle window destruction
connectWindowSignals(windowManager: IWindowManager): void {
const windowId = this._window.get_id();
this._signals.push(
this._window.connect('unmanaging', window => {
this._window.connect('unmanaging', () => {
Logger.log("REMOVING WINDOW", windowId);
windowManager.handleWindowClosed(this)
windowManager.handleWindowClosed(this);
}),
this._window.connect('notify::minimized', (we) => {
this._window.connect('notify::minimized', () => {
if (this._window.minimized) {
Logger.log(`Window minimized: ${windowId}`);
windowManager.handleWindowMinimized(this);
} else if (!this._window.minimized) {
} else {
Logger.log(`Window unminimized: ${windowId}`);
windowManager.handleWindowUnminimized(this);
}
}),
this._window.connect('notify::maximized-horizontally', () => {
@@ -92,18 +98,20 @@ export class WindowWrapper {
Logger.log(`Window unmaximized: ${windowId}`);
}
}),
this._window.connect("workspace-changed", (_metaWindow) => {
this._window.connect("workspace-changed", () => {
Logger.log("WORKSPACE CHANGED FOR WINDOW", this._window.get_id());
windowManager.handleWindowChangedWorkspace(this);
}),
this._window.connect("position-changed", (_metaWindow) => {
this._window.connect("position-changed", () => {
windowManager.handleWindowPositionChanged(this);
}),
this._window.connect("size-changed", () => {
windowManager.handleWindowPositionChanged(this);
}),
);
}
disconnectWindowSignals(): void {
if (this._signals) {
this._signals.forEach(signal => {
try {
@@ -117,37 +125,54 @@ export class WindowWrapper {
}
}
safelyResizeWindow(rect: Rect, _retry: number = 2): void {
// Keep minimal logging
safelyResizeWindow(rect: Rect, _retry: number = 3): void {
if (this._dragging) {
Logger.info("STOPPED RESIZE BECAUSE ITEM IS BEING DRAGGED")
Logger.info("STOPPED RESIZE BECAUSE ITEM IS BEING DRAGGED");
return;
}
// Logger.log("SAFELY RESIZE", rect.x, rect.y, rect.width, rect.height);
const actor = this._window.get_compositor_private();
const actor = this._window.get_compositor_private() as Clutter.Actor | null;
if (!actor) {
Logger.log("No actor available, can't resize safely yet");
return;
}
let windowActor = this._window.get_compositor_private() as Clutter.Actor;
if (!windowActor) return;
windowActor.remove_all_transitions();
// Logger.info("MOVING")
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);
// Logger.info("RESIZING MOVING")
this._window.move_resize_frame(true, rect.x, rect.y, rect.width, rect.height);
let new_rect = this._window.get_frame_rect();
if ( _retry > 0 && (new_rect.x != rect.x || rect.y != new_rect.y || rect.width < new_rect.width || rect.height < new_rect.height)) {
Logger.warn("RESIZING FAILED AS SMALLER", new_rect.x, new_rect.y, new_rect.width, new_rect.height, rect.x, rect.y, rect.width, rect.height);
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: "attempting_delayed_resize",
callback: () => {
this.safelyResizeWindow(rect, _retry-1);
}
})
name: `delayed_resize_${this.getWindowId()}`,
callback: () => this.safelyResizeWindow(rect, _retry - 1),
}, 50);
}
}
}
}

View File

@@ -1,20 +1,17 @@
import Meta from "gi://Meta";
// import Gio from "gi://Gio";
// import GLib from "gi://GLib";
import Gio from "gi://Gio";
import {WindowWrapper} from './window.js';
import * as Main from "resource:///org/gnome/shell/ui/main.js";
// import Mtk from "@girs/mtk-16";
import {Logger} from "../utils/logger.js";
import Monitor from "./monitor.js";
import WindowContainer from "./container.js";
import WindowContainer, {Layout} from "./container.js";
import {Rect} from "../utils/rect.js";
export interface IWindowManager {
_activeWindowId: number | null;
// addWindow(window: Meta.Window): void;
handleWindowClosed(winWrap: WindowWrapper): void;
handleWindowMinimized(winWrap: WindowWrapper): void;
@@ -29,8 +26,8 @@ export interface IWindowManager {
}
const _UNUSED_MONITOR_ID = -1
const _UNUSED_WINDOW_ID = -1
const _UNUSED_MONITOR_ID = -1;
const _UNUSED_WINDOW_ID = -1;
export default class WindowManager implements IWindowManager {
_displaySignals: number[] = [];
@@ -40,23 +37,29 @@ export default class WindowManager implements IWindowManager {
_activeWindowId: number | null = null;
_monitors: Map<number, Monitor> = new Map<number, Monitor>();
_minimizedItems: Map<number, WindowWrapper> = new Map<number, WindowWrapper>();
_grabbedWindowMonitor: number = _UNUSED_MONITOR_ID;
_grabbedWindowId: number = _UNUSED_WINDOW_ID;
_changingGrabbedMonitor: boolean = false;
_showingOverview: boolean = false;
constructor() {
// -- Resize-drag tracking --------------------------------------------------
_isResizeDrag: boolean = false;
_resizeDragWindowId: number = _UNUSED_WINDOW_ID;
_resizeDragOp: Meta.GrabOp = Meta.GrabOp.NONE;
_resizeDragLastMouseX: number = 0;
_resizeDragLastMouseY: number = 0;
_isTiling: boolean = false;
private readonly _settings: Gio.Settings;
constructor(settings: Gio.Settings) {
this._settings = settings;
}
public enable(): void {
Logger.log("Starting Aerospike Window Manager");
// Connect window signals
this.instantiateDisplaySignals();
const mon_count = global.display.get_n_monitors();
@@ -65,8 +68,6 @@ export default class WindowManager implements IWindowManager {
}
this.captureExistingWindows();
// Sync the initially focused window
this.syncActiveWindow();
}
@@ -93,7 +94,6 @@ export default class WindowManager implements IWindowManager {
global.display.connect('notify::focus-window', () => {
this.syncActiveWindow();
}),
global.display.connect("showing-desktop-changed", () => {
Logger.log("SHOWING DESKTOP CHANGED");
}),
@@ -104,13 +104,7 @@ export default class WindowManager implements IWindowManager {
global.display.connect("in-fullscreen-changed", () => {
Logger.log("IN FULL SCREEN CHANGED");
}),
)
// this._windowManagerSignals = [
// global.window_manager.connect("show-tile-preview", (_, _metaWindow, _rect, _num) => {
// Logger.log("SHOW TITLE PREVIEW!")
// }),
// ];
);
this._workspaceManagerSignals = [
global.workspace_manager.connect("showing-desktop-changed", () => {
@@ -135,45 +129,37 @@ export default class WindowManager implements IWindowManager {
this._overviewSignals = [
Main.overview.connect("hiding", () => {
// this.fromOverview = true;
Logger.log("HIDING OVERVIEW")
this._showingOverview = false;
this._tileMonitors();
// const eventObj = {
// name: "focus-after-overview",
// callback: () => {
// Logger.log("FOCUSING AFTER OVERVIEW");
// },
// };
// this.queueEvent(eventObj);
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!")
// Disconnect the focus signal and remove any existing borders
this.disconnectSignals();
this.removeAllWindows();
}
removeAllWindows(): void {
// Disconnect signals from minimized windows before clearing
this.disconnectMinimizedSignals();
this._minimizedItems.clear();
this._monitors.forEach((monitor: Monitor) => {
monitor.removeAllWindows();
})
}
disconnectSignals(): void {
this.disconnectDisplaySignals();
this.disconnectMonitorSignals();
@@ -207,35 +193,61 @@ export default class WindowManager implements IWindowManager {
})
}
_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 {
if (op === Meta.GrabOp.MOVING_UNCONSTRAINED){
}
Logger.log("Grab Op Start", op);
Logger.log(display, window, op)
Logger.log(window.get_monitor())
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);
Logger.log("primary display", display.get_primary_monitor())
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;
}
if (wrapped !== undefined) break;
}
return wrapped;
}
@@ -265,9 +277,13 @@ export default class WindowManager implements IWindowManager {
}
public handleWindowPositionChanged(winWrap: WindowWrapper): void {
if (this._changingGrabbedMonitor) {
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();
@@ -280,19 +296,82 @@ export default class WindowManager implements IWindowManager {
break;
}
}
if (monitorIndex === -1) {
return
}
if (monitorIndex === -1) return;
if (monitorIndex !== this._grabbedWindowMonitor) {
this._changingGrabbedMonitor = true;
this._moveWindowToMonitor(winWrap.getWindow(), monitorIndex);
this._changingGrabbedMonitor = false
this._changingGrabbedMonitor = false;
}
this._isTiling = true;
try {
this._monitors.get(monitorIndex)?.itemDragged(winWrap, mouseX, mouseY);
} finally {
this._isTiling = false;
}
}
}
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()
@@ -307,7 +386,6 @@ export default class WindowManager implements IWindowManager {
this._tileMonitors()
}
public handleWindowChangedWorkspace(winWrap: WindowWrapper): void {
const monitor = winWrap.getWindow().get_monitor();
this._monitors.get(monitor)?.removeWindow(winWrap);
@@ -326,41 +404,26 @@ export default class WindowManager implements IWindowManager {
this._tileMonitors();
}
handleWindowCreated(display: Meta.Display, window: Meta.Window) {
Logger.log("WINDOW CREATED ON DISPLAY", window, display);
if (!this._isWindowTileable(window)) {
return;
}
if (!this._isWindowTileable(window)) return;
Logger.log("WINDOW IS TILABLE");
this.addWindowToMonitor(window);
}
/**
* Handle window closed event
*/
handleWindowClosed(window: WindowWrapper): void {
const mon_id = window._window.get_monitor();
this._monitors.get(mon_id)?.removeWindow(window);
window.disconnectWindowSignals()
// Remove from managed windows
this.syncActiveWindow();
// Retile remaining windows
this._tileMonitors();
}
public addWindowToMonitor(window: Meta.Window) {
Logger.log("ADDING WINDOW TO MONITOR", window, window);
var wrapper = new WindowWrapper(window, (winWrap) => this.handleWindowMinimized(winWrap))
wrapper.connectWindowSignals(this);
this._addWindowWrapperToMonitor(wrapper);
}
_addWindowWrapperToMonitor(winWrap: WindowWrapper) {
@@ -372,9 +435,15 @@ export default class WindowManager implements IWindowManager {
}
_tileMonitors(): void {
this._isTiling = true;
try {
for (const monitor of this._monitors.values()) {
monitor.tileWindows()
monitor.tileWindows();
}
} catch (e) {
Logger.error("_tileMonitors FAILED", e);
} finally {
this._isTiling = false;
}
}
@@ -397,17 +466,12 @@ export default class WindowManager implements IWindowManager {
}
_isWindowTileable(window: Meta.Window) {
if (!window || !window.get_compositor_private()) return false;
if (this._isWindowTilingBlocked(window)) return false;
if (!window || !window.get_compositor_private()) {
return false;
}
if (this._isWindowTilingBlocked(window)) {
return false;
}
const windowType = window.get_window_type();
Logger.log("WINDOW TILING CHECK",);
// Skip certain types of windows
return !window.is_skip_taskbar() &&
windowType !== Meta.WindowType.DESKTOP &&
windowType !== Meta.WindowType.DOCK &&
@@ -417,14 +481,6 @@ export default class WindowManager implements IWindowManager {
windowType !== Meta.WindowType.MENU;
}
/**
* Synchronizes the active window with GNOME's currently active window
*
* This function queries GNOME Shell for the current focused window and
* updates the extension's active window tracking to match.
*
* @returns The window ID of the active window, or null if no window is active
*/
public syncActiveWindow(): number | null {
const focusWindow = global.display.focus_window;
if (focusWindow) {
@@ -437,83 +493,56 @@ export default class WindowManager implements IWindowManager {
return this._activeWindowId;
}
/**
* Toggles the orientation of the active container (the container holding the active window)
*/
public toggleActiveContainerOrientation(): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot toggle container orientation");
return;
}
// Find the active window's container
const activeContainer = this._findActiveContainer();
if (activeContainer) {
activeContainer.toggleOrientation();
const container = this._findContainerForWindowAcrossMonitors(this._activeWindowId);
if (container) {
container.toggleOrientation();
} else {
Logger.warn("Could not find container for active window");
}
}
/**
* Finds the container that directly contains the active window
* @returns The container holding the active window, or null if not found
*/
private _findActiveContainer(): WindowContainer | null {
public resetActiveContainerRatios(): void {
if (this._activeWindowId === null) {
return null;
Logger.warn("No active window, cannot reset container ratios");
return;
}
for (const monitor of this._monitors.values()) {
const activeWorkspaceIndex = global.workspace_manager.get_active_workspace().index();
// Bounds check to prevent accessing invalid workspace
if (activeWorkspaceIndex >= monitor._workspaces.length || activeWorkspaceIndex < 0) {
Logger.warn(`Active workspace index ${activeWorkspaceIndex} out of bounds for monitor with ${monitor._workspaces.length} workspaces`);
continue;
}
const workspace = monitor._workspaces[activeWorkspaceIndex];
// Check if the window is directly in the workspace container
const windowWrapper = workspace.getWindow(this._activeWindowId);
if (windowWrapper) {
// Try to find the parent container
const container = this._findContainerHoldingWindow(workspace, this._activeWindowId);
return container;
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");
}
}
return null;
public toggleActiveContainerTabbed(): void {
if (this._activeWindowId === null) {
Logger.warn("No active window, cannot toggle tabbed mode");
return;
}
/**
* Recursively finds the container that directly contains a specific window
* @param container The container to search
* @param windowId The window ID to find
* @returns The container that directly contains the window, or null if not found
*/
private _findContainerHoldingWindow(container: WindowContainer, windowId: number): WindowContainer | null {
// Check if this container directly contains the window
for (const item of container._tiledItems) {
if (item instanceof WindowContainer) {
// Recursively search nested containers
const result = this._findContainerHoldingWindow(item, windowId);
if (result) {
return result;
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;
}
} else if (item.getWindowId() === windowId) {
// Found it! Return this container as it directly holds the window
return container;
container.setTabbed();
}
this._tileMonitors();
} else {
Logger.warn("Could not find container for active window");
}
}
return null;
}
/**
* Prints the tree structure of all monitors, workspaces, containers, and windows to the logs
*/
public printTreeStructure(): void {
Logger.info("=".repeat(80));
Logger.info("WINDOW TREE STRUCTURE");
@@ -526,19 +555,18 @@ export default class WindowManager implements IWindowManager {
this._monitors.forEach((monitor: Monitor, monitorId: number) => {
const isActiveMonitor = this._activeWindowId !== null &&
monitor.getWindow(this._activeWindowId) !== undefined;
const monitorMarker = isActiveMonitor ? ' *' : '';
Logger.info(`Monitor ${monitorId}${monitorMarker}:`);
Logger.info(`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;
const workspaceMarker = isActiveWorkspace && isActiveMonitor ? ' *' : '';
Logger.info(` Workspace ${workspaceIndex}${workspaceMarker}:`);
Logger.info(` Orientation: ${workspace._orientation === 0 ? 'HORIZONTAL' : 'VERTICAL'}`);
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);
});
});
@@ -546,31 +574,20 @@ export default class WindowManager implements IWindowManager {
Logger.info("=".repeat(80));
}
/**
* Recursively prints the container tree structure
* @param container The container to print
* @param indentLevel The indentation level (number of spaces)
*/
private _printContainerTree(container: any, indentLevel: number): void {
private _printContainerTree(container: WindowContainer, indentLevel: number): void {
const indent = " ".repeat(indentLevel);
container._tiledItems.forEach((item: any, index: number) => {
container._tiledItems.forEach((item, index) => {
if (item instanceof WindowContainer) {
// Check if this container contains the active window
const containsActiveWindow = this._activeWindowId !== null &&
const containsActive = this._activeWindowId !== null &&
item.getWindow(this._activeWindowId) !== undefined;
const containerMarker = containsActiveWindow ? ' *' : '';
Logger.info(`${indent}[${index}] Container (${item._orientation === 0 ? 'HORIZONTAL' : 'VERTICAL'})${containerMarker}:`);
Logger.info(`${indent}[${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();
const isActiveWindow = this._activeWindowId === item.getWindowId();
const windowMarker = isActiveWindow ? ' *' : '';
Logger.info(`${indent}[${index}] Window ID: ${item.getWindowId()}${windowMarker}`);
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();
@@ -578,6 +595,4 @@ export default class WindowManager implements IWindowManager {
}
});
}
}

View File

@@ -1,37 +1,31 @@
/* 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;
}
/*@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;
}