From f6a08ab52eb9568ccd24670b1519e0dbc432d57f Mon Sep 17 00:00:00 2001 From: Lucas Oskorep Date: Mon, 2 Mar 2026 18:46:26 -0500 Subject: [PATCH] feat: adding active window selection and refactoring keybindings --- extension.ts | 21 +- ...ome.shell.extensions.aerospike.gschema.xml | 94 ++++---- src/prefs/prefs.ts | 220 ++++++++---------- src/wm/container.ts | 148 ++++++++++++ src/wm/windowManager.ts | 166 ++++++++++++- 5 files changed, 471 insertions(+), 178 deletions(-) diff --git a/extension.ts b/extension.ts index fcc5ca4..15b7d26 100644 --- a/extension.ts +++ b/extension.ts @@ -4,6 +4,7 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import Gio from 'gi://Gio'; import Shell from 'gi://Shell'; 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 { @@ -37,14 +38,18 @@ export default class aerospike extends Extension { private keybindingActions(): Record 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(); }, + '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); }, }; } @@ -56,14 +61,6 @@ export default class aerospike extends Extension { this.refreshKeybinding(name); }); }); - - 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')}`); - }); } private refreshKeybinding(settingName: string) { diff --git a/schemas/org.gnome.shell.extensions.aerospike.gschema.xml b/schemas/org.gnome.shell.extensions.aerospike.gschema.xml index b029d9f..1fc7ce4 100644 --- a/schemas/org.gnome.shell.extensions.aerospike.gschema.xml +++ b/schemas/org.gnome.shell.extensions.aerospike.gschema.xml @@ -1,40 +1,72 @@ - - 'option1' - Dropdown selection - Option selected from the dropdown menu + + + h']]]> + Focus window to the left + Move focus to the window to the left of the current window. In tabbed mode, switches to the previous tab. - - 'rgb(255,0,0)' - Selected color - Color chosen from the color picker + + l']]]> + Focus window to the right + Move focus to the window to the right of the current window. In tabbed mode, switches to the next tab. + + k']]]> + Focus window above + Move focus to the window above the current window. + + + + j']]]> + Focus window below + Move focus to the window below the current window. + + + - 1']]]> - Keybinding for action 1 - Keyboard shortcut for triggering action 1 + h']]]> + Move window to the left + Move the active window one position to the left within its container - 2']]]> - Keybinding for action 2 - Keyboard shortcut for triggering action 2 + l']]]> + Move window to the right + Move the active window one position to the right within its container - - 3']]]> - Keybinding for action 3 - Keyboard shortcut for triggering action 3 + + k']]]> + Move window up + Move the active window one position up within its container - - 4']]]> - Keybinding for action 4 - Keyboard shortcut for triggering action 4 + + j']]]> + Move window down + Move the active window one position down within its container + + + + comma']]]> + Toggle active container orientation + Toggles the orientation of the container holding the active window between horizontal and vertical + + + + z']]]> + Reset container ratios to equal splits + 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 @@ -43,23 +75,5 @@ Prints the current tree of containers and windows per monitor to logs - - comma']]]> - Toggle active container orientation - Toggles the orientation of the container holding the active window between horizontal and vertical - - - - z']]]> - Reset container ratios to equal splits - 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/prefs/prefs.ts b/src/prefs/prefs.ts index 6ca591d..62ed9c7 100644 --- a/src/prefs/prefs.ts +++ b/src/prefs/prefs.ts @@ -2,7 +2,7 @@ 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 {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; import {Logger} from "../utils/logger.js"; import {EntryRow} from "./keybindings.js"; @@ -11,160 +11,115 @@ export default class AerospikeExtensions extends ExtensionPreferences { // 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', + // Create keybindings page (top-level) + const keybindingsPage = new Adw.PreferencesPage({ + title: _('Keybindings'), + icon_name: 'input-keyboard-symbolic', }); - window.add(page); + window.add(keybindingsPage); + const keybindingMap = this.createKeybindingMap(); - // 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); - }); - - // Create keybindings group - const keybindingsGroup = new Adw.PreferencesGroup({ - title: _('Keyboard Shortcuts'), + // Top-level Keybindings header group with syntax help + const keybindingsHeader = new Adw.PreferencesGroup({ + title: _('Keybindings'), description: `${_("Syntax")}: h, g, h ${_("Legend")}: - ${_("Windows key")}, - ${_("Control key")} ${_("Delete text to unset. Press Return key to accept.")}`, }); - page.add(keybindingsGroup); + keybindingsPage.add(keybindingsHeader); - // Add keybinding rows as EntryRows with proper mapping - // Use the helper function to create the map object - const keybindingMap = this.createKeybindingMap(); - - keybindingsGroup.add( + // --- Focus group --- + const focusGroup = new Adw.PreferencesGroup({ + title: _('Focus'), + }); + keybindingsPage.add(focusGroup); + + focusGroup.add( new EntryRow({ - title: _('Action 1'), + 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 }) ); - - keybindingsGroup.add( + + moveGroup.add( new EntryRow({ - title: _('Action 2'), + title: _('Move Right'), settings: settings, bind: 'move-right', map: keybindingMap }) ); - - keybindingsGroup.add( + + moveGroup.add( new EntryRow({ - title: _('Action 3'), + title: _('Move Up'), settings: settings, - bind: 'join-with-left', - map: keybindingMap - }) - ); - - keybindingsGroup.add( - new EntryRow({ - title: _('Action 4'), - settings: settings, - bind: 'join-with-right', + bind: 'move-up', map: keybindingMap }) ); - keybindingsGroup.add( + moveGroup.add( new EntryRow({ - title: _('Print Tree Structure'), + title: _('Move Down'), settings: settings, - bind: 'print-tree', + bind: 'move-down', map: keybindingMap }) ); - keybindingsGroup.add( + // --- Container Interactions group --- + const containerGroup = new Adw.PreferencesGroup({ + title: _('Container Interactions'), + }); + keybindingsPage.add(containerGroup); + + containerGroup.add( new EntryRow({ title: _('Toggle Orientation'), settings: settings, @@ -173,7 +128,7 @@ export default class AerospikeExtensions extends ExtensionPreferences { }) ); - keybindingsGroup.add( + containerGroup.add( new EntryRow({ title: _('Reset Container Ratios to Equal'), settings: settings, @@ -182,7 +137,7 @@ export default class AerospikeExtensions extends ExtensionPreferences { }) ); - keybindingsGroup.add( + containerGroup.add( new EntryRow({ title: _('Toggle Tabbed Mode'), settings: settings, @@ -191,6 +146,21 @@ export default class AerospikeExtensions extends ExtensionPreferences { }) ); + // --- 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 diff --git a/src/wm/container.ts b/src/wm/container.ts index 09e0137..3d4a06d 100644 --- a/src/wm/container.ts +++ b/src/wm/container.ts @@ -10,6 +10,13 @@ export enum Layout { 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 []; @@ -537,4 +544,145 @@ export default class WindowContainer { 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; + } } diff --git a/src/wm/windowManager.ts b/src/wm/windowManager.ts index fa79b89..ff85c64 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, {Layout} from "./container.js"; +import WindowContainer, {Direction, Layout} from "./container.js"; import {Rect} from "../utils/rect.js"; @@ -565,6 +565,170 @@ export default class WindowManager implements IWindowManager { } } + /** + * Move (swap) the active window in the given direction within its container. + * + * 1. Find the container holding the active window. + * 2. Ask the container to swap the window with its neighbour in that direction. + * 3. 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(); + } + } + + /** + * 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`); + } + + /** + * When at the edge of a container, find the nearest window on the adjacent + * monitor in the given direction. + * + * Determines the adjacent monitor by comparing work-area centres: + * - LEFT: monitor whose work-area is to the left of the current one + * - RIGHT: monitor whose work-area is to the right of the current one + * - UP: monitor whose work-area is above the current one + * - DOWN: monitor whose work-area is below the current one + * + * On the target monitor, picks the edge-most window: + * - Navigating LEFT → last (rightmost) window of the target container + * - Navigating RIGHT → first (leftmost) window of the target container + * - Navigating UP → last (bottommost) window + * - Navigating DOWN → first (topmost) window + */ + private _findCrossMonitorWindow(direction: Direction): number | null { + if (this._activeWindowId === null) return null; + + // Find which monitor the active window is on + let currentMonitorId: number | null = null; + for (const [monId, monitor] of this._monitors.entries()) { + if (monitor.getWindow(this._activeWindowId) !== undefined) { + currentMonitorId = monId; + break; + } + } + if (currentMonitorId === null) return 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; + + // Find the best adjacent monitor in the given direction + 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; + } + } + + if (bestMonitorId === null) return null; + + const targetMonitor = this._monitors.get(bestMonitorId)!; + 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; + + // Pick the window on the "entry edge" of the target container + if (direction === Direction.LEFT || direction === Direction.UP) { + return targetContainer._lastLeafWindowId(); + } else { + return targetContainer._firstLeafWindowId(); + } + } + public printTreeStructure(): void { Logger.info("=".repeat(80)); Logger.info("WINDOW TREE STRUCTURE");