From 656e448927de17e41ac46a622067b2b2984adb86 Mon Sep 17 00:00:00 2001 From: Lucas Oskorep Date: Thu, 26 Feb 2026 02:26:00 -0500 Subject: [PATCH] feat: add tabbed container layout mode with tab bar UI fix: tab bars no longer shown in overview. Tab bars show name of app with pipe and then title of the app --- extension.ts | 14 +- ...ome.shell.extensions.aerospike.gschema.xml | 8 +- src/__tests__/container.test.ts | 96 +++++- src/prefs/prefs.ts | 9 + src/wm/container.ts | 311 ++++++++++++++++-- src/wm/monitor.ts | 13 +- src/wm/tabBar.ts | 125 +++++++ src/wm/window.ts | 19 ++ src/wm/windowManager.ts | 46 ++- stylesheet.css | 58 ++-- 10 files changed, 611 insertions(+), 88 deletions(-) create mode 100644 src/wm/tabBar.ts diff --git a/extension.ts b/extension.ts index c675673..fcc5ca4 100644 --- a/extension.ts +++ b/extension.ts @@ -19,10 +19,15 @@ export default class aerospike extends Extension { } enable() { - Logger.log("STARTING AEROSPIKE!") - this.bindSettings(); - this.setupKeybindings(); - this.windowManager.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() { @@ -39,6 +44,7 @@ export default class aerospike extends Extension { 'print-tree': () => { this.windowManager.printTreeStructure(); }, 'toggle-orientation': () => { this.windowManager.toggleActiveContainerOrientation(); }, 'reset-ratios': () => { this.windowManager.resetActiveContainerRatios(); }, + 'toggle-tabbed': () => { this.windowManager.toggleActiveContainerTabbed(); }, }; } diff --git a/schemas/org.gnome.shell.extensions.aerospike.gschema.xml b/schemas/org.gnome.shell.extensions.aerospike.gschema.xml index f0f74a4..b029d9f 100644 --- a/schemas/org.gnome.shell.extensions.aerospike.gschema.xml +++ b/schemas/org.gnome.shell.extensions.aerospike.gschema.xml @@ -44,7 +44,7 @@ - comma']]]> + comma']]]> Toggle active container orientation Toggles the orientation of the container holding the active window between horizontal and vertical @@ -55,5 +55,11 @@ Resets all window size ratios in the active window's container to equal splits + + slash']]]> + Toggle tabbed container mode + Toggles the active window's container between tabbed and accordion layout modes + + \ No newline at end of file diff --git a/src/__tests__/container.test.ts b/src/__tests__/container.test.ts index aad077e..611ddc8 100644 --- a/src/__tests__/container.test.ts +++ b/src/__tests__/container.test.ts @@ -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 = [ diff --git a/src/prefs/prefs.ts b/src/prefs/prefs.ts index 4daa950..6ca591d 100644 --- a/src/prefs/prefs.ts +++ b/src/prefs/prefs.ts @@ -182,6 +182,15 @@ export default class AerospikeExtensions extends ExtensionPreferences { }) ); + keybindingsGroup.add( + new EntryRow({ + title: _('Toggle Tabbed Mode'), + settings: settings, + bind: 'toggle-tabbed', + map: keybindingMap + }) + ); + } // Helper function to create a keybinding mapping object diff --git a/src/wm/container.ts b/src/wm/container.ts index 9ff86d7..896ab62 100644 --- a/src/wm/container.ts +++ b/src/wm/container.ts @@ -2,10 +2,12 @@ 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"; -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. @@ -22,10 +24,17 @@ export default class WindowContainer { _tiledItems: (WindowWrapper | WindowContainer)[]; _tiledWindowLookup: Map; - _orientation: Orientation = Orientation.HORIZONTAL; + _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(); @@ -33,7 +42,7 @@ export default class WindowContainer { this._splitRatios = []; } - // ─── Helpers ──────────────────────────────────────────────────────────────── + // --- Helpers ---------------------------------------------------------------- private _resetRatios(): void { this._splitRatios = equalRatios(this._tiledItems.length); @@ -46,41 +55,136 @@ export default class WindowContainer { return; } const newRatio = 1 / n; - const scale = 1 - newRatio; - const scaled = this._splitRatios.map(r => r * scale); + const scale = 1 - newRatio; + const scaled = this._splitRatios.map(r => r * scale); const partialSum = scaled.reduce((a, b) => a + b, 0) + newRatio; scaled[scaled.length - 1] += (1.0 - partialSum); this._splitRatios = [...scaled, newRatio]; } private _totalDimension(): number { - return this._orientation === Orientation.HORIZONTAL + return this._orientation === Layout.ACC_HORIZONTAL ? this._workArea.width : this._workArea.height; } - // ─── Public API ───────────────────────────────────────────────────────────── + 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(), + callback: () => this.drawWindows(), }, 100); } @@ -111,13 +215,28 @@ export default class WindowContainer { 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) { @@ -125,7 +244,7 @@ export default class WindowContainer { } } } - this.tileWindows(); + this.drawWindows(); } disconnectSignals(): void { @@ -139,37 +258,152 @@ export default class WindowContainer { } 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; } - tileWindows(): void { + drawWindows(): void { Logger.log("TILING WINDOWS IN CONTAINER"); Logger.log("WorkArea", this._workArea); - this._tileItems(); + + if (this.isTabbed()) { + this._tileTab(); + } else { + this._tileAccordion(); + } } - _tileItems() { + _tileAccordion() { if (this._tiledItems.length === 0) return; const bounds = this.getBounds(); - Logger.info(`_tileItems: ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}] bounds=[${bounds.map(b => `(${b.x},${b.y},${b.width},${b.height})`).join(', ')}]`); + 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(`_tileItems: window[${index}] id=${item.getWindowId()} dragging=${item._dragging} → rect=(${rect.x},${rect.y},${rect.width},${rect.height})`); + Logger.info(`_tileAccordion: window[${index}] id=${item.getWindowId()} dragging=${item._dragging} → rect=(${rect.x},${rect.y},${rect.width},${rect.height})`); item.safelyResizeWindow(rect); } }); } - // ─── Bounds Calculation ────────────────────────────────────────────────────── + 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[] { - return this._orientation === Orientation.HORIZONTAL + 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'); } @@ -187,14 +421,15 @@ export default class WindowContainer { 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 }; + ? {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}; }); } - // ─── Boundary Adjustment ───────────────────────────────────────────────────── - 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; @@ -204,22 +439,22 @@ export default class WindowContainer { if (totalDim === 0) return false; const ratioDelta = deltaPixels / totalDim; - const newLeft = this._splitRatios[boundaryIndex] + ratioDelta; - const newRight = this._splitRatios[boundaryIndex + 1] - ratioDelta; + 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] = newLeft; this._splitRatios[boundaryIndex + 1] = newRight; Logger.info(`adjustBoundary: boundary=${boundaryIndex} ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}]`); return true; } - // ─── Container Lookup ──────────────────────────────────────────────────────── + // --- Container Lookup -------------------------------------------------------- getContainerForWindow(win_id: number): WindowContainer | null { for (const item of this._tiledItems) { @@ -248,6 +483,12 @@ 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 { + // 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) { @@ -266,12 +507,12 @@ export default class WindowContainer { Logger.info(`itemDragged: swapped slots ${original_index}<->${new_index}, ratios=[${this._splitRatios.map(r => r.toFixed(3)).join(', ')}]`); [this._tiledItems[original_index], this._tiledItems[new_index]] = [this._tiledItems[new_index], this._tiledItems[original_index]]; - this.tileWindows(); + this.drawWindows(); } } resetRatios(): void { this._resetRatios(); - this.tileWindows(); + this.drawWindows(); } } diff --git a/src/wm/monitor.ts b/src/wm/monitor.ts index b020cb7..63d430a 100644 --- a/src/wm/monitor.ts +++ b/src/wm/monitor.ts @@ -1,7 +1,6 @@ import {WindowWrapper} from "./window.js"; import {Rect} from "../utils/rect.js"; import {Logger} from "../utils/logger.js"; -import Meta from "gi://Meta"; import WindowContainer from "./container.js"; @@ -73,6 +72,18 @@ 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); } diff --git a/src/wm/tabBar.ts b/src/wm/tabBar.ts new file mode 100644 index 0000000..b324ad1 --- /dev/null +++ b/src/wm/tabBar.ts @@ -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; + } +} diff --git a/src/wm/window.ts b/src/wm/window.ts index f6fbd54..da1406a 100644 --- a/src/wm/window.ts +++ b/src/wm/window.ts @@ -47,6 +47,25 @@ 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; } diff --git a/src/wm/windowManager.ts b/src/wm/windowManager.ts index fb9906b..c4a410e 100644 --- a/src/wm/windowManager.ts +++ b/src/wm/windowManager.ts @@ -5,7 +5,7 @@ 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 from "./container.js"; +import WindowContainer, {Layout} from "./container.js"; import {Rect} from "../utils/rect.js"; @@ -44,7 +44,7 @@ export default class WindowManager implements IWindowManager { _changingGrabbedMonitor: boolean = false; _showingOverview: boolean = false; - // ── Resize-drag tracking ────────────────────────────────────────────────── + // -- Resize-drag tracking -------------------------------------------------- _isResizeDrag: boolean = false; _resizeDragWindowId: number = _UNUSED_WINDOW_ID; _resizeDragOp: Meta.GrabOp = Meta.GrabOp.NONE; @@ -132,10 +132,16 @@ export default class WindowManager implements IWindowManager { 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(); + } }), ]; } @@ -329,7 +335,7 @@ export default class WindowManager implements IWindowManager { const itemIndex = container._getIndexOfWindow(winId); if (itemIndex === -1) return; - const isHorizontal = container._orientation === 0; + const isHorizontal = container._orientation === Layout.ACC_HORIZONTAL; // E/S edge → boundary after the item; W/N edge → boundary before it. let adjusted = false; @@ -350,7 +356,7 @@ export default class WindowManager implements IWindowManager { if (adjusted) { this._isTiling = true; try { - container.tileWindows(); + container.drawWindows(); } finally { this._isTiling = false; } @@ -434,6 +440,8 @@ export default class WindowManager implements IWindowManager { for (const monitor of this._monitors.values()) { monitor.tileWindows(); } + } catch (e) { + Logger.error("_tileMonitors FAILED", e); } finally { this._isTiling = false; } @@ -512,6 +520,29 @@ export default class WindowManager implements IWindowManager { } } + 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"); + } + } + public printTreeStructure(): void { Logger.info("=".repeat(80)); Logger.info("WINDOW TREE STRUCTURE"); @@ -531,8 +562,11 @@ export default class WindowManager implements IWindowManager { monitor._workspaces.forEach((workspace, workspaceIndex) => { const isActiveWorkspace = workspaceIndex === activeWorkspaceIndex; Logger.info(` Workspace ${workspaceIndex}${isActiveWorkspace && isActiveMonitor ? ' *' : ''}:`); - Logger.info(` Orientation: ${workspace._orientation === 0 ? 'HORIZONTAL' : 'VERTICAL'}`); + Logger.info(` Orientation: ${Layout[workspace._orientation]}`); Logger.info(` Items: ${workspace._tiledItems.length}`); + if (workspace.isTabbed()) { + Logger.info(` Active Tab: ${workspace._activeTabIndex}`); + } this._printContainerTree(workspace, 4); }); }); @@ -547,7 +581,7 @@ export default class WindowManager implements IWindowManager { if (item instanceof WindowContainer) { const containsActive = this._activeWindowId !== null && item.getWindow(this._activeWindowId) !== undefined; - Logger.info(`${indent}[${index}] Container (${item._orientation === 0 ? 'HORIZONTAL' : 'VERTICAL'})${containsActive ? ' *' : ''}:`); + Logger.info(`${indent}[${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); diff --git a/stylesheet.css b/stylesheet.css index a69d151..b1f894e 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -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;*/ -/*}*/ \ No newline at end of file +.aerospike-tab-label { + font-size: 11px; +}