From 76813027bb02f77a705e4fe750aef9eb6029faf6 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Mon, 1 Jun 2026 22:35:52 +0200 Subject: [PATCH 01/33] chore: remove empty test-monitors.js --- test-monitors.js | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test-monitors.js diff --git a/test-monitors.js b/test-monitors.js deleted file mode 100644 index 62c7e8b..0000000 --- a/test-monitors.js +++ /dev/null @@ -1 +0,0 @@ -// Just a thought experiment script to see how to map logical to physical From 4a013769348714f8eadf2e9fe9fc23177424a8a9 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Thu, 4 Jun 2026 16:45:26 +0200 Subject: [PATCH 02/33] docs: create CONTRIBUTING.md and refine AGENTS.md --- AGENTS.md | 4 +++- CONTRIBUTING.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 CONTRIBUTING.md diff --git a/AGENTS.md b/AGENTS.md index 3322061..bc8a672 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,9 @@ # Workflow Tiling AI Development Guidelines ## Role & Mission -You are an AI developer assisting with the Workflow Tiling GNOME Shell extension. +You are a useful AI developer assisting with the Workflow Tiling GNOME Shell extension. +For this purpose, you will find general project-level guidelines here. +Please also refer to your developer's exclusive instruction file (e.g., `CLAUDE.md`, `CODEX.md`, `CURSOR.md`, or `GEMINI.md`) for detailed instructions on how you can serve them best. ## General Mandates diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..994d487 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Ground rules on contributing to Workflow Tiling + +Welcome! Thanks for wanting to contribute. If you have any issues reach out to the maintainer. + +## Branching Structure + +We follow a structured branching model to keep things organized: + +* **`master`**: This is our stable, release-ready branch. Please do not push directly here. +* **`develop`**: This is where new features come together for integration testing. Once the testing phase is complete, `develop` merges into `master` for a new release. +* **`feature/*`, `bugfix/*`, `refactor/*`**: Use these short-lived branches for your daily work. When ready, open a Pull Request into `develop`. +* **`hotfix/*`** (Exception): If `master` breaks, you can create a hotfix branch to merge directly into `master`. + +## Development Guidelines + +### Testing +We highly value stability! Please make sure to write unit tests for new features—wherever possible—and run existing tests before proposing a change. +Run them easily using: +```bash +npm test +``` + +### Commentary & Documentation +We prefer documentation that describes the current state and behavior of the system. +* Try to keep comments focused without diary entries or historical tracking. +* If your changes affect how the system works (class names, execution flow, or API), please help us keep `architecture.md`, `layouts.md`, and `README.md` up-to-date! + +### Logging +Using debuggable logging (`Logger` in `lib/logger.js`) is highly suggested. Including verbose logs for complex state sequences makes troubleshooting much easier for everyone—especially me. Thanks! + +### Settings UI Design +We love a clean UI! For `prefs.js`, follow a **"minimal clutter, expand only after needed"** design philosophy. +Feel free to use `Adw` (libadwaita) components and bind visibility state dynamically to reduce visual noise. + +### GNOME APIs & Event-Driven Architecture +We aim to use the newest solutions and APIs available for GNOME Shell. +Please rely on GNOME Shell signals (`size-changed`, `window-created`, etc.) or frame-synced deferrals (`GLib.idle_add`, `Meta.LaterType.BEFORE_REDRAW`) instead of arbitrary timeouts (`GLib.timeout_add`). + +### AI Friendliness +This project has a project-level `AGENTS.md` set up. +You may have your own requirements in `CLAUDE.md`, `CODEX.md`, `CURSOR.md`, or `GEMINI.md`. +Please make sure to tell your agent to respect both `AGENTS.md` and your local instruction file. + From 6bc577e781cd37d1ef0c3095adbc10492a729d83 Mon Sep 17 00:00:00 2001 From: MAkexander <86495108+MAkexander@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:50:39 +0200 Subject: [PATCH 03/33] Refactor/Restructure documentation (#14) Moved architecture.md, layouts.md, vision.md to docs/ Added Issues and Feature Requests to CONTRIBUTING.md Restructured CONTRIBUTING.md topics to follow the typical development process Added a Summary, Customization Information to README.md and restructured the file --------- Co-authored-by: Alexander Pechmann Co-authored-by: Konstantin Merkel --- CONTRIBUTING.md | 44 ++++++++++++------------- README.md | 28 +++++++++++++--- architecture.md => docs/architecture.md | 0 layouts.md => docs/layouts.md | 0 vision.md => docs/vision.md | 0 5 files changed, 44 insertions(+), 28 deletions(-) rename architecture.md => docs/architecture.md (100%) rename layouts.md => docs/layouts.md (100%) rename vision.md => docs/vision.md (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 994d487..45d9fa6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,7 @@ -# Ground rules on contributing to Workflow Tiling - -Welcome! Thanks for wanting to contribute. If you have any issues reach out to the maintainer. - -## Branching Structure +# Development Guidelines for `Workflow Tiling` +Welcome! Thanks for wanting to contribute. If you have any issues please reach out to the maintainer. +## Branch Structure We follow a structured branching model to keep things organized: * **`master`**: This is our stable, release-ready branch. Please do not push directly here. @@ -11,7 +9,24 @@ We follow a structured branching model to keep things organized: * **`feature/*`, `bugfix/*`, `refactor/*`**: Use these short-lived branches for your daily work. When ready, open a Pull Request into `develop`. * **`hotfix/*`** (Exception): If `master` breaks, you can create a hotfix branch to merge directly into `master`. -## Development Guidelines +## Issues and Feature Requests +Please report issues and request features under issues. + +### AI Friendliness +This project has a project-level `AGENTS.md` set up. +You may have your own requirements in `CLAUDE.md`, `CODEX.md`, `CURSOR.md`, or `GEMINI.md`. +Please make sure to tell your agent to respect both `AGENTS.md` and your local instruction file. + +### Settings UI Design +We love a clean UI! For `prefs.js`, follow a **"minimal clutter, expand only after needed"** design philosophy. +Feel free to use `Adw` (libadwaita) components and bind visibility state dynamically to reduce visual noise. + +### Logging +Using debuggable logging (`Logger` in `lib/logger.js`) is highly suggested. Including verbose logs for complex state sequences makes troubleshooting much easier for everyone — especially me. Thank you! + +### GNOME APIs & Event-Driven Architecture +We aim to use the newest solutions and APIs available for GNOME Shell. +Please rely on GNOME Shell signals (`size-changed`, `window-created`, etc.) or frame-synced deferrals (`GLib.idle_add`, `Meta.LaterType.BEFORE_REDRAW`) instead of arbitrary timeouts (`GLib.timeout_add`). ### Testing We highly value stability! Please make sure to write unit tests for new features—wherever possible—and run existing tests before proposing a change. @@ -24,20 +39,3 @@ npm test We prefer documentation that describes the current state and behavior of the system. * Try to keep comments focused without diary entries or historical tracking. * If your changes affect how the system works (class names, execution flow, or API), please help us keep `architecture.md`, `layouts.md`, and `README.md` up-to-date! - -### Logging -Using debuggable logging (`Logger` in `lib/logger.js`) is highly suggested. Including verbose logs for complex state sequences makes troubleshooting much easier for everyone—especially me. Thanks! - -### Settings UI Design -We love a clean UI! For `prefs.js`, follow a **"minimal clutter, expand only after needed"** design philosophy. -Feel free to use `Adw` (libadwaita) components and bind visibility state dynamically to reduce visual noise. - -### GNOME APIs & Event-Driven Architecture -We aim to use the newest solutions and APIs available for GNOME Shell. -Please rely on GNOME Shell signals (`size-changed`, `window-created`, etc.) or frame-synced deferrals (`GLib.idle_add`, `Meta.LaterType.BEFORE_REDRAW`) instead of arbitrary timeouts (`GLib.timeout_add`). - -### AI Friendliness -This project has a project-level `AGENTS.md` set up. -You may have your own requirements in `CLAUDE.md`, `CODEX.md`, `CURSOR.md`, or `GEMINI.md`. -Please make sure to tell your agent to respect both `AGENTS.md` and your local instruction file. - diff --git a/README.md b/README.md index b80b4e9..4d31973 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,35 @@ [![Project Status: Beta](https://img.shields.io/badge/status-beta-orange.svg)](https://github.com/KonstantinMerkel/workflow-tiling) # Workflow Tiling - A deterministic customizable auto-tiler extension for GNOME Shell (GNOME 50+). +## Summary +- [Workflow Tiling](#workflow-tiling) + - [Summary](#summary) + - [Features](#features) + - [Customizations](#customizations) + - [Custom Layouts](#custom-layouts) + - [Development](#development) + - [Contributing](#contributing) + - [Running Tests](#running-tests) + - [Installation](#installation) + - [Recommended Extensions](#recommended-extensions) + ## Features - **Deterministic Escalation**: Automatically tiles windows in a customizable geometric sequence. - **Multi-Monitor Support**: Each monitor tiles independently. - **Workspace Isolation**: Tiling states are unique to each GNOME workspace. - **Stability Focused**: Uses WindowWrapper object modeling and compositor-native synchronization (Meta.LaterType) to prevent race conditions and Shell crashes. -## Recommended Extensions -Workflow Tiling does not natively draw an active window border. For visual indication of the focused window, it is highly recommended to use an extension like **P7 Border** +## Customizations + +Customizations like window gaps and keybinds can be customized under `Gnome Extension Manager` > `Workflow Tiling` > Settings. +Please be warned that duplicate shortcuts can lead to unexpected window movement. -## Custom Layouts +### Custom Layouts Layout transitions are configured via JSON string, supporting custom window counts and sizes. Optional `id` (1-indexed) integer properties in the JSON structure define how windows transition between states. It is required for all elements: - ```json { "1": [ @@ -37,6 +49,9 @@ Optional `id` (1-indexed) integer properties in the JSON structure define how wi ## Development +### Contributing +see [CONTRIBUTING.md](CONTRIBUTING.md) + ### Running Tests Unit tests are written using **Vitest**. ```bash @@ -49,3 +64,6 @@ To deploy the extension to your local GNOME Shell directory: ```bash make install ``` + +## Recommended Extensions +Workflow Tiling does not natively draw an active window border. For visual indication of the focused window, it is highly recommended to use an extension like [P7 Border](https://github.com/prasannavl/p7-borders-shell-extension) \ No newline at end of file diff --git a/architecture.md b/docs/architecture.md similarity index 100% rename from architecture.md rename to docs/architecture.md diff --git a/layouts.md b/docs/layouts.md similarity index 100% rename from layouts.md rename to docs/layouts.md diff --git a/vision.md b/docs/vision.md similarity index 100% rename from vision.md rename to docs/vision.md From 6c468f00a13a60520526dc11ab2e49268c025672 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 10 Jun 2026 16:49:56 +0200 Subject: [PATCH 04/33] =?UTF-8?q?refactor(keybindings):=20transition=20to?= =?UTF-8?q?=20native=20Mutter=20intercept=20and=20SRP=20=E2=80=A6=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(keybindings): transition to native Mutter intercept and SRP architecture * chore: update docs acccordingly * feat: implement dynamic schema shadowing for custom keybindings * docs: add dynamic schema shadowing architecture documentation --- docs/architecture.md | 13 +- docs/keybindings.md | 26 +++ extension.js | 12 +- lib/keybindings.js | 203 ++++++++++++++++++ lib/shadows.js | 133 ++++++++++++ lib/signals.js | 122 ----------- schemas/gschemas.compiled | Bin 2524 -> 2600 bytes ...ell.extensions.workflow-tiling.gschema.xml | 6 + 8 files changed, 390 insertions(+), 125 deletions(-) create mode 100644 docs/keybindings.md create mode 100644 lib/keybindings.js create mode 100644 lib/shadows.js diff --git a/docs/architecture.md b/docs/architecture.md index 6f92df2..a760552 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,9 +19,20 @@ Manages window evacuation during monitor removal. Binds GNOME Shell signals. Intercepts `window-created`, `window-entered-monitor`, `window-left-monitor`. Intercepts drag operations via `grab-op-begin` and `grab-op-end`. -Binds global keyboard shortcuts. Translates events to `TilingController` calls. +## KeybindingManager (`lib/keybindings.js`) +Binds global keyboard shortcuts. +Hijacks hardcoded native GNOME shortcuts via C-handlers (`default` mode). +Delegates custom shortcut conflict resolution to `ShadowManager`. +Translates keyboard events to `TilingController` actions. + +## ShadowManager (`lib/shadows.js`) +Implements Dynamic Schema Shadowing. +Scans GNOME native schemas for custom shortcut conflicts. +Temporarily unbinds conflicting native keys to allow `Main.wm.addKeybinding` to succeed. +Persists original keys in `shadowed-keybindings` state for perfect restoration on disable. + ## WindowWrapper (`lib/window.js`) Encapsulates `Meta.Window`. Applies calculated geometry. diff --git a/docs/keybindings.md b/docs/keybindings.md new file mode 100644 index 0000000..bebe522 --- /dev/null +++ b/docs/keybindings.md @@ -0,0 +1,26 @@ +# Keybindings & Schema Shadowing + +The extension uses two different strategies to bind shortcuts due to GNOME Mutter architecture limitations: + +## 1. Native C-Handler Hijacking (`Meta.keybindings_set_custom_handler`) +Used strictly in `Default` mode for known GNOME shortcuts (like `Left` which GNOME maps to `toggle-tiled-left`). +* **Why:** High priority. Safely intercepts the GNOME action at the C-level before Mutter can process it. +* **Limitation:** Requires knowing the hardcoded GNOME string action name (e.g. `toggle-tiled-left`). It cannot intercept an arbitrary custom keystroke without knowing what action it triggers. + +## 2. Extension Bindings (`Main.wm.addKeybinding`) +Used for all `Custom` modes and utility shortcuts. +* **Why:** Allows binding to custom `gsettings` schema keys that the user configures. +* **Limitation:** Extremely low priority. If the user picks a key (e.g., `Down`) that Mutter already listens to globally (e.g., `shift-overview-down`), Mutter consumes the event. The extension never fires. + +## Dynamic Schema Shadowing (`ShadowManager`) +To bypass the limitation of `Main.wm.addKeybinding`, `ShadowManager` temporarily deletes conflicting shortcuts from GNOME settings while the extension is active. + +### Execution Flow: +1. **Normalize:** Target custom keystrokes are parsed via `Gtk.accelerator_parse` to normalize modifier ordering (`` vs ``). +2. **Scan:** Iterates through native schemas (`wm.keybindings`, `mutter.keybindings`, `shell.keybindings`). +3. **Filter:** If a native array contains the normalized shortcut string, it is explicitly filtered out. +4. **Backup:** The *original* array is saved into `org.gnome.shell.extensions.workflow-tiling.shadowed-keybindings` (JSON string). +5. **Restore:** On `disable()` or `rebindAll()`, the JSON backup is read, and the native schema keys are written back to their exact original arrays. + +### Crash Resilience +Because the backup is written to standard `gsettings` before any keys are unbound, an unexpected shell crash will not permanently destroy native user shortcuts. The state is restored perfectly on the next instantiation. diff --git a/extension.js b/extension.js index 1f9f0bc..f91cbe1 100644 --- a/extension.js +++ b/extension.js @@ -3,6 +3,7 @@ import { TilingController } from './lib/controller.js'; import { SignalListener } from './lib/signals.js'; import { SettingsManager } from './lib/settings.js'; import { Logger } from './lib/logger.js'; +import { KeybindingManager } from './lib/keybindings.js'; /** * Main extension class. Manages controller and signals. @@ -16,6 +17,7 @@ export default class WorkflowTilingExtension extends Extension { this._settings = new SettingsManager(this); this._controller = new TilingController(this._settings); this._signals = new SignalListener(this._controller); + this._keybindings = new KeybindingManager(this._controller); this._isActive = false; this._wasSuspended = false; @@ -27,7 +29,7 @@ export default class WorkflowTilingExtension extends Extension { }; this._settings.onKeybindingsChanged = () => { - if (this._isActive) this._signals.rebindKeybindings(); + if (this._isActive) this._keybindings.rebindAll(); } this._applyCustomLayouts(); @@ -46,6 +48,7 @@ export default class WorkflowTilingExtension extends Extension { Main.notifyError('Workflow Tiling', `Invalid layouts JSON. Suspending extension.\n${e.message}`); if (this._isActive) { this._signals.unbind(); + this._keybindings.unbindAll(); this._controller.clear(); this._isActive = false; this._wasSuspended = true; @@ -57,6 +60,7 @@ export default class WorkflowTilingExtension extends Extension { if (!this._isActive) { this._signals.bind(); + this._keybindings.bindAll(); this._isActive = true; if (this._wasSuspended) { Main.notify('Workflow Tiling', 'Valid layout provided. Extension resumed.'); @@ -68,10 +72,14 @@ export default class WorkflowTilingExtension extends Extension { disable() { Logger.info(`Disabling ${this.metadata.name}`); - if (this._isActive) this._signals.unbind(); + if (this._isActive) { + this._signals.unbind(); + this._keybindings.unbindAll(); + } if (this._settings) this._settings.destroy(); if (this._controller) this._controller.clear(); this._signals = null; + this._keybindings = null; this._settings = null; this._controller = null; this._isActive = false; diff --git a/lib/keybindings.js b/lib/keybindings.js new file mode 100644 index 0000000..f66c802 --- /dev/null +++ b/lib/keybindings.js @@ -0,0 +1,203 @@ +import Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import { Logger } from './logger.js'; +import { ShadowManager } from './shadows.js'; + +const NATIVE_CONFLICTS = { + 'move-window-left': 'toggle-tiled-left', + 'move-window-right': 'toggle-tiled-right', + 'move-window-up': 'maximize', + 'move-window-down': 'unmaximize' +}; + +export class KeybindingManager { + constructor(controller) { + this.controller = controller; + this.settings = controller.settings ? controller.settings.settings : null; + this._boundKeys = []; + this._activeConflicts = []; + this._definitions = this._buildDefinitions(); + this._shadowManager = new ShadowManager(this.settings); + } + + _buildDefinitions() { + const defs = []; + + // Move Directions + ['left', 'right', 'up', 'down'].forEach(dir => { + defs.push({ + defaultKey: `move-window-${dir}`, + customKey: `custom-move-window-${dir}`, + modeSetting: 'keybindings-mode', + action: (c, win) => { + Logger.debug(`Action triggered: move-window-${dir}`); + c.moveWindowDirection(win, dir); + }, + conflict: NATIVE_CONFLICTS[`move-window-${dir}`] || null + }); + }); + + // Focus Directions + ['left', 'right', 'up', 'down'].forEach(dir => { + defs.push({ + defaultKey: `focus-window-${dir}`, + customKey: `custom-focus-window-${dir}`, + modeSetting: 'focus-window-mode', + action: (c, win) => { + Logger.debug(`Action triggered: focus-window-${dir}`); + c.focusWindowDirection(win, dir); + }, + conflict: null + }); + }); + + // Batch Utilities + const utilities = { + 'shortcut-close-monitor': (c) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), + 'shortcut-close-workspace': (c) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), + 'shortcut-switch-monitor': (c) => c.switchMonitors(global.display.get_current_monitor()), + 'shortcut-port-monitor-left': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), + 'shortcut-port-monitor-right': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), + 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) + }; + + for (const [key, action] of Object.entries(utilities)) { + defs.push({ + defaultKey: key, + action: (c) => { + Logger.debug(`Action triggered: ${key}`); + action(c); + }, + conflict: null + }); + } + + return defs; + } + + bindAll() { + if (!this.settings) return; + + const conflictsToHijack = []; + const keysToShadow = []; + + for (const def of this._definitions) { + const { active, keyToBind, isCustom } = this._resolveBinding(def); + if (!active) { + Logger.debug(`Binding ${def.defaultKey} is inactive.`); + continue; + } + + Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, isCustom=${isCustom}, conflict=${def.conflict}`); + + if (!isCustom && def.conflict) { + Logger.debug(`Will hijack native conflict ${def.conflict} instead of binding extension shortcut.`); + conflictsToHijack.push(def.conflict); + } else { + keysToShadow.push(keyToBind); + } + } + + if (keysToShadow.length > 0) { + this._shadowManager.shadowShortcuts(keysToShadow); + } + + // Bind extension shortcuts + for (const def of this._definitions) { + const { active, keyToBind, isCustom } = this._resolveBinding(def); + if (active && !(!isCustom && def.conflict)) { + this._bindExtensionShortcut(def, keyToBind); + } + } + + conflictsToHijack.forEach(conflictKey => this._hijackNativeShortcut(conflictKey)); + } + + _resolveBinding(def) { + let keyToBind = def.defaultKey; + let active = true; + let isCustom = false; + + if (def.modeSetting) { + const mode = this.settings.get_string(def.modeSetting); + if (mode === 'disabled') active = false; + if (mode === 'custom' && def.customKey) { + keyToBind = def.customKey; + isCustom = true; + } + } + + return { active, keyToBind, isCustom }; + } + + _bindExtensionShortcut(def, keyToBind) { + try { + Main.wm.addKeybinding( + keyToBind, + this.settings, + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL, + (display, window, binding) => { + const focusWindow = window || global.display.get_focus_window(); + def.action(this.controller, focusWindow); + } + ); + + this._boundKeys.push(keyToBind); + Logger.debug(`Successfully bound extension shortcut: ${keyToBind}`); + } catch (e) { + Logger.warn(`Failed to bind shortcut ${keyToBind}`, e); + } + } + + _hijackNativeShortcut(conflictKey) { + try { + Meta.keybindings_set_custom_handler(conflictKey, (display, window, binding) => { + const focusWindow = window || global.display.get_focus_window(); + const def = this._definitions.find(d => d.conflict === conflictKey); + if (def) def.action(this.controller, focusWindow); + }); + + if (!this._activeConflicts.includes(conflictKey)) { + this._activeConflicts.push(conflictKey); + } + Logger.debug(`Successfully hijacked native shortcut: ${conflictKey}`); + } catch (e) { + Logger.warn(`Failed to set custom handler for ${conflictKey}`, e); + } + } + + unbindAll() { + // Clean up your extension's keybinding out of runtime + for (const key of this._boundKeys) { + try { + Main.wm.removeKeybinding(key); + Logger.debug(`Unbound extension shortcut: ${key}`); + } catch (e) { + Logger.warn(`Failed to unbind shortcut ${key}`, e); + } + } + this._boundKeys = []; + + // Restore GNOME's native C handling immediately by passing null + for (const conflictKey of this._activeConflicts) { + try { + Meta.keybindings_set_custom_handler(conflictKey, null); + Logger.debug(`Restored native handler for ${conflictKey}`); + } catch (e) { + Logger.warn(`Failed to restore native handler for ${conflictKey}`, e); + } + } + this._activeConflicts = []; + + if (this._shadowManager) { + this._shadowManager.restoreAll(); + } + } + + rebindAll() { + this.unbindAll(); + this.bindAll(); + } +} diff --git a/lib/shadows.js b/lib/shadows.js new file mode 100644 index 0000000..8903574 --- /dev/null +++ b/lib/shadows.js @@ -0,0 +1,133 @@ +import Gio from 'gi://Gio'; +import Gtk from 'gi://Gtk?version=4.0'; +import { Logger } from './logger.js'; + +export class ShadowManager { + constructor(extensionSettings) { + this.extensionSettings = extensionSettings; + this._nativeSchemas = []; + + const schemaIds = [ + 'org.gnome.desktop.wm.keybindings', + 'org.gnome.mutter.keybindings', + 'org.gnome.mutter.wayland.keybindings', + 'org.gnome.shell.keybindings' + ]; + + const schemaSource = Gio.SettingsSchemaSource.get_default(); + for (const id of schemaIds) { + const schema = schemaSource.lookup(id, true); + if (schema) { + this._nativeSchemas.push(new Gio.Settings({ schema_id: id })); + } + } + } + + _normalize(shortcut) { + if (!shortcut) return ''; + try { + const [valid, keyval, mods] = Gtk.accelerator_parse(shortcut); + if (valid) { + return Gtk.accelerator_name(keyval, mods); + } + } catch (e) { + // Ignore parse errors, fallback to raw string + } + return shortcut; + } + + shadowShortcuts(extensionKeys) { + // First restore everything to ensure a clean slate + this.restoreAll(); + + const stateJson = this.extensionSettings.get_string('shadowed-keybindings'); + let state = {}; + try { + if (stateJson) state = JSON.parse(stateJson); + } catch (e) { + Logger.warn('ShadowManager: Failed to parse shadowed-keybindings JSON', e); + } + + // Gather all target accelerators we want to bind + const targetAccels = new Set(); + for (const extKey of extensionKeys) { + const accels = this.extensionSettings.get_strv(extKey); + for (const a of accels) { + const norm = this._normalize(a); + if (norm) targetAccels.add(norm); + } + } + + if (targetAccels.size === 0) return; + + // Scan native schemas + for (const settings of this._nativeSchemas) { + const schemaId = settings.schema_id; + const keys = settings.list_keys(); + + for (const key of keys) { + try { + const value = settings.get_value(key); + if (value.get_type_string() !== 'as') continue; + + const accels = settings.get_strv(key); + let changed = false; + const newAccels = []; + + for (const accel of accels) { + const norm = this._normalize(accel); + if (targetAccels.has(norm)) { + changed = true; + Logger.debug(`ShadowManager: Conflicting native shortcut found: ${schemaId}.${key} -> ${accel}`); + } else { + newAccels.push(accel); + } + } + + if (changed) { + // Save original in state + if (!state[schemaId]) state[schemaId] = {}; + // Only save the very first time we modify this key, to not overwrite our backup with a partial array + if (!state[schemaId][key]) state[schemaId][key] = accels; + + settings.set_strv(key, newAccels); + Logger.debug(`ShadowManager: Shadowed ${schemaId}.${key} -> remaining: [${newAccels.join(', ')}]`); + } + } catch (e) { + // Ignore keys that fail to read or aren't string arrays + } + } + } + + this.extensionSettings.set_string('shadowed-keybindings', JSON.stringify(state)); + } + + restoreAll() { + const stateJson = this.extensionSettings.get_string('shadowed-keybindings'); + let state = {}; + try { + if (stateJson) state = JSON.parse(stateJson); + } catch (e) { + return; + } + + if (Object.keys(state).length === 0) return; + + for (const settings of this._nativeSchemas) { + const schemaId = settings.schema_id; + if (state[schemaId]) { + for (const [key, originalAccels] of Object.entries(state[schemaId])) { + try { + settings.set_strv(key, originalAccels); + Logger.debug(`ShadowManager: Restored native shortcut ${schemaId}.${key} -> [${originalAccels.join(', ')}]`); + } catch (e) { + Logger.warn(`ShadowManager: Failed to restore ${schemaId}.${key}`, e); + } + } + } + } + + // Clear the state + this.extensionSettings.set_string('shadowed-keybindings', '{}'); + } +} diff --git a/lib/signals.js b/lib/signals.js index d314cec..cdaaf27 100644 --- a/lib/signals.js +++ b/lib/signals.js @@ -24,7 +24,6 @@ export class SignalListener { this.controller = controller; this._signals = []; - this._keybindings = []; } bind() { @@ -80,113 +79,10 @@ export class SignalListener { } }); - this._bindKeybindings(); - // Sort and tile tracked windows geometrically to preserve stable slots. this.controller.hydrate(); } - _bindKeybindings() { - const settings = this.controller.settings ? this.controller.settings.settings : null; - if (!settings) return; - - const isMoveCustom = settings.get_string('keybindings-mode') === 'custom'; - const isMoveDisabled = settings.get_string('keybindings-mode') === 'disabled'; - - const bindDirection = (defaultName, customName, direction) => { - if (isMoveDisabled) return; - const keyToBind = isMoveCustom ? customName : defaultName; - Main.wm.addKeybinding( - keyToBind, - settings, - Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.NORMAL, - (display, window, binding) => { - const focusWindow = window || global.display.get_focus_window(); - Logger.info(`Keybinding triggered: ${keyToBind}, direction: ${direction}, window: ${focusWindow ? focusWindow.get_title() : 'none'}`); - if (focusWindow) { - this.controller.moveWindowDirection(focusWindow, direction); - } - } - ); - this._keybindings.push(keyToBind); - }; - - bindDirection('move-window-left', 'custom-move-window-left', 'left'); - bindDirection('move-window-right', 'custom-move-window-right', 'right'); - bindDirection('move-window-up', 'custom-move-window-up', 'up'); - bindDirection('move-window-down', 'custom-move-window-down', 'down'); - - const isFocusCustom = settings.get_string('focus-window-mode') === 'custom'; - const isFocusDisabled = settings.get_string('focus-window-mode') === 'disabled'; - - const bindFocusDirection = (defaultName, customName, direction) => { - if (isFocusDisabled) return; - const keyToBind = isFocusCustom ? customName : defaultName; - Main.wm.addKeybinding( - keyToBind, - settings, - Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.NORMAL, - (display, window, binding) => { - const focusWindow = window || global.display.get_focus_window(); - Logger.info(`Focus keybinding triggered: ${keyToBind}, direction: ${direction}, window: ${focusWindow ? focusWindow.get_title() : 'none'}`); - if (focusWindow) { - this.controller.focusWindowDirection(focusWindow, direction); - } - } - ); - this._keybindings.push(keyToBind); - }; - - bindFocusDirection('focus-window-left', 'custom-focus-window-left', 'left'); - bindFocusDirection('focus-window-right', 'custom-focus-window-right', 'right'); - bindFocusDirection('focus-window-up', 'custom-focus-window-up', 'up'); - bindFocusDirection('focus-window-down', 'custom-focus-window-down', 'down'); - - const bindBatch = (settingName, callback) => { - Main.wm.addKeybinding( - settingName, - settings, - Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.NORMAL, - callback - ); - this._keybindings.push(settingName); - }; - - bindBatch('shortcut-close-monitor', () => { - const monitorIndex = global.display.get_current_monitor(); - const includeMin = settings.get_boolean('close-monitor-include-minimized'); - this.controller.closeMonitorWindows(monitorIndex, includeMin); - }); - - bindBatch('shortcut-close-workspace', () => { - const workspace = global.workspace_manager.get_active_workspace(); - this.controller.closeWorkspaceWindows(workspace); - }); - - bindBatch('shortcut-switch-monitor', () => { - const monitorIndex = global.display.get_current_monitor(); - this.controller.switchMonitors(monitorIndex); - }); - - bindBatch('shortcut-port-monitor-left', () => { - const monitorIndex = global.display.get_current_monitor(); - this.controller.portMonitorToWorkspace(monitorIndex, 'left'); - }); - - bindBatch('shortcut-port-monitor-right', () => { - const monitorIndex = global.display.get_current_monitor(); - this.controller.portMonitorToWorkspace(monitorIndex, 'right'); - }); - - bindBatch('shortcut-unminimize-workspace', () => { - const workspace = global.workspace_manager.get_active_workspace(); - this.controller.unminimizeWorkspace(workspace); - }); - } - _addWindow(window) { if (!window) return; GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { @@ -197,11 +93,6 @@ export class SignalListener { }); } - rebindKeybindings() { - this._unbindKeybindings(); - this._bindKeybindings(); - } - unbind() { this._signals.forEach(({ obj, id }) => { try { @@ -214,24 +105,11 @@ export class SignalListener { }); this._signals = []; - this._unbindKeybindings(); - if (SignalListener.activeInstance === this) { SignalListener.activeInstance = null; } } - _unbindKeybindings() { - this._keybindings.forEach(name => { - try { - Main.wm.removeKeybinding(name); - } catch (e) { - Logger.warn(`Failed to unbind keybinding ${name}`, e); - } - }); - this._keybindings = []; - } - _shouldTile(window) { if (!window) return false; diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index 082b79401cf6c364f56f82683bddabf361db30ab..a700a828b54016afad677e008f1432dfabf88666 100644 GIT binary patch literal 2600 zcmbtUU1%It7`;(bYm>HVO`A=B?6N9sYi4(wP?Z=$2to@sQd-o46gt_R-I-=~W}TUB z8kdzkh(xeYR!G4glv+|y31XrCAp0PpDA=MFkxI#9z=|z-P@nXiyEB{F*~J$ZPR^IJ z-+uSr@9*IAs$-~@C;U$ge7tTa@A0(`urvGJ4`hC~PaG1jg1he%qH~)N|KRiR1_YWDea7>S*~GQZfe?grt+pet$2oMSOxx0B;%5VxEGADqSMFy z;Pt=*5%4&o+s7twPXt@>e=o4zM<1B)+v#H$_#t2q5C>}2SGxEX`7Uu<#Bh;=z}VZh zw|EcQJD}^}BCtF+ah^Ku81y;tS)k*W+$?q4eb5)cUjXCJEIdJ-_7>>Z!7IQw7pzt4 zw6{a&<6}Usw>(arb{F&{SOJ2Z(`L>y;00jm@=1&Sw0q!x1^g|r@5YC(Q>V>)u7Jfl zA#OkZ(o@uF^FA@~V?cMY^JD6?H$Z<9JO+I7!Abcp+I-h>@G0Q?Te&;*r_FO`!EXT9 z?$)x@Y43)<2)+#b(R)GWfi};*3SI_QhbAu4pY|r`cfbO|>Tl!EQm4HWdK~;H@Y9|R|i*s8=u$CQK!xO&x79q4pia_b=q5@FM_WEKfn9F%o}aS zSqA?F%zt`L14+9VdJh`35BU4;k2k5)X5NzE4A6f&C+k9+`56Nr1iZ-YJuo(F#IxFqvNn|WIVe?|SXkNW9Pdo%Rw;1yu^__eR8)9#199zzrd zZoRkkJ9XO3a|X=$O_fwnht5wL1DuuvoR$NeUK`+aWPsB`xUOzHUbgHhC9qPoEyJ@N z#nkeR#gbIry$!FdX}g-jIBrSJYCMqNXUnc<7xDAc_|mX)*bza9kz?hO=8PU@#BS-F zx8WXj6C+0)qo_JFqa(+3BkzsssAwp3(~rs}(c(Yl`)iguX=+MAExEj%=n%=ty;%*D)1bMOid^`=)&xfD9rWXd;Ykg;smvCIiDCj}G{P;(uv{9zeE_<32WQ6Y$ zNuyoKcsSE{lUhGnhQnmYTL^$zEg z-Q~Y4G?$HM)rZ{AT6hS}`RNf~eO&kUS~swe8bAnWrlDMMfG literal 2524 zcmbtVZD<@-6n#-sYn!%dP1>eyY*-LBC9~TmR6`7*1i_-Igcd)b7BbnH-5oPKv(C(R z6T`}aB2^It6N8`!IU`y-jZ?GuN^OW@@@g=p^<;vYOq_dpO6 z;tKd0;Ci`K&T$K7%Fmm&oib+w)A23W@l$2jo5|X4Sqm)Na&ml6B=<@Zg5M+%TB04i zt^pfhcPMNEZ)t(8(C!7gfb9VH)C25N*bUwT^eXV%E5DCFz75Ph?G`6R=UqZ{14dVA zoI34|(EGsm1B;(eIn-%)LLUY{3S3>>^D=eX-O#7NMWCb5ex5q*4bZFLQ^40}ofYb| zw?ls)d;zFcUf9UI(r$-d1K$L`ziuqkpEkdXA4Lc7>!~AWsMFpFJqgx;%Qfe9>a?jJ z1|J7*Jn-B@)M;;pUIf1gTz_xz59+ixLq83E4F_=PRxLxFb}#e=@OfbAV(l&Jw0A)N z2K*h+JAUaa>a=^H{{|L!qc%SKq=!1~Ezmo`Nucc)qe`7N;~WAX1unmNZXI>nd!f&Q zD?nIxv{`pm@Snh;cWQ6bpY|@sUypMDR)&t8qfVRqN`i-hYi%DtNS!wGFbRGJ`1Hdm zxgOeF&n);=VBgY5uh5@1^SJ=70ppJ?$T+ka=L&ckI5^*@(VzA<=<9Je`hcI`{XoW{ z%{as0N#JJJSs8~mMU1c2Fte-c)r-7auhO9%{tcL~g7r>dPzWj^+v{_d_ zfO$v#yz2#-QlJ&VTETU!!1XlG%H>rHNa}vS>FCq8spa&d-zJjN{RQ`=sg*6qz@-Ho z#2r0WDw^Kd!~Cy9@TE-258MJmuKJdWq2CilteReKDKI@58A0%%jB)5z_;%JsnDA40 zU5rk8RzdgX#zv3jt!ywhgDf&ms;CEfyqZR%Ny|o)mW?L8+Gx^EjV6uV&zN)5*azB< zkChsxFwCr8va$E1-yh{BW4k^!j6UTT^^D0S-foLw=2Sk8%0zCG-i?_E-NJk{`l-a( z`W%wu%Z~{$pE!^hIX<75O^oaxNX*H<<;2K={rE4h*fL;LP7EXlTe%)w<2fM1QIFy^ zI}I}2(Ekm`s9fA&CH6_m*n|3J=rmM0QHiI&JSON string defining custom window layouts. + + + Shadowed Native Keybindings + Internal state tracking GNOME native keybindings that have been temporarily removed to prevent conflicts with custom shortcuts. + + From 29ad42492ab3b6c26c2cda37d379d0eb2421141e Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 10 Jun 2026 17:01:10 +0200 Subject: [PATCH 05/33] Makefile reminders for logging out --- Makefile | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index b400d6e..faddde5 100644 --- a/Makefile +++ b/Makefile @@ -2,15 +2,24 @@ UUID = workflow-tiling@konstantin.dev EXT_DIR = ~/.local/share/gnome-shell/extensions/$(UUID) FILES = extension.js metadata.json lib/ schemas/ prefs.js -.PHONY: all sync install test pack enable disable clean +.PHONY: all sync install test pack enable disable clean reminder do_sync do_enable all: sync -sync: +reminder: + @echo "" + @echo "=========================================================" + @echo " REMINDER: Log out and log back in for changes to take effect" + @echo "=========================================================" + @echo "" + +sync: do_sync reminder + +do_sync: mkdir -p $(EXT_DIR) cp -r $(FILES) $(EXT_DIR)/ -install: sync enable +install: do_sync do_enable reminder test: npm test @@ -19,7 +28,9 @@ pack: glib-compile-schemas schemas/ 2>/dev/null || true zip -r extension.zip $(FILES) -enable: +enable: do_enable reminder + +do_enable: gnome-extensions enable $(UUID) disable: From 6942c882964b8b0881523ea6cc74a12b3b155926 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Thu, 11 Jun 2026 16:43:15 +0200 Subject: [PATCH 06/33] fix(drag): prevent out of bounds matrix access --- lib/drag.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/drag.js b/lib/drag.js index afffd72..ca57b45 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -76,6 +76,7 @@ export class DragManager { if (hoveredSlot !== -1 && hoveredSlot !== originalSlot) { const matrix = layout.escalator.getLayoutForCount(tracker.size); + if (!matrix || hoveredSlot >= matrix.size || originalSlot >= matrix.size) return; const targetRect = matrix.getEstate(hoveredSlot).toAbsolute(monitorRect, gaps); indicator.set_position(targetRect.x, targetRect.y); @@ -120,6 +121,7 @@ export class DragManager { } _restoreWindowGeometry(tracker, matrix, slotToFind, targetEstateSlot, monitorRect, gaps) { + if (!matrix || targetEstateSlot >= matrix.size) return; const win = tracker.windows.find(w => tracker.getSlot(w) === slotToFind); if (!win) return; const wrap = this.controller._windowWrappers.get(win); From aa4d6a9e332934dc163c8d8423482b2a7e044464 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Tue, 16 Jun 2026 14:12:39 +0200 Subject: [PATCH 07/33] bugfix: ensures no old signals survive a disablement of the extension --- lib/signals.js | 9 ++++- lib/window.js | 14 +++++-- tests/setup.js | 42 ++++++++++++--------- tests/signals.test.js | 85 +++++++++++++++++++++++++++++++++++++++++++ tests/window.test.js | 28 ++++++++++++++ 5 files changed, 157 insertions(+), 21 deletions(-) create mode 100644 tests/signals.test.js diff --git a/lib/signals.js b/lib/signals.js index cdaaf27..516f319 100644 --- a/lib/signals.js +++ b/lib/signals.js @@ -24,6 +24,7 @@ export class SignalListener { this.controller = controller; this._signals = []; + this._pendingIdles = new Set(); } bind() { @@ -85,15 +86,21 @@ export class SignalListener { _addWindow(window) { if (!window) return; - GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + const sourceId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._pendingIdles.delete(sourceId); + if (!SignalListener.activeInstance) return GLib.SOURCE_REMOVE; if (this._shouldTile(window)) { this.controller.tilingRequest(window); } return GLib.SOURCE_REMOVE; }); + this._pendingIdles.add(sourceId); } unbind() { + this._pendingIdles.forEach(id => GLib.source_remove(id)); + this._pendingIdles.clear(); + this._signals.forEach(({ obj, id }) => { try { if (obj) { diff --git a/lib/window.js b/lib/window.js index 36b84a2..fed194e 100644 --- a/lib/window.js +++ b/lib/window.js @@ -15,6 +15,7 @@ export class WindowWrapper { this.monitorId = null; this._sizeChangedHandled = false; + this._pendingLaters = []; } get unmanaged() { @@ -81,6 +82,12 @@ export class WindowWrapper { } destroy() { + const laters = global.compositor.get_laters(); + for (const id of this._pendingLaters) { + try { laters.remove(id); } catch (e) { /* already fired */ } + } + this._pendingLaters = []; + const keys = Array.from(this.signals.keys()); for (const name of keys) { this.disconnectSignal(name); @@ -99,12 +106,12 @@ export class WindowWrapper { if (this.window.maximized_horizontally || this.window.maximized_vertically) { this.window.unmaximize(); - // Delay unmaximize via compositor. - global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { + const laterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { if (this.unmanaged) return false; this._doResize(rect); return false; }); + this._pendingLaters.push(laterId); } else { this._doResize(rect); } @@ -123,10 +130,11 @@ export class WindowWrapper { Math.round(rect.width), Math.round(rect.height) ); - global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { + const laterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { this._isResizing = false; return false; }); + this._pendingLaters.push(laterId); } catch (e) { this._isResizing = false; Logger.warn(`Resize failed for "${this.title}"`, e); diff --git a/tests/setup.js b/tests/setup.js index 7df516f..89a5887 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,22 +1,28 @@ import { vi } from 'vitest'; // Mock GNOME's 'gi://' imports which don't exist in Node environment -vi.mock('gi://GLib', () => ({ - default: { - idle_add: vi.fn((priority, callback) => { - callback(); // Execute immediately in tests - return 1; - }), - timeout_add: vi.fn((priority, interval, callback) => { - callback(); // Execute immediately in tests - return 1; - }), - source_remove: vi.fn(), - SOURCE_REMOVE: false, - PRIORITY_DEFAULT: 0, - PRIORITY_DEFAULT_IDLE: 0 - } -})); +vi.mock('gi://GLib', () => { + let _idleCounter = 0; + let _timeoutCounter = 0; + return { + default: { + idle_add: vi.fn((priority, callback) => { + const id = ++_idleCounter; + callback(); + return id; + }), + timeout_add: vi.fn((priority, interval, callback) => { + const id = ++_timeoutCounter; + callback(); + return id; + }), + source_remove: vi.fn(), + SOURCE_REMOVE: false, + PRIORITY_DEFAULT: 0, + PRIORITY_DEFAULT_IDLE: 0 + } + }; +}); vi.mock('gi://St', () => ({ default: { @@ -78,11 +84,13 @@ global.workspace_manager = { list_windows: vi.fn(() => []) })) }; +let _laterCounter = 0; global.compositor = { get_laters: vi.fn(() => ({ add: vi.fn((type, callback) => { + const id = ++_laterCounter; callback(); - return 1; + return id; }), remove: vi.fn() })) diff --git a/tests/signals.test.js b/tests/signals.test.js new file mode 100644 index 0000000..251d4ac --- /dev/null +++ b/tests/signals.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import GLib from 'gi://GLib'; +import { SignalListener, TILABLE_WINDOW_TYPES } from '../lib/signals.js'; +import Meta from 'gi://Meta'; + +describe('SignalListener', () => { + let mockController; + + beforeEach(() => { + SignalListener.activeInstance = null; + vi.clearAllMocks(); + + mockController = { + tilingRequest: vi.fn(), + handleMonitorsChanged: vi.fn(), + startDragTracking: vi.fn(), + endDragTracking: vi.fn(), + hydrate: vi.fn(), + }; + }); + + afterEach(() => { + SignalListener.activeInstance = null; + }); + + describe('_pendingIdles tracking', () => { + it('should initialize _pendingIdles as empty Set', () => { + const listener = new SignalListener(mockController); + expect(listener._pendingIdles).toBeInstanceOf(Set); + expect(listener._pendingIdles.size).toBe(0); + }); + + it('should track idle source IDs when _addWindow is called', () => { + // Override idle_add to NOT execute callback (simulate async) + GLib.idle_add = vi.fn((priority, cb) => 42); + + const listener = new SignalListener(mockController); + const mockWindow = { + get_window_type: () => Meta.WindowType.NORMAL, + is_skip_taskbar: () => false, + }; + + listener._addWindow(mockWindow); + expect(listener._pendingIdles.has(42)).toBe(true); + }); + + it('should clear pending idles on unbind and call source_remove', () => { + GLib.idle_add = vi.fn((priority, cb) => 77); + + const listener = new SignalListener(mockController); + const mockWindow = { + get_window_type: () => Meta.WindowType.NORMAL, + is_skip_taskbar: () => false, + }; + + listener._addWindow(mockWindow); + expect(listener._pendingIdles.size).toBe(1); + + listener.unbind(); + expect(GLib.source_remove).toHaveBeenCalledWith(77); + expect(listener._pendingIdles.size).toBe(0); + }); + + it('should reject idle callback when activeInstance is null', () => { + let capturedCb; + GLib.idle_add = vi.fn((priority, cb) => { + capturedCb = cb; + return 88; + }); + + const listener = new SignalListener(mockController); + const mockWindow = { + get_window_type: () => Meta.WindowType.NORMAL, + is_skip_taskbar: () => false, + }; + + listener._addWindow(mockWindow); + listener.unbind(); // sets activeInstance = null + + // Simulate late callback firing + capturedCb(); + expect(mockController.tilingRequest).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/window.test.js b/tests/window.test.js index fcd4c35..5a7d08f 100644 --- a/tests/window.test.js +++ b/tests/window.test.js @@ -118,4 +118,32 @@ describe('WindowWrapper', () => { const wrapper = new WindowWrapper(mockWindow, mockController); wrapper.applyGeometry({ x: 10, y: 10, width: 100, height: 100 }); // should not throw }); + + describe('_pendingLaters tracking', () => { + it('should track and remove compositor laters on destroy', () => { + const wrapper = new WindowWrapper(mockWindow, mockController); + const mockLaters = { add: vi.fn(() => 42), remove: vi.fn() }; + global.compositor.get_laters = vi.fn(() => mockLaters); + + wrapper.applyGeometry({ x: 10, y: 10, width: 100, height: 100 }); + expect(wrapper._pendingLaters.length).toBeGreaterThan(0); + expect(wrapper._pendingLaters.includes(42)).toBe(true); + + wrapper.destroy(); + expect(mockLaters.remove).toHaveBeenCalledWith(42); + expect(wrapper._pendingLaters.length).toBe(0); + }); + + it('should catch errors when removing already-fired laters in destroy', () => { + const wrapper = new WindowWrapper(mockWindow, mockController); + const mockLaters = { + add: vi.fn(() => 42), + remove: vi.fn(() => { throw new Error('Already removed'); }) + }; + global.compositor.get_laters = vi.fn(() => mockLaters); + + wrapper.applyGeometry({ x: 10, y: 10, width: 100, height: 100 }); + expect(() => wrapper.destroy()).not.toThrow(); + }); + }); }); From 41b3ea3779fd2ce3130749fb98cfe39229bd98a6 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 7 Jun 2026 15:35:36 +0200 Subject: [PATCH 08/33] bugfix: implemented Expected State Cache to avoid race-conditions --- lib/controller.js | 16 +++++++++------- lib/monitor.js | 14 +++++++++++--- lib/window.js | 26 ++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/lib/controller.js b/lib/controller.js index c345d2c..8ce7d5e 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -134,8 +134,8 @@ export class TilingController { } _resolveTilingContext(window, wrapper) { - let workspace = window.get_workspace ? window.get_workspace() : null; - let monitorIndex = window.get_monitor ? window.get_monitor() : -1; + let workspace = wrapper ? wrapper.effectiveWorkspace : (window.get_workspace ? window.get_workspace() : null); + let monitorIndex = wrapper ? wrapper.effectiveMonitorIndex : (window.get_monitor ? window.get_monitor() : -1); if (!workspace) workspace = global.workspace_manager.get_active_workspace(); if (monitorIndex < 0) monitorIndex = global.display.get_current_monitor(); @@ -146,7 +146,7 @@ export class TilingController { const preferredSlot = isRestoring ? this._restoringWindows.get(window) : undefined; if (isRestoring) { monitorIndex = wrapper.monitorIndex; - let currentMon = window.get_monitor ? window.get_monitor() : -1; + let currentMon = wrapper ? wrapper.effectiveMonitorIndex : (window.get_monitor ? window.get_monitor() : -1); if (!window.minimized && currentMon === wrapper.monitorIndex) { this._restoringWindows.delete(window); } @@ -324,10 +324,11 @@ export class TilingController { */ moveWindowDirection(window, direction) { if (!window) return; - const workspace = window.get_workspace(); + const wrapper = this._windowWrappers.get(window); + const workspace = wrapper ? wrapper.effectiveWorkspace : window.get_workspace(); if (!workspace) return; - const monitorIndex = window.get_monitor(); + const monitorIndex = wrapper ? wrapper.effectiveMonitorIndex : window.get_monitor(); const monitorId = this.monitorManager.getMonitorId(monitorIndex); const layout = this.workspaceManager.getLayout(workspace); @@ -338,10 +339,11 @@ export class TilingController { focusWindowDirection(window, direction) { if (!window) return; - const workspace = window.get_workspace(); + const wrapper = this._windowWrappers.get(window); + const workspace = wrapper ? wrapper.effectiveWorkspace : window.get_workspace(); if (!workspace) return; - const monitorIndex = window.get_monitor(); + const monitorIndex = wrapper ? wrapper.effectiveMonitorIndex : window.get_monitor(); const monitorId = this.monitorManager.getMonitorId(monitorIndex); const layout = this.workspaceManager.getLayout(workspace); diff --git a/lib/monitor.js b/lib/monitor.js index cf6f5be..65a717f 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -193,7 +193,9 @@ export class MonitorManager { this.controller.setBatchMode(true); const windows = workspace.list_windows(); windows.forEach(w => { - if (w.get_monitor() === monitorIndex && (!w.minimized || includeMinimized)) { + const wrapper = this.controller._windowWrappers.get(w); + const m = wrapper ? wrapper.effectiveMonitorIndex : w.get_monitor(); + if (m === monitorIndex && (!w.minimized || includeMinimized)) { w.delete(global.get_current_time()); } }); @@ -221,10 +223,13 @@ export class MonitorManager { this.controller.setBatchMode(true); const windows = workspace.list_windows(); windows.forEach(w => { - const m = w.get_monitor(); + const wrapper = this.controller._windowWrappers.get(w); + const m = wrapper ? wrapper.effectiveMonitorIndex : w.get_monitor(); if (m === activeMonitorIndex) { + if (wrapper) wrapper._expectedMonitorIndex = targetMonitorIndex; w.move_to_monitor(targetMonitorIndex); } else if (m === targetMonitorIndex) { + if (wrapper) wrapper._expectedMonitorIndex = activeMonitorIndex; w.move_to_monitor(activeMonitorIndex); } }); @@ -251,7 +256,10 @@ export class MonitorManager { this.controller.setBatchMode(true); const windows = activeWorkspace.list_windows(); windows.forEach(w => { - if (w.get_monitor() === monitorIndex) { + const wrapper = this.controller._windowWrappers.get(w); + const m = wrapper ? wrapper.effectiveMonitorIndex : w.get_monitor(); + if (m === monitorIndex) { + if (wrapper) wrapper._expectedWorkspace = targetWorkspace; w.change_workspace(targetWorkspace); } }); diff --git a/lib/window.js b/lib/window.js index fed194e..0db381a 100644 --- a/lib/window.js +++ b/lib/window.js @@ -30,6 +30,32 @@ export class WindowWrapper { return (this.window.get_title && this.window.get_title()) || 'Unknown'; } + // Expected State Cache to prevent race condition when working with monitors in rapid succession + get effectiveMonitorIndex() { + let m = this.window.get_monitor ? this.window.get_monitor() : -1; + if (this._expectedMonitorIndex !== undefined) { + if (m !== this._expectedMonitorIndex) { + m = this._expectedMonitorIndex; + } else { + delete this._expectedMonitorIndex; + } + } + return m; + } + + // Expected State Cache to prevent race condition when working with workspaces in rapid succession + get effectiveWorkspace() { + let w = this.window.get_workspace ? this.window.get_workspace() : null; + if (this._expectedWorkspace !== undefined) { + if (w !== this._expectedWorkspace) { + w = this._expectedWorkspace; + } else { + delete this._expectedWorkspace; + } + } + return w; + } + bindSignals() { if (!this.signals.has('unmanaged')) { this.signals.set('unmanaged', this.window.connect('unmanaged', () => this.controller.untile(this.window))); From c3482fb9acfdf4d2b99bbd73892008a6c00535a8 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 10 Jun 2026 17:51:41 +0200 Subject: [PATCH 09/33] bugfix: prevent infinite Wayland loop during Expected State Cache resolution --- lib/window.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/window.js b/lib/window.js index 0db381a..a2c5791 100644 --- a/lib/window.js +++ b/lib/window.js @@ -123,6 +123,11 @@ export class WindowWrapper { applyGeometry(rect) { if (this.unmanaged || !this.window.move_resize_frame || this.window.minimized) return; + // Prevent infinite resize loops caused by Wayland clamping premature out-of-bounds resizes. + // Wait for Mutter's asynchronous state to catch up to our Expected State Cache. + if (this._expectedMonitorIndex !== undefined && this.window.get_monitor() !== this._expectedMonitorIndex) return; + if (this._expectedWorkspace !== undefined && this.window.get_workspace() !== this._expectedWorkspace) return; + try { if (this.window.get_monitor && this.monitorIndex >= 0 && this.window.get_monitor() !== this.monitorIndex) { if (this.window.move_to_monitor) { From 1a8c75774ca697f30228801afc819e3f8b5eaed3 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Thu, 11 Jun 2026 12:31:18 +0200 Subject: [PATCH 10/33] premature: changes, should amend this commit once working --- lib/controller.js | 6 +- lib/drag.js | 249 +++++++++++++++--- lib/monitor.js | 73 ++++++ lib/workspace.js | 214 ++++++++++++++- multi_monitor_refactoring_plan.md | 423 ++++++++++++++++++++++++++++++ tests/drag.test.js | 217 +++++++++++++++ tests/monitor.test.js | 122 ++++++--- tests/setup.js | 1 + tests/workspace.test.js | 182 +++++++++++++ 9 files changed, 1391 insertions(+), 96 deletions(-) create mode 100644 multi_monitor_refactoring_plan.md create mode 100644 tests/drag.test.js diff --git a/lib/controller.js b/lib/controller.js index 8ce7d5e..1fbfd92 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -363,7 +363,7 @@ export class TilingController { } closeMonitorWindows(monitorIndex, includeMinimized) { - this.monitorManager.closeMonitorWindows(monitorIndex, includeMinimized); + this.workspaceManager.closeMonitorWindows(monitorIndex, includeMinimized); } closeWorkspaceWindows(workspace) { @@ -371,11 +371,11 @@ export class TilingController { } switchMonitors(activeMonitorIndex) { - this.monitorManager.switchMonitors(activeMonitorIndex); + this.workspaceManager.switchMonitors(activeMonitorIndex); } portMonitorToWorkspace(monitorIndex, direction) { - this.monitorManager.portMonitorToWorkspace(monitorIndex, direction); + this.workspaceManager.portMonitorToWorkspace(monitorIndex, direction); } unminimizeWorkspace(workspace) { diff --git a/lib/drag.js b/lib/drag.js index ca57b45..5416092 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -7,7 +7,7 @@ import St from 'gi://St'; export class DragManager { constructor(controller) { this.controller = controller; - this._activeDrag = null; // { window, originalSlot, indicator, signalId, lastHoveredSlot } + this._activeDrag = null; // { window, originalSlot, indicator, signalId, lastHoveredSlot, lastHoveredMonitorId, origRect } } startDragTracking(window) { @@ -31,7 +31,7 @@ export class DragManager { }); const origRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; - this._activeDrag = { window, originalSlot, indicator, signalId, lastHoveredSlot: -1, origRect }; + this._activeDrag = { window, originalSlot, indicator, signalId, lastHoveredSlot: -1, lastHoveredMonitorId: null, origRect }; } /** @@ -40,7 +40,7 @@ export class DragManager { _createIndicator() { const indicator = new St.Widget({ style: ` - border: 2px solid -st-accent-color; + border: 2px solid var(--accent-color, #3584e4); border-radius: 8px; `, visible: false @@ -48,10 +48,9 @@ export class DragManager { const bg = new St.Widget({ style: ` - background-color: -st-accent-color; + background-color: var(--accent-bg-color, rgba(53, 132, 228, 0.3)); border-radius: 6px; - `, - opacity: 76 + ` }); indicator.add_child(bg); indicator._bg = bg; @@ -68,23 +67,58 @@ export class DragManager { const workspace = wrapper.workspace; if (!workspace.get_work_area_for_monitor) return; - const monitorRect = workspace.get_work_area_for_monitor(wrapper.monitorIndex); const gaps = this.controller.settings ? this.controller.settings.getGaps() : { inner: 6, outer: 4 }; const [x, y] = global.get_pointer(); - const hoveredSlot = layout.getSlotAtPointer(wrapper.monitorId, x, y, monitorRect, gaps); + let monitorIndex = global.display.get_current_monitor(); + if (monitorIndex === -1) { + monitorIndex = wrapper.monitorIndex; + } - if (hoveredSlot !== -1 && hoveredSlot !== originalSlot) { - const matrix = layout.escalator.getLayoutForCount(tracker.size); - if (!matrix || hoveredSlot >= matrix.size || originalSlot >= matrix.size) return; - const targetRect = matrix.getEstate(hoveredSlot).toAbsolute(monitorRect, gaps); - + const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); + const targetTracker = layout._getTracker(monitorId); + const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); + + let hoveredSlot = -1; + let targetRect = null; + + if (targetTracker.size === 0) { + hoveredSlot = 0; + targetRect = { + x: monitorRect.x + gaps.outer, + y: monitorRect.y + gaps.outer, + width: monitorRect.width - (gaps.outer * 2), + height: monitorRect.height - (gaps.outer * 2) + }; + } else { + hoveredSlot = layout.getSlotAtPointer(monitorId, x, y, monitorRect, gaps); + if (hoveredSlot !== -1) { + const matrixCount = (monitorId === wrapper.monitorId) ? targetTracker.size : (targetTracker.size + 1); + const matrix = layout.escalator.getLayoutForCount(matrixCount); + if (matrix) { + const estate = matrix.getEstate(hoveredSlot); + if (estate) { + targetRect = estate.toAbsolute(monitorRect, gaps); + } else { + hoveredSlot = -1; + } + } else { + hoveredSlot = -1; + } + } + } + + if (hoveredSlot !== -1 && targetRect) { indicator.set_position(targetRect.x, targetRect.y); indicator.set_size(targetRect.width, targetRect.height); if (indicator._bg) indicator._bg.set_size(targetRect.width, targetRect.height); indicator.show(); - this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); + if (monitorId !== wrapper.monitorId) { + this._applyCrossMonitorVisualSwap(wrapper, targetTracker, layout, monitorId, hoveredSlot, monitorRect, gaps); + } else { + this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); + } } else { indicator.hide(); this._revertVisualSwap(tracker, layout, monitorRect, gaps); @@ -96,18 +130,53 @@ export class DragManager { * previously hovered window and shifting the newly hovered window. */ _applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps) { - if (!this._activeDrag || this._activeDrag.lastHoveredSlot === hoveredSlot) return; + if (!this._activeDrag) return; + const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); + const sourceMonId = wrapper ? wrapper.monitorId : null; + if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; + + // Revert previous hover + this._revertVisualSwap(tracker, layout, monitorRect, gaps); const matrix = layout.escalator.getLayoutForCount(tracker.size); + if (matrix) { + // Apply new hover (move hovered window to dragged window's original slot) + this._restoreWindowGeometry(tracker, matrix, hoveredSlot, originalSlot, monitorRect, gaps); + } + + this._activeDrag.lastHoveredSlot = hoveredSlot; + this._activeDrag.lastHoveredMonitorId = sourceMonId; + } + + _applyCrossMonitorVisualSwap(wrapper, targetTracker, layout, monitorId, hoveredSlot, monitorRect, gaps) { + if (!this._activeDrag) return; + if (this._activeDrag.lastHoveredMonitorId === monitorId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; // Revert previous hover - if (this._activeDrag.lastHoveredSlot !== -1) { - this._restoreWindowGeometry(tracker, matrix, this._activeDrag.lastHoveredSlot, this._activeDrag.lastHoveredSlot, monitorRect, gaps); + const sourceTracker = layout._getTracker(wrapper.monitorId); + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); + const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); + this._revertVisualSwap(sourceTracker, layout, sourceMonitorRect, gaps); + + // Apply new cross-monitor visual swap preview using size N+1 matrix + const matrix = layout.escalator.getLayoutForCount(targetTracker.size + 1); + if (matrix) { + for (const win of targetTracker.windows) { + const slot = targetTracker.getSlot(win); + if (slot !== undefined) { + const targetEstateSlot = (slot >= hoveredSlot) ? (slot + 1) : slot; + const wrap = this.controller._windowWrappers.get(win); + const estate = matrix.getEstate(targetEstateSlot); + if (wrap && estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } + } + } } - // Apply new hover (move hovered window to dragged window's original slot) - this._restoreWindowGeometry(tracker, matrix, hoveredSlot, originalSlot, monitorRect, gaps); this._activeDrag.lastHoveredSlot = hoveredSlot; + this._activeDrag.lastHoveredMonitorId = monitorId; } /** @@ -115,9 +184,65 @@ export class DragManager { */ _revertVisualSwap(tracker, layout, monitorRect, gaps) { if (!this._activeDrag || this._activeDrag.lastHoveredSlot === -1) return; - const matrix = layout.escalator.getLayoutForCount(tracker.size); - this._restoreWindowGeometry(tracker, matrix, this._activeDrag.lastHoveredSlot, this._activeDrag.lastHoveredSlot, monitorRect, gaps); + + const lastMonId = this._activeDrag.lastHoveredMonitorId; + const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); + const sourceMonId = wrapper ? wrapper.monitorId : null; + + if (lastMonId && lastMonId !== sourceMonId) { + const lastTracker = layout._getTracker(lastMonId); + const lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); + if (lastMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); + this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); + } + } else { + this._restoreTrackerGeometries(tracker, layout, monitorRect, gaps); + } + this._activeDrag.lastHoveredSlot = -1; + this._activeDrag.lastHoveredMonitorId = null; + } + + _revertVisualSwapForEnd(tracker, layout, monitorRect, gaps) { + if (!this._activeDrag || this._activeDrag.lastHoveredSlot === -1) return; + + const lastMonId = this._activeDrag.lastHoveredMonitorId; + const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); + const sourceMonId = wrapper ? wrapper.monitorId : null; + + if (lastMonId && lastMonId !== sourceMonId) { + const lastTracker = layout._getTracker(lastMonId); + const lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); + if (lastMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); + this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); + } + } else { + this._restoreTrackerGeometries(tracker, layout, monitorRect, gaps); + } + } + + _restoreTrackerGeometries(tracker, layout, monitorRect, gaps) { + const matrix = layout.escalator.getLayoutForCount(tracker.size); + if (!matrix) return; + const draggedWindow = this._activeDrag ? this._activeDrag.window : null; + for (const win of tracker.windows) { + if (win === draggedWindow) continue; + const slot = tracker.getSlot(win); + if (slot !== undefined) { + const wrap = this.controller._windowWrappers.get(win); + if (wrap) { + const estate = matrix.getEstate(slot); + if (estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } + } + } + } } _restoreWindowGeometry(tracker, matrix, slotToFind, targetEstateSlot, monitorRect, gaps) { @@ -126,20 +251,51 @@ export class DragManager { if (!win) return; const wrap = this.controller._windowWrappers.get(win); if (wrap) { - const targetRect = matrix.getEstate(targetEstateSlot).toAbsolute(monitorRect, gaps); - wrap.applyGeometry(targetRect); + const estate = matrix.getEstate(targetEstateSlot); + if (estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } } } endDragTracking(window) { if (!this._activeDrag || this._activeDrag.window !== window) return; - window.disconnect(this._activeDrag.signalId); - if (this._activeDrag.indicator) { - this._activeDrag.indicator.destroy(); + const activeDrag = this._activeDrag; + const origRect = activeDrag.origRect; + const lastHoveredSlot = activeDrag.lastHoveredSlot; + const lastHoveredMonitorId = activeDrag.lastHoveredMonitorId; + + const wrapper = this.controller._windowWrappers.get(window); + if (!wrapper || !wrapper.workspace || !wrapper.monitorId) { + window.disconnect(activeDrag.signalId); + if (activeDrag.indicator) activeDrag.indicator.destroy(); + this._activeDrag = null; + return; + } + + const workspace = wrapper.workspace; + if (!workspace.get_work_area_for_monitor) { + window.disconnect(activeDrag.signalId); + if (activeDrag.indicator) activeDrag.indicator.destroy(); + this._activeDrag = null; + return; + } + + const monitorRect = workspace.get_work_area_for_monitor(wrapper.monitorIndex); + const gaps = this.controller.settings ? this.controller.settings.getGaps() : { inner: 6, outer: 4 }; + const layout = this.controller.workspaceManager.getLayout(workspace); + + // Revert temporary visual swaps before performing final tracking and retile + const sourceTracker = layout._getTracker(wrapper.monitorId); + this._revertVisualSwapForEnd(sourceTracker, layout, monitorRect, gaps); + + window.disconnect(activeDrag.signalId); + if (activeDrag.indicator) { + activeDrag.indicator.destroy(); } - const origRect = this._activeDrag.origRect; this._activeDrag = null; if (this._deferredRetiles && this._deferredRetiles.length > 0) { @@ -147,25 +303,32 @@ export class DragManager { this._deferredRetiles = []; } - const wrapper = this.controller._windowWrappers.get(window); - if (!wrapper || !wrapper.workspace || !wrapper.monitorId) return; + if (lastHoveredMonitorId && lastHoveredMonitorId !== wrapper.monitorId) { + const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastHoveredMonitorId); + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; - const workspace = wrapper.workspace; - if (!workspace.get_work_area_for_monitor) return; - - const monitorRect = workspace.get_work_area_for_monitor(wrapper.monitorIndex); - const gaps = this.controller.settings ? this.controller.settings.getGaps() : { inner: 6, outer: 4 }; + layout.untrackWindow(window, sourceMonitorId); + if (targetMonitorIndex !== -1) { + window.move_to_monitor(targetMonitorIndex); + } + layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); - const layout = this.controller.workspaceManager.getLayout(workspace); - - const [x, y] = global.get_pointer(); - const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); - - const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; - const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + } else { + const [x, y] = global.get_pointer(); + const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); + + const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; + const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; - if (swapped || rectChanged) { - this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + if (swapped || rectChanged) { + this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + } } } } diff --git a/lib/monitor.js b/lib/monitor.js index 65a717f..b70bf34 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -268,4 +268,77 @@ export class MonitorManager { this.controller.hydrate(activeWorkspace); this.controller.hydrate(targetWorkspace); } + + getMonitorInDirection(currentMonitorIndex, direction) { + try { + const manager = global.backend.get_monitor_manager(); + const logicalMonitors = manager.get_logical_monitors(); + const sourceMonitor = logicalMonitors[currentMonitorIndex]; + if (!sourceMonitor) { + Logger.info(`getMonitorInDirection: invalid sourceMonitor for index ${currentMonitorIndex}`); + return -1; + } + + const sRect = global.display.get_monitor_geometry(currentMonitorIndex); + Logger.info(`[DEBUG] getMonitorInDirection: source monitor ${currentMonitorIndex} rect: ${sRect.x}, ${sRect.y}, ${sRect.width}, ${sRect.height}`); + + let candidates = []; + const eps = 1; + + for (let i = 0; i < logicalMonitors.length; i++) { + if (i === currentMonitorIndex) continue; + const cRect = global.display.get_monitor_geometry(i); + + let inDirection = false; + let dist = Infinity; + + if (direction === 'left') { + inDirection = cRect.x + cRect.width <= sRect.x + eps; + dist = sRect.x - (cRect.x + cRect.width); + } else if (direction === 'right') { + inDirection = cRect.x >= sRect.x + sRect.width - eps; + dist = cRect.x - (sRect.x + sRect.width); + } else if (direction === 'up') { + inDirection = cRect.y + cRect.height <= sRect.y + eps; + dist = sRect.y - (cRect.y + cRect.height); + } else if (direction === 'down') { + inDirection = cRect.y >= sRect.y + sRect.height - eps; + dist = cRect.y - (sRect.y + sRect.height); + } + + Logger.info(`getMonitorInDirection: checking monitor ${i} (${cRect.x},${cRect.y},${cRect.width},${cRect.height}) for direction ${direction}: inDirection=${inDirection}`); + + if (inDirection) { + let overlap = 0; + if (direction === 'left' || direction === 'right') { + overlap = Math.max(0, Math.min(cRect.y + cRect.height, sRect.y + sRect.height) - Math.max(cRect.y, sRect.y)); + } else { + overlap = Math.max(0, Math.min(cRect.x + cRect.width, sRect.x + sRect.width) - Math.max(cRect.x, sRect.x)); + } + candidates.push({ index: i, dist, overlap, rect: cRect }); + } + } + + if (candidates.length === 0) { + Logger.info('getMonitorInDirection: no candidates found'); + return -1; + } + + candidates.sort((a, b) => { + if (Math.abs(a.dist - b.dist) > eps) { + return a.dist - b.dist; + } + if (Math.abs(a.overlap - b.overlap) > eps) { + return b.overlap - a.overlap; + } + return a.index - b.index; + }); + + Logger.info(`getMonitorInDirection: best candidate is ${candidates[0].index}`); + return candidates[0].index; + } catch (e) { + Logger.error(`Failed to get monitor in direction ${direction}`, e); + return -1; + } + } } diff --git a/lib/workspace.js b/lib/workspace.js index d0db0a2..324a060 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -1,12 +1,14 @@ import { StateTracker } from './state.js'; +import { Logger } from './logger.js'; /** * WorkspaceLayout: Manages internal state for a specific Meta.Workspace. */ export class WorkspaceLayout { - constructor(workspace, escalator) { + constructor(workspace, controller) { this.workspace = workspace; - this.escalator = escalator; + this.controller = controller; + this.escalator = (controller && controller.escalator) ? controller.escalator : controller; this.monitors = new Map(); } @@ -62,6 +64,44 @@ export class WorkspaceLayout { }).filter(op => op !== null); } + _findClosestBoundaryWindow(targetTracker, direction, sourceRect) { + if (!targetTracker || targetTracker.size === 0) return null; + let candidates = []; + for (const win of targetTracker.windows) { + if (!win) continue; + const targetRect = win.get_frame_rect ? win.get_frame_rect() : { x: 0, y: 0, width: 100, height: 100 }; + + let overlap = 0; + if (direction === 'left' || direction === 'right') { + overlap = Math.min(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height) - Math.max(sourceRect.y, targetRect.y); + } else if (direction === 'up' || direction === 'down') { + overlap = Math.min(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width) - Math.max(sourceRect.x, targetRect.x); + } + candidates.push({ win, rect: targetRect, overlap }); + } + + if (candidates.length === 0) return null; + + candidates.sort((a, b) => { + if (Math.abs(b.overlap - a.overlap) > 0.001) { + return b.overlap - a.overlap; + } + if (direction === 'left' || direction === 'right') { + if (a.rect.y !== b.rect.y) { + return a.rect.y - b.rect.y; + } + return b.rect.x - a.rect.x; + } else { + if (b.rect.x !== a.rect.x) { + return b.rect.x - a.rect.x; + } + return a.rect.y - b.rect.y; + } + }); + + return candidates[0].win; + } + _getTargetWindowInDirection(monitorId, window, direction) { const tracker = this._getTracker(monitorId); const slot = tracker.getSlot(window); @@ -75,9 +115,33 @@ export class WorkspaceLayout { if (!estate) return null; const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); - if (targetSlot === -1) return null; + if (targetSlot !== -1) { + return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; + } - return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; + let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; + Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { + currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); + } + + Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + + if (currentMonitorIndex === -1) return null; + + if (this.controller && this.controller.monitorManager) { + Logger.info(`[DEBUG] calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + Logger.info(`[DEBUG] getMonitorInDirection returned ${adjacentMonitorIndex}`); + Logger.info(`[DEBUG] _getTargetWindowInDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); + if (adjacentMonitorIndex !== -1) { + const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); + const targetTracker = this._getTracker(targetMonitorId); + const sourceRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 100, height: 100 }; + return this._findClosestBoundaryWindow(targetTracker, direction, sourceRect); + } + } + return null; } /** @@ -85,11 +149,67 @@ export class WorkspaceLayout { * Computes orthogonal overlap and distance to determine the best candidate. */ moveWindowDirection(monitorId, window, direction) { - const targetWindow = this._getTargetWindowInDirection(monitorId, window, direction); - if (targetWindow) { - const tracker = this._getTracker(monitorId); - tracker.swapWindows(window, targetWindow); - return true; + const tracker = this._getTracker(monitorId); + const slot = tracker.getSlot(window); + if (slot === undefined) return false; + + const windowCount = tracker.size; + const layout = this.escalator.getLayoutForCount(windowCount); + if (!layout) return false; + + const estate = layout.getEstate(slot); + if (!estate) return false; + + const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + if (targetSlot !== -1) { + const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); + if (targetWindow) { + tracker.swapWindows(window, targetWindow); + return true; + } + } + + let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; + Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { + currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); + } + + Logger.info(`[DEBUG] moveWindowDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + + if (currentMonitorIndex === -1) return false; + + if (this.controller && this.controller.monitorManager) { + Logger.info(`[DEBUG] calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + Logger.info(`[DEBUG] getMonitorInDirection returned ${adjacentMonitorIndex}`); + Logger.info(`[DEBUG] moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); + if (adjacentMonitorIndex !== -1) { + const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); + + tracker.untrack(window); + + const targetTracker = this._getTracker(targetMonitorId); + targetTracker.track(window, targetTracker.size); + + if (this.controller._windowWrappers) { + const wrapper = this.controller._windowWrappers.get(window); + if (wrapper) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = adjacentMonitorIndex; + } + } + + if (window.move_to_monitor) { + window.move_to_monitor(adjacentMonitorIndex); + } + + if (this.controller._scheduleRetile) { + this.controller._scheduleRetile(this.workspace, monitorId, currentMonitorIndex); + this.controller._scheduleRetile(this.workspace, targetMonitorId, adjacentMonitorIndex); + } + return true; + } } return false; } @@ -222,7 +342,7 @@ export class WorkspaceManager { getLayout(workspace) { if (!this.layouts.has(workspace)) { - this.layouts.set(workspace, new WorkspaceLayout(workspace, this.controller.escalator)); + this.layouts.set(workspace, new WorkspaceLayout(workspace, this.controller)); } return this.layouts.get(workspace); } @@ -254,4 +374,78 @@ export class WorkspaceManager { windows[0].activate(global.get_current_time()); } } + + closeMonitorWindows(monitorIndex, includeMinimized) { + const workspace = global.workspace_manager.get_active_workspace(); + if (!workspace) return; + this.controller.setBatchMode(true); + const windows = workspace.list_windows(); + windows.forEach(w => { + if (w.get_monitor() === monitorIndex && (!w.minimized || includeMinimized)) { + w.delete(global.get_current_time()); + } + }); + this.controller.setBatchMode(false); + this.controller.hydrate(workspace); + } + + switchMonitors(activeMonitorIndex) { + const workspace = global.workspace_manager.get_active_workspace(); + if (!workspace) return; + + const manager = global.backend.get_monitor_manager(); + const numMonitors = manager.get_logical_monitors().length; + if (numMonitors < 2) return; + + let targetMonitorIndex; + if (numMonitors === 2) { + targetMonitorIndex = activeMonitorIndex === 0 ? 1 : 0; + } else { + const primaryIndex = global.display.get_primary_monitor(); + if (activeMonitorIndex === primaryIndex) return; + targetMonitorIndex = primaryIndex; + } + + this.controller.setBatchMode(true); + const windows = workspace.list_windows(); + windows.forEach(w => { + const m = w.get_monitor(); + if (m === activeMonitorIndex) { + w.move_to_monitor(targetMonitorIndex); + } else if (m === targetMonitorIndex) { + w.move_to_monitor(activeMonitorIndex); + } + }); + this.controller.setBatchMode(false); + this.controller.hydrate(workspace); + } + + portMonitorToWorkspace(monitorIndex, direction) { + const activeWorkspaceIndex = global.workspace_manager.get_active_workspace_index(); + const numWorkspaces = global.workspace_manager.n_workspaces; + let targetIndex = activeWorkspaceIndex; + + if (direction === 'left' && activeWorkspaceIndex > 0) { + targetIndex--; + } else if (direction === 'right' && activeWorkspaceIndex < numWorkspaces - 1) { + targetIndex++; + } + + if (targetIndex === activeWorkspaceIndex) return; + + const targetWorkspace = global.workspace_manager.get_workspace_by_index(targetIndex); + const activeWorkspace = global.workspace_manager.get_active_workspace(); + + this.controller.setBatchMode(true); + const windows = activeWorkspace.list_windows(); + windows.forEach(w => { + if (w.get_monitor() === monitorIndex) { + w.change_workspace(targetWorkspace); + } + }); + this.controller.setBatchMode(false); + + this.controller.hydrate(activeWorkspace); + this.controller.hydrate(targetWorkspace); + } } diff --git a/multi_monitor_refactoring_plan.md b/multi_monitor_refactoring_plan.md new file mode 100644 index 0000000..f130473 --- /dev/null +++ b/multi_monitor_refactoring_plan.md @@ -0,0 +1,423 @@ +# Multi-Monitor Refactoring Plan + +## Architecture Analysis & Responsibility Shifts + +This section details updates to files and classes to support workspaces spanning multiple displays. + +| File Path | Target Class | Role & Proposed Architecture Updates | +|---|---|---| +| `lib/workspace.js` | `WorkspaceLayout` | Allocates slots per workspace and monitor. Handles cross-monitor actions (like switching monitors, closing monitor windows, and workspace porting) and integrates cross-monitor navigation fallback by querying adjacent monitor state trackers when spatial boundaries are reached. | +| `lib/drag.js` | `DragManager` | Tracks drag positions. Implements cross-monitor pointer-to-slot mapping, renders visual indicators on target monitors, and coordinates visual swaps across monitor boundaries. | +| `lib/keybindings.js` | `KeybindingManager` | Resolves and binds keyboard shortcuts. Delegates focus-switching and window-movement actions to the controller, supporting boundary traversal to adjacent displays. | +| `lib/monitor.js` | `MonitorManager` | Tracks display topologies. Listens to hardware changes via backend signals, and manages transient storage for windows evicted by monitor unplug events (leaving cross-monitor actions to Workspace). | +| `lib/controller.js` | `TilingController` | Orchestrates operations. Coordinates window state changes between workspace layouts, coordinates drag state initialization/termination, and dispatches batch updates to avoid layout thrashing. | + +### Responsibility Changes + +* `WorkspaceLayout` / `WorkspaceManager`: Tracks logical state mapped per monitor ID. Spatial search fallbacks to adjacent monitors when keyboard navigation hits display edges. Handles cross-monitor actions (switching monitors, closing monitor windows, workspace porting). +* `DragManager`: Evaluates pointer location globally. Renders indicators relative to target displays and manages preview transitions across screens. +* `MonitorManager`: Listens to topology updates, matching logical monitors to stable physical IDs. Manages transient storage/metadata for windows evicted by monitor unplug events (uses existing evacuation logic, does not destroy windows, preserves state). +* `TilingController`: Dispatches coordinates and sizes to the correct target workspace layout based on pointer position or active focus. + +--- + +## Core Multi-Monitor Scenarios + +### Dynamic Handling of Monitor Hotplug Events + +* Proposed GNOME Shell Signals: + * `monitors-changed`: Connected via `global.backend.get_monitor_manager()` to detect display hardware hotplug and configuration updates. + * `size-changed`: Connected on tracked windows or workspace boundaries to trigger retiling upon size changes or display resolution alterations. +* Evacuation Logic: + * Detects removed displays by comparing active stable monitor IDs against cached IDs. + * Use existing logic; do not destroy windows, preserve state. Specifically, minimize windows instead of deleting/destroying them, untrack them, and record their original monitor, workspace, and slot in `MonitorManager._evacuatedWindows` for later restoration. +* Restoration Logic: + * Restores minimized windows to their original slots when matching displays reconnect. + * Triggers workspace hydration to update tiling allocations on target displays. + +### Cross-Monitor Drag-and-Drop + +* Pointer Intersection Tracking: + * Resolves absolute coordinates from `global.get_pointer()` during window drag. + * Queries `global.display.get_monitor_index_for_rect` to identify the monitor containing the pointer. +* Visual Drop Feedback: + * Renders a `St.Widget` backdrop overlay within the resolved slot geometry on the target display. + * Applies visual preview layout updates on the target display by shifting target windows out of the hovered slot. + +### Keyboard Shortcuts for Cross-Monitor Focus and Movement + +* Focus Navigation Fallback: + * Triggers when intra-monitor focus search returns no window in the requested direction. + * Locates the adjacent monitor index and targets the corresponding slot tracker. + * Focuses the boundary window on the adjacent display. + * Goalslot fallback: Resolve via adjacent edge. If multiple windows intersect, pick the one with highest overlap. If equal overlap, pick the top/right one. +* Window Transference: + * Moves the active window to the adjacent monitor when moving past the monitor border. + * Registers the window with the target monitor's state tracker and triggers retiling on both screens. + * Shift cross-monitor actions (like moving windows across monitors, switching monitors, closing monitor windows, workspace porting) from Monitor to Workspace. + +--- + +## Mermaid Diagrams + +### Hotplug Event Handling Sequence + +```mermaid +sequenceDiagram + participant MM as global.backend.get_monitor_manager() + participant MonM as MonitorManager + participant TC as TilingController + participant WL as WorkspaceLayout + participant WW as WindowWrapper + + MM->>MonM: monitors-changed + activate MonM + MonM->>MonM: Detect monitor addition/removal + alt Monitor Removed + MonM->>WW: Evacuate window (minimize/store metadata) + MonM->>WL: Untrack window + else Monitor Added + MonM->>WW: Restore window (unminimize/update monitor index) + MonM->>WL: Track window on target monitor + end + MonM->>TC: hydrate() + deactivate MonM + activate TC + TC->>WL: getRetileOperations() + WL-->>TC: Return window rectangles + TC->>WW: applyGeometry() + deactivate TC +``` + +### Cross-Monitor Drag-and-Drop Sequence + +```mermaid +sequenceDiagram + actor User + participant Win as Meta.Window + participant DM as DragManager + participant TC as TilingController + participant WL as WorkspaceLayout + participant Ind as DragIndicator + + User->>Win: Starts dragging window + DM->>Win: Connect position-changed signal + loop Every position-changed event + Win->>DM: position-changed + activate DM + DM->>DM: Resolve pointer coordinates (global.get_pointer()) + DM->>DM: Determine active monitor under pointer + DM->>WL: getSlotAtPointer(monitorId, x, y) + WL-->>DM: Target slot index + alt Valid slot on target monitor + DM->>Ind: Position and size indicator to slot boundaries + DM->>Ind: show() + DM->>DM: Apply visual swap preview + else Out of bounds + DM->>Ind: hide() + DM->>DM: Revert visual swap preview + end + deactivate DM + end + User->>Win: Releases window + activate DM + DM->>Win: Disconnect position-changed signal + DM->>Ind: destroy() + DM->>WL: swapWindowByPointer() or trackWindow() on target monitor + DM->>TC: _scheduleRetile() for source and target monitors + deactivate DM +``` + +--- + +## Pseudo-code + +### Pointer-to-Slot Mapping and Indicator Rendering + +```javascript +// Located in lib/drag.js +_handlePositionChanged(wrapper, layout, tracker, originalSlot, indicator) { + const [pointerX, pointerY] = global.get_pointer(); + const gaps = this.controller.settings.getGaps(); + const workspace = wrapper.workspace; + + // Identify monitor matching pointer coordinates + const monitorIndex = global.display.get_monitor_index_for_rect({ + x: pointerX, + y: pointerY, + width: 1, + height: 1 + }); + + // Guard against out-of-bounds monitor index + if (monitorIndex === -1) { + indicator.hide(); + const fallbackMonitorIndex = global.display.get_current_monitor(); + const fallbackRect = workspace.get_work_area_for_monitor(fallbackMonitorIndex); + this._revertVisualSwap(tracker, layout, fallbackRect, gaps); + return; + } + + const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); + const targetLayout = this.controller.workspaceManager.getLayout(workspace); + const targetTracker = targetLayout._getTracker(monitorId); + const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); + + let targetRect = null; + let hoveredSlot = -1; + + // Handle empty monitor drop target explicitly + if (targetTracker.size === 0) { + hoveredSlot = 0; + targetRect = { + x: monitorRect.x + gaps.outer, + y: monitorRect.y + gaps.outer, + width: monitorRect.width - (gaps.outer * 2), + height: monitorRect.height - (gaps.outer * 2) + }; + } else { + hoveredSlot = targetLayout.getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps); + if (hoveredSlot !== -1) { + const matrix = targetLayout.escalator.getLayoutForCount(targetTracker.size); + const estate = matrix.getEstate(hoveredSlot); + if (estate) { + targetRect = estate.toAbsolute(monitorRect, gaps); + } else { + hoveredSlot = -1; + } + } + } + + if (hoveredSlot !== -1 && targetRect) { + // Update indicator layout coordinates and display + indicator.set_position(targetRect.x, targetRect.y); + indicator.set_size(targetRect.width, targetRect.height); + if (indicator._bg) { + indicator._bg.set_size(targetRect.width, targetRect.height); + } + indicator.show(); + + // Apply preview layout modifications + if (monitorId !== wrapper.monitorId) { + this._applyCrossMonitorVisualSwap(wrapper, targetTracker, targetLayout, hoveredSlot, monitorRect, gaps); + } else { + this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); + } + } else { + indicator.hide(); + this._revertVisualSwap(tracker, layout, monitorRect, gaps); + } +} +``` + +### Cross-Monitor Keyboard Navigation Fallback + +```javascript +// Located in lib/workspace.js +_getTargetWindowInDirection(monitorId, window, direction) { + const tracker = this._getTracker(monitorId); + const slot = tracker.getSlot(window); + if (slot === undefined) return null; + + const windowCount = tracker.size; + const layout = this.escalator.getLayoutForCount(windowCount); + if (!layout) return null; + + const estate = layout.getEstate(slot); + if (!estate) return null; + + // Evaluate spatial targets within same monitor + const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + if (targetSlot !== -1) { + return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; + } + + // Fetch boundary adjacent display + const currentMonitorIndex = this.workspace.get_display().get_monitor_index_for_rect(window.get_frame_rect()); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + if (adjacentMonitorIndex === -1) return null; + + const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); + const targetTracker = this._getTracker(targetMonitorId); + if (targetTracker.size === 0) return null; + + // Match coordinate boundary overlap on adjacent display via adjacent edge + return this._findClosestBoundaryWindow(targetTracker, direction, window.get_frame_rect()); +} + +_findClosestBoundaryWindow(targetTracker, direction, sourceRect) { + let candidates = []; + for (const win of targetTracker.windows) { + if (!win || win.unmanaged) continue; + const targetRect = win.get_frame_rect(); + + let overlap = 0; + if (direction === 'left' || direction === 'right') { + // Overlap along Y axis + overlap = Math.max(0, Math.min(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height) - Math.max(sourceRect.y, targetRect.y)); + } else if (direction === 'up' || direction === 'down') { + // Overlap along X axis + overlap = Math.max(0, Math.min(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width) - Math.max(sourceRect.x, targetRect.x)); + } + + if (overlap > 0) { + candidates.push({ win, rect: targetRect, overlap }); + } + } + + if (candidates.length === 0) return null; + + candidates.sort((a, b) => { + if (b.overlap !== a.overlap) { + return b.overlap - a.overlap; // Highest overlap first + } + // If equal overlap, pick the top/right one + if (direction === 'left' || direction === 'right') { + return a.rect.y - b.rect.y; // Top-most (smaller Y) + } else { + return b.rect.x - a.rect.x; // Right-most (larger X) + } + }); + + return candidates[0].win; +} +``` + +### Cross-Monitor Keyboard Window Movement + +```javascript +// Located in lib/workspace.js +moveWindowDirection(monitorId, window, direction) { + const tracker = this._getTracker(monitorId); + const slot = tracker.getSlot(window); + if (slot === undefined) return false; + + const windowCount = tracker.size; + const layout = this.escalator.getLayoutForCount(windowCount); + if (!layout) return false; + + const estate = layout.getEstate(slot); + if (!estate) return false; + + // Evaluate spatial targets within same monitor + const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + if (targetSlot !== -1) { + const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); + if (targetWindow) { + tracker.swapWindows(window, targetWindow); + this.controller.scheduleRetile(this.workspace, monitorId); + return true; + } + } + + // Fallback: cross-monitor window movement + const currentMonitorIndex = this.workspace.get_display().get_monitor_index_for_rect(window.get_frame_rect()); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + if (adjacentMonitorIndex === -1) return false; + + const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); + + // Untrack from source monitor tracker + tracker.untrack(window); + + // Track on target monitor tracker + const targetTracker = this._getTracker(targetMonitorId); + targetTracker.track(window, targetTracker.size); + + // Update window wrapper cache/metadata + const wrapper = this.controller.getWindowWrapper(window); + if (wrapper) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = adjacentMonitorIndex; + } + + // Physical transfer using GNOME Shell API + window.move_to_monitor(adjacentMonitorIndex); + + // Schedule retiles on both source and target monitors + this.controller.scheduleRetile(this.workspace, monitorId); + this.controller.scheduleRetile(this.workspace, targetMonitorId); + + return true; +} +``` + +### Cross-Monitor Workspace Actions (Shifted from MonitorManager) + +```javascript +// Located in lib/workspace.js (WorkspaceLayout / WorkspaceManager) +closeMonitorWindows(monitorIndex, includeMinimized) { + const workspace = this.workspace || global.workspace_manager.get_active_workspace(); + if (!workspace) return; + this.controller.setBatchMode(true); + const windows = workspace.list_windows(); + windows.forEach(w => { + if (w.get_monitor() === monitorIndex && (!w.minimized || includeMinimized)) { + w.delete(global.get_current_time()); + } + }); + this.controller.setBatchMode(false); + this.controller.hydrate(workspace); +} + +switchMonitors(activeMonitorIndex) { + const workspace = this.workspace || global.workspace_manager.get_active_workspace(); + if (!workspace) return; + + const manager = global.backend.get_monitor_manager(); + const numMonitors = manager.get_logical_monitors().length; + if (numMonitors < 2) return; + + let targetMonitorIndex; + if (numMonitors === 2) { + targetMonitorIndex = activeMonitorIndex === 0 ? 1 : 0; + } else { + const primaryIndex = global.display.get_primary_monitor(); + if (activeMonitorIndex === primaryIndex) return; + targetMonitorIndex = primaryIndex; + } + + this.controller.setBatchMode(true); + const windows = workspace.list_windows(); + windows.forEach(w => { + const m = w.get_monitor(); + if (m === activeMonitorIndex) { + w.move_to_monitor(targetMonitorIndex); + } else if (m === targetMonitorIndex) { + w.move_to_monitor(activeMonitorIndex); + } + }); + this.controller.setBatchMode(false); + this.controller.hydrate(workspace); +} + +portMonitorToWorkspace(monitorIndex, direction) { + const activeWorkspaceIndex = global.workspace_manager.get_active_workspace_index(); + const numWorkspaces = global.workspace_manager.n_workspaces; + let targetIndex = activeWorkspaceIndex; + + if (direction === 'left' && activeWorkspaceIndex > 0) { + targetIndex--; + } else if (direction === 'right' && activeWorkspaceIndex < numWorkspaces - 1) { + targetIndex++; + } + + if (targetIndex === activeWorkspaceIndex) return; + + const targetWorkspace = global.workspace_manager.get_workspace_by_index(targetIndex); + const activeWorkspace = this.workspace || global.workspace_manager.get_active_workspace(); + + this.controller.setBatchMode(true); + const windows = activeWorkspace.list_windows(); + windows.forEach(w => { + if (w.get_monitor() === monitorIndex) { + w.change_workspace(targetWorkspace); + } + }); + this.controller.setBatchMode(false); + + this.controller.hydrate(activeWorkspace); + this.controller.hydrate(targetWorkspace); +} +``` + diff --git a/tests/drag.test.js b/tests/drag.test.js new file mode 100644 index 0000000..a6c8fd3 --- /dev/null +++ b/tests/drag.test.js @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TilingController } from '../lib/controller.js'; +import { LayoutParser } from '../lib/layout.js'; +import Meta from 'gi://Meta'; + +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; + +describe('DragManager Cross-Monitor', () => { + let controller; + let dragManager; + let ws; + + beforeEach(() => { + vi.clearAllMocks(); + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + vi.mocked(manager.get_primary_monitor).mockReturnValue(0); + + global.display.get_primary_monitor = () => manager.get_primary_monitor(); + global.display.get_current_monitor = vi.fn(() => { + const [x, y] = global.get_pointer ? global.get_pointer() : [0, 0]; + if (x >= 1000) return 1; + return 0; + }); + + TilingController.activeInstance = null; + controller = new TilingController(); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + controller.monitorManager.initializeMonitorState(); + dragManager = controller.dragManager; + + ws = { + id: 'ws1', + get_work_area_for_monitor: vi.fn((idx) => { + if (idx === 1) { + return { x: 1000, y: 0, width: 1000, height: 1000 }; + } + return { x: 0, y: 0, width: 1000, height: 1000 }; + }), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }) + }; + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const createMockWindow = (id, workspace, initialMonitor) => { + let monitor = initialMonitor; + return { + id, + get_workspace: () => workspace, + get_monitor: vi.fn(() => monitor), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }), + get_frame_rect: () => ({ + x: monitor === 1 ? 1010 : 10, + y: 10, + width: 100, + height: 100 + }), + move_resize_frame: vi.fn(), + move_to_monitor: vi.fn((m) => { monitor = m; }), + get_title: () => `Window ${id}`, + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false, + connect: vi.fn(() => 123), + disconnect: vi.fn(), + handler_is_connected: vi.fn(() => true), + minimize: vi.fn(function() { this.minimized = true; }), + unminimize: vi.fn(function() { this.minimized = false; }), + delete: vi.fn() + }; + }; + + it('should initialize and cleanup active drag state', () => { + const win = createMockWindow(1, ws, 0); + controller.tilingRequest(win); + + dragManager.startDragTracking(win); + expect(dragManager._activeDrag).toBeDefined(); + expect(dragManager._activeDrag.window).toBe(win); + expect(dragManager._activeDrag.lastHoveredSlot).toBe(-1); + expect(dragManager._activeDrag.lastHoveredMonitorId).toBeNull(); + + dragManager.endDragTracking(win); + expect(dragManager._activeDrag).toBeNull(); + }); + + it('should map pointer to slot and position indicator on same monitor', () => { + const win1 = createMockWindow(1, ws, 0); + const win2 = createMockWindow(2, ws, 0); + controller.tilingRequest(win1); + controller.tilingRequest(win2); + + let posChangedCb; + win1.connect = vi.fn((event, cb) => { + if (event === 'position-changed') posChangedCb = cb; + return 123; + }); + + dragManager.startDragTracking(win1); + expect(posChangedCb).toBeDefined(); + + // Hover over slot 1 on DP-1 (same monitor) + global.get_pointer = vi.fn(() => [600, 500]); // right side of monitor-0 + + posChangedCb(); + + expect(dragManager._activeDrag.lastHoveredSlot).toBe(1); + expect(dragManager._activeDrag.lastHoveredMonitorId).toBe('monitor-0'); + expect(dragManager._activeDrag.indicator).toBeDefined(); + }); + + it('should map pointer to slot and position indicator on different monitor', () => { + const win1 = createMockWindow(1, ws, 0); // on monitor-0 + const win2 = createMockWindow(2, ws, 1); // on monitor-1 + controller.tilingRequest(win1); + controller.tilingRequest(win2); + + let posChangedCb; + win1.connect = vi.fn((event, cb) => { + if (event === 'position-changed') posChangedCb = cb; + return 123; + }); + + dragManager.startDragTracking(win1); + + // Hover on different monitor (HDMI-1) at slot 0 (which has win2) + global.get_pointer = vi.fn(() => [1200, 500]); // coordinates on monitor-1 + + posChangedCb(); + + expect(dragManager._activeDrag.lastHoveredSlot).toBe(0); + expect(dragManager._activeDrag.lastHoveredMonitorId).toBe('monitor-1'); + }); + + it('should handle cross-monitor drop behavior', () => { + const win1 = createMockWindow(1, ws, 0); // monitor-0 + const win2 = createMockWindow(2, ws, 1); // monitor-1 + controller.tilingRequest(win1); + controller.tilingRequest(win2); + + let posChangedCb; + win1.connect = vi.fn((event, cb) => { + if (event === 'position-changed') posChangedCb = cb; + return 123; + }); + + dragManager.startDragTracking(win1); + + // Hover on different monitor (HDMI-1) at slot 0 + global.get_pointer = vi.fn(() => [1200, 500]); + posChangedCb(); + + vi.spyOn(controller, '_scheduleRetile'); + + dragManager.endDragTracking(win1); + + // Verify window physical movement + expect(win1.move_to_monitor).toHaveBeenCalledWith(1); + + // Verify window tracked on target and untracked on source + const layout = controller.workspaceManager.getLayout(ws); + const sourceTracker = layout._getTracker('monitor-0'); + const targetTracker = layout._getTracker('monitor-1'); + + expect(sourceTracker.getSlot(win1)).toBeUndefined(); + expect(targetTracker.getSlot(win1)).toBe(0); // target slot preferred slot 0 + + // Verify wrapper monitor details updated + const wrapper = controller._windowWrappers.get(win1); + expect(wrapper.monitorId).toBe('monitor-1'); + expect(wrapper.monitorIndex).toBe(1); + + // Verify schedule retiles called on both + expect(controller._scheduleRetile).toHaveBeenCalledWith(ws, 'monitor-0', 0); + expect(controller._scheduleRetile).toHaveBeenCalledWith(ws, 'monitor-1', 1); + }); + + it('should handle empty monitor target drop', () => { + const win1 = createMockWindow(1, ws, 0); // monitor-0 + controller.tilingRequest(win1); + + // target monitor-1 starts empty + + let posChangedCb; + win1.connect = vi.fn((event, cb) => { + if (event === 'position-changed') posChangedCb = cb; + return 123; + }); + + dragManager.startDragTracking(win1); + + // Hover on empty monitor-1 + global.get_pointer = vi.fn(() => [1200, 500]); + posChangedCb(); + + // Check active drag preview mapping + expect(dragManager._activeDrag.lastHoveredSlot).toBe(0); + expect(dragManager._activeDrag.lastHoveredMonitorId).toBe('monitor-1'); + + dragManager.endDragTracking(win1); + + // Verify window tracked on target monitor-1 slot 0 + const layout = controller.workspaceManager.getLayout(ws); + const targetTracker = layout._getTracker('monitor-1'); + expect(targetTracker.getSlot(win1)).toBe(0); + }); +}); diff --git a/tests/monitor.test.js b/tests/monitor.test.js index 2965feb..b669388 100644 --- a/tests/monitor.test.js +++ b/tests/monitor.test.js @@ -30,6 +30,7 @@ describe('MonitorManager', () => { _windowWrappers: new Map(), _restoringWindows: new Set(), updateWindowWrapperMonitor: vi.fn(), + addRestoringWindow: vi.fn(), tilingRequest: vi.fn() }; @@ -44,53 +45,94 @@ describe('MonitorManager', () => { monitorManager.initializeMonitorState(); }); - it('should close all windows on a monitor', () => { - const win1 = { delete: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false, minimized: false }; - const win2 = { delete: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false, minimized: false }; - const win3 = { delete: vi.fn(), get_monitor: () => 1, is_skip_taskbar: () => false, minimized: false }; - const ws = { list_windows: () => [win1, win2, win3] }; - global.workspace_manager.get_active_workspace.mockReturnValue(ws); - monitorManager.closeMonitorWindows(0, false); - - expect(controller.setBatchMode).toHaveBeenCalledWith(true); - expect(controller.setBatchMode).toHaveBeenCalledWith(false); - expect(win1.delete).toHaveBeenCalled(); - expect(win2.delete).toHaveBeenCalled(); - expect(win3.delete).not.toHaveBeenCalled(); - expect(controller.hydrate).toHaveBeenCalled(); + + it('should evacuate window when its monitor is disconnected', () => { + const mockWin = { + unmanaged: false, + minimized: false, + minimize: vi.fn() + }; + const mockWorkspace = { index: () => 0 }; + const mockWrapper = { + title: 'Test Window', + monitorId: 'monitor-1', + workspace: mockWorkspace + }; + + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] } + ]); + + const mockTracker = { + getSlot: vi.fn().mockReturnValue(2) + }; + const mockGrid = { + _getTracker: vi.fn().mockReturnValue(mockTracker), + untrackWindow: vi.fn() + }; + controller.workspaceManager.getLayout.mockReturnValue(mockGrid); + + const evacuated = monitorManager.checkEvacuation(mockWin, mockWrapper, 'monitor-0', mockWorkspace); + + expect(evacuated).toBe(true); + expect(mockWin.minimize).toHaveBeenCalled(); + expect(mockGrid._getTracker).toHaveBeenCalledWith('monitor-1'); + expect(mockGrid.untrackWindow).toHaveBeenCalledWith(mockWin, 'monitor-1'); + expect(monitorManager.isEvacuated(mockWin)).toBe(true); }); - it('should switch monitors for all windows', () => { - const win1 = { move_to_monitor: vi.fn(), get_monitor: () => 0, minimized: false, is_skip_taskbar: () => false }; - const win2 = { move_to_monitor: vi.fn(), get_monitor: () => 1, minimized: false, is_skip_taskbar: () => false }; - const ws = { list_windows: () => [win1, win2] }; - global.workspace_manager.get_active_workspace.mockReturnValue(ws); + it('should restore evacuated window when its monitor is reconnected', () => { + const mockWin = { + unmanaged: false, + minimized: true, + unminimize: vi.fn(), + move_to_monitor: vi.fn() + }; + const mockWorkspace = { index: () => 0 }; + const mockInfo = { + monitorId: 'monitor-1', + workspace: mockWorkspace, + slot: 2 + }; + monitorManager._evacuatedWindows.set(mockWin, mockInfo); - monitorManager.switchMonitors(0, 1); - - expect(controller.setBatchMode).toHaveBeenCalledWith(true); - expect(controller.setBatchMode).toHaveBeenCalledWith(false); - expect(win1.move_to_monitor).toHaveBeenCalledWith(1); - expect(win2.move_to_monitor).toHaveBeenCalledWith(0); + monitorManager._lastMonitorCount = 1; + monitorManager._knownMonitorIds = new Set(['monitor-0']); + + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + + monitorManager.handleMonitorsChanged(); + + expect(mockWin.move_to_monitor).toHaveBeenCalledWith(1); + expect(mockWin.unminimize).toHaveBeenCalled(); + expect(controller.updateWindowWrapperMonitor).toHaveBeenCalledWith(mockWin, 'monitor-1', 1); + expect(controller.addRestoringWindow).toHaveBeenCalledWith(mockWin, 2); + expect(monitorManager.isEvacuated(mockWin)).toBe(false); expect(controller.hydrate).toHaveBeenCalled(); + expect(monitorManager._lastMonitorCount).toBe(2); + expect(monitorManager._knownMonitorIds.has('monitor-1')).toBe(true); }); - it('should port all monitor windows to another workspace', () => { - const win1 = { change_workspace: vi.fn(), get_monitor: () => 0, minimized: false, is_skip_taskbar: () => false }; - const win2 = { change_workspace: vi.fn(), get_monitor: () => 1, minimized: false, is_skip_taskbar: () => false }; - const sourceWorkspace = { list_windows: () => [win1, win2] }; - const targetWorkspace = { list_windows: () => [] }; - - global.workspace_manager.get_active_workspace.mockReturnValue(sourceWorkspace); - global.workspace_manager.get_workspace_by_index.mockReturnValue(targetWorkspace); + it('should find adjacent monitor in direction using logical geometries', () => { + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { rect: { x: 0, y: 0, width: 1920, height: 1080 }, get_monitors: () => [] }, + { rect: { x: 1920, y: 0, width: 1920, height: 1080 }, get_monitors: () => [] } + ]); - monitorManager.portMonitorToWorkspace(0, 'right'); - - expect(controller.setBatchMode).toHaveBeenCalledWith(true); - expect(controller.setBatchMode).toHaveBeenCalledWith(false); - expect(win1.change_workspace).toHaveBeenCalledWith(targetWorkspace); - expect(win2.change_workspace).not.toHaveBeenCalled(); - expect(controller.hydrate).toHaveBeenCalled(); + const targetRight = monitorManager.getMonitorInDirection(0, 'right'); + expect(targetRight).toBe(1); + + const targetLeft = monitorManager.getMonitorInDirection(1, 'left'); + expect(targetLeft).toBe(0); + + const targetUp = monitorManager.getMonitorInDirection(0, 'up'); + expect(targetUp).toBe(-1); }); }); diff --git a/tests/setup.js b/tests/setup.js index 89a5887..af4c822 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -76,6 +76,7 @@ global.display = { get_current_monitor: vi.fn(() => 0), get_focus_window: vi.fn(() => null), list_all_windows: vi.fn(() => []), + get_monitor_index_for_rect: vi.fn(() => -1), connect: vi.fn(), disconnect: vi.fn() }; diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 02e6622..08883eb 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -216,6 +216,12 @@ describe('WorkspaceManager', () => { beforeEach(() => { global.get_current_time = vi.fn(() => 1234); + global.workspace_manager = { + get_active_workspace: vi.fn(), + get_active_workspace_index: vi.fn(() => 0), + get_workspace_by_index: vi.fn(), + n_workspaces: 4 + }; controller = { setBatchMode: vi.fn(), retileAll: vi.fn(), @@ -259,4 +265,180 @@ describe('WorkspaceManager', () => { expect(controller.hydrate).toHaveBeenCalledWith(ws); expect(win1.activate).toHaveBeenCalledWith(1234); }); + + it('should close all windows on a monitor', () => { + const win1 = { delete: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false, minimized: false }; + const win2 = { delete: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false, minimized: false }; + const win3 = { delete: vi.fn(), get_monitor: () => 1, is_skip_taskbar: () => false, minimized: false }; + const ws = { list_windows: () => [win1, win2, win3] }; + global.workspace_manager.get_active_workspace.mockReturnValue(ws); + + manager.closeMonitorWindows(0, false); + + expect(controller.setBatchMode).toHaveBeenCalledWith(true); + expect(controller.setBatchMode).toHaveBeenCalledWith(false); + expect(win1.delete).toHaveBeenCalled(); + expect(win2.delete).toHaveBeenCalled(); + expect(win3.delete).not.toHaveBeenCalled(); + expect(controller.hydrate).toHaveBeenCalledWith(ws); + }); + + it('should switch monitors for all windows', () => { + const win1 = { move_to_monitor: vi.fn(), get_monitor: () => 0, minimized: false, is_skip_taskbar: () => false }; + const win2 = { move_to_monitor: vi.fn(), get_monitor: () => 1, minimized: false, is_skip_taskbar: () => false }; + const ws = { list_windows: () => [win1, win2] }; + global.workspace_manager.get_active_workspace.mockReturnValue(ws); + + const mockManager = { + get_logical_monitors: () => [{}, {}] + }; + global.backend = { + get_monitor_manager: () => mockManager + }; + + manager.switchMonitors(0); + + expect(controller.setBatchMode).toHaveBeenCalledWith(true); + expect(controller.setBatchMode).toHaveBeenCalledWith(false); + expect(win1.move_to_monitor).toHaveBeenCalledWith(1); + expect(win2.move_to_monitor).toHaveBeenCalledWith(0); + expect(controller.hydrate).toHaveBeenCalledWith(ws); + }); + + it('should port all monitor windows to another workspace', () => { + const win1 = { change_workspace: vi.fn(), get_monitor: () => 0, minimized: false, is_skip_taskbar: () => false }; + const win2 = { change_workspace: vi.fn(), get_monitor: () => 1, minimized: false, is_skip_taskbar: () => false }; + const sourceWorkspace = { list_windows: () => [win1, win2] }; + const targetWorkspace = { list_windows: () => [] }; + + global.workspace_manager.get_active_workspace.mockReturnValue(sourceWorkspace); + global.workspace_manager.get_workspace_by_index.mockReturnValue(targetWorkspace); + + manager.portMonitorToWorkspace(0, 'right'); + + expect(controller.setBatchMode).toHaveBeenCalledWith(true); + expect(controller.setBatchMode).toHaveBeenCalledWith(false); + expect(win1.change_workspace).toHaveBeenCalledWith(targetWorkspace); + expect(win2.change_workspace).not.toHaveBeenCalled(); + expect(controller.hydrate).toHaveBeenCalledWith(sourceWorkspace); + expect(controller.hydrate).toHaveBeenCalledWith(targetWorkspace); + }); +}); + +describe('WorkspaceLayout Cross-Monitor Fallback', () => { + const defaultJson = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; + const escalator = LayoutParser.parse(defaultJson); + + describe('_findClosestBoundaryWindow', () => { + it('should choose the window with highest overlap on adjacent edge', () => { + const layout = new WorkspaceLayout({}, escalator); + const targetTracker = { + size: 2, + windows: [ + { get_frame_rect: () => ({ x: 1000, y: 0, width: 500, height: 400 }) }, + { get_frame_rect: () => ({ x: 1000, y: 300, width: 500, height: 700 }) } + ] + }; + const sourceRect = { x: 0, y: 100, width: 1000, height: 400 }; + + const best = layout._findClosestBoundaryWindow(targetTracker, 'right', sourceRect); + expect(best).toBe(targetTracker.windows[0]); + }); + + it('should resolve ties using top-most/right-most tie breakers', () => { + const layout = new WorkspaceLayout({}, escalator); + const targetTrackerY = { + size: 2, + windows: [ + { get_frame_rect: () => ({ x: 1000, y: 200, width: 500, height: 300 }) }, + { get_frame_rect: () => ({ x: 1000, y: 100, width: 500, height: 300 }) } + ] + }; + const sourceRectY = { x: 0, y: 200, width: 1000, height: 200 }; + const bestY = layout._findClosestBoundaryWindow(targetTrackerY, 'right', sourceRectY); + expect(bestY).toBe(targetTrackerY.windows[1]); + + const targetTrackerX = { + size: 2, + windows: [ + { get_frame_rect: () => ({ x: 100, y: 1000, width: 300, height: 500 }) }, + { get_frame_rect: () => ({ x: 200, y: 1000, width: 300, height: 500 }) } + ] + }; + const sourceRectX = { x: 200, y: 0, width: 200, height: 1000 }; + const bestX = layout._findClosestBoundaryWindow(targetTrackerX, 'down', sourceRectX); + expect(bestX).toBe(targetTrackerX.windows[1]); + }); + }); + + it('should fall back to cross-monitor focus when intra-monitor search fails', () => { + const mockMonitorManager = { + getMonitorIndex: vi.fn(id => id === 'monitor-0' ? 0 : 1), + getMonitorInDirection: vi.fn((idx, dir) => idx === 0 && dir === 'right' ? 1 : -1), + getMonitorId: vi.fn(idx => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + const controller = { + escalator: escalator, + monitorManager: mockMonitorManager + }; + const layout = new WorkspaceLayout({}, controller); + + const win0 = { + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 0, width: 1000, height: 1000 }) + }; + const win1 = { + get_monitor: () => 1, + get_frame_rect: () => ({ x: 1000, y: 0, width: 1000, height: 1000 }), + activate: vi.fn() + }; + + layout.trackWindow(win0, 'monitor-0'); + layout.trackWindow(win1, 'monitor-1'); + + const result = layout.focusWindowDirection('monitor-0', win0, 'right'); + expect(result).toBe(true); + expect(win1.activate).toHaveBeenCalled(); + }); + + it('should fall back to cross-monitor movement when intra-monitor swap fails', () => { + const mockMonitorManager = { + getMonitorIndex: vi.fn(id => id === 'monitor-0' ? 0 : 1), + getMonitorInDirection: vi.fn((idx, dir) => idx === 0 && dir === 'right' ? 1 : -1), + getMonitorId: vi.fn(idx => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + const controller = { + escalator: escalator, + monitorManager: mockMonitorManager, + _windowWrappers: new Map(), + _scheduleRetile: vi.fn() + }; + const ws = { index: () => 0 }; + const layout = new WorkspaceLayout(ws, controller); + + const win0 = { + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 0, width: 1000, height: 1000 }), + move_to_monitor: vi.fn() + }; + const wrapper0 = { monitorId: 'monitor-0', monitorIndex: 0 }; + controller._windowWrappers.set(win0, wrapper0); + + layout.trackWindow(win0, 'monitor-0'); + + const result = layout.moveWindowDirection('monitor-0', win0, 'right'); + expect(result).toBe(true); + + const tracker0 = layout._getTracker('monitor-0'); + const tracker1 = layout._getTracker('monitor-1'); + expect(tracker0.getSlot(win0)).toBeUndefined(); + expect(tracker1.getSlot(win0)).toBe(0); + + expect(wrapper0.monitorId).toBe('monitor-1'); + expect(wrapper0.monitorIndex).toBe(1); + expect(win0.move_to_monitor).toHaveBeenCalledWith(1); + + expect(controller._scheduleRetile).toHaveBeenCalledWith(ws, 'monitor-0', 0); + expect(controller._scheduleRetile).toHaveBeenCalledWith(ws, 'monitor-1', 1); + }); }); From d126a773eeaaf235b8d125e50bf4f097cd57d1a7 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Fri, 12 Jun 2026 22:17:34 +0200 Subject: [PATCH 11/33] feat: Implement robust cross-monitor drag and tiling transitions - first fully working version - Added cross-monitor dragging with dynamic visual N+1 and N-1 matrix previews - Introduced 'Monitor Transition Behavior' settings ('Escalate' vs 'Swap') - Implemented floating fallback when attempting to drag over a monitor at maximum layout capacity - Resolved cross-monitor preview bugs by cleanly reverting both source and target tracker geometries - Prevented drag-and-drop desync bugs during rapid movements by calculating monitor indices using absolute pointer geometry instead of global.display.get_current_monitor() - Updated keybinding logic and restored missing dragging indicator styles - Extensive unit tests covering cross-monitor transitions and regressions --- lib/controller.js | 49 ++- lib/drag.js | 234 ++++++++++-- lib/keybindings.js | 36 +- lib/layout.js | 49 +++ lib/settings.js | 10 + lib/workspace.js | 180 ++++++++-- prefs.js | 20 ++ schemas/gschemas.compiled | Bin 2600 -> 2652 bytes ...ell.extensions.workflow-tiling.gschema.xml | 6 + tests/monitor-transition.test.js | 296 ++++++++++++++++ tests/regressions.test.js | 334 ++++++++++++++++++ tests/setup.js | 9 +- 12 files changed, 1122 insertions(+), 101 deletions(-) create mode 100644 tests/monitor-transition.test.js create mode 100644 tests/regressions.test.js diff --git a/lib/controller.js b/lib/controller.js index 1fbfd92..faf90a4 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -88,6 +88,11 @@ export class TilingController { wrapper.bindSizeChanged(); try { + if (this.dragManager && this.dragManager.isWindowInDragPreview(window)) { + Logger.debug(`tilingRequest: Ignored for window in active drag preview.`); + return; + } + const context = this._resolveTilingContext(window, wrapper); if (!context) { Logger.debug(`tilingRequest: Aborted. No context resolved.`); @@ -103,13 +108,43 @@ export class TilingController { return; } - const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); - const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); - this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); - this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); - - Logger.debug(`tilingRequest: State applied. Scheduling retile.`); - this._scheduleRetile(workspace, monitorId, monitorIndex); + const layout = this.workspaceManager.getLayout(workspace); + const isMonitorChange = wrapper.workspace && wrapper.workspace === workspace && wrapper.monitorId && wrapper.monitorId !== monitorId; + + if (isMonitorChange) { + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; + const sourceTracker = layout._getTracker(sourceMonitorId); + const sourceSlot = sourceTracker.getSlot(window) !== undefined ? sourceTracker.getSlot(window) : 0; + + const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); + const targetRect = global.display.get_monitor_geometry(monitorIndex); + + let enteringEdge = 'left'; + if (targetRect.x > sourceRect.x) { + enteringEdge = 'left'; + } else if (targetRect.x < sourceRect.x) { + enteringEdge = 'right'; + } else if (targetRect.y > sourceRect.y) { + enteringEdge = 'top'; + } else if (targetRect.y < sourceRect.y) { + enteringEdge = 'bottom'; + } + + layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + + Logger.debug(`tilingRequest: Monitor transition handled. Scheduling retile.`); + this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } else { + const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); + const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); + this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); + this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); + + Logger.debug(`tilingRequest: State applied. Scheduling retile.`); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } } catch (e) { Logger.warn(`Tiling attempt failed for "${wrapper ? wrapper.title : 'unknown'}"`, e); } diff --git a/lib/drag.js b/lib/drag.js index 5416092..22de3f0 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -1,5 +1,6 @@ import Gio from 'gi://Gio'; import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; /** * DragManager: Manages pointer drag tracking and visual drop indicators. @@ -10,6 +11,22 @@ export class DragManager { this._activeDrag = null; // { window, originalSlot, indicator, signalId, lastHoveredSlot, lastHoveredMonitorId, origRect } } + isWindowInDragPreview(window) { + if (!this._activeDrag) return false; + if (this._activeDrag.window === window) return true; + + const wrapper = this.controller._windowWrappers.get(window); + if (!wrapper) return false; + + const draggedWrapper = this.controller._windowWrappers.get(this._activeDrag.window); + const sourceMonitorId = draggedWrapper ? draggedWrapper.monitorId : null; + + if (wrapper.monitorId === sourceMonitorId) return true; + if (this._activeDrag.lastHoveredMonitorId && wrapper.monitorId === this._activeDrag.lastHoveredMonitorId) return true; + + return false; + } + startDragTracking(window) { if (this._activeDrag) this.endDragTracking(this._activeDrag.window); const wrapper = this.controller._windowWrappers.get(window); @@ -40,7 +57,7 @@ export class DragManager { _createIndicator() { const indicator = new St.Widget({ style: ` - border: 2px solid var(--accent-color, #3584e4); + border: 2px solid -st-accent-color; border-radius: 8px; `, visible: false @@ -48,9 +65,10 @@ export class DragManager { const bg = new St.Widget({ style: ` - background-color: var(--accent-bg-color, rgba(53, 132, 228, 0.3)); + background-color: -st-accent-color; border-radius: 6px; - ` + `, + opacity: 76 }); indicator.add_child(bg); indicator._bg = bg; @@ -91,9 +109,16 @@ export class DragManager { height: monitorRect.height - (gaps.outer * 2) }; } else { - hoveredSlot = layout.getSlotAtPointer(monitorId, x, y, monitorRect, gaps); + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + const matrixCount = (monitorId === wrapper.monitorId || behavior === 'swap') ? targetTracker.size : (targetTracker.size + 1); + + if (matrixCount > layout.escalator.getMaxCount()) { + hoveredSlot = -1; + } else { + hoveredSlot = layout.getSlotAtPointer(monitorId, x, y, monitorRect, gaps, matrixCount); + } + if (hoveredSlot !== -1) { - const matrixCount = (monitorId === wrapper.monitorId) ? targetTracker.size : (targetTracker.size + 1); const matrix = layout.escalator.getLayoutForCount(matrixCount); if (matrix) { const estate = matrix.getEstate(hoveredSlot); @@ -156,20 +181,73 @@ export class DragManager { const sourceTracker = layout._getTracker(wrapper.monitorId); const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); - this._revertVisualSwap(sourceTracker, layout, sourceMonitorRect, gaps); + + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + + if (behavior === 'escalate') { + const sourceMatrix = layout.escalator.getLayoutForCount(sourceTracker.size > 0 ? sourceTracker.size - 1 : 0); + if (sourceMatrix) { + const origSlot = this._activeDrag.originalSlot; + for (const win of sourceTracker.windows) { + if (win === this._activeDrag.window) continue; + const slot = sourceTracker.getSlot(win); + if (slot !== undefined) { + const targetSlot = (slot > origSlot) ? (slot - 1) : slot; + const wrap = this.controller._windowWrappers.get(win); + const estate = sourceMatrix.getEstate(targetSlot); + if (wrap && estate) { + const rect = estate.toAbsolute(sourceMonitorRect, gaps); + wrap.applyGeometry(rect); + } + } + } + } + } else { + this._revertVisualSwap(sourceTracker, layout, sourceMonitorRect, gaps); + } // Apply new cross-monitor visual swap preview using size N+1 matrix - const matrix = layout.escalator.getLayoutForCount(targetTracker.size + 1); + const matrix = layout.escalator.getLayoutForCount(behavior === 'swap' && targetTracker.size > 0 ? targetTracker.size : targetTracker.size + 1); + if (matrix) { - for (const win of targetTracker.windows) { - const slot = targetTracker.getSlot(win); - if (slot !== undefined) { - const targetEstateSlot = (slot >= hoveredSlot) ? (slot + 1) : slot; - const wrap = this.controller._windowWrappers.get(win); - const estate = matrix.getEstate(targetEstateSlot); - if (wrap && estate) { - const targetRect = estate.toAbsolute(monitorRect, gaps); - wrap.applyGeometry(targetRect); + if (behavior === 'swap' && targetTracker.size > 0) { + // In swap mode, we visually move the hovered target window to the source window's slot + const sourceTracker = layout._getTracker(wrapper.monitorId); + const sourceMatrix = layout.escalator.getLayoutForCount(sourceTracker.size); + const sourceEstate = sourceMatrix ? sourceMatrix.getEstate(this._activeDrag.originalSlot) : null; + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); + const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); + + for (const win of targetTracker.windows) { + const slot = targetTracker.getSlot(win); + if (slot !== undefined) { + const wrap = this.controller._windowWrappers.get(win); + if (wrap) { + if (slot === hoveredSlot && sourceEstate) { + const targetRect = sourceEstate.toAbsolute(sourceMonitorRect, gaps); + wrap.applyGeometry(targetRect); + } else { + const estate = matrix.getEstate(slot); + if (estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } + } + } + } + } + } else { + // Escalate N+1 preview + for (const win of targetTracker.windows) { + const slot = targetTracker.getSlot(win); + if (slot !== undefined) { + const targetEstateSlot = (slot >= hoveredSlot) ? (slot + 1) : slot; + const wrap = this.controller._windowWrappers.get(win); + const estate = matrix.getEstate(targetEstateSlot); + if (wrap && estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } } } } @@ -197,8 +275,16 @@ export class DragManager { const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); } - } else { - this._restoreTrackerGeometries(tracker, layout, monitorRect, gaps); + } + + if (sourceMonId) { + const sourceTracker = layout._getTracker(sourceMonId); + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); + if (sourceMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + const sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); + this._restoreTrackerGeometries(sourceTracker, layout, sourceMonitorRect, gaps); + } } this._activeDrag.lastHoveredSlot = -1; @@ -220,8 +306,16 @@ export class DragManager { const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); } - } else { - this._restoreTrackerGeometries(tracker, layout, monitorRect, gaps); + } + + if (sourceMonId) { + const sourceTracker = layout._getTracker(sourceMonId); + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); + if (sourceMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + const sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); + this._restoreTrackerGeometries(sourceTracker, layout, sourceMonitorRect, gaps); + } } } @@ -307,27 +401,99 @@ export class DragManager { const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastHoveredMonitorId); const sourceMonitorId = wrapper.monitorId; const sourceMonitorIndex = wrapper.monitorIndex; + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + + if (behavior === 'swap') { + const targetTracker = layout._getTracker(lastHoveredMonitorId); + const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === lastHoveredSlot); + + if (targetWindow) { + layout.untrackWindow(window, sourceMonitorId); + layout.untrackWindow(targetWindow, lastHoveredMonitorId); + + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + + const targetWrapper = this.controller._windowWrappers.get(targetWindow); + if (targetWrapper) { + targetWrapper.monitorId = sourceMonitorId; + targetWrapper.monitorIndex = sourceMonitorIndex; + } - layout.untrackWindow(window, sourceMonitorId); - if (targetMonitorIndex !== -1) { - window.move_to_monitor(targetMonitorIndex); - } - layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); + targetTracker.track(window, lastHoveredSlot); + const sourceTracker = layout._getTracker(sourceMonitorId); + sourceTracker.track(targetWindow, activeDrag.originalSlot); - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; + if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); + if (sourceMonitorIndex !== -1) targetWindow.move_to_monitor(sourceMonitorIndex); - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + } else { + layout.untrackWindow(window, sourceMonitorId); + + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + + layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); + + if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + } + } else { + layout.untrackWindow(window, sourceMonitorId); + + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + + layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); + + if (targetMonitorIndex !== -1) { + window.move_to_monitor(targetMonitorIndex); + } + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + } } else { const [x, y] = global.get_pointer(); - const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); - const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; - const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; + let pointerMonitorIndex = -1; + const numMonitors = global.display.get_n_monitors(); + for (let i = 0; i < numMonitors; i++) { + const rect = global.display.get_monitor_geometry(i); + if (x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height) { + pointerMonitorIndex = i; + break; + } + } + + if (pointerMonitorIndex === -1) pointerMonitorIndex = wrapper.monitorIndex; + const pointerMonitorId = this.controller.monitorManager.getMonitorId(pointerMonitorIndex); + + if (pointerMonitorId && pointerMonitorId !== wrapper.monitorId) { + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; + + layout.untrackWindow(window, sourceMonitorId); + + wrapper.monitorId = pointerMonitorId; + wrapper.monitorIndex = pointerMonitorIndex; + + if (pointerMonitorIndex !== -1) window.move_to_monitor(pointerMonitorIndex); + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + } else { + const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); + + const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; + const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; - if (swapped || rectChanged) { - this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + if (swapped || rectChanged) { + this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + } } } } diff --git a/lib/keybindings.js b/lib/keybindings.js index f66c802..a011a9b 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -54,20 +54,20 @@ export class KeybindingManager { // Batch Utilities const utilities = { - 'shortcut-close-monitor': (c) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), - 'shortcut-close-workspace': (c) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), - 'shortcut-switch-monitor': (c) => c.switchMonitors(global.display.get_current_monitor()), - 'shortcut-port-monitor-left': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), - 'shortcut-port-monitor-right': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), - 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) + 'shortcut-close-monitor': (c, win) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), + 'shortcut-close-workspace': (c, win) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), + 'shortcut-switch-monitor': (c, win) => c.switchMonitors(win ? win.get_monitor() : global.display.get_current_monitor()), + 'shortcut-port-monitor-left': (c, win) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), + 'shortcut-port-monitor-right': (c, win) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), + 'shortcut-unminimize-workspace': (c, win) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) }; for (const [key, action] of Object.entries(utilities)) { defs.push({ defaultKey: key, - action: (c) => { + action: (c, win) => { Logger.debug(`Action triggered: ${key}`); - action(c); + action(c, win); }, conflict: null }); @@ -83,20 +83,14 @@ export class KeybindingManager { const keysToShadow = []; for (const def of this._definitions) { - const { active, keyToBind, isCustom } = this._resolveBinding(def); + const { active, keyToBind } = this._resolveBinding(def); if (!active) { Logger.debug(`Binding ${def.defaultKey} is inactive.`); continue; } - Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, isCustom=${isCustom}, conflict=${def.conflict}`); - - if (!isCustom && def.conflict) { - Logger.debug(`Will hijack native conflict ${def.conflict} instead of binding extension shortcut.`); - conflictsToHijack.push(def.conflict); - } else { - keysToShadow.push(keyToBind); - } + Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, conflict=${def.conflict}`); + keysToShadow.push(keyToBind); } if (keysToShadow.length > 0) { @@ -105,13 +99,11 @@ export class KeybindingManager { // Bind extension shortcuts for (const def of this._definitions) { - const { active, keyToBind, isCustom } = this._resolveBinding(def); - if (active && !(!isCustom && def.conflict)) { + const { active, keyToBind } = this._resolveBinding(def); + if (active) { this._bindExtensionShortcut(def, keyToBind); } } - - conflictsToHijack.forEach(conflictKey => this._hijackNativeShortcut(conflictKey)); } _resolveBinding(def) { @@ -128,7 +120,7 @@ export class KeybindingManager { } } - return { active, keyToBind, isCustom }; + return { active, keyToBind }; } _bindExtensionShortcut(def, keyToBind) { diff --git a/lib/layout.js b/lib/layout.js index 21293c9..5fe73f7 100644 --- a/lib/layout.js +++ b/lib/layout.js @@ -81,8 +81,53 @@ export class Layout { get size() { return this.estates.length; } + + getEdgingSlot(direction) { + const eps = 0.01; + let candidates = []; + + this.estates.forEach((estate, index) => { + let touches = false; + if (direction === 'left') { + touches = estate.pct_x <= eps; + } else if (direction === 'right') { + touches = (estate.pct_x + estate.pct_w) >= (100 - eps); + } else if (direction === 'top') { + touches = estate.pct_y <= eps; + } else if (direction === 'bottom') { + touches = (estate.pct_y + estate.pct_h) >= (100 - eps); + } + + if (touches) { + candidates.push({ estate, index }); + } + }); + + if (candidates.length === 0) { + return -1; + } + + candidates.sort((a, b) => { + if (direction === 'left' || direction === 'right') { + const diffHeight = b.estate.pct_h - a.estate.pct_h; + if (Math.abs(diffHeight) > eps) { + return diffHeight; + } + return a.estate.pct_y - b.estate.pct_y; + } else { + const diffWidth = b.estate.pct_w - a.estate.pct_w; + if (Math.abs(diffWidth) > eps) { + return diffWidth; + } + return b.estate.pct_x - a.estate.pct_x; + } + }); + + return candidates[0].index; + } } + /** * Escalator class. Maps window count to layout. */ @@ -99,6 +144,10 @@ export class LayoutEscalator { } return this._layouts.get(windowCount) || null; } + + getMaxCount() { + return this._maxCount; + } } diff --git a/lib/settings.js b/lib/settings.js index a29ab4a..2787d52 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -12,6 +12,7 @@ export class SettingsManager { this._gaps = { inner: 6, outer: 4 }; this._customLayouts = ''; + this._monitorTransitionBehavior = 'escalate'; if (this.settings) { this._load(); @@ -31,6 +32,10 @@ export class SettingsManager { this._load(); if (this.onSettingsChanged) this.onSettingsChanged(); })); + this._changedIds.push(this.settings.connect('changed::monitor-transition-behavior', () => { + this._load(); + if (this.onSettingsChanged) this.onSettingsChanged(); + })); const kbKeys = [ 'keybindings-mode', 'move-window-left', 'move-window-right', 'move-window-up', 'move-window-down', @@ -63,6 +68,7 @@ export class SettingsManager { this._gaps.outer = 0; } this._customLayouts = this.settings.get_string('custom-layouts'); + this._monitorTransitionBehavior = this.settings.get_string('monitor-transition-behavior') || 'escalate'; } getGaps() { @@ -73,6 +79,10 @@ export class SettingsManager { return this._customLayouts; } + getMonitorTransitionBehavior() { + return this._monitorTransitionBehavior; + } + destroy() { if (this.settings) { this._changedIds.forEach(id => this.settings.disconnect(id)); diff --git a/lib/workspace.js b/lib/workspace.js index 324a060..ccb4c6a 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -70,38 +70,40 @@ export class WorkspaceLayout { for (const win of targetTracker.windows) { if (!win) continue; const targetRect = win.get_frame_rect ? win.get_frame_rect() : { x: 0, y: 0, width: 100, height: 100 }; - - let overlap = 0; - if (direction === 'left' || direction === 'right') { - overlap = Math.min(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height) - Math.max(sourceRect.y, targetRect.y); - } else if (direction === 'up' || direction === 'down') { - overlap = Math.min(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width) - Math.max(sourceRect.x, targetRect.x); - } - candidates.push({ win, rect: targetRect, overlap }); + candidates.push({ win, rect: targetRect }); } if (candidates.length === 0) return null; candidates.sort((a, b) => { - if (Math.abs(b.overlap - a.overlap) > 0.001) { - return b.overlap - a.overlap; - } - if (direction === 'left' || direction === 'right') { + if (direction === 'left') { + if (a.rect.x !== b.rect.x) { + return b.rect.x - a.rect.x; + } + return a.rect.y - b.rect.y; + } else if (direction === 'right') { + if (a.rect.x !== b.rect.x) { + return a.rect.x - b.rect.x; + } + return a.rect.y - b.rect.y; + } else if (direction === 'up') { if (a.rect.y !== b.rect.y) { - return a.rect.y - b.rect.y; + return b.rect.y - a.rect.y; } return b.rect.x - a.rect.x; - } else { - if (b.rect.x !== a.rect.x) { - return b.rect.x - a.rect.x; + } else if (direction === 'down') { + if (a.rect.y !== b.rect.y) { + return a.rect.y - b.rect.y; } - return a.rect.y - b.rect.y; + return b.rect.x - a.rect.x; } + return 0; }); return candidates[0].win; } + _getTargetWindowInDirection(monitorId, window, direction) { const tracker = this._getTracker(monitorId); const slot = tracker.getSlot(window); @@ -144,6 +146,79 @@ export class WorkspaceLayout { return null; } + /** + * Finds the nearest window in the specified geometric direction and swaps slots. + * Computes orthogonal overlap and distance to determine the best candidate. + */ + handleMonitorTransition(window, sourceMonitorId, targetMonitorId, enteringEdge, sourceSlot) { + const sourceTracker = this._getTracker(sourceMonitorId); + const targetTracker = this._getTracker(targetMonitorId); + + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonitorId); + const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(targetMonitorId); + + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + + if (behavior === 'swap' && targetTracker.size > 0) { + const targetLayout = this.escalator.getLayoutForCount(targetTracker.size); + if (targetLayout) { + const targetEdgingSlot = targetLayout.getEdgingSlot(enteringEdge); + if (targetEdgingSlot !== -1) { + const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === targetEdgingSlot); + if (targetWindow) { + sourceTracker.untrack(window); + targetTracker.untrack(targetWindow); + + targetTracker.track(window, targetEdgingSlot); + sourceTracker.track(targetWindow, sourceSlot); + + const wrapper = this.controller._windowWrappers.get(window); + if (wrapper) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + } + const targetWrapper = this.controller._windowWrappers.get(targetWindow); + if (targetWrapper) { + targetWrapper.monitorId = sourceMonitorId; + targetWrapper.monitorIndex = sourceMonitorIndex; + } + + if (window.move_to_monitor) { + window.move_to_monitor(targetMonitorIndex); + } + if (targetWindow.move_to_monitor) { + targetWindow.move_to_monitor(sourceMonitorIndex); + } + return; + } + } + } + } + + sourceTracker.untrack(window); + + const targetLayout = this.escalator.getLayoutForCount(targetTracker.size + 1); + let preferredSlot = targetTracker.size; + if (targetLayout) { + const edgingSlot = targetLayout.getEdgingSlot(enteringEdge); + if (edgingSlot !== -1) { + preferredSlot = edgingSlot; + } + } + + this.trackWindow(window, targetMonitorId, preferredSlot); + + const wrapper = this.controller._windowWrappers.get(window); + if (wrapper) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + } + + if (window.move_to_monitor) { + window.move_to_monitor(targetMonitorIndex); + } + } + /** * Finds the nearest window in the specified geometric direction and swaps slots. * Computes orthogonal overlap and distance to determine the best candidate. @@ -186,23 +261,15 @@ export class WorkspaceLayout { Logger.info(`[DEBUG] moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); if (adjacentMonitorIndex !== -1) { const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); - - tracker.untrack(window); - - const targetTracker = this._getTracker(targetMonitorId); - targetTracker.track(window, targetTracker.size); - - if (this.controller._windowWrappers) { - const wrapper = this.controller._windowWrappers.get(window); - if (wrapper) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = adjacentMonitorIndex; - } - } + const enteringEdgeMap = { + 'left': 'right', + 'right': 'left', + 'up': 'bottom', + 'down': 'top' + }; + const enteringEdge = enteringEdgeMap[direction]; - if (window.move_to_monitor) { - window.move_to_monitor(adjacentMonitorIndex); - } + this.handleMonitorTransition(window, monitorId, targetMonitorId, enteringEdge, slot); if (this.controller._scheduleRetile) { this.controller._scheduleRetile(this.workspace, monitorId, currentMonitorIndex); @@ -214,6 +281,7 @@ export class WorkspaceLayout { return false; } + /** * Finds the nearest window in the specified geometric direction and activates it. */ @@ -284,9 +352,9 @@ export class WorkspaceLayout { * Resolves absolute pointer coordinates to a window layout slot index. * Returns -1 if pointer does not intersect any calculated slot estate. */ - getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps) { + getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps, customCount = null) { const tracker = this._getTracker(monitorId); - const windowCount = tracker.size; + const windowCount = customCount !== null ? customCount : tracker.size; const layout = this.escalator.getLayoutForCount(windowCount); if (!layout) return -1; @@ -406,8 +474,39 @@ export class WorkspaceManager { targetMonitorIndex = primaryIndex; } - this.controller.setBatchMode(true); + const activeMonitorId = this.controller.monitorManager ? this.controller.monitorManager.getMonitorId(activeMonitorIndex) : `monitor-${activeMonitorIndex}`; + const targetMonitorId = this.controller.monitorManager ? this.controller.monitorManager.getMonitorId(targetMonitorIndex) : `monitor-${targetMonitorIndex}`; + + const layout = this.getLayout(workspace); + + // Swap layout trackers + const trackerA = layout._getTracker(activeMonitorId); + const trackerB = layout._getTracker(targetMonitorId); + const tempWindowToSlot = trackerA._windowToSlot; + const tempOriginalGeometries = trackerA._originalGeometries; + trackerA._windowToSlot = trackerB._windowToSlot; + trackerA._originalGeometries = trackerB._originalGeometries; + trackerB._windowToSlot = tempWindowToSlot; + trackerB._originalGeometries = tempOriginalGeometries; + + // Update window wrapper cache const windows = workspace.list_windows(); + windows.forEach(w => { + const m = w.get_monitor(); + const wrapper = this.controller._windowWrappers.get(w); + if (wrapper) { + if (m === activeMonitorIndex) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + } else if (m === targetMonitorIndex) { + wrapper.monitorId = activeMonitorId; + wrapper.monitorIndex = activeMonitorIndex; + } + } + }); + + // Move windows + this.controller.setBatchMode(true); windows.forEach(w => { const m = w.get_monitor(); if (m === activeMonitorIndex) { @@ -417,7 +516,14 @@ export class WorkspaceManager { } }); this.controller.setBatchMode(false); - this.controller.hydrate(workspace); + + // Schedule retile rather than hydrate + if (this.controller._scheduleRetile) { + this.controller._scheduleRetile(workspace, activeMonitorId, activeMonitorIndex); + this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); + } else { + this.controller.hydrate(workspace); + } } portMonitorToWorkspace(monitorIndex, direction) { diff --git a/prefs.js b/prefs.js index 55ebb59..063961d 100644 --- a/prefs.js +++ b/prefs.js @@ -131,6 +131,26 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { page.add(gapsGroup); + // --- Monitor Transition Group --- + const transitionGroup = new Adw.PreferencesGroup({ title: 'Monitor Transition' }); + const transitionRow = new Adw.SwitchRow({ + title: 'Swap Windows', + subtitle: 'Swap windows across monitors instead of escalating/de-escalating' + }); + transitionRow.active = settings.get_string('monitor-transition-behavior') === 'swap'; + transitionRow.connect('notify::active', () => { + settings.set_string('monitor-transition-behavior', transitionRow.active ? 'swap' : 'escalate'); + }); + settings.connect('changed::monitor-transition-behavior', () => { + const active = settings.get_string('monitor-transition-behavior') === 'swap'; + if (transitionRow.active !== active) { + transitionRow.active = active; + } + }); + transitionGroup.add(transitionRow); + page.add(transitionGroup); + + // --- Core Keybindings Group --- const keysGroup = new Adw.PreferencesGroup({ title: 'Keybindings' }); const modeRow = new Adw.ComboRow({ diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index a700a828b54016afad677e008f1432dfabf88666..21efeafd8aa21da450c041196a1a8a6cc086dde2 100644 GIT binary patch literal 2652 zcmbtVU1%It7`;(bYm>Cwn%1UmY}}_!$?UcX)evI{L9x(OilrLRLMA)2J7Z>N*7?ac zhDaVn;)5typ%s5nYKfpCqM#IH5ecGTi?DR~iuR!qr*@k!6QGqc^@Y@QrAd%it; z_ulXK&f`z1uBqC-h@PF`vCvN45ot|eckaiZ$^31fI3!*Ir|uG>dAkt*;yJzvf*2KJ z;Aa8H&8PFWQ_$0%p<7m3Kk4hXXF9f*E;;UG&T>kMZ(63E=l?|N0ZF$%cs8~d%~jY4 z`<@6quC)qnu(w3m3MTH202jrM2)n>BI$_@r>;<|a?7RHoD~yw|?G#gD7(7-LCv6!g z_!6MC2P0fhy9Mi)z`q06-&_2XI_=HSSHU+B%wO$iAEHisD|E2|=KvROm9x}ocS7$6 zKLV6zUu0D2{9r`@@HK23k z!Z*}ucR*hNmw|t7{c@E$?M=`xg0BFrh2~GF)299hcp3P1Zg`10ZN{5GGjsx%%J!?& zY17Ys@L}q2oZCR1_Fm{kZ~!cRIc`&@oq#?MehWA_)1^?Sy$$+j;O~KJUwqs_o%R;! zSHR1_<*&=9sMBU1Hli`xfZ?~xZ&Ihd3wjEy06$&VmZ{U`xx?U5pyQ?{{h>{L9P9!s zeMio5J#F^S9QbwM=7Y~aOr19CxBxy6Tx9Gi~N)6`a8ETwUDr zGWVgq9eN7f51e`W{NL1RvmcIvIosL5^PPf{b6_YX)7CJ*mY(wmkGf_-b*G00j~Qmp zA2N`=l>gk@kN`OE zT|f)KI}2@rI)wLyZmScPuH@CCCz?cRqC0ZlQm383_pnNcndE`wz%w(+lgWYoJ;`bL zSxOEZ*pF{U#HI#g_C!yzx6#srYgYEi?WkAwt6$X1?fU-jcHEWD_0HBDNf|LjXU2>A zW>3swj=k=h+M#|n^H5*E=c4*~hU5C#z*hphVA^KEd_h-uaeGBItMiX6a=NJc2EPcA z!G)iZ7k)-w_?g=mex~li&j{gFwlCrsrQq16@3@L<=8gDG#QmeX6`Uy?9`;qqKws(n|*>)6K& zS((rcb;@*Ht`NGHRV~#=iP13)@*kHdit9;Pu{K$NFf;K1rk%9{O;_UX(d72{z&ti6 zXtdvY^^A{Q)30$JYO0Sy*5_+Y9let2XN|b^;1>F=aaV?=?IC)n9 literal 2600 zcmbtUU1%It7`;(bYm>HVO`A=B?6N9sYi4(wP?Z=$2to@sQd-o46gt_R-I-=~W}TUB z8kdzkh(xeYR!G4glv+|y31XrCAp0PpDA=MFkxI#9z=|z-P@nXiyEB{F*~J$ZPR^IJ z-+uSr@9*IAs$-~@C;U$ge7tTa@A0(`urvGJ4`hC~PaG1jg1he%qH~)N|KRiR1_YWDea7>S*~GQZfe?grt+pet$2oMSOxx0B;%5VxEGADqSMFy z;Pt=*5%4&o+s7twPXt@>e=o4zM<1B)+v#H$_#t2q5C>}2SGxEX`7Uu<#Bh;=z}VZh zw|EcQJD}^}BCtF+ah^Ku81y;tS)k*W+$?q4eb5)cUjXCJEIdJ-_7>>Z!7IQw7pzt4 zw6{a&<6}Usw>(arb{F&{SOJ2Z(`L>y;00jm@=1&Sw0q!x1^g|r@5YC(Q>V>)u7Jfl zA#OkZ(o@uF^FA@~V?cMY^JD6?H$Z<9JO+I7!Abcp+I-h>@G0Q?Te&;*r_FO`!EXT9 z?$)x@Y43)<2)+#b(R)GWfi};*3SI_QhbAu4pY|r`cfbO|>Tl!EQm4HWdK~;H@Y9|R|i*s8=u$CQK!xO&x79q4pia_b=q5@FM_WEKfn9F%o}aS zSqA?F%zt`L14+9VdJh`35BU4;k2k5)X5NzE4A6f&C+k9+`56Nr1iZ-YJuo(F#IxFqvNn|WIVe?|SXkNW9Pdo%Rw;1yu^__eR8)9#199zzrd zZoRkkJ9XO3a|X=$O_fwnht5wL1DuuvoR$NeUK`+aWPsB`xUOzHUbgHhC9qPoEyJ@N z#nkeR#gbIry$!FdX}g-jIBrSJYCMqNXUnc<7xDAc_|mX)*bza9kz?hO=8PU@#BS-F zx8WXj6C+0)qo_JFqa(+3BkzsssAwp3(~rs}(c(Yl`)iguX=+MAExEj%=n%=ty;%*D)1bMOid^`=)&xfD9rWXd;Ykg;smvCIiDCj}G{P;(uv{9zeE_<32WQ6Y$ zNuyoKcsSE{lUhGnhQnmYTL^$zEg z-Q~Y4G?$HM)rZ{AT6hS}`RNf~eO&kUS~swe8bAnWrlDMMfG diff --git a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml index 3db5d1e..6f67ffe 100644 --- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml +++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml @@ -6,6 +6,12 @@ Enable Gaps Toggle gaps on or off globally. + + 'escalate' + Monitor Transition Behavior + Behavior when window crosses monitor boundary ('escalate' or 'swap'). + + 'default' Keybindings Mode diff --git a/tests/monitor-transition.test.js b/tests/monitor-transition.test.js new file mode 100644 index 0000000..7dcdd46 --- /dev/null +++ b/tests/monitor-transition.test.js @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Layout, ScreenEstate, LayoutParser } from '../lib/layout.js'; +import { WorkspaceLayout } from '../lib/workspace.js'; +import { SettingsManager } from '../lib/settings.js'; +import Meta from 'gi://Meta'; +import Gio from 'gi://Gio'; + +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; + +describe('Edging Tile Identification', () => { + it('should identify edging tile for layout of size 1', () => { + const estates = [new ScreenEstate(0, 0, 100, 100)]; + const layout = new Layout(estates); + expect(layout.getEdgingSlot('left')).toBe(0); + expect(layout.getEdgingSlot('right')).toBe(0); + expect(layout.getEdgingSlot('top')).toBe(0); + expect(layout.getEdgingSlot('bottom')).toBe(0); + }); + + it('should identify edging tile for layout of size 2 (tie-break right/upper)', () => { + // Vertical split: left slot 0, right slot 1 + const estates = [ + new ScreenEstate(0, 0, 50, 100), + new ScreenEstate(50, 0, 50, 100) + ]; + const layout = new Layout(estates); + expect(layout.getEdgingSlot('left')).toBe(0); + expect(layout.getEdgingSlot('right')).toBe(1); + // Tie breaker for top/bottom edge: right-most (slot 1) + expect(layout.getEdgingSlot('top')).toBe(1); + expect(layout.getEdgingSlot('bottom')).toBe(1); + }); + + it('should identify edging tile for layout of size 3 (escalator layout)', () => { + // Left slot 0, top-right slot 1, bottom-right slot 2 + const estates = [ + new ScreenEstate(0, 0, 50, 100), + new ScreenEstate(50, 0, 50, 50), + new ScreenEstate(50, 50, 50, 50) + ]; + const layout = new Layout(estates); + expect(layout.getEdgingSlot('left')).toBe(0); + // Tie breaker for right edge: upper (slot 1) + expect(layout.getEdgingSlot('right')).toBe(1); + // Tie breaker for top edge: right-most (slot 1) + expect(layout.getEdgingSlot('top')).toBe(1); + // Tie breaker for bottom edge: right-most (slot 2) + expect(layout.getEdgingSlot('bottom')).toBe(2); + }); + + it('should prioritize longest edge for edging tile identification', () => { + // Left slot 0 (30 width), top-right slot 1 (70 width, 40 height), bottom-right slot 2 (70 width, 60 height) + const estates = [ + new ScreenEstate(0, 0, 30, 100), + new ScreenEstate(30, 0, 70, 40), + new ScreenEstate(30, 40, 70, 60) + ]; + const layout = new Layout(estates); + expect(layout.getEdgingSlot('left')).toBe(0); + // Right edge: slot 2 has height 60, slot 1 has height 40 -> slot 2 is longer edge + expect(layout.getEdgingSlot('right')).toBe(2); + // Top edge: slot 1 has width 70, slot 0 has width 30 -> slot 1 is longer edge + expect(layout.getEdgingSlot('top')).toBe(1); + // Bottom edge: slot 2 has width 70, slot 0 has width 30 -> slot 2 is longer edge + expect(layout.getEdgingSlot('bottom')).toBe(2); + }); +}); + +describe('Monitor Transitions', () => { + let controller; + let mockMonitorManager; + let mockSettings; + const escalator = LayoutParser.parse(DEFAULT_JSON); + + const createMockWindow = (id, workspace, initialMonitorIndex) => { + let monitor = initialMonitorIndex; + return { + id, + get_workspace: () => workspace, + get_monitor: vi.fn(() => monitor), + get_frame_rect: () => ({ x: monitor * 1920 + 10, y: 10, width: 100, height: 100 }), + move_to_monitor: vi.fn((m) => { monitor = m; }), + get_title: () => `Window ${id}`, + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockMonitorManager = { + getMonitorIndex: vi.fn((id) => id === 'monitor-0' ? 0 : 1), + getMonitorInDirection: vi.fn((idx, dir) => idx === 0 && dir === 'right' ? 1 : (idx === 1 && dir === 'left' ? 0 : -1)), + getMonitorId: vi.fn((idx) => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + + mockSettings = { + getGaps: () => ({ inner: 6, outer: 4 }), + getMonitorTransitionBehavior: vi.fn(() => 'escalate') + }; + + controller = { + escalator: escalator, + monitorManager: mockMonitorManager, + settings: mockSettings, + _windowWrappers: new Map(), + _scheduleRetile: vi.fn() + }; + }); + + describe('Escalate/De-escalate monitor transition behavior', () => { + it('should de-escalate source and escalate target into edging slot', () => { + mockSettings.getMonitorTransitionBehavior.mockReturnValue('escalate'); + const ws = { index: () => 0 }; + const layout = new WorkspaceLayout(ws, controller); + + // Source: 2 windows. Target: 2 windows. + const winA = createMockWindow('A', ws, 0); + const winB = createMockWindow('B', ws, 0); + const winC = createMockWindow('C', ws, 1); + const winD = createMockWindow('D', ws, 1); + + controller._windowWrappers.set(winA, { monitorId: 'monitor-0', monitorIndex: 0 }); + controller._windowWrappers.set(winB, { monitorId: 'monitor-0', monitorIndex: 0 }); + controller._windowWrappers.set(winC, { monitorId: 'monitor-1', monitorIndex: 1 }); + controller._windowWrappers.set(winD, { monitorId: 'monitor-1', monitorIndex: 1 }); + + layout.trackWindow(winA, 'monitor-0'); + layout.trackWindow(winB, 'monitor-0'); + layout.trackWindow(winC, 'monitor-1'); + layout.trackWindow(winD, 'monitor-1'); + + // Move winB to right (cross-monitor movement DP-1 -> HDMI-1) + const result = layout.moveWindowDirection('monitor-0', winB, 'right'); + expect(result).toBe(true); + + const tracker0 = layout._getTracker('monitor-0'); + const tracker1 = layout._getTracker('monitor-1'); + + // Source de-escalates: winB untracked, winA remains at slot 0 + expect(tracker0.size).toBe(1); + expect(tracker0.getSlot(winA)).toBe(0); + expect(tracker0.getSlot(winB)).toBeUndefined(); + + // Target escalates: new layout size is 3. Entering edge is 'left'. + // size 3 'left' edging slot is 0. + // winB should be at slot 0, winC pushed to 1, winD pushed to 2. + expect(tracker1.size).toBe(3); + expect(tracker1.getSlot(winB)).toBe(0); + expect(tracker1.getSlot(winC)).toBe(1); + expect(tracker1.getSlot(winD)).toBe(2); + + expect(winB.move_to_monitor).toHaveBeenCalledWith(1); + expect(controller._windowWrappers.get(winB).monitorId).toBe('monitor-1'); + expect(controller._windowWrappers.get(winB).monitorIndex).toBe(1); + }); + }); + + describe('Swap monitor transition behavior', () => { + it('should swap moved window with target edged window without escalation/de-escalation', () => { + mockSettings.getMonitorTransitionBehavior.mockReturnValue('swap'); + const ws = { index: () => 0 }; + const layout = new WorkspaceLayout(ws, controller); + + // Source: 2 windows. Target: 2 windows. + const winA = createMockWindow('A', ws, 0); + const winB = createMockWindow('B', ws, 0); + const winC = createMockWindow('C', ws, 1); + const winD = createMockWindow('D', ws, 1); + + controller._windowWrappers.set(winA, { monitorId: 'monitor-0', monitorIndex: 0 }); + controller._windowWrappers.set(winB, { monitorId: 'monitor-0', monitorIndex: 0 }); + controller._windowWrappers.set(winC, { monitorId: 'monitor-1', monitorIndex: 1 }); + controller._windowWrappers.set(winD, { monitorId: 'monitor-1', monitorIndex: 1 }); + + layout.trackWindow(winA, 'monitor-0'); + layout.trackWindow(winB, 'monitor-0'); // slot 1 + layout.trackWindow(winC, 'monitor-1'); // slot 0 + layout.trackWindow(winD, 'monitor-1'); // slot 1 + + // Move winB to right + const result = layout.moveWindowDirection('monitor-0', winB, 'right'); + expect(result).toBe(true); + + const tracker0 = layout._getTracker('monitor-0'); + const tracker1 = layout._getTracker('monitor-1'); + + // No change in sizes + expect(tracker0.size).toBe(2); + expect(tracker1.size).toBe(2); + + // winB swaps with winC (edged window on target for entering edge 'left') + // winB gets slot 0 on monitor 1 + // winC gets slot 1 on monitor 0 (winB's old slot) + expect(tracker0.getSlot(winA)).toBe(0); + expect(tracker0.getSlot(winC)).toBe(1); + expect(tracker0.getSlot(winB)).toBeUndefined(); + + expect(tracker1.getSlot(winB)).toBe(0); + expect(tracker1.getSlot(winD)).toBe(1); + expect(tracker1.getSlot(winC)).toBeUndefined(); + + // Check physical movement + expect(winB.move_to_monitor).toHaveBeenCalledWith(1); + expect(winC.move_to_monitor).toHaveBeenCalledWith(0); + + // Check wrapper updates + expect(controller._windowWrappers.get(winB).monitorId).toBe('monitor-1'); + expect(controller._windowWrappers.get(winB).monitorIndex).toBe(1); + expect(controller._windowWrappers.get(winC).monitorId).toBe('monitor-0'); + expect(controller._windowWrappers.get(winC).monitorIndex).toBe(0); + }); + + it('should fall back to moving window if target monitor has no windows', () => { + mockSettings.getMonitorTransitionBehavior.mockReturnValue('swap'); + const ws = { index: () => 0 }; + const layout = new WorkspaceLayout(ws, controller); + + // Source: 1 window. Target: 0 windows. + const winA = createMockWindow('A', ws, 0); + + controller._windowWrappers.set(winA, { monitorId: 'monitor-0', monitorIndex: 0 }); + + layout.trackWindow(winA, 'monitor-0'); + + const result = layout.moveWindowDirection('monitor-0', winA, 'right'); + expect(result).toBe(true); + + const tracker0 = layout._getTracker('monitor-0'); + const tracker1 = layout._getTracker('monitor-1'); + + expect(tracker0.size).toBe(0); + expect(tracker1.size).toBe(1); + expect(tracker1.getSlot(winA)).toBe(0); + + expect(winA.move_to_monitor).toHaveBeenCalledWith(1); + expect(controller._windowWrappers.get(winA).monitorId).toBe('monitor-1'); + expect(controller._windowWrappers.get(winA).monitorIndex).toBe(1); + }); + }); +}); + +describe('Settings Binding Toggle Updates', () => { + let mockGioSettings; + let mockExtension; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGioSettings = { + connect: vi.fn((signal, cb) => { + if (signal.startsWith('changed::')) { + mockGioSettings._callbacks = mockGioSettings._callbacks || {}; + mockGioSettings._callbacks[signal] = cb; + } + return 123; + }), + disconnect: vi.fn(), + get_boolean: vi.fn(() => false), + get_int: vi.fn(() => 0), + get_string: vi.fn((key) => { + if (key === 'monitor-transition-behavior') return 'swap'; + return ''; + }) + }; + + mockExtension = { + getSettings: () => mockGioSettings + }; + }); + + it('should connect settings change signal for monitor-transition-behavior', () => { + const onSettingsChanged = vi.fn(); + const settingsManager = new SettingsManager(mockExtension, onSettingsChanged); + + // Verify connected change handler + expect(mockGioSettings.connect).toHaveBeenCalledWith( + 'changed::monitor-transition-behavior', + expect.any(Function) + ); + + // Verify loading of setting + expect(mockGioSettings.get_string).toHaveBeenCalledWith('monitor-transition-behavior'); + + // Trigger the signal callback + const callback = mockGioSettings._callbacks['changed::monitor-transition-behavior']; + expect(callback).toBeDefined(); + + callback(); + + expect(onSettingsChanged).toHaveBeenCalled(); + }); +}); diff --git a/tests/regressions.test.js b/tests/regressions.test.js new file mode 100644 index 0000000..a3a2fd4 --- /dev/null +++ b/tests/regressions.test.js @@ -0,0 +1,334 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TilingController } from '../lib/controller.js'; +import { LayoutParser } from '../lib/layout.js'; +import { WorkspaceLayout } from '../lib/workspace.js'; +import Meta from 'gi://Meta'; + +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; +const escalator = LayoutParser.parse(DEFAULT_JSON); + +describe('Regressions', () => { + beforeEach(() => { + global.get_current_time = vi.fn(() => 1234); + }); + + describe('R1: Focus Jumping over Monitor Boundary', () => { + it('should select the closest edge window on the target monitor when focusing right over boundary', () => { + const mockMonitorManager = { + getMonitorIndex: vi.fn(id => id === 'monitor-0' ? 0 : 1), + getMonitorInDirection: vi.fn((idx, dir) => idx === 0 && dir === 'right' ? 1 : -1), + getMonitorId: vi.fn(idx => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + const controller = { + escalator: escalator, + monitorManager: mockMonitorManager + }; + const layout = new WorkspaceLayout({}, controller); + + const winSource = { + id: 'source', + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 0, width: 1000, height: 1000 }), + activate: vi.fn() + }; + const winClose = { + id: 'close', + get_monitor: () => 1, + get_frame_rect: () => ({ x: 1000, y: 0, width: 500, height: 1000 }), + activate: vi.fn() + }; + const winFar = { + id: 'far', + get_monitor: () => 1, + get_frame_rect: () => ({ x: 1500, y: 0, width: 500, height: 1000 }), + activate: vi.fn() + }; + + layout.trackWindow(winSource, 'monitor-0'); + layout.trackWindow(winClose, 'monitor-1'); + layout.trackWindow(winFar, 'monitor-1'); + + const result = layout.focusWindowDirection('monitor-0', winSource, 'right'); + expect(result).toBe(true); + expect(winClose.activate).toHaveBeenCalled(); + expect(winFar.activate).not.toHaveBeenCalled(); + }); + + it('should select the closest edge window on the target monitor when focusing up over boundary', () => { + const mockMonitorManager = { + getMonitorIndex: vi.fn(id => id === 'monitor-1' ? 1 : 0), + getMonitorInDirection: vi.fn((idx, dir) => idx === 1 && dir === 'up' ? 0 : -1), + getMonitorId: vi.fn(idx => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + const controller = { + escalator: escalator, + monitorManager: mockMonitorManager + }; + const layout = new WorkspaceLayout({}, controller); + + const winSource = { + id: 'source', + get_monitor: () => 1, + get_frame_rect: () => ({ x: 0, y: 1000, width: 1000, height: 1000 }), + activate: vi.fn() + }; + const winClose = { + id: 'close', + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 500, width: 1000, height: 500 }), + activate: vi.fn() + }; + const winFar = { + id: 'far', + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 0, width: 1000, height: 500 }), + activate: vi.fn() + }; + + layout.trackWindow(winSource, 'monitor-1'); + layout.trackWindow(winClose, 'monitor-0'); + layout.trackWindow(winFar, 'monitor-0'); + + const result = layout.focusWindowDirection('monitor-1', winSource, 'up'); + expect(result).toBe(true); + expect(winClose.activate).toHaveBeenCalled(); + expect(winFar.activate).not.toHaveBeenCalled(); + }); + }); + + describe('R3: Drag-and-Drop Swap Slot Confusion', () => { + let controller; + let ws; + + beforeEach(() => { + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + vi.mocked(manager.get_primary_monitor).mockReturnValue(0); + + global.display.get_primary_monitor = () => manager.get_primary_monitor(); + global.display.get_current_monitor = vi.fn(() => 1); + + const mockSettings = { + getGaps: () => ({ inner: 6, outer: 4 }), + getMonitorTransitionBehavior: () => 'swap', + settings: { + get_boolean: () => false + } + }; + + TilingController.activeInstance = null; + controller = new TilingController(mockSettings); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + controller.monitorManager.initializeMonitorState(); + + ws = { + id: 'ws1', + get_work_area_for_monitor: vi.fn((idx) => { + if (idx === 1) return { x: 1000, y: 0, width: 1000, height: 1000 }; + return { x: 0, y: 0, width: 1000, height: 1000 }; + }), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }) + }; + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const createMockWindow = (id, workspace, initialMonitor) => { + let monitor = initialMonitor; + return { + id, + get_workspace: () => workspace, + get_monitor: vi.fn(() => monitor), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }), + get_frame_rect: () => ({ + x: monitor === 1 ? 1010 : 10, + y: 10, + width: 100, + height: 100 + }), + move_resize_frame: vi.fn(), + move_to_monitor: vi.fn((m) => { monitor = m; }), + get_title: () => `Window ${id}`, + get_id: () => id, + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false, + connect: vi.fn(() => 123), + disconnect: vi.fn(), + handler_is_connected: vi.fn(() => true), + minimize: vi.fn(function() { this.minimized = true; }), + unminimize: vi.fn(function() { this.minimized = false; }), + delete: vi.fn() + }; + }; + + it('should swap dragged window with target window exactly and not shift other windows', () => { + const winA = createMockWindow('A', ws, 0); + const winB = createMockWindow('B', ws, 0); + const winC = createMockWindow('C', ws, 1); + const winD = createMockWindow('D', ws, 1); + + controller.tilingRequest(winA); + controller.tilingRequest(winB); + controller.tilingRequest(winC); + controller.tilingRequest(winD); + + const layout = controller.workspaceManager.getLayout(ws); + const sourceTracker = layout._getTracker('monitor-0'); + const targetTracker = layout._getTracker('monitor-1'); + + expect(sourceTracker.getSlot(winA)).toBe(0); + expect(sourceTracker.getSlot(winB)).toBe(1); + expect(targetTracker.getSlot(winC)).toBe(0); + expect(targetTracker.getSlot(winD)).toBe(1); + + controller.dragManager.startDragTracking(winB); + + global.get_pointer = vi.fn(() => [1200, 500]); + + const dragInfo = controller.dragManager._activeDrag; + expect(dragInfo).toBeDefined(); + + controller.dragManager._handlePositionChanged( + controller._windowWrappers.get(winB), + layout, + sourceTracker, + 1, + dragInfo.indicator + ); + + expect(dragInfo.lastHoveredSlot).toBe(0); + expect(dragInfo.lastHoveredMonitorId).toBe('monitor-1'); + + const retileSpy = vi.spyOn(controller, '_scheduleRetile').mockImplementation(() => {}); + controller.dragManager.endDragTracking(winB); + + expect(targetTracker.getSlot(winB)).toBe(0); + expect(targetTracker.getSlot(winD)).toBe(1); + expect(sourceTracker.getSlot(winC)).toBe(1); + expect(sourceTracker.getSlot(winA)).toBe(0); + }); + }); + + describe('R4: Swap Monitor Keyboard Shortcut', () => { + let controller; + let ws; + + beforeEach(() => { + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + vi.mocked(manager.get_primary_monitor).mockReturnValue(0); + + global.display.get_primary_monitor = () => manager.get_primary_monitor(); + global.display.get_current_monitor = vi.fn(() => 0); + + const mockSettings = { + getGaps: () => ({ inner: 6, outer: 4 }), + getMonitorTransitionBehavior: () => 'escalate', + settings: { + get_boolean: () => false + } + }; + + TilingController.activeInstance = null; + controller = new TilingController(mockSettings); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + controller.monitorManager.initializeMonitorState(); + + ws = { + id: 'ws1', + get_work_area_for_monitor: vi.fn((idx) => { + if (idx === 1) return { x: 1000, y: 0, width: 1000, height: 1000 }; + return { x: 0, y: 0, width: 1000, height: 1000 }; + }), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }), + list_windows: vi.fn() + }; + global.workspace_manager.get_active_workspace = () => ws; + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const createMockWindow = (id, workspace, initialMonitor) => { + let monitor = initialMonitor; + return { + id, + get_workspace: () => workspace, + get_monitor: vi.fn(() => monitor), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }), + get_frame_rect: () => ({ + x: monitor === 1 ? 1010 : 10, + y: 10, + width: 100, + height: 100 + }), + move_resize_frame: vi.fn(), + move_to_monitor: vi.fn((m) => { monitor = m; }), + get_title: () => `Window ${id}`, + get_id: () => id, + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false, + connect: vi.fn(() => 123), + disconnect: vi.fn(), + handler_is_connected: vi.fn(() => true), + minimize: vi.fn(function() { this.minimized = true; }), + unminimize: vi.fn(function() { this.minimized = false; }), + delete: vi.fn() + }; + }; + + it('should successfully swap all windows between monitors and preserve slots', () => { + const winA = createMockWindow(101, ws, 0); + const winB = createMockWindow(102, ws, 0); + const winC = createMockWindow(201, ws, 1); + const winD = createMockWindow(202, ws, 1); + + ws.list_windows.mockReturnValue([winA, winB, winC, winD]); + + controller.tilingRequest(winA); + controller.tilingRequest(winB); + controller.tilingRequest(winC); + controller.tilingRequest(winD); + + const layout = controller.workspaceManager.getLayout(ws); + const sourceTracker = layout._getTracker('monitor-0'); + const targetTracker = layout._getTracker('monitor-1'); + + expect(sourceTracker.getSlot(winA)).toBe(0); + expect(sourceTracker.getSlot(winB)).toBe(1); + expect(targetTracker.getSlot(winC)).toBe(0); + expect(targetTracker.getSlot(winD)).toBe(1); + + controller.switchMonitors(0); + + expect(sourceTracker.getSlot(winC)).toBe(0); + expect(sourceTracker.getSlot(winD)).toBe(1); + expect(targetTracker.getSlot(winA)).toBe(0); + expect(targetTracker.getSlot(winB)).toBe(1); + + expect(winA.get_monitor()).toBe(1); + expect(winB.get_monitor()).toBe(1); + expect(winC.get_monitor()).toBe(0); + expect(winD.get_monitor()).toBe(0); + }); + }); +}); diff --git a/tests/setup.js b/tests/setup.js index af4c822..2464f3c 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -76,7 +76,8 @@ global.display = { get_current_monitor: vi.fn(() => 0), get_focus_window: vi.fn(() => null), list_all_windows: vi.fn(() => []), - get_monitor_index_for_rect: vi.fn(() => -1), + get_n_monitors: vi.fn(() => 2), + get_monitor_geometry: vi.fn((index) => mockMonitorManager.get_logical_monitors()[index]?.rect || { x: 0, y: 0, width: 1920, height: 1080 }), connect: vi.fn(), disconnect: vi.fn() }; @@ -132,5 +133,11 @@ vi.mock('resource:///org/gnome/shell/ui/main.js', () => ({ wm: { addKeybinding: vi.fn(), removeKeybinding: vi.fn() + }, + layoutManager: { + uiGroup: { + add_child: vi.fn(), + remove_child: vi.fn() + } } })); From c5c5a41e63a6fe9868c8f32bb408d46531fe826f Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 14 Jun 2026 08:46:53 +0200 Subject: [PATCH 12/33] gitignore .agents --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 07f3420..5351dc7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ CLAUDE.md CODEX.md CURSOR.md GEMINI.md +.agents coverage/ From 1cd9b31aebb39f72b830732d6a2878980e1e6cb2 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 14 Jun 2026 10:30:53 +0200 Subject: [PATCH 13/33] refactor: first iteration --- lib/controller.js | 14 +-- lib/drag.js | 105 +++++------------ lib/keybindings.js | 10 +- lib/monitor.js | 10 +- lib/workspace.js | 41 +++---- tests/adversarial.test.js | 230 ++++++++++++++++++++++++++++++++++++++ tests/workspace.test.js | 30 ++--- 7 files changed, 311 insertions(+), 129 deletions(-) create mode 100644 tests/adversarial.test.js diff --git a/lib/controller.js b/lib/controller.js index faf90a4..3fba26a 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -120,15 +120,13 @@ export class TilingController { const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); const targetRect = global.display.get_monitor_geometry(monitorIndex); + const dx = targetRect.x - sourceRect.x; + const dy = targetRect.y - sourceRect.y; let enteringEdge = 'left'; - if (targetRect.x > sourceRect.x) { - enteringEdge = 'left'; - } else if (targetRect.x < sourceRect.x) { - enteringEdge = 'right'; - } else if (targetRect.y > sourceRect.y) { - enteringEdge = 'top'; - } else if (targetRect.y < sourceRect.y) { - enteringEdge = 'bottom'; + if (Math.abs(dx) >= Math.abs(dy)) { + enteringEdge = dx > 0 ? 'left' : 'right'; + } else { + enteringEdge = dy > 0 ? 'top' : 'bottom'; } layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); diff --git a/lib/drag.js b/lib/drag.js index 22de3f0..2230ac1 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -146,7 +146,7 @@ export class DragManager { } } else { indicator.hide(); - this._revertVisualSwap(tracker, layout, monitorRect, gaps); + this._revertVisualSwap(layout, gaps); } } @@ -161,7 +161,7 @@ export class DragManager { if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; // Revert previous hover - this._revertVisualSwap(tracker, layout, monitorRect, gaps); + this._revertVisualSwap(layout, gaps); const matrix = layout.escalator.getLayoutForCount(tracker.size); if (matrix) { @@ -203,7 +203,7 @@ export class DragManager { } } } else { - this._revertVisualSwap(sourceTracker, layout, sourceMonitorRect, gaps); + this._revertVisualSwap(layout, gaps); } // Apply new cross-monitor visual swap preview using size N+1 matrix @@ -260,7 +260,7 @@ export class DragManager { /** * Restores window geometry to its original slot when the pointer leaves an active tile. */ - _revertVisualSwap(tracker, layout, monitorRect, gaps) { + _revertVisualSwap(layout, gaps, clearState = true) { if (!this._activeDrag || this._activeDrag.lastHoveredSlot === -1) return; const lastMonId = this._activeDrag.lastHoveredMonitorId; @@ -287,35 +287,9 @@ export class DragManager { } } - this._activeDrag.lastHoveredSlot = -1; - this._activeDrag.lastHoveredMonitorId = null; - } - - _revertVisualSwapForEnd(tracker, layout, monitorRect, gaps) { - if (!this._activeDrag || this._activeDrag.lastHoveredSlot === -1) return; - - const lastMonId = this._activeDrag.lastHoveredMonitorId; - const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); - const sourceMonId = wrapper ? wrapper.monitorId : null; - - if (lastMonId && lastMonId !== sourceMonId) { - const lastTracker = layout._getTracker(lastMonId); - const lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); - if (lastMonitorIndex !== -1) { - const workspace = wrapper ? wrapper.workspace : layout.workspace; - const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); - this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); - } - } - - if (sourceMonId) { - const sourceTracker = layout._getTracker(sourceMonId); - const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); - if (sourceMonitorIndex !== -1) { - const workspace = wrapper ? wrapper.workspace : layout.workspace; - const sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); - this._restoreTrackerGeometries(sourceTracker, layout, sourceMonitorRect, gaps); - } + if (clearState) { + this._activeDrag.lastHoveredSlot = -1; + this._activeDrag.lastHoveredMonitorId = null; } } @@ -382,8 +356,7 @@ export class DragManager { const layout = this.controller.workspaceManager.getLayout(workspace); // Revert temporary visual swaps before performing final tracking and retile - const sourceTracker = layout._getTracker(wrapper.monitorId); - this._revertVisualSwapForEnd(sourceTracker, layout, monitorRect, gaps); + this._revertVisualSwap(layout, gaps, false); window.disconnect(activeDrag.signalId); if (activeDrag.indicator) { @@ -403,45 +376,31 @@ export class DragManager { const sourceMonitorIndex = wrapper.monitorIndex; const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; - if (behavior === 'swap') { - const targetTracker = layout._getTracker(lastHoveredMonitorId); - const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === lastHoveredSlot); - - if (targetWindow) { - layout.untrackWindow(window, sourceMonitorId); - layout.untrackWindow(targetWindow, lastHoveredMonitorId); + const targetTracker = layout._getTracker(lastHoveredMonitorId); + const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === lastHoveredSlot); - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; + if (behavior === 'swap' && targetWindow) { + layout.untrackWindow(window, sourceMonitorId); + layout.untrackWindow(targetWindow, lastHoveredMonitorId); - const targetWrapper = this.controller._windowWrappers.get(targetWindow); - if (targetWrapper) { - targetWrapper.monitorId = sourceMonitorId; - targetWrapper.monitorIndex = sourceMonitorIndex; - } + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; - targetTracker.track(window, lastHoveredSlot); - const sourceTracker = layout._getTracker(sourceMonitorId); - sourceTracker.track(targetWindow, activeDrag.originalSlot); + const targetWrapper = this.controller._windowWrappers.get(targetWindow); + if (targetWrapper) { + targetWrapper.monitorId = sourceMonitorId; + targetWrapper.monitorIndex = sourceMonitorIndex; + } - if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); - if (sourceMonitorIndex !== -1) targetWindow.move_to_monitor(sourceMonitorIndex); + targetTracker.track(window, lastHoveredSlot); + const sourceTracker = layout._getTracker(sourceMonitorId); + sourceTracker.track(targetWindow, activeDrag.originalSlot); - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); - } else { - layout.untrackWindow(window, sourceMonitorId); - - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - - layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); - - if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); - - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); - } + if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); + if (sourceMonitorIndex !== -1) targetWindow.move_to_monitor(sourceMonitorIndex); + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); } else { layout.untrackWindow(window, sourceMonitorId); @@ -449,11 +408,9 @@ export class DragManager { wrapper.monitorIndex = targetMonitorIndex; layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); - - if (targetMonitorIndex !== -1) { - window.move_to_monitor(targetMonitorIndex); - } - + + if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); } diff --git a/lib/keybindings.js b/lib/keybindings.js index a011a9b..02a183e 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -54,12 +54,12 @@ export class KeybindingManager { // Batch Utilities const utilities = { - 'shortcut-close-monitor': (c, win) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), - 'shortcut-close-workspace': (c, win) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), + 'shortcut-close-monitor': (c) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), + 'shortcut-close-workspace': (c) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), 'shortcut-switch-monitor': (c, win) => c.switchMonitors(win ? win.get_monitor() : global.display.get_current_monitor()), - 'shortcut-port-monitor-left': (c, win) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), - 'shortcut-port-monitor-right': (c, win) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), - 'shortcut-unminimize-workspace': (c, win) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) + 'shortcut-port-monitor-left': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), + 'shortcut-port-monitor-right': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), + 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) }; for (const [key, action] of Object.entries(utilities)) { diff --git a/lib/monitor.js b/lib/monitor.js index b70bf34..b9d9ec3 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -275,12 +275,12 @@ export class MonitorManager { const logicalMonitors = manager.get_logical_monitors(); const sourceMonitor = logicalMonitors[currentMonitorIndex]; if (!sourceMonitor) { - Logger.info(`getMonitorInDirection: invalid sourceMonitor for index ${currentMonitorIndex}`); + Logger.debug(`getMonitorInDirection: invalid sourceMonitor for index ${currentMonitorIndex}`); return -1; } const sRect = global.display.get_monitor_geometry(currentMonitorIndex); - Logger.info(`[DEBUG] getMonitorInDirection: source monitor ${currentMonitorIndex} rect: ${sRect.x}, ${sRect.y}, ${sRect.width}, ${sRect.height}`); + Logger.debug(`getMonitorInDirection: source monitor ${currentMonitorIndex} rect: ${sRect.x}, ${sRect.y}, ${sRect.width}, ${sRect.height}`); let candidates = []; const eps = 1; @@ -306,7 +306,7 @@ export class MonitorManager { dist = cRect.y - (sRect.y + sRect.height); } - Logger.info(`getMonitorInDirection: checking monitor ${i} (${cRect.x},${cRect.y},${cRect.width},${cRect.height}) for direction ${direction}: inDirection=${inDirection}`); + Logger.debug(`getMonitorInDirection: checking monitor ${i} (${cRect.x},${cRect.y},${cRect.width},${cRect.height}) for direction ${direction}: inDirection=${inDirection}`); if (inDirection) { let overlap = 0; @@ -320,7 +320,7 @@ export class MonitorManager { } if (candidates.length === 0) { - Logger.info('getMonitorInDirection: no candidates found'); + Logger.debug('getMonitorInDirection: no candidates found'); return -1; } @@ -334,7 +334,7 @@ export class MonitorManager { return a.index - b.index; }); - Logger.info(`getMonitorInDirection: best candidate is ${candidates[0].index}`); + Logger.debug(`getMonitorInDirection: best candidate is ${candidates[0].index}`); return candidates[0].index; } catch (e) { Logger.error(`Failed to get monitor in direction ${direction}`, e); diff --git a/lib/workspace.js b/lib/workspace.js index ccb4c6a..aa5e07c 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -8,7 +8,7 @@ export class WorkspaceLayout { constructor(workspace, controller) { this.workspace = workspace; this.controller = controller; - this.escalator = (controller && controller.escalator) ? controller.escalator : controller; + this.escalator = controller.escalator; this.monitors = new Map(); } @@ -122,20 +122,20 @@ export class WorkspaceLayout { } let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; - Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); } - Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1) return null; if (this.controller && this.controller.monitorManager) { - Logger.info(`[DEBUG] calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); - Logger.info(`[DEBUG] getMonitorInDirection returned ${adjacentMonitorIndex}`); - Logger.info(`[DEBUG] _getTargetWindowInDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); + Logger.debug(`getMonitorInDirection returned ${adjacentMonitorIndex}`); + Logger.debug(`_getTargetWindowInDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); if (adjacentMonitorIndex !== -1) { const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); const targetTracker = this._getTracker(targetMonitorId); @@ -147,10 +147,12 @@ export class WorkspaceLayout { } /** - * Finds the nearest window in the specified geometric direction and swaps slots. - * Computes orthogonal overlap and distance to determine the best candidate. + * Handles the transition of a window moving between monitors. + * Depending on configuration, it either swaps the window with another window + * at the entering edge of the target monitor, or scales/escalates the layouts. */ handleMonitorTransition(window, sourceMonitorId, targetMonitorId, enteringEdge, sourceSlot) { + if (!this.controller) return; const sourceTracker = this._getTracker(sourceMonitorId); const targetTracker = this._getTracker(targetMonitorId); @@ -245,20 +247,20 @@ export class WorkspaceLayout { } let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; - Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); } - Logger.info(`[DEBUG] moveWindowDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`moveWindowDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1) return false; if (this.controller && this.controller.monitorManager) { - Logger.info(`[DEBUG] calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); - Logger.info(`[DEBUG] getMonitorInDirection returned ${adjacentMonitorIndex}`); - Logger.info(`[DEBUG] moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); + Logger.debug(`getMonitorInDirection returned ${adjacentMonitorIndex}`); + Logger.debug(`moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); if (adjacentMonitorIndex !== -1) { const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); const enteringEdgeMap = { @@ -271,7 +273,7 @@ export class WorkspaceLayout { this.handleMonitorTransition(window, monitorId, targetMonitorId, enteringEdge, slot); - if (this.controller._scheduleRetile) { + if (this.controller && this.controller._scheduleRetile) { this.controller._scheduleRetile(this.workspace, monitorId, currentMonitorIndex); this.controller._scheduleRetile(this.workspace, targetMonitorId, adjacentMonitorIndex); } @@ -465,14 +467,7 @@ export class WorkspaceManager { const numMonitors = manager.get_logical_monitors().length; if (numMonitors < 2) return; - let targetMonitorIndex; - if (numMonitors === 2) { - targetMonitorIndex = activeMonitorIndex === 0 ? 1 : 0; - } else { - const primaryIndex = global.display.get_primary_monitor(); - if (activeMonitorIndex === primaryIndex) return; - targetMonitorIndex = primaryIndex; - } + const targetMonitorIndex = (activeMonitorIndex + 1) % numMonitors; const activeMonitorId = this.controller.monitorManager ? this.controller.monitorManager.getMonitorId(activeMonitorIndex) : `monitor-${activeMonitorIndex}`; const targetMonitorId = this.controller.monitorManager ? this.controller.monitorManager.getMonitorId(targetMonitorIndex) : `monitor-${targetMonitorIndex}`; @@ -518,7 +513,7 @@ export class WorkspaceManager { this.controller.setBatchMode(false); // Schedule retile rather than hydrate - if (this.controller._scheduleRetile) { + if (this.controller && this.controller._scheduleRetile) { this.controller._scheduleRetile(workspace, activeMonitorId, activeMonitorIndex); this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); } else { diff --git a/tests/adversarial.test.js b/tests/adversarial.test.js new file mode 100644 index 0000000..83e10d3 --- /dev/null +++ b/tests/adversarial.test.js @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TilingController } from '../lib/controller.js'; +import { LayoutParser } from '../lib/layout.js'; +import Meta from 'gi://Meta'; + +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; + +describe('Adversarial Tests', () => { + describe('enteringEdge calculation in tilingRequest', () => { + let controller; + let mockMonitorGeometries; + + beforeEach(() => { + vi.clearAllMocks(); + const manager = Meta.Backend.get_monitor_manager(); + + // Set up mock monitor list + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + vi.mocked(manager.get_primary_monitor).mockReturnValue(0); + global.display.get_primary_monitor = () => manager.get_primary_monitor(); + + TilingController.activeInstance = null; + controller = new TilingController(); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + controller.monitorManager.initializeMonitorState(); + + mockMonitorGeometries = { + 0: { x: 0, y: 0, width: 1920, height: 1080 }, + 1: { x: 0, y: 0, width: 1920, height: 1080 } + }; + + global.display.get_monitor_geometry = vi.fn(idx => mockMonitorGeometries[idx]); + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const testEnteringEdge = (sourceRect, targetRect) => { + mockMonitorGeometries[0] = sourceRect; + mockMonitorGeometries[1] = targetRect; + + const ws = { id: 'ws1', index: () => 0, get_work_area_for_monitor: () => ({ x: 0, y: 0, width: 1000, height: 1000 }) }; + const win = { + id: 'win1', + get_workspace: () => ws, + get_monitor: vi.fn(() => 1), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 3840, height: 1080 }), + get_frame_rect: () => ({ x: 10, y: 10, width: 100, height: 100 }), + move_resize_frame: vi.fn(), + move_to_monitor: vi.fn(), + get_title: () => 'Window 1', + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false, + connect: vi.fn(() => 123), + disconnect: vi.fn(), + handler_is_connected: vi.fn(() => true) + }; + + // Setup wrapper to make it look like a monitor change + const wrapper = { + workspace: ws, + monitorIndex: 0, + monitorId: 'monitor-0', + applyGeometry: vi.fn(), + bindSignals: vi.fn(), + bindSizeChanged: vi.fn(), + destroy: vi.fn() + }; + controller._windowWrappers.set(win, wrapper); + + // Spy on handleMonitorTransition + const layout = controller.workspaceManager.getLayout(ws); + const spy = vi.spyOn(layout, 'handleMonitorTransition').mockImplementation(() => {}); + + controller.tilingRequest(win); + + expect(spy).toHaveBeenCalled(); + const calledEdge = spy.mock.calls[0][3]; + return calledEdge; + }; + + it('should detect top edge when target is below source', () => { + const edge = testEnteringEdge( + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 0, y: 1080, width: 1920, height: 1080 } + ); + expect(edge).toBe('top'); + }); + + it('should detect bottom edge when target is above source', () => { + const edge = testEnteringEdge( + { x: 0, y: 1080, width: 1920, height: 1080 }, + { x: 0, y: 0, width: 1920, height: 1080 } + ); + expect(edge).toBe('bottom'); + }); + + it('should detect left edge when target is right of source', () => { + const edge = testEnteringEdge( + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 1920, y: 0, width: 1920, height: 1080 } + ); + expect(edge).toBe('left'); + }); + + it('should detect right edge when target is left of source', () => { + const edge = testEnteringEdge( + { x: 1920, y: 0, width: 1920, height: 1080 }, + { x: 0, y: 0, width: 1920, height: 1080 } + ); + expect(edge).toBe('right'); + }); + + it('should handle vertical stack with slight horizontal misalignment', () => { + const edge = testEnteringEdge( + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 10, y: 1080, width: 1920, height: 1080 } + ); + expect(edge).toBe('top'); + }); + + it('should handle diagonal layout (45 degrees)', () => { + const edge = testEnteringEdge( + { x: 0, y: 0, width: 1000, height: 1000 }, + { x: 1000, y: 1000, width: 1000, height: 1000 } + ); + expect(edge).toBe('left'); // Math.abs(dx) >= Math.abs(dy) (1000 >= 1000) -> left + }); + }); + + describe('switchMonitors modulo arithmetic', () => { + let controller; + beforeEach(() => { + vi.clearAllMocks(); + TilingController.activeInstance = null; + controller = new TilingController(); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const setupMonitors = (num) => { + const manager = Meta.Backend.get_monitor_manager(); + const list = []; + for (let i = 0; i < num; i++) { + list.push({ + get_monitors: () => [{ + get_stable_id: () => `monitor-${i}`, + get_connector: () => `CONN-${i}` + }] + }); + } + vi.mocked(manager.get_logical_monitors).mockReturnValue(list); + controller.monitorManager.initializeMonitorState(); + }; + + it('should cycle correctly on 3 monitors', () => { + setupMonitors(3); + const ws = { + id: 'ws1', + list_windows: () => [win0, win1, win2], + get_work_area_for_monitor: () => ({ x: 0, y: 0, width: 1000, height: 1000 }) + }; + global.workspace_manager.get_active_workspace.mockReturnValue(ws); + + const win0 = { move_to_monitor: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false }; + const win1 = { move_to_monitor: vi.fn(), get_monitor: () => 1, is_skip_taskbar: () => false }; + const win2 = { move_to_monitor: vi.fn(), get_monitor: () => 2, is_skip_taskbar: () => false }; + + // Wrap them + controller._windowWrappers.set(win0, { monitorId: 'monitor-0', monitorIndex: 0, destroy: vi.fn() }); + controller._windowWrappers.set(win1, { monitorId: 'monitor-1', monitorIndex: 1, destroy: vi.fn() }); + controller._windowWrappers.set(win2, { monitorId: 'monitor-2', monitorIndex: 2, destroy: vi.fn() }); + + // Switch monitors with active index = 0 + // targetMonitorIndex = (0 + 1) % 3 = 1 + // Windows on 0 should move to 1, windows on 1 should move to 0. Win2 remains untouched. + controller.switchMonitors(0); + + expect(win0.move_to_monitor).toHaveBeenCalledWith(1); + expect(win1.move_to_monitor).toHaveBeenCalledWith(0); + expect(win2.move_to_monitor).not.toHaveBeenCalled(); + }); + + it('should cycle correctly on 4 monitors', () => { + setupMonitors(4); + const ws = { + id: 'ws1', + list_windows: () => [win0, win1, win2, win3], + get_work_area_for_monitor: () => ({ x: 0, y: 0, width: 1000, height: 1000 }) + }; + global.workspace_manager.get_active_workspace.mockReturnValue(ws); + + const win0 = { move_to_monitor: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false }; + const win1 = { move_to_monitor: vi.fn(), get_monitor: () => 1, is_skip_taskbar: () => false }; + const win2 = { move_to_monitor: vi.fn(), get_monitor: () => 2, is_skip_taskbar: () => false }; + const win3 = { move_to_monitor: vi.fn(), get_monitor: () => 3, is_skip_taskbar: () => false }; + + // Wrap them + controller._windowWrappers.set(win0, { monitorId: 'monitor-0', monitorIndex: 0, destroy: vi.fn() }); + controller._windowWrappers.set(win1, { monitorId: 'monitor-1', monitorIndex: 1, destroy: vi.fn() }); + controller._windowWrappers.set(win2, { monitorId: 'monitor-2', monitorIndex: 2, destroy: vi.fn() }); + controller._windowWrappers.set(win3, { monitorId: 'monitor-3', monitorIndex: 3, destroy: vi.fn() }); + + // Switch monitors with active index = 2 + // targetMonitorIndex = (2 + 1) % 4 = 3 + // Windows on 2 should move to 3, windows on 3 should move to 2. Others untouched. + controller.switchMonitors(2); + + expect(win2.move_to_monitor).toHaveBeenCalledWith(3); + expect(win3.move_to_monitor).toHaveBeenCalledWith(2); + expect(win0.move_to_monitor).not.toHaveBeenCalled(); + expect(win1.move_to_monitor).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 08883eb..2f9b1bf 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -5,10 +5,11 @@ import { LayoutParser } from '../lib/layout.js'; describe('WorkspaceLayout', () => { const defaultJson = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; const escalator = LayoutParser.parse(defaultJson); + const controller = { escalator }; const monitorRect = { x: 0, y: 0, width: 1000, height: 1000 }; it('should track windows in sequence with gaps', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const win1 = { id: 1 }; const win2 = { id: 2 }; @@ -22,7 +23,7 @@ describe('WorkspaceLayout', () => { }); it('should handle multiple monitors independently', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const rectM0 = { x: 0, y: 0, width: 1920, height: 1080 }; const rectM1 = { x: 1920, y: 0, width: 1920, height: 1080 }; @@ -39,7 +40,7 @@ describe('WorkspaceLayout', () => { }); it('should handle different resolutions on different monitors', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const rect4K = { x: 0, y: 0, width: 3840, height: 2160 }; const rectHD = { x: 3840, y: 0, width: 1920, height: 1080 }; @@ -56,7 +57,7 @@ describe('WorkspaceLayout', () => { }); it('should provide retile operations when a window is removed', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; const w2 = { id: 2 }; const w3 = { id: 3 }; @@ -73,7 +74,7 @@ describe('WorkspaceLayout', () => { }); it('should maintain independent window counts across monitors', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); layout.trackWindow({ id: 1 }, 0); layout.trackWindow({ id: 2 }, 1); layout.trackWindow({ id: 3 }, 1); @@ -84,7 +85,7 @@ describe('WorkspaceLayout', () => { describe('moveWindowDirection', () => { it('should correctly swap windows left/right with 2 windows', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; const w2 = { id: 2 }; layout.trackWindow(w1, 0); @@ -103,7 +104,7 @@ describe('WorkspaceLayout', () => { }); it('should correctly prioritize older windows when moving left/right in 3-window layout', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; // left const w2 = { id: 2 }; // top right const w3 = { id: 3 }; // bottom right @@ -122,7 +123,7 @@ describe('WorkspaceLayout', () => { }); it('should swap up/down in 3-window layout', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; // left const w2 = { id: 2 }; // top right const w3 = { id: 3 }; // bottom right @@ -140,7 +141,7 @@ describe('WorkspaceLayout', () => { }); it('should not move if no window in that direction', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; // left const w2 = { id: 2 }; // right @@ -160,7 +161,7 @@ describe('WorkspaceLayout', () => { const mockRect = { x: 0, y: 0, width: 1000, height: 1000 }; it('should swap windows if center is dropped over another window', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1, get_frame_rect: () => ({ x: 700, y: 100, width: 100, height: 100 }) }; // dropped center at (750, 150), which is in the right half const w2 = { id: 2, get_frame_rect: () => ({ x: 500, y: 0, width: 500, height: 1000 }) }; @@ -177,7 +178,7 @@ describe('WorkspaceLayout', () => { }); it('should not swap if dropped outside of any other window', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); // Dropped way off screen (e.g. invalid drag or over a panel) const w1 = { id: 1, get_frame_rect: () => ({ x: 2000, y: 2000, width: 100, height: 100 }) }; const w2 = { id: 2, get_frame_rect: () => ({ x: 500, y: 0, width: 500, height: 1000 }) }; @@ -195,7 +196,7 @@ describe('WorkspaceLayout', () => { }); it('should not swap with itself if dropped in its own original area', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); // Dropped in the left half (its own area in 2-window layout) const w1 = { id: 1, get_frame_rect: () => ({ x: 100, y: 100, width: 100, height: 100 }) }; const w2 = { id: 2, get_frame_rect: () => ({ x: 500, y: 0, width: 500, height: 1000 }) }; @@ -328,10 +329,11 @@ describe('WorkspaceManager', () => { describe('WorkspaceLayout Cross-Monitor Fallback', () => { const defaultJson = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; const escalator = LayoutParser.parse(defaultJson); + const controller = { escalator }; describe('_findClosestBoundaryWindow', () => { it('should choose the window with highest overlap on adjacent edge', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const targetTracker = { size: 2, windows: [ @@ -346,7 +348,7 @@ describe('WorkspaceLayout Cross-Monitor Fallback', () => { }); it('should resolve ties using top-most/right-most tie breakers', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const targetTrackerY = { size: 2, windows: [ From 1a63de048b36d7673a06add8541d600e1ca15bc9 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 14 Jun 2026 11:01:20 +0200 Subject: [PATCH 14/33] refactor: 2 iteration --- docs/architecture.md | 12 + docs/vision.md | 2 +- lib/controller.js | 13 +- lib/drag.js | 207 +++++++-------- lib/keybindings.js | 20 +- lib/state.js | 9 + lib/workspace.js | 98 ++++--- multi_monitor_refactoring_plan.md | 423 ------------------------------ tests/monitor-transition.test.js | 6 +- tests/regressions.test.js | 1 - tests/workspace.test.js | 10 +- 11 files changed, 199 insertions(+), 602 deletions(-) delete mode 100644 multi_monitor_refactoring_plan.md diff --git a/docs/architecture.md b/docs/architecture.md index a760552..9a172b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,6 +14,7 @@ Tracks monitor topology. Detects hotplug events. Identifies stable monitor IDs. Manages window evacuation during monitor removal. +Provides directional monitor lookup via `getMonitorInDirection()` for cross-monitor transitions. ## SignalListener (`lib/signals.js`) Binds GNOME Shell signals. @@ -40,9 +41,11 @@ Binds single-shot `size-changed` signals to detect external resizing. ## WorkspaceManager & WorkspaceLayout (`lib/workspace.js`) `WorkspaceManager` tracks multiple layouts across GNOME workspaces. +`WorkspaceManager` provides batch monitor operations (close, switch, port to workspace). `WorkspaceLayout` tracks windows per workspace and monitor. Calculates window slots based on insertion order. Provides window displacement and swapping logic. +Handles cross-monitor window transitions via configurable swap or escalate behavior. ## StateTracker (`lib/state.js`) Maintains stable ordered list of windows. @@ -53,10 +56,12 @@ Swaps window positions. Tracks window drag-and-drop operations. Renders visual swap indicators. Triggers geometric swapping based on pointer intersections. +Handles cross-monitor drag transitions with visual previews and deferred retiles. ## SettingsManager (`lib/settings.js`) Loads configuration preferences. Parses layout JSON into valid escalator transitions. +Exposes monitor transition behavior configuration. ## Logger (`lib/logger.js`) Provides debug and trace logging. @@ -65,6 +70,7 @@ Configurable output verbosity. ## Escalator (`lib/layout.js`) Generates tile geometries. Provides geometric estates based on current window count. +Provides edge-adjacent slot lookup via `getEdgingSlot()` for directional transitions. ## Execution Flow Signal triggers event. @@ -73,3 +79,9 @@ Controller updates WorkspaceLayout state. Controller schedules deferred retile. Retile queries Escalator for layouts. Retile invokes WindowWrapper to apply geometries. + +## Cross-Monitor Flow +Keyboard/drag triggers direction detection. +Controller/DragManager delegates to WorkspaceLayout. +WorkspaceLayout escalates or swaps window between monitor trackers. +Controller schedules retile on both monitors. diff --git a/docs/vision.md b/docs/vision.md index 9b6a7e1..c39a2bf 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -21,7 +21,7 @@ Layout transitions are predictable, minimal, and fully customizable. The system - **Error Shielding**: Every Mutter API interaction is wrapped in defensive checks and error handling to prevent Shell crashes. ### Scaling & Isolation -- **Multi-Monitor Support**: Each monitor maintains an independent tiling state and respects its own work area/resolution. +- **Multi-Monitor Support**: Each monitor maintains an independent tiling state and respects its own work area/resolution. Monitors support cross-monitor window transitions via keyboard and drag, with configurable swap/escalate behavior. - **Workspace Isolation**: Tiling is scoped per GNOME workspace. ## Future Roadmap diff --git a/lib/controller.js b/lib/controller.js index 3fba26a..146aa85 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -114,8 +114,8 @@ export class TilingController { if (isMonitorChange) { const sourceMonitorId = wrapper.monitorId; const sourceMonitorIndex = wrapper.monitorIndex; - const sourceTracker = layout._getTracker(sourceMonitorId); - const sourceSlot = sourceTracker.getSlot(window) !== undefined ? sourceTracker.getSlot(window) : 0; + const slot = layout.getWindowSlot(sourceMonitorId, window); + const sourceSlot = slot !== undefined ? slot : 0; const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); const targetRect = global.display.get_monitor_geometry(monitorIndex); @@ -129,7 +129,14 @@ export class TilingController { enteringEdge = dy > 0 ? 'top' : 'bottom'; } - layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + const result = layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + + this.updateWindowWrapperMonitor(window, monitorId, monitorIndex); + + if (result && result.swappedWindow) { + this.updateWindowWrapperMonitor(result.swappedWindow, sourceMonitorId, sourceMonitorIndex); + if (result.swappedWindow.move_to_monitor) result.swappedWindow.move_to_monitor(sourceMonitorIndex); + } Logger.debug(`tilingRequest: Monitor transition handled. Scheduling retile.`); this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); diff --git a/lib/drag.js b/lib/drag.js index 2230ac1..81ab97b 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -1,6 +1,6 @@ -import Gio from 'gi://Gio'; +import Meta from 'gi://Meta'; import St from 'gi://St'; -import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import { Logger } from './logger.js'; /** * DragManager: Manages pointer drag tracking and visual drop indicators. @@ -34,17 +34,17 @@ export class DragManager { const workspace = wrapper.workspace; const layout = this.controller.workspaceManager.getLayout(workspace); - const tracker = layout._getTracker(wrapper.monitorId); - const originalSlot = tracker.getSlot(window); + const windowCount = layout.getWindowCount(wrapper.monitorId); + const originalSlot = layout.getWindowSlot(wrapper.monitorId, window); if (originalSlot === undefined) return; - const matrix = layout.escalator.getLayoutForCount(tracker.size); + const matrix = layout.escalator.getLayoutForCount(windowCount); if (!matrix || originalSlot >= matrix.size) return; const indicator = this._createIndicator(); const signalId = window.connect('position-changed', () => { - this._handlePositionChanged(wrapper, layout, tracker, originalSlot, indicator); + this._handlePositionChanged(wrapper, layout, originalSlot, indicator); }); const origRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; @@ -81,7 +81,7 @@ export class DragManager { * Continuously handles window pointer positioning during an active drag, * triggering visual slot swaps when the pointer crosses bounds. */ - _handlePositionChanged(wrapper, layout, tracker, originalSlot, indicator) { + _handlePositionChanged(wrapper, layout, originalSlot, indicator) { const workspace = wrapper.workspace; if (!workspace.get_work_area_for_monitor) return; @@ -94,13 +94,13 @@ export class DragManager { } const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); - const targetTracker = layout._getTracker(monitorId); + const targetWindowCount = layout.getWindowCount(monitorId); const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); let hoveredSlot = -1; let targetRect = null; - if (targetTracker.size === 0) { + if (targetWindowCount === 0) { hoveredSlot = 0; targetRect = { x: monitorRect.x + gaps.outer, @@ -110,7 +110,7 @@ export class DragManager { }; } else { const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; - const matrixCount = (monitorId === wrapper.monitorId || behavior === 'swap') ? targetTracker.size : (targetTracker.size + 1); + const matrixCount = (monitorId === wrapper.monitorId || behavior === 'swap') ? targetWindowCount : (targetWindowCount + 1); if (matrixCount > layout.escalator.getMaxCount()) { hoveredSlot = -1; @@ -140,9 +140,9 @@ export class DragManager { indicator.show(); if (monitorId !== wrapper.monitorId) { - this._applyCrossMonitorVisualSwap(wrapper, targetTracker, layout, monitorId, hoveredSlot, monitorRect, gaps); + this._applyCrossMonitorVisualSwap(wrapper, targetWindowCount, layout, monitorId, hoveredSlot, monitorRect, gaps); } else { - this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); + this._applyVisualSwap(monitorId, layout, originalSlot, hoveredSlot, monitorRect, gaps); } } else { indicator.hide(); @@ -154,43 +154,47 @@ export class DragManager { * Applies a temporary visual preview of window positions, reverting the * previously hovered window and shifting the newly hovered window. */ - _applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps) { + _applyVisualSwap(monitorId, layout, originalSlot, hoveredSlot, monitorRect, gaps) { if (!this._activeDrag) return; const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); const sourceMonId = wrapper ? wrapper.monitorId : null; - if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; - // Revert previous hover - this._revertVisualSwap(layout, gaps); + if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; - const matrix = layout.escalator.getLayoutForCount(tracker.size); - if (matrix) { - // Apply new hover (move hovered window to dragged window's original slot) - this._restoreWindowGeometry(tracker, matrix, hoveredSlot, originalSlot, monitorRect, gaps); + // If we were hovering a different monitor previously, revert it first + if (this._activeDrag.lastHoveredMonitorId && this._activeDrag.lastHoveredMonitorId !== sourceMonId) { + this._revertVisualSwap(layout, gaps, true); } + const windowCount = layout.getWindowCount(monitorId); + const matrix = layout.escalator.getLayoutForCount(windowCount); + if (!matrix) return; + this._activeDrag.lastHoveredSlot = hoveredSlot; this._activeDrag.lastHoveredMonitorId = sourceMonId; + + this._restoreWindowGeometry(monitorId, layout, matrix, hoveredSlot, originalSlot, monitorRect, gaps); } - _applyCrossMonitorVisualSwap(wrapper, targetTracker, layout, monitorId, hoveredSlot, monitorRect, gaps) { + _applyCrossMonitorVisualSwap(wrapper, targetWindowCount, layout, monitorId, hoveredSlot, monitorRect, gaps) { if (!this._activeDrag) return; if (this._activeDrag.lastHoveredMonitorId === monitorId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; // Revert previous hover - const sourceTracker = layout._getTracker(wrapper.monitorId); const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; if (behavior === 'escalate') { - const sourceMatrix = layout.escalator.getLayoutForCount(sourceTracker.size > 0 ? sourceTracker.size - 1 : 0); + const sourceCount = layout.getWindowCount(wrapper.monitorId); + const sourceMatrix = layout.escalator.getLayoutForCount(sourceCount > 0 ? sourceCount - 1 : 0); if (sourceMatrix) { const origSlot = this._activeDrag.originalSlot; - for (const win of sourceTracker.windows) { + const windows = layout.getWindowsForMonitor(wrapper.monitorId); + for (const win of windows) { if (win === this._activeDrag.window) continue; - const slot = sourceTracker.getSlot(win); + const slot = layout.getWindowSlot(wrapper.monitorId, win); if (slot !== undefined) { const targetSlot = (slot > origSlot) ? (slot - 1) : slot; const wrap = this.controller._windowWrappers.get(win); @@ -207,19 +211,18 @@ export class DragManager { } // Apply new cross-monitor visual swap preview using size N+1 matrix - const matrix = layout.escalator.getLayoutForCount(behavior === 'swap' && targetTracker.size > 0 ? targetTracker.size : targetTracker.size + 1); + const matrix = layout.escalator.getLayoutForCount(behavior === 'swap' && targetWindowCount > 0 ? targetWindowCount : targetWindowCount + 1); if (matrix) { - if (behavior === 'swap' && targetTracker.size > 0) { - // In swap mode, we visually move the hovered target window to the source window's slot - const sourceTracker = layout._getTracker(wrapper.monitorId); - const sourceMatrix = layout.escalator.getLayoutForCount(sourceTracker.size); + if (behavior === 'swap' && targetWindowCount > 0) { + const sourceMatrix = layout.escalator.getLayoutForCount(layout.getWindowCount(wrapper.monitorId)); const sourceEstate = sourceMatrix ? sourceMatrix.getEstate(this._activeDrag.originalSlot) : null; const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); - for (const win of targetTracker.windows) { - const slot = targetTracker.getSlot(win); + const windows = layout.getWindowsForMonitor(monitorId); + for (const win of windows) { + const slot = layout.getWindowSlot(monitorId, win); if (slot !== undefined) { const wrap = this.controller._windowWrappers.get(win); if (wrap) { @@ -237,9 +240,9 @@ export class DragManager { } } } else { - // Escalate N+1 preview - for (const win of targetTracker.windows) { - const slot = targetTracker.getSlot(win); + const windows = layout.getWindowsForMonitor(monitorId); + for (const win of windows) { + const slot = layout.getWindowSlot(monitorId, win); if (slot !== undefined) { const targetEstateSlot = (slot >= hoveredSlot) ? (slot + 1) : slot; const wrap = this.controller._windowWrappers.get(win); @@ -268,22 +271,20 @@ export class DragManager { const sourceMonId = wrapper ? wrapper.monitorId : null; if (lastMonId && lastMonId !== sourceMonId) { - const lastTracker = layout._getTracker(lastMonId); const lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); if (lastMonitorIndex !== -1) { const workspace = wrapper ? wrapper.workspace : layout.workspace; const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); - this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); + this._restoreTrackerGeometries(lastMonId, layout, lastMonitorRect, gaps); } } if (sourceMonId) { - const sourceTracker = layout._getTracker(sourceMonId); const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); if (sourceMonitorIndex !== -1) { const workspace = wrapper ? wrapper.workspace : layout.workspace; const sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); - this._restoreTrackerGeometries(sourceTracker, layout, sourceMonitorRect, gaps); + this._restoreTrackerGeometries(sourceMonId, layout, sourceMonitorRect, gaps); } } @@ -293,13 +294,14 @@ export class DragManager { } } - _restoreTrackerGeometries(tracker, layout, monitorRect, gaps) { - const matrix = layout.escalator.getLayoutForCount(tracker.size); + _restoreTrackerGeometries(monitorId, layout, monitorRect, gaps) { + const matrix = layout.escalator.getLayoutForCount(layout.getWindowCount(monitorId)); if (!matrix) return; const draggedWindow = this._activeDrag ? this._activeDrag.window : null; - for (const win of tracker.windows) { + const windows = layout.getWindowsForMonitor(monitorId); + for (const win of windows) { if (win === draggedWindow) continue; - const slot = tracker.getSlot(win); + const slot = layout.getWindowSlot(monitorId, win); if (slot !== undefined) { const wrap = this.controller._windowWrappers.get(win); if (wrap) { @@ -313,9 +315,8 @@ export class DragManager { } } - _restoreWindowGeometry(tracker, matrix, slotToFind, targetEstateSlot, monitorRect, gaps) { - if (!matrix || targetEstateSlot >= matrix.size) return; - const win = tracker.windows.find(w => tracker.getSlot(w) === slotToFind); + _restoreWindowGeometry(monitorId, layout, matrix, slotToFind, targetEstateSlot, monitorRect, gaps) { + const win = layout.getWindowsForMonitor(monitorId).find(w => layout.getWindowSlot(monitorId, w) === slotToFind); if (!win) return; const wrap = this.controller._windowWrappers.get(win); if (wrap) { @@ -365,55 +366,8 @@ export class DragManager { this._activeDrag = null; - if (this._deferredRetiles && this._deferredRetiles.length > 0) { - this._deferredRetiles.forEach(r => this.controller._scheduleRetile(r.workspace, r.monitorId, r.monitorIndex)); - this._deferredRetiles = []; - } - if (lastHoveredMonitorId && lastHoveredMonitorId !== wrapper.monitorId) { - const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastHoveredMonitorId); - const sourceMonitorId = wrapper.monitorId; - const sourceMonitorIndex = wrapper.monitorIndex; - const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; - - const targetTracker = layout._getTracker(lastHoveredMonitorId); - const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === lastHoveredSlot); - - if (behavior === 'swap' && targetWindow) { - layout.untrackWindow(window, sourceMonitorId); - layout.untrackWindow(targetWindow, lastHoveredMonitorId); - - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - - const targetWrapper = this.controller._windowWrappers.get(targetWindow); - if (targetWrapper) { - targetWrapper.monitorId = sourceMonitorId; - targetWrapper.monitorIndex = sourceMonitorIndex; - } - - targetTracker.track(window, lastHoveredSlot); - const sourceTracker = layout._getTracker(sourceMonitorId); - sourceTracker.track(targetWindow, activeDrag.originalSlot); - - if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); - if (sourceMonitorIndex !== -1) targetWindow.move_to_monitor(sourceMonitorIndex); - - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); - } else { - layout.untrackWindow(window, sourceMonitorId); - - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - - layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); - - if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); - - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); - } + this._commitCrossMonitorTransfer(window, wrapper, layout, lastHoveredMonitorId, lastHoveredSlot, activeDrag.originalSlot); } else { const [x, y] = global.get_pointer(); @@ -431,27 +385,54 @@ export class DragManager { const pointerMonitorId = this.controller.monitorManager.getMonitorId(pointerMonitorIndex); if (pointerMonitorId && pointerMonitorId !== wrapper.monitorId) { - const sourceMonitorId = wrapper.monitorId; - const sourceMonitorIndex = wrapper.monitorIndex; - - layout.untrackWindow(window, sourceMonitorId); - - wrapper.monitorId = pointerMonitorId; - wrapper.monitorIndex = pointerMonitorIndex; - - if (pointerMonitorIndex !== -1) window.move_to_monitor(pointerMonitorIndex); - - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + // Pointer fallback cross-monitor drop + this._commitCrossMonitorTransfer(window, wrapper, layout, pointerMonitorId, -1, activeDrag.originalSlot, pointerMonitorIndex); } else { - const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); - - const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; - const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; - - if (swapped || rectChanged) { - this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); - } + this._commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect); } } } + + _commitCrossMonitorTransfer(window, wrapper, layout, targetMonitorId, targetSlot, sourceSlot, targetMonitorIndexOverride = -1) { + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; + const workspace = wrapper.workspace; + let targetMonitorIndex = targetMonitorIndexOverride !== -1 ? targetMonitorIndexOverride : this.controller.monitorManager.getMonitorIndex(targetMonitorId); + + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + const targetWindows = layout.getWindowsForMonitor(targetMonitorId); + const targetWindow = targetWindows.find(w => layout.getWindowSlot(targetMonitorId, w) === targetSlot); + + if (behavior === 'swap' && targetWindow) { + layout.replaceWindow(targetWindow, window, targetMonitorId); + layout.replaceWindow(window, targetWindow, sourceMonitorId); + + this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); + this.controller.updateWindowWrapperMonitor(targetWindow, sourceMonitorId, sourceMonitorIndex); + + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + if (sourceMonitorIndex !== -1 && targetWindow.move_to_monitor) targetWindow.move_to_monitor(sourceMonitorIndex); + + } else { + layout.untrackWindow(window, sourceMonitorId); + layout.trackWindow(window, targetMonitorId, targetSlot !== -1 ? targetSlot : undefined); + + this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + } + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); + } + + _commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect) { + const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); + + const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; + const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; + + if (swapped || rectChanged) { + this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + } + } } diff --git a/lib/keybindings.js b/lib/keybindings.js index 02a183e..d49889a 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -83,14 +83,20 @@ export class KeybindingManager { const keysToShadow = []; for (const def of this._definitions) { - const { active, keyToBind } = this._resolveBinding(def); + const { active, keyToBind, isCustom } = this._resolveBinding(def); if (!active) { Logger.debug(`Binding ${def.defaultKey} is inactive.`); continue; } - Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, conflict=${def.conflict}`); - keysToShadow.push(keyToBind); + Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, isCustom=${isCustom}, conflict=${def.conflict}`); + + if (!isCustom && def.conflict) { + Logger.debug(`Will hijack native conflict ${def.conflict} instead of binding extension shortcut.`); + conflictsToHijack.push(def.conflict); + } else { + keysToShadow.push(keyToBind); + } } if (keysToShadow.length > 0) { @@ -99,11 +105,13 @@ export class KeybindingManager { // Bind extension shortcuts for (const def of this._definitions) { - const { active, keyToBind } = this._resolveBinding(def); - if (active) { + const { active, keyToBind, isCustom } = this._resolveBinding(def); + if (active && !(!isCustom && def.conflict)) { this._bindExtensionShortcut(def, keyToBind); } } + + conflictsToHijack.forEach(conflictKey => this._hijackNativeShortcut(conflictKey)); } _resolveBinding(def) { @@ -120,7 +128,7 @@ export class KeybindingManager { } } - return { active, keyToBind }; + return { active, keyToBind, isCustom }; } _bindExtensionShortcut(def, keyToBind) { diff --git a/lib/state.js b/lib/state.js index cdb924b..680a5d7 100644 --- a/lib/state.js +++ b/lib/state.js @@ -56,4 +56,13 @@ export class StateTracker { this._windowToSlot.clear(); this._originalGeometries.clear(); } + + swapWith(otherTracker) { + const tempWindowToSlot = this._windowToSlot; + const tempOriginalGeometries = this._originalGeometries; + this._windowToSlot = otherTracker._windowToSlot; + this._originalGeometries = otherTracker._originalGeometries; + otherTracker._windowToSlot = tempWindowToSlot; + otherTracker._originalGeometries = tempOriginalGeometries; + } } diff --git a/lib/workspace.js b/lib/workspace.js index aa5e07c..3c1e5d5 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -40,6 +40,33 @@ export class WorkspaceLayout { tracker.untrack(window); } + /** + * Replaces an existing window with a new window in the exact same slot. + */ + replaceWindow(oldWindow, newWindow, monitorId) { + const tracker = this._getTracker(monitorId); + const slot = tracker.getSlot(oldWindow); + if (slot !== undefined) { + tracker.untrack(oldWindow); + tracker.track(newWindow, slot); + } + } + + /** + * Tracker proxy methods + */ + getWindowSlot(monitorId, window) { + return this._getTracker(monitorId).getSlot(window); + } + + getWindowsForMonitor(monitorId) { + return this._getTracker(monitorId).windows; + } + + getWindowCount(monitorId) { + return this._getTracker(monitorId).size; + } + /** * Calculates absolute rects for all currently tracked windows on a monitor. */ @@ -122,7 +149,6 @@ export class WorkspaceLayout { } let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; - Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); } @@ -152,13 +178,10 @@ export class WorkspaceLayout { * at the entering edge of the target monitor, or scales/escalates the layouts. */ handleMonitorTransition(window, sourceMonitorId, targetMonitorId, enteringEdge, sourceSlot) { - if (!this.controller) return; + if (!this.controller) return null; const sourceTracker = this._getTracker(sourceMonitorId); const targetTracker = this._getTracker(targetMonitorId); - const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonitorId); - const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(targetMonitorId); - const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; if (behavior === 'swap' && targetTracker.size > 0) { @@ -174,24 +197,7 @@ export class WorkspaceLayout { targetTracker.track(window, targetEdgingSlot); sourceTracker.track(targetWindow, sourceSlot); - const wrapper = this.controller._windowWrappers.get(window); - if (wrapper) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - } - const targetWrapper = this.controller._windowWrappers.get(targetWindow); - if (targetWrapper) { - targetWrapper.monitorId = sourceMonitorId; - targetWrapper.monitorIndex = sourceMonitorIndex; - } - - if (window.move_to_monitor) { - window.move_to_monitor(targetMonitorIndex); - } - if (targetWindow.move_to_monitor) { - targetWindow.move_to_monitor(sourceMonitorIndex); - } - return; + return { swappedWindow: targetWindow, behavior: 'swap' }; } } } @@ -209,16 +215,7 @@ export class WorkspaceLayout { } this.trackWindow(window, targetMonitorId, preferredSlot); - - const wrapper = this.controller._windowWrappers.get(window); - if (wrapper) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - } - - if (window.move_to_monitor) { - window.move_to_monitor(targetMonitorIndex); - } + return { swappedWindow: null, behavior: 'escalate' }; } /** @@ -247,12 +244,9 @@ export class WorkspaceLayout { } let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; - Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); } - - Logger.debug(`moveWindowDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1) return false; @@ -260,7 +254,6 @@ export class WorkspaceLayout { Logger.debug(`calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); Logger.debug(`getMonitorInDirection returned ${adjacentMonitorIndex}`); - Logger.debug(`moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); if (adjacentMonitorIndex !== -1) { const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); const enteringEdgeMap = { @@ -271,7 +264,16 @@ export class WorkspaceLayout { }; const enteringEdge = enteringEdgeMap[direction]; - this.handleMonitorTransition(window, monitorId, targetMonitorId, enteringEdge, slot); + const result = this.handleMonitorTransition(window, monitorId, targetMonitorId, enteringEdge, slot); + + // Update wrappers and physical monitors + this.controller.updateWindowWrapperMonitor(window, targetMonitorId, adjacentMonitorIndex); + if (window.move_to_monitor) window.move_to_monitor(adjacentMonitorIndex); + + if (result && result.swappedWindow) { + this.controller.updateWindowWrapperMonitor(result.swappedWindow, monitorId, currentMonitorIndex); + if (result.swappedWindow.move_to_monitor) result.swappedWindow.move_to_monitor(currentMonitorIndex); + } if (this.controller && this.controller._scheduleRetile) { this.controller._scheduleRetile(this.workspace, monitorId, currentMonitorIndex); @@ -477,26 +479,16 @@ export class WorkspaceManager { // Swap layout trackers const trackerA = layout._getTracker(activeMonitorId); const trackerB = layout._getTracker(targetMonitorId); - const tempWindowToSlot = trackerA._windowToSlot; - const tempOriginalGeometries = trackerA._originalGeometries; - trackerA._windowToSlot = trackerB._windowToSlot; - trackerA._originalGeometries = trackerB._originalGeometries; - trackerB._windowToSlot = tempWindowToSlot; - trackerB._originalGeometries = tempOriginalGeometries; + trackerA.swapWith(trackerB); // Update window wrapper cache const windows = workspace.list_windows(); windows.forEach(w => { const m = w.get_monitor(); - const wrapper = this.controller._windowWrappers.get(w); - if (wrapper) { - if (m === activeMonitorIndex) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - } else if (m === targetMonitorIndex) { - wrapper.monitorId = activeMonitorId; - wrapper.monitorIndex = activeMonitorIndex; - } + if (m === activeMonitorIndex) { + this.controller.updateWindowWrapperMonitor(w, targetMonitorId, targetMonitorIndex); + } else if (m === targetMonitorIndex) { + this.controller.updateWindowWrapperMonitor(w, activeMonitorId, activeMonitorIndex); } }); diff --git a/multi_monitor_refactoring_plan.md b/multi_monitor_refactoring_plan.md deleted file mode 100644 index f130473..0000000 --- a/multi_monitor_refactoring_plan.md +++ /dev/null @@ -1,423 +0,0 @@ -# Multi-Monitor Refactoring Plan - -## Architecture Analysis & Responsibility Shifts - -This section details updates to files and classes to support workspaces spanning multiple displays. - -| File Path | Target Class | Role & Proposed Architecture Updates | -|---|---|---| -| `lib/workspace.js` | `WorkspaceLayout` | Allocates slots per workspace and monitor. Handles cross-monitor actions (like switching monitors, closing monitor windows, and workspace porting) and integrates cross-monitor navigation fallback by querying adjacent monitor state trackers when spatial boundaries are reached. | -| `lib/drag.js` | `DragManager` | Tracks drag positions. Implements cross-monitor pointer-to-slot mapping, renders visual indicators on target monitors, and coordinates visual swaps across monitor boundaries. | -| `lib/keybindings.js` | `KeybindingManager` | Resolves and binds keyboard shortcuts. Delegates focus-switching and window-movement actions to the controller, supporting boundary traversal to adjacent displays. | -| `lib/monitor.js` | `MonitorManager` | Tracks display topologies. Listens to hardware changes via backend signals, and manages transient storage for windows evicted by monitor unplug events (leaving cross-monitor actions to Workspace). | -| `lib/controller.js` | `TilingController` | Orchestrates operations. Coordinates window state changes between workspace layouts, coordinates drag state initialization/termination, and dispatches batch updates to avoid layout thrashing. | - -### Responsibility Changes - -* `WorkspaceLayout` / `WorkspaceManager`: Tracks logical state mapped per monitor ID. Spatial search fallbacks to adjacent monitors when keyboard navigation hits display edges. Handles cross-monitor actions (switching monitors, closing monitor windows, workspace porting). -* `DragManager`: Evaluates pointer location globally. Renders indicators relative to target displays and manages preview transitions across screens. -* `MonitorManager`: Listens to topology updates, matching logical monitors to stable physical IDs. Manages transient storage/metadata for windows evicted by monitor unplug events (uses existing evacuation logic, does not destroy windows, preserves state). -* `TilingController`: Dispatches coordinates and sizes to the correct target workspace layout based on pointer position or active focus. - ---- - -## Core Multi-Monitor Scenarios - -### Dynamic Handling of Monitor Hotplug Events - -* Proposed GNOME Shell Signals: - * `monitors-changed`: Connected via `global.backend.get_monitor_manager()` to detect display hardware hotplug and configuration updates. - * `size-changed`: Connected on tracked windows or workspace boundaries to trigger retiling upon size changes or display resolution alterations. -* Evacuation Logic: - * Detects removed displays by comparing active stable monitor IDs against cached IDs. - * Use existing logic; do not destroy windows, preserve state. Specifically, minimize windows instead of deleting/destroying them, untrack them, and record their original monitor, workspace, and slot in `MonitorManager._evacuatedWindows` for later restoration. -* Restoration Logic: - * Restores minimized windows to their original slots when matching displays reconnect. - * Triggers workspace hydration to update tiling allocations on target displays. - -### Cross-Monitor Drag-and-Drop - -* Pointer Intersection Tracking: - * Resolves absolute coordinates from `global.get_pointer()` during window drag. - * Queries `global.display.get_monitor_index_for_rect` to identify the monitor containing the pointer. -* Visual Drop Feedback: - * Renders a `St.Widget` backdrop overlay within the resolved slot geometry on the target display. - * Applies visual preview layout updates on the target display by shifting target windows out of the hovered slot. - -### Keyboard Shortcuts for Cross-Monitor Focus and Movement - -* Focus Navigation Fallback: - * Triggers when intra-monitor focus search returns no window in the requested direction. - * Locates the adjacent monitor index and targets the corresponding slot tracker. - * Focuses the boundary window on the adjacent display. - * Goalslot fallback: Resolve via adjacent edge. If multiple windows intersect, pick the one with highest overlap. If equal overlap, pick the top/right one. -* Window Transference: - * Moves the active window to the adjacent monitor when moving past the monitor border. - * Registers the window with the target monitor's state tracker and triggers retiling on both screens. - * Shift cross-monitor actions (like moving windows across monitors, switching monitors, closing monitor windows, workspace porting) from Monitor to Workspace. - ---- - -## Mermaid Diagrams - -### Hotplug Event Handling Sequence - -```mermaid -sequenceDiagram - participant MM as global.backend.get_monitor_manager() - participant MonM as MonitorManager - participant TC as TilingController - participant WL as WorkspaceLayout - participant WW as WindowWrapper - - MM->>MonM: monitors-changed - activate MonM - MonM->>MonM: Detect monitor addition/removal - alt Monitor Removed - MonM->>WW: Evacuate window (minimize/store metadata) - MonM->>WL: Untrack window - else Monitor Added - MonM->>WW: Restore window (unminimize/update monitor index) - MonM->>WL: Track window on target monitor - end - MonM->>TC: hydrate() - deactivate MonM - activate TC - TC->>WL: getRetileOperations() - WL-->>TC: Return window rectangles - TC->>WW: applyGeometry() - deactivate TC -``` - -### Cross-Monitor Drag-and-Drop Sequence - -```mermaid -sequenceDiagram - actor User - participant Win as Meta.Window - participant DM as DragManager - participant TC as TilingController - participant WL as WorkspaceLayout - participant Ind as DragIndicator - - User->>Win: Starts dragging window - DM->>Win: Connect position-changed signal - loop Every position-changed event - Win->>DM: position-changed - activate DM - DM->>DM: Resolve pointer coordinates (global.get_pointer()) - DM->>DM: Determine active monitor under pointer - DM->>WL: getSlotAtPointer(monitorId, x, y) - WL-->>DM: Target slot index - alt Valid slot on target monitor - DM->>Ind: Position and size indicator to slot boundaries - DM->>Ind: show() - DM->>DM: Apply visual swap preview - else Out of bounds - DM->>Ind: hide() - DM->>DM: Revert visual swap preview - end - deactivate DM - end - User->>Win: Releases window - activate DM - DM->>Win: Disconnect position-changed signal - DM->>Ind: destroy() - DM->>WL: swapWindowByPointer() or trackWindow() on target monitor - DM->>TC: _scheduleRetile() for source and target monitors - deactivate DM -``` - ---- - -## Pseudo-code - -### Pointer-to-Slot Mapping and Indicator Rendering - -```javascript -// Located in lib/drag.js -_handlePositionChanged(wrapper, layout, tracker, originalSlot, indicator) { - const [pointerX, pointerY] = global.get_pointer(); - const gaps = this.controller.settings.getGaps(); - const workspace = wrapper.workspace; - - // Identify monitor matching pointer coordinates - const monitorIndex = global.display.get_monitor_index_for_rect({ - x: pointerX, - y: pointerY, - width: 1, - height: 1 - }); - - // Guard against out-of-bounds monitor index - if (monitorIndex === -1) { - indicator.hide(); - const fallbackMonitorIndex = global.display.get_current_monitor(); - const fallbackRect = workspace.get_work_area_for_monitor(fallbackMonitorIndex); - this._revertVisualSwap(tracker, layout, fallbackRect, gaps); - return; - } - - const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); - const targetLayout = this.controller.workspaceManager.getLayout(workspace); - const targetTracker = targetLayout._getTracker(monitorId); - const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); - - let targetRect = null; - let hoveredSlot = -1; - - // Handle empty monitor drop target explicitly - if (targetTracker.size === 0) { - hoveredSlot = 0; - targetRect = { - x: monitorRect.x + gaps.outer, - y: monitorRect.y + gaps.outer, - width: monitorRect.width - (gaps.outer * 2), - height: monitorRect.height - (gaps.outer * 2) - }; - } else { - hoveredSlot = targetLayout.getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps); - if (hoveredSlot !== -1) { - const matrix = targetLayout.escalator.getLayoutForCount(targetTracker.size); - const estate = matrix.getEstate(hoveredSlot); - if (estate) { - targetRect = estate.toAbsolute(monitorRect, gaps); - } else { - hoveredSlot = -1; - } - } - } - - if (hoveredSlot !== -1 && targetRect) { - // Update indicator layout coordinates and display - indicator.set_position(targetRect.x, targetRect.y); - indicator.set_size(targetRect.width, targetRect.height); - if (indicator._bg) { - indicator._bg.set_size(targetRect.width, targetRect.height); - } - indicator.show(); - - // Apply preview layout modifications - if (monitorId !== wrapper.monitorId) { - this._applyCrossMonitorVisualSwap(wrapper, targetTracker, targetLayout, hoveredSlot, monitorRect, gaps); - } else { - this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); - } - } else { - indicator.hide(); - this._revertVisualSwap(tracker, layout, monitorRect, gaps); - } -} -``` - -### Cross-Monitor Keyboard Navigation Fallback - -```javascript -// Located in lib/workspace.js -_getTargetWindowInDirection(monitorId, window, direction) { - const tracker = this._getTracker(monitorId); - const slot = tracker.getSlot(window); - if (slot === undefined) return null; - - const windowCount = tracker.size; - const layout = this.escalator.getLayoutForCount(windowCount); - if (!layout) return null; - - const estate = layout.getEstate(slot); - if (!estate) return null; - - // Evaluate spatial targets within same monitor - const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); - if (targetSlot !== -1) { - return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; - } - - // Fetch boundary adjacent display - const currentMonitorIndex = this.workspace.get_display().get_monitor_index_for_rect(window.get_frame_rect()); - const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); - if (adjacentMonitorIndex === -1) return null; - - const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); - const targetTracker = this._getTracker(targetMonitorId); - if (targetTracker.size === 0) return null; - - // Match coordinate boundary overlap on adjacent display via adjacent edge - return this._findClosestBoundaryWindow(targetTracker, direction, window.get_frame_rect()); -} - -_findClosestBoundaryWindow(targetTracker, direction, sourceRect) { - let candidates = []; - for (const win of targetTracker.windows) { - if (!win || win.unmanaged) continue; - const targetRect = win.get_frame_rect(); - - let overlap = 0; - if (direction === 'left' || direction === 'right') { - // Overlap along Y axis - overlap = Math.max(0, Math.min(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height) - Math.max(sourceRect.y, targetRect.y)); - } else if (direction === 'up' || direction === 'down') { - // Overlap along X axis - overlap = Math.max(0, Math.min(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width) - Math.max(sourceRect.x, targetRect.x)); - } - - if (overlap > 0) { - candidates.push({ win, rect: targetRect, overlap }); - } - } - - if (candidates.length === 0) return null; - - candidates.sort((a, b) => { - if (b.overlap !== a.overlap) { - return b.overlap - a.overlap; // Highest overlap first - } - // If equal overlap, pick the top/right one - if (direction === 'left' || direction === 'right') { - return a.rect.y - b.rect.y; // Top-most (smaller Y) - } else { - return b.rect.x - a.rect.x; // Right-most (larger X) - } - }); - - return candidates[0].win; -} -``` - -### Cross-Monitor Keyboard Window Movement - -```javascript -// Located in lib/workspace.js -moveWindowDirection(monitorId, window, direction) { - const tracker = this._getTracker(monitorId); - const slot = tracker.getSlot(window); - if (slot === undefined) return false; - - const windowCount = tracker.size; - const layout = this.escalator.getLayoutForCount(windowCount); - if (!layout) return false; - - const estate = layout.getEstate(slot); - if (!estate) return false; - - // Evaluate spatial targets within same monitor - const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); - if (targetSlot !== -1) { - const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); - if (targetWindow) { - tracker.swapWindows(window, targetWindow); - this.controller.scheduleRetile(this.workspace, monitorId); - return true; - } - } - - // Fallback: cross-monitor window movement - const currentMonitorIndex = this.workspace.get_display().get_monitor_index_for_rect(window.get_frame_rect()); - const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); - if (adjacentMonitorIndex === -1) return false; - - const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); - - // Untrack from source monitor tracker - tracker.untrack(window); - - // Track on target monitor tracker - const targetTracker = this._getTracker(targetMonitorId); - targetTracker.track(window, targetTracker.size); - - // Update window wrapper cache/metadata - const wrapper = this.controller.getWindowWrapper(window); - if (wrapper) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = adjacentMonitorIndex; - } - - // Physical transfer using GNOME Shell API - window.move_to_monitor(adjacentMonitorIndex); - - // Schedule retiles on both source and target monitors - this.controller.scheduleRetile(this.workspace, monitorId); - this.controller.scheduleRetile(this.workspace, targetMonitorId); - - return true; -} -``` - -### Cross-Monitor Workspace Actions (Shifted from MonitorManager) - -```javascript -// Located in lib/workspace.js (WorkspaceLayout / WorkspaceManager) -closeMonitorWindows(monitorIndex, includeMinimized) { - const workspace = this.workspace || global.workspace_manager.get_active_workspace(); - if (!workspace) return; - this.controller.setBatchMode(true); - const windows = workspace.list_windows(); - windows.forEach(w => { - if (w.get_monitor() === monitorIndex && (!w.minimized || includeMinimized)) { - w.delete(global.get_current_time()); - } - }); - this.controller.setBatchMode(false); - this.controller.hydrate(workspace); -} - -switchMonitors(activeMonitorIndex) { - const workspace = this.workspace || global.workspace_manager.get_active_workspace(); - if (!workspace) return; - - const manager = global.backend.get_monitor_manager(); - const numMonitors = manager.get_logical_monitors().length; - if (numMonitors < 2) return; - - let targetMonitorIndex; - if (numMonitors === 2) { - targetMonitorIndex = activeMonitorIndex === 0 ? 1 : 0; - } else { - const primaryIndex = global.display.get_primary_monitor(); - if (activeMonitorIndex === primaryIndex) return; - targetMonitorIndex = primaryIndex; - } - - this.controller.setBatchMode(true); - const windows = workspace.list_windows(); - windows.forEach(w => { - const m = w.get_monitor(); - if (m === activeMonitorIndex) { - w.move_to_monitor(targetMonitorIndex); - } else if (m === targetMonitorIndex) { - w.move_to_monitor(activeMonitorIndex); - } - }); - this.controller.setBatchMode(false); - this.controller.hydrate(workspace); -} - -portMonitorToWorkspace(monitorIndex, direction) { - const activeWorkspaceIndex = global.workspace_manager.get_active_workspace_index(); - const numWorkspaces = global.workspace_manager.n_workspaces; - let targetIndex = activeWorkspaceIndex; - - if (direction === 'left' && activeWorkspaceIndex > 0) { - targetIndex--; - } else if (direction === 'right' && activeWorkspaceIndex < numWorkspaces - 1) { - targetIndex++; - } - - if (targetIndex === activeWorkspaceIndex) return; - - const targetWorkspace = global.workspace_manager.get_workspace_by_index(targetIndex); - const activeWorkspace = this.workspace || global.workspace_manager.get_active_workspace(); - - this.controller.setBatchMode(true); - const windows = activeWorkspace.list_windows(); - windows.forEach(w => { - if (w.get_monitor() === monitorIndex) { - w.change_workspace(targetWorkspace); - } - }); - this.controller.setBatchMode(false); - - this.controller.hydrate(activeWorkspace); - this.controller.hydrate(targetWorkspace); -} -``` - diff --git a/tests/monitor-transition.test.js b/tests/monitor-transition.test.js index 7dcdd46..5d67cad 100644 --- a/tests/monitor-transition.test.js +++ b/tests/monitor-transition.test.js @@ -107,7 +107,11 @@ describe('Monitor Transitions', () => { monitorManager: mockMonitorManager, settings: mockSettings, _windowWrappers: new Map(), - _scheduleRetile: vi.fn() + _scheduleRetile: vi.fn(), + updateWindowWrapperMonitor: function(win, id, idx) { + const w = this._windowWrappers.get(win); + if (w) { w.monitorId = id; w.monitorIndex = idx; } + } }; }); diff --git a/tests/regressions.test.js b/tests/regressions.test.js index a3a2fd4..411e990 100644 --- a/tests/regressions.test.js +++ b/tests/regressions.test.js @@ -201,7 +201,6 @@ describe('Regressions', () => { controller.dragManager._handlePositionChanged( controller._windowWrappers.get(winB), layout, - sourceTracker, 1, dragInfo.indicator ); diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 2f9b1bf..462e3bc 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -228,6 +228,10 @@ describe('WorkspaceManager', () => { retileAll: vi.fn(), hydrate: vi.fn(), _windowWrappers: new Map(), + updateWindowWrapperMonitor: function(win, id, idx) { + const w = this._windowWrappers.get(win); + if (w) { w.monitorId = id; w.monitorIndex = idx; } + }, escalator: LayoutParser.parse('{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}') }; manager = new WorkspaceManager(controller); @@ -413,7 +417,11 @@ describe('WorkspaceLayout Cross-Monitor Fallback', () => { escalator: escalator, monitorManager: mockMonitorManager, _windowWrappers: new Map(), - _scheduleRetile: vi.fn() + _scheduleRetile: vi.fn(), + updateWindowWrapperMonitor: function(win, id, idx) { + const w = this._windowWrappers.get(win); + if (w) { w.monitorId = id; w.monitorIndex = idx; } + } }; const ws = { index: () => 0 }; const layout = new WorkspaceLayout(ws, controller); From 30408bc273b701c0172110867137f4801ceee667 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 14 Jun 2026 13:55:41 +0200 Subject: [PATCH 15/33] bugfix: resolve floating window drag regressions and overtiling --- lib/drag.js | 17 +++++++++++------ lib/workspace.js | 3 +++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/drag.js b/lib/drag.js index 81ab97b..a83d81d 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -39,7 +39,9 @@ export class DragManager { if (originalSlot === undefined) return; const matrix = layout.escalator.getLayoutForCount(windowCount); - if (!matrix || originalSlot >= matrix.size) return; + // We do not require a valid matrix or originalSlot < matrix.size here. + // We must still track the drag so `isWindowInDragPreview` correctly suppresses + // monitor-changed retiles when the user drags this floating window to another monitor. const indicator = this._createIndicator(); @@ -65,7 +67,7 @@ export class DragManager { const bg = new St.Widget({ style: ` - background-color: -st-accent-color; + background-color: --st-accent-color; border-radius: 6px; `, opacity: 76 @@ -161,10 +163,8 @@ export class DragManager { if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; - // If we were hovering a different monitor previously, revert it first - if (this._activeDrag.lastHoveredMonitorId && this._activeDrag.lastHoveredMonitorId !== sourceMonId) { - this._revertVisualSwap(layout, gaps, true); - } + // Revert previous hover + this._revertVisualSwap(layout, gaps); const windowCount = layout.getWindowCount(monitorId); const matrix = layout.escalator.getLayoutForCount(windowCount); @@ -366,6 +366,11 @@ export class DragManager { this._activeDrag = null; + if (this._deferredRetiles && this._deferredRetiles.length > 0) { + this._deferredRetiles.forEach(r => this.controller._scheduleRetile(r.workspace, r.monitorId, r.monitorIndex)); + this._deferredRetiles = []; + } + if (lastHoveredMonitorId && lastHoveredMonitorId !== wrapper.monitorId) { this._commitCrossMonitorTransfer(window, wrapper, layout, lastHoveredMonitorId, lastHoveredSlot, activeDrag.originalSlot); } else { diff --git a/lib/workspace.js b/lib/workspace.js index 3c1e5d5..0a29eaa 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -384,6 +384,9 @@ export class WorkspaceLayout { const targetSlot = this.getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps); + const maxCount = this.escalator.getMaxCount(); + if (slot >= maxCount) return false; + if (targetSlot !== -1 && targetSlot !== slot) { const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); if (targetWindow) { From 06c891019cd78ee93d550443d0a8d2ec7b2d044b Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 17 Jun 2026 08:14:57 +0200 Subject: [PATCH 16/33] fix: adversarial tests mock wrappers --- tests/adversarial.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/adversarial.test.js b/tests/adversarial.test.js index 83e10d3..dd2a541 100644 --- a/tests/adversarial.test.js +++ b/tests/adversarial.test.js @@ -68,7 +68,9 @@ describe('Adversarial Tests', () => { // Setup wrapper to make it look like a monitor change const wrapper = { workspace: ws, + get effectiveWorkspace() { return ws; }, monitorIndex: 0, + get effectiveMonitorIndex() { return win.get_monitor(); }, monitorId: 'monitor-0', applyGeometry: vi.fn(), bindSignals: vi.fn(), From 52fb76575fcb48ad1cd9ca8a1c786194900e995a Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 17 Jun 2026 19:38:28 +0200 Subject: [PATCH 17/33] refactor: iteration 3 outsourcing and function unbloating --- lib/controller.js | 72 +++++++------- lib/layout.js | 44 +-------- lib/monitor.js | 76 +++----------- lib/utils/geometry.js | 224 ++++++++++++++++++++++++++++++++++++++++++ lib/workspace.js | 86 +--------------- 5 files changed, 280 insertions(+), 222 deletions(-) create mode 100644 lib/utils/geometry.js diff --git a/lib/controller.js b/lib/controller.js index 146aa85..748192c 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -10,6 +10,7 @@ import { Logger } from './logger.js'; import { MonitorManager } from './monitor.js'; import { DragManager } from './drag.js'; import { TILABLE_WINDOW_TYPES } from './signals.js'; +import { getEnteringEdge } from './utils/geometry.js'; /** * TilingController: The central orchestration layer. @@ -112,49 +113,48 @@ export class TilingController { const isMonitorChange = wrapper.workspace && wrapper.workspace === workspace && wrapper.monitorId && wrapper.monitorId !== monitorId; if (isMonitorChange) { - const sourceMonitorId = wrapper.monitorId; - const sourceMonitorIndex = wrapper.monitorIndex; - const slot = layout.getWindowSlot(sourceMonitorId, window); - const sourceSlot = slot !== undefined ? slot : 0; - - const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); - const targetRect = global.display.get_monitor_geometry(monitorIndex); - - const dx = targetRect.x - sourceRect.x; - const dy = targetRect.y - sourceRect.y; - let enteringEdge = 'left'; - if (Math.abs(dx) >= Math.abs(dy)) { - enteringEdge = dx > 0 ? 'left' : 'right'; - } else { - enteringEdge = dy > 0 ? 'top' : 'bottom'; - } - - const result = layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); - - this.updateWindowWrapperMonitor(window, monitorId, monitorIndex); - - if (result && result.swappedWindow) { - this.updateWindowWrapperMonitor(result.swappedWindow, sourceMonitorId, sourceMonitorIndex); - if (result.swappedWindow.move_to_monitor) result.swappedWindow.move_to_monitor(sourceMonitorIndex); - } - - Logger.debug(`tilingRequest: Monitor transition handled. Scheduling retile.`); - this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this._scheduleRetile(workspace, monitorId, monitorIndex); + this._handleMonitorTransitionChange(window, wrapper, layout, wrapper.monitorIndex, wrapper.monitorId, monitorIndex, monitorId, workspace); } else { - const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); - const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); - this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); - this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); - - Logger.debug(`tilingRequest: State applied. Scheduling retile.`); - this._scheduleRetile(workspace, monitorId, monitorIndex); + this._handleNormalTilingRequest(window, wrapper, workspace, monitorId, monitorIndex, isRestoring, preferredSlot); } } catch (e) { Logger.warn(`Tiling attempt failed for "${wrapper ? wrapper.title : 'unknown'}"`, e); } } + _handleMonitorTransitionChange(window, wrapper, layout, sourceMonitorIndex, sourceMonitorId, monitorIndex, monitorId, workspace) { + const slot = layout.getWindowSlot(sourceMonitorId, window); + const sourceSlot = slot !== undefined ? slot : 0; + + const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); + const targetRect = global.display.get_monitor_geometry(monitorIndex); + + const enteringEdge = getEnteringEdge(sourceRect, targetRect); + + const result = layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + + this.updateWindowWrapperMonitor(window, monitorId, monitorIndex); + + if (result && result.swappedWindow) { + this.updateWindowWrapperMonitor(result.swappedWindow, sourceMonitorId, sourceMonitorIndex); + if (result.swappedWindow.move_to_monitor) result.swappedWindow.move_to_monitor(sourceMonitorIndex); + } + + Logger.debug(`tilingRequest: Monitor transition handled. Scheduling retile.`); + this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } + + _handleNormalTilingRequest(window, wrapper, workspace, monitorId, monitorIndex, isRestoring, preferredSlot) { + const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); + const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); + this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); + this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); + + Logger.debug(`tilingRequest: State applied. Scheduling retile.`); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } + _ensureWrapper(window) { const type = window.get_window_type ? window.get_window_type() : Meta.WindowType.NORMAL; const skipTaskbar = window.is_skip_taskbar ? window.is_skip_taskbar() : false; diff --git a/lib/layout.js b/lib/layout.js index 5fe73f7..ec09b51 100644 --- a/lib/layout.js +++ b/lib/layout.js @@ -1,4 +1,4 @@ - +import { getEdgingSlotForEstates } from './utils/geometry.js'; /** * ScreenEstate: Immutable data object holding percentages (0-100). @@ -83,47 +83,7 @@ export class Layout { } getEdgingSlot(direction) { - const eps = 0.01; - let candidates = []; - - this.estates.forEach((estate, index) => { - let touches = false; - if (direction === 'left') { - touches = estate.pct_x <= eps; - } else if (direction === 'right') { - touches = (estate.pct_x + estate.pct_w) >= (100 - eps); - } else if (direction === 'top') { - touches = estate.pct_y <= eps; - } else if (direction === 'bottom') { - touches = (estate.pct_y + estate.pct_h) >= (100 - eps); - } - - if (touches) { - candidates.push({ estate, index }); - } - }); - - if (candidates.length === 0) { - return -1; - } - - candidates.sort((a, b) => { - if (direction === 'left' || direction === 'right') { - const diffHeight = b.estate.pct_h - a.estate.pct_h; - if (Math.abs(diffHeight) > eps) { - return diffHeight; - } - return a.estate.pct_y - b.estate.pct_y; - } else { - const diffWidth = b.estate.pct_w - a.estate.pct_w; - if (Math.abs(diffWidth) > eps) { - return diffWidth; - } - return b.estate.pct_x - a.estate.pct_x; - } - }); - - return candidates[0].index; + return getEdgingSlotForEstates(this.estates, direction); } } diff --git a/lib/monitor.js b/lib/monitor.js index b9d9ec3..0383417 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -1,6 +1,7 @@ import Meta from 'gi://Meta'; import GLib from 'gi://GLib'; import { Logger } from './logger.js'; +import { findMonitorInDirection } from './utils/geometry.js'; /** * MonitorManager class. Tracks monitor changes. @@ -273,69 +274,20 @@ export class MonitorManager { try { const manager = global.backend.get_monitor_manager(); const logicalMonitors = manager.get_logical_monitors(); - const sourceMonitor = logicalMonitors[currentMonitorIndex]; - if (!sourceMonitor) { - Logger.debug(`getMonitorInDirection: invalid sourceMonitor for index ${currentMonitorIndex}`); - return -1; + + const index = findMonitorInDirection( + currentMonitorIndex, + direction, + logicalMonitors, + (i) => global.display.get_monitor_geometry(i) + ); + + if (index !== -1) { + Logger.debug(`getMonitorInDirection: best candidate is ${index}`); + } else { + Logger.debug('getMonitorInDirection: no candidates found or invalid source monitor'); } - - const sRect = global.display.get_monitor_geometry(currentMonitorIndex); - Logger.debug(`getMonitorInDirection: source monitor ${currentMonitorIndex} rect: ${sRect.x}, ${sRect.y}, ${sRect.width}, ${sRect.height}`); - - let candidates = []; - const eps = 1; - - for (let i = 0; i < logicalMonitors.length; i++) { - if (i === currentMonitorIndex) continue; - const cRect = global.display.get_monitor_geometry(i); - - let inDirection = false; - let dist = Infinity; - - if (direction === 'left') { - inDirection = cRect.x + cRect.width <= sRect.x + eps; - dist = sRect.x - (cRect.x + cRect.width); - } else if (direction === 'right') { - inDirection = cRect.x >= sRect.x + sRect.width - eps; - dist = cRect.x - (sRect.x + sRect.width); - } else if (direction === 'up') { - inDirection = cRect.y + cRect.height <= sRect.y + eps; - dist = sRect.y - (cRect.y + cRect.height); - } else if (direction === 'down') { - inDirection = cRect.y >= sRect.y + sRect.height - eps; - dist = cRect.y - (sRect.y + sRect.height); - } - - Logger.debug(`getMonitorInDirection: checking monitor ${i} (${cRect.x},${cRect.y},${cRect.width},${cRect.height}) for direction ${direction}: inDirection=${inDirection}`); - - if (inDirection) { - let overlap = 0; - if (direction === 'left' || direction === 'right') { - overlap = Math.max(0, Math.min(cRect.y + cRect.height, sRect.y + sRect.height) - Math.max(cRect.y, sRect.y)); - } else { - overlap = Math.max(0, Math.min(cRect.x + cRect.width, sRect.x + sRect.width) - Math.max(cRect.x, sRect.x)); - } - candidates.push({ index: i, dist, overlap, rect: cRect }); - } - } - - if (candidates.length === 0) { - Logger.debug('getMonitorInDirection: no candidates found'); - return -1; - } - - candidates.sort((a, b) => { - if (Math.abs(a.dist - b.dist) > eps) { - return a.dist - b.dist; - } - if (Math.abs(a.overlap - b.overlap) > eps) { - return b.overlap - a.overlap; - } - return a.index - b.index; - }); - - Logger.debug(`getMonitorInDirection: best candidate is ${candidates[0].index}`); - return candidates[0].index; + return index; } catch (e) { Logger.error(`Failed to get monitor in direction ${direction}`, e); return -1; diff --git a/lib/utils/geometry.js b/lib/utils/geometry.js new file mode 100644 index 0000000..99f324d --- /dev/null +++ b/lib/utils/geometry.js @@ -0,0 +1,224 @@ +/** + * geometry.js + * Utility functions for spatial and directional calculations. + */ + +/** + * Determines the entering edge when moving from a source rectangle to a target rectangle. + * @param {Object} sourceRect - The source geometry {x, y, width, height}. + * @param {Object} targetRect - The target geometry {x, y, width, height}. + * @returns {string} The entering edge ('left', 'right', 'top', 'bottom'). + */ +export function getEnteringEdge(sourceRect, targetRect) { + const dx = targetRect.x - sourceRect.x; + const dy = targetRect.y - sourceRect.y; + if (Math.abs(dx) >= Math.abs(dy)) { + return dx > 0 ? 'left' : 'right'; + } else { + return dy > 0 ? 'top' : 'bottom'; + } +} + +/** + * Finds the best monitor in a given direction from a source monitor. + * @param {number} currentMonitorIndex - The index of the source monitor. + * @param {string} direction - The direction to look ('left', 'right', 'up', 'down'). + * @param {Array} logicalMonitors - The array of logical monitors. + * @param {Function} getGeometryFn - Function that returns geometry given a monitor index. + * @returns {number} The index of the selected monitor, or -1 if none found. + */ +export function findMonitorInDirection(currentMonitorIndex, direction, logicalMonitors, getGeometryFn) { + const sourceMonitor = logicalMonitors[currentMonitorIndex]; + if (!sourceMonitor) return -1; + + const sRect = getGeometryFn(currentMonitorIndex); + let candidates = []; + const eps = 1; + + for (let i = 0; i < logicalMonitors.length; i++) { + if (i === currentMonitorIndex) continue; + const cRect = getGeometryFn(i); + + let inDirection = false; + let dist = Infinity; + + if (direction === 'left') { + inDirection = cRect.x + cRect.width <= sRect.x + eps; + dist = sRect.x - (cRect.x + cRect.width); + } else if (direction === 'right') { + inDirection = cRect.x >= sRect.x + sRect.width - eps; + dist = cRect.x - (sRect.x + sRect.width); + } else if (direction === 'up') { + inDirection = cRect.y + cRect.height <= sRect.y + eps; + dist = sRect.y - (cRect.y + cRect.height); + } else if (direction === 'down') { + inDirection = cRect.y >= sRect.y + sRect.height - eps; + dist = cRect.y - (sRect.y + sRect.height); + } + + if (inDirection) { + let overlap = 0; + if (direction === 'left' || direction === 'right') { + overlap = Math.max(0, Math.min(cRect.y + cRect.height, sRect.y + sRect.height) - Math.max(cRect.y, sRect.y)); + } else { + overlap = Math.max(0, Math.min(cRect.x + cRect.width, sRect.x + sRect.width) - Math.max(cRect.x, sRect.x)); + } + candidates.push({ index: i, dist, overlap, rect: cRect }); + } + } + + if (candidates.length === 0) { + return -1; + } + + candidates.sort((a, b) => { + if (Math.abs(a.dist - b.dist) > eps) { + return a.dist - b.dist; + } + if (Math.abs(a.overlap - b.overlap) > eps) { + return b.overlap - a.overlap; + } + return a.index - b.index; + }); + + return candidates[0].index; +} + +/** + * Gets the most appropriate estate index on a specific edge of the layout. + * @param {Array} estates - Array of layout estates. + * @param {string} direction - The edge direction ('left', 'right', 'top', 'bottom'). + * @returns {number} The index of the chosen estate, or -1 if none found. + */ +export function getEdgingSlotForEstates(estates, direction) { + const eps = 0.01; + let candidates = []; + + estates.forEach((estate, index) => { + let touches = false; + if (direction === 'left') { + touches = estate.pct_x <= eps; + } else if (direction === 'right') { + touches = (estate.pct_x + estate.pct_w) >= (100 - eps); + } else if (direction === 'top') { + touches = estate.pct_y <= eps; + } else if (direction === 'bottom') { + touches = (estate.pct_y + estate.pct_h) >= (100 - eps); + } + + if (touches) { + candidates.push({ estate, index }); + } + }); + + if (candidates.length === 0) { + return -1; + } + + candidates.sort((a, b) => { + if (direction === 'left' || direction === 'right') { + const diffHeight = b.estate.pct_h - a.estate.pct_h; + if (Math.abs(diffHeight) > eps) { + return diffHeight; + } + return a.estate.pct_y - b.estate.pct_y; + } else { + const diffWidth = b.estate.pct_w - a.estate.pct_w; + if (Math.abs(diffWidth) > eps) { + return diffWidth; + } + return b.estate.pct_x - a.estate.pct_x; + } + }); + + return candidates[0].index; +} + +/** + * Checks if a target slot estate is in a specific direction relative to a source estate. + */ +export function isSlotInDirection(estate, c, direction, eps = 0.01) { + let orthoOverlap = false; + + if (direction === 'left' || direction === 'right') { + orthoOverlap = Math.max(c.pct_y, estate.pct_y) < Math.min(c.pct_y + c.pct_h, estate.pct_y + estate.pct_h) - eps; + if (direction === 'left') { + return orthoOverlap && c.pct_x + c.pct_w <= estate.pct_x + eps; + } + return orthoOverlap && c.pct_x >= estate.pct_x + estate.pct_w - eps; + } else if (direction === 'up' || direction === 'down') { + orthoOverlap = Math.max(c.pct_x, estate.pct_x) < Math.min(c.pct_x + c.pct_w, estate.pct_x + estate.pct_w) - eps; + if (direction === 'up') { + return orthoOverlap && c.pct_y + c.pct_h <= estate.pct_y + eps; + } + return orthoOverlap && c.pct_y >= estate.pct_y + estate.pct_h - eps; + } + return false; +} + +/** + * Calculates distance between two slot estates in a specific direction. + */ +export function calculateSlotDistance(estate, c, direction) { + if (direction === 'left') return estate.pct_x - (c.pct_x + c.pct_w); + if (direction === 'right') return c.pct_x - (estate.pct_x + estate.pct_w); + if (direction === 'up') return estate.pct_y - (c.pct_y + c.pct_h); + if (direction === 'down') return c.pct_y - (estate.pct_y + estate.pct_h); + return 0; +} + +/** + * Finds the nearest slot index in a specific direction. + */ +export function findTargetSlotInDirection(layout, slot, estate, direction) { + const eps = 0.01; + let candidates = []; + + for (let i = 0; i < layout.size; i++) { + if (i === slot) continue; + const c = layout.getEstate(i); + + if (isSlotInDirection(estate, c, direction, eps)) { + candidates.push({ + index: i, + distance: calculateSlotDistance(estate, c, direction) + }); + } + } + + if (candidates.length === 0) return -1; + + candidates.sort((a, b) => a.distance - b.distance); + const minDist = candidates[0].distance; + + candidates = candidates.filter(c => c.distance <= minDist + eps); + candidates.sort((a, b) => a.index - b.index); + + return candidates[0].index; +} + +/** + * Finds the closest boundary window out of candidate windows in a given direction. + */ +export function findClosestBoundaryWindow(candidates, direction) { + if (!candidates || candidates.length === 0) return null; + + candidates.sort((a, b) => { + if (direction === 'left') { + if (a.rect.x !== b.rect.x) return b.rect.x - a.rect.x; + return a.rect.y - b.rect.y; + } else if (direction === 'right') { + if (a.rect.x !== b.rect.x) return a.rect.x - b.rect.x; + return a.rect.y - b.rect.y; + } else if (direction === 'up') { + if (a.rect.y !== b.rect.y) return b.rect.y - a.rect.y; + return b.rect.x - a.rect.x; + } else if (direction === 'down') { + if (a.rect.y !== b.rect.y) return a.rect.y - b.rect.y; + return b.rect.x - a.rect.x; + } + return 0; + }); + + return candidates[0].win; +} diff --git a/lib/workspace.js b/lib/workspace.js index 0a29eaa..28b635f 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -1,5 +1,6 @@ import { StateTracker } from './state.js'; import { Logger } from './logger.js'; +import { findTargetSlotInDirection, findClosestBoundaryWindow } from './utils/geometry.js'; /** * WorkspaceLayout: Manages internal state for a specific Meta.Workspace. @@ -100,34 +101,7 @@ export class WorkspaceLayout { candidates.push({ win, rect: targetRect }); } - if (candidates.length === 0) return null; - - candidates.sort((a, b) => { - if (direction === 'left') { - if (a.rect.x !== b.rect.x) { - return b.rect.x - a.rect.x; - } - return a.rect.y - b.rect.y; - } else if (direction === 'right') { - if (a.rect.x !== b.rect.x) { - return a.rect.x - b.rect.x; - } - return a.rect.y - b.rect.y; - } else if (direction === 'up') { - if (a.rect.y !== b.rect.y) { - return b.rect.y - a.rect.y; - } - return b.rect.x - a.rect.x; - } else if (direction === 'down') { - if (a.rect.y !== b.rect.y) { - return a.rect.y - b.rect.y; - } - return b.rect.x - a.rect.x; - } - return 0; - }); - - return candidates[0].win; + return findClosestBoundaryWindow(candidates, direction); } @@ -143,7 +117,7 @@ export class WorkspaceLayout { const estate = layout.getEstate(slot); if (!estate) return null; - const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + const targetSlot = findTargetSlotInDirection(layout, slot, estate, direction); if (targetSlot !== -1) { return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; } @@ -234,7 +208,7 @@ export class WorkspaceLayout { const estate = layout.getEstate(slot); if (!estate) return false; - const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + const targetSlot = findTargetSlotInDirection(layout, slot, estate, direction); if (targetSlot !== -1) { const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); if (targetWindow) { @@ -298,59 +272,7 @@ export class WorkspaceLayout { return false; } - _findTargetSlotInDirection(layout, slot, estate, direction) { - const eps = 0.01; - let candidates = []; - - for (let i = 0; i < layout.size; i++) { - if (i === slot) continue; - const c = layout.getEstate(i); - - if (this._isSlotInDirection(estate, c, direction, eps)) { - candidates.push({ - index: i, - distance: this._calculateSlotDistance(estate, c, direction) - }); - } - } - if (candidates.length === 0) return -1; - - candidates.sort((a, b) => a.distance - b.distance); - const minDist = candidates[0].distance; - - candidates = candidates.filter(c => c.distance <= minDist + eps); - candidates.sort((a, b) => a.index - b.index); - - return candidates[0].index; - } - - _isSlotInDirection(estate, c, direction, eps) { - let orthoOverlap = false; - - if (direction === 'left' || direction === 'right') { - orthoOverlap = Math.max(c.pct_y, estate.pct_y) < Math.min(c.pct_y + c.pct_h, estate.pct_y + estate.pct_h) - eps; - if (direction === 'left') { - return orthoOverlap && c.pct_x + c.pct_w <= estate.pct_x + eps; - } - return orthoOverlap && c.pct_x >= estate.pct_x + estate.pct_w - eps; - } else if (direction === 'up' || direction === 'down') { - orthoOverlap = Math.max(c.pct_x, estate.pct_x) < Math.min(c.pct_x + c.pct_w, estate.pct_x + estate.pct_w) - eps; - if (direction === 'up') { - return orthoOverlap && c.pct_y + c.pct_h <= estate.pct_y + eps; - } - return orthoOverlap && c.pct_y >= estate.pct_y + estate.pct_h - eps; - } - return false; - } - - _calculateSlotDistance(estate, c, direction) { - if (direction === 'left') return estate.pct_x - (c.pct_x + c.pct_w); - if (direction === 'right') return c.pct_x - (estate.pct_x + estate.pct_w); - if (direction === 'up') return estate.pct_y - (c.pct_y + c.pct_h); - if (direction === 'down') return c.pct_y - (estate.pct_y + estate.pct_h); - return 0; - } /** * Resolves absolute pointer coordinates to a window layout slot index. From d7201b44d79cd87ef8083318d340e02d9350e171 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 17 Jun 2026 20:38:20 +0200 Subject: [PATCH 18/33] fix: window move deferral, geometry overlap calculation, and add state wait timeouts --- lib/drag.js | 14 +++++++++++--- lib/utils/geometry.js | 19 +++++++++++++++++-- lib/window.js | 18 ++++++++++++++++-- lib/workspace.js | 2 +- tests/workspace.test.js | 6 +++--- 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/lib/drag.js b/lib/drag.js index a83d81d..359708d 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -1,5 +1,6 @@ import Meta from 'gi://Meta'; import St from 'gi://St'; +import GLib from 'gi://GLib'; import { Logger } from './logger.js'; /** @@ -415,15 +416,22 @@ export class DragManager { this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); this.controller.updateWindowWrapperMonitor(targetWindow, sourceMonitorId, sourceMonitorIndex); - if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); - if (sourceMonitorIndex !== -1 && targetWindow.move_to_monitor) targetWindow.move_to_monitor(sourceMonitorIndex); + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + if (sourceMonitorIndex !== -1 && targetWindow.move_to_monitor) targetWindow.move_to_monitor(sourceMonitorIndex); + return GLib.SOURCE_REMOVE; + }); } else { layout.untrackWindow(window, sourceMonitorId); layout.trackWindow(window, targetMonitorId, targetSlot !== -1 ? targetSlot : undefined); this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); - if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + return GLib.SOURCE_REMOVE; + }); } this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); diff --git a/lib/utils/geometry.js b/lib/utils/geometry.js index 99f324d..a00ab85 100644 --- a/lib/utils/geometry.js +++ b/lib/utils/geometry.js @@ -63,7 +63,9 @@ export function findMonitorInDirection(currentMonitorIndex, direction, logicalMo } else { overlap = Math.max(0, Math.min(cRect.x + cRect.width, sRect.x + sRect.width) - Math.max(cRect.x, sRect.x)); } - candidates.push({ index: i, dist, overlap, rect: cRect }); + if (overlap > 0) { + candidates.push({ index: i, dist, overlap, rect: cRect }); + } } } @@ -200,10 +202,23 @@ export function findTargetSlotInDirection(layout, slot, estate, direction) { /** * Finds the closest boundary window out of candidate windows in a given direction. */ -export function findClosestBoundaryWindow(candidates, direction) { +export function findClosestBoundaryWindow(candidates, direction, sourceRect) { if (!candidates || candidates.length === 0) return null; + if (!sourceRect) return candidates[0].win; candidates.sort((a, b) => { + let overA = 0, overB = 0; + + if (direction === 'left' || direction === 'right') { + overA = Math.max(0, Math.min(a.rect.y + a.rect.height, sourceRect.y + sourceRect.height) - Math.max(a.rect.y, sourceRect.y)); + overB = Math.max(0, Math.min(b.rect.y + b.rect.height, sourceRect.y + sourceRect.height) - Math.max(b.rect.y, sourceRect.y)); + } else { + overA = Math.max(0, Math.min(a.rect.x + a.rect.width, sourceRect.x + sourceRect.width) - Math.max(a.rect.x, sourceRect.x)); + overB = Math.max(0, Math.min(b.rect.x + b.rect.width, sourceRect.x + sourceRect.width) - Math.max(b.rect.x, sourceRect.x)); + } + + if (overA !== overB) return overB - overA; + if (direction === 'left') { if (a.rect.x !== b.rect.x) return b.rect.x - a.rect.x; return a.rect.y - b.rect.y; diff --git a/lib/window.js b/lib/window.js index a2c5791..6ddb637 100644 --- a/lib/window.js +++ b/lib/window.js @@ -35,9 +35,16 @@ export class WindowWrapper { let m = this.window.get_monitor ? this.window.get_monitor() : -1; if (this._expectedMonitorIndex !== undefined) { if (m !== this._expectedMonitorIndex) { - m = this._expectedMonitorIndex; + if (!this._monitorWaitCycles) this._monitorWaitCycles = 0; + if (++this._monitorWaitCycles > 15) { + delete this._expectedMonitorIndex; + this._monitorWaitCycles = 0; + } else { + m = this._expectedMonitorIndex; + } } else { delete this._expectedMonitorIndex; + this._monitorWaitCycles = 0; } } return m; @@ -48,9 +55,16 @@ export class WindowWrapper { let w = this.window.get_workspace ? this.window.get_workspace() : null; if (this._expectedWorkspace !== undefined) { if (w !== this._expectedWorkspace) { - w = this._expectedWorkspace; + if (!this._workspaceWaitCycles) this._workspaceWaitCycles = 0; + if (++this._workspaceWaitCycles > 15) { + delete this._expectedWorkspace; + this._workspaceWaitCycles = 0; + } else { + w = this._expectedWorkspace; + } } else { delete this._expectedWorkspace; + this._workspaceWaitCycles = 0; } } return w; diff --git a/lib/workspace.js b/lib/workspace.js index 28b635f..d78a635 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -101,7 +101,7 @@ export class WorkspaceLayout { candidates.push({ win, rect: targetRect }); } - return findClosestBoundaryWindow(candidates, direction); + return findClosestBoundaryWindow(candidates, direction, sourceRect); } diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 462e3bc..e173917 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -341,14 +341,14 @@ describe('WorkspaceLayout Cross-Monitor Fallback', () => { const targetTracker = { size: 2, windows: [ - { get_frame_rect: () => ({ x: 1000, y: 0, width: 500, height: 400 }) }, - { get_frame_rect: () => ({ x: 1000, y: 300, width: 500, height: 700 }) } + { get_frame_rect: () => ({ x: 1000, y: 0, width: 500, height: 150 }) }, + { get_frame_rect: () => ({ x: 1000, y: 150, width: 500, height: 850 }) } ] }; const sourceRect = { x: 0, y: 100, width: 1000, height: 400 }; const best = layout._findClosestBoundaryWindow(targetTracker, 'right', sourceRect); - expect(best).toBe(targetTracker.windows[0]); + expect(best).toBe(targetTracker.windows[1]); }); it('should resolve ties using top-most/right-most tie breakers', () => { From 836f18c20c2735e3430d2d9437ab01cf849738f2 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Fri, 19 Jun 2026 16:11:58 +0200 Subject: [PATCH 19/33] bugfix: missing monitor notification --- lib/monitor.js | 1 + lib/window.js | 3 +++ tests/controller.test.js | 2 +- tests/window.test.js | 4 ++-- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/monitor.js b/lib/monitor.js index 0383417..8448473 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -98,6 +98,7 @@ export class MonitorManager { if (window && !window.unmanaged && !window.minimized) { window.minimize(); } + this.controller.setBatchMode(false); return GLib.SOURCE_REMOVE; }); diff --git a/lib/window.js b/lib/window.js index 6ddb637..ee6883c 100644 --- a/lib/window.js +++ b/lib/window.js @@ -80,6 +80,9 @@ export class WindowWrapper { if (!this.signals.has('notify::minimized')) { this.signals.set('notify::minimized', this.window.connect('notify::minimized', () => this.controller.tilingRequest(this.window))); } + if (!this.signals.has('notify::monitor')) { + this.signals.set('notify::monitor', this.window.connect('notify::monitor', () => this.controller.tilingRequest(this.window))); + } if (!this.signals.has('notify::maximized-horizontally')) { this.signals.set('notify::maximized-horizontally', this.window.connect('notify::maximized-horizontally', () => { if (this.window.maximized_horizontally || this.window.maximized_vertically) { diff --git a/tests/controller.test.js b/tests/controller.test.js index 89c026d..7ecafb2 100644 --- a/tests/controller.test.js +++ b/tests/controller.test.js @@ -183,7 +183,7 @@ describe('TilingController', () => { // Window should have been minimized by our controller because monitor-1 is gone expect(win.minimize).toHaveBeenCalled(); - expect(controller._batchMode).toBe(true); + expect(controller._batchMode).toBe(false); // Window tracked for restoration (keyed by window reference) expect(controller.monitorManager._evacuatedWindows.has(win)).toBe(true); diff --git a/tests/window.test.js b/tests/window.test.js index 5a7d08f..88c52d2 100644 --- a/tests/window.test.js +++ b/tests/window.test.js @@ -48,7 +48,7 @@ describe('WindowWrapper', () => { const wrapper = new WindowWrapper(mockWindow, mockController); wrapper.bindSignals(); wrapper.bindSignals(); // should not connect again - expect(mockWindow.connect).toHaveBeenCalledTimes(6); + expect(mockWindow.connect).toHaveBeenCalledTimes(7); }); it('should bind size changed and respect _isResizing', () => { @@ -80,7 +80,7 @@ describe('WindowWrapper', () => { wrapper.bindSizeChanged(); wrapper.destroy(); - expect(mockWindow.disconnect).toHaveBeenCalledTimes(7); + expect(mockWindow.disconnect).toHaveBeenCalledTimes(8); }); it('should apply geometry skipping unmanaged', () => { From 78c4a56f5a4babb3f0d523d7ae614092494e91bc Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Thu, 11 Jun 2026 12:43:20 +0200 Subject: [PATCH 20/33] feat: phase 1 - create keyboard shortcuts tab --- prefs.js | 14 +- tests/prefs.test.js | 566 ++++++++++++++++++++++++++++++++++++++++++++ vitest.config.js | 1 + 3 files changed, 575 insertions(+), 6 deletions(-) create mode 100644 tests/prefs.test.js diff --git a/prefs.js b/prefs.js index 063961d..3a58796 100644 --- a/prefs.js +++ b/prefs.js @@ -108,6 +108,7 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { const settings = this.getSettings(); const page = new Adw.PreferencesPage({ title: 'General', icon_name: 'preferences-system-symbolic' }); + const shortcutsPage = new Adw.PreferencesPage({ title: 'Keyboard Shortcuts', icon_name: 'input-keyboard-symbolic' }); // --- Gaps Group --- const gapsGroup = new Adw.PreferencesGroup({ title: 'Gaps' }); @@ -152,7 +153,7 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { // --- Core Keybindings Group --- - const keysGroup = new Adw.PreferencesGroup({ title: 'Keybindings' }); + const keysGroup = new Adw.PreferencesGroup({ title: 'Window Position Swapping' }); const modeRow = new Adw.ComboRow({ title: 'Mode', subtitle: '', @@ -188,10 +189,10 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { settings.connect('changed::keybindings-mode', updateVisibility); updateVisibility(); - page.add(keysGroup); + shortcutsPage.add(keysGroup); // --- Focus Window Group --- - const focusGroup = new Adw.PreferencesGroup({ title: 'Focus Keybindings' }); + const focusGroup = new Adw.PreferencesGroup({ title: 'Focus Navigation' }); const focusModeRow = new Adw.ComboRow({ title: 'Mode', @@ -228,10 +229,10 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { settings.connect('changed::focus-window-mode', updateFocusVisibility); updateFocusVisibility(); - page.add(focusGroup); + shortcutsPage.add(focusGroup); // --- Additional Batch Shortcuts Group --- - const batchKeysGroup = new Adw.PreferencesGroup({ title: 'Batch Operations' }); + const batchKeysGroup = new Adw.PreferencesGroup({ title: 'Workspace & Monitor Actions' }); const closeMonitorRow = new ShortcutRow(settings, 'shortcut-close-monitor', 'Close Monitor Windows'); batchKeysGroup.add(closeMonitorRow); @@ -254,9 +255,10 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { { id: 'shortcut-unminimize-workspace', label: 'Unminimize Workspace' } ].forEach(s => batchKeysGroup.add(new ShortcutRow(settings, s.id, s.label))); - page.add(batchKeysGroup); + shortcutsPage.add(batchKeysGroup); window.add(page); + window.add(shortcutsPage); // --- Custom Layouts (JSON debug) Page --- const layoutPage = new LayoutEditorPage(settings); diff --git a/tests/prefs.test.js b/tests/prefs.test.js new file mode 100644 index 0000000..ada1dd1 --- /dev/null +++ b/tests/prefs.test.js @@ -0,0 +1,566 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Gtk from 'gi://Gtk'; + +const { mockSettingsStore, mockListeners, mockSettings, BaseMockWidget, mockAdw } = vi.hoisted(() => { + const mockSettingsStore = { + 'enable-gaps': true, + 'inner-gaps': 6, + 'outer-gaps': 4, + 'keybindings-mode': 'default', + 'focus-window-mode': 'default', + 'shortcut-close-monitor': [], + 'close-monitor-include-minimized': false, + 'shortcut-close-workspace': [], + 'shortcut-switch-monitor': [], + 'shortcut-port-monitor-left': [], + 'shortcut-port-monitor-right': [], + 'shortcut-unminimize-workspace': [], + 'custom-layouts': '{}' + }; + + const mockListeners = {}; + + const mockSettings = { + get_boolean: (key) => mockSettingsStore[key] ?? false, + get_string: (key) => mockSettingsStore[key] ?? '', + set_string: (key, val) => { + mockSettingsStore[key] = val; + if (mockListeners[`changed::${key}`]) { + mockListeners[`changed::${key}`].forEach(cb => cb()); + } + }, + get_strv: (key) => mockSettingsStore[key] || [], + set_strv: (key, val) => { + mockSettingsStore[key] = val; + if (mockListeners[`changed::${key}`]) { + mockListeners[`changed::${key}`].forEach(cb => cb()); + } + }, + bind: (key, object, property, flags) => { + Object.defineProperty(object, property, { + get: () => mockSettingsStore[key], + set: (val) => { + mockSettingsStore[key] = val; + if (mockListeners[`changed::${key}`]) { + mockListeners[`changed::${key}`].forEach(cb => cb()); + } + }, + configurable: true + }); + }, + connect: (signal, callback) => { + if (!mockListeners[signal]) { + mockListeners[signal] = []; + } + mockListeners[signal].push(callback); + return signal; + } + }; + + class BaseMockWidget { + constructor(...args) { + this._listeners = {}; + if (typeof this._init === 'function') { + this._init(...args); + } + } + _init(params) { + Object.assign(this, params); + } + connect(signal, callback) { + if (!this._listeners[signal]) { + this._listeners[signal] = []; + } + this._listeners[signal].push(callback); + return signal; + } + emit(signal) { + if (this._listeners[signal]) { + this._listeners[signal].forEach(cb => cb()); + } + } + get_root() { + return { + get_surface() { + return { + inhibit_system_shortcuts() {}, + restore_system_shortcuts() {} + }; + }, + present() {} + }; + } + } + + class MockPreferencesPage extends BaseMockWidget { + _init(params) { + super._init(params); + this.groups = []; + } + add(group) { + this.groups.push(group); + } + } + + class MockPreferencesGroup extends BaseMockWidget { + _init(params) { + super._init(params); + this.rows = []; + } + add(row) { + this.rows.push(row); + } + } + + const mockAdw = { + PreferencesPage: MockPreferencesPage, + PreferencesGroup: MockPreferencesGroup, + SwitchRow: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.active = false; + } + }, + SpinRow: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.value = 0; + } + }, + ComboRow: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.selected = 0; + } + }, + ActionRow: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.suffixes = []; + } + add_suffix(widget) { + this.suffixes.push(widget); + } + }, + AlertDialog: class extends BaseMockWidget { + _init(params) { + super._init(params); + this.responses = []; + this.controllers = []; + mockAdw.lastAlertDialog = this; + } + add_response(id, label) { + this.responses.push({ id, label }); + } + add_controller(controller) { + this.controllers.push(controller); + } + present(window) { + this.presented = true; + this.window = window; + } + close() { + this.closed = true; + } + } + }; + + return { mockSettingsStore, mockListeners, mockSettings, BaseMockWidget, mockAdw }; +}); + +vi.mock('gi://Gio', () => ({ + default: { + Settings: class { + constructor() { + return mockSettings; + } + }, + SettingsBindFlags: { + DEFAULT: 0 + } + } +})); + +vi.mock('gi://Adw', () => ({ + default: mockAdw +})); + +vi.mock('gi://Gtk', () => ({ + default: { + Adjustment: class {}, + Align: { + CENTER: 0, + START: 1, + END: 2 + }, + ShortcutLabel: class { + constructor(params) { + Object.assign(this, params); + } + }, + StringList: { + new(strings) { + return { strings }; + } + }, + EventControllerKey: class { + connect(signal, callback) { + this.signal = signal; + this.callback = callback; + } + }, + accelerator_get_default_mod_mask() { + return 0; + }, + accelerator_name() { + return 'mock-accel'; + } + } +})); + +vi.mock('gi://Gdk', () => ({ + default: { + KEY_BackSpace: 8, + KEY_Shift_L: 0xffe1, + KEY_Hyper_R: 0xffed, + KEY_Alt_L: 0xffe9, + KEY_Alt_R: 0xffea, + KEY_Meta_L: 0xffe7, + KEY_Meta_R: 0xffe8, + KEY_Super_L: 0xffeb, + KEY_Super_R: 0xffec, + KEY_Control_L: 0xffe3, + KEY_Control_R: 0xffe4, + EVENT_STOP: true, + EVENT_PROPAGATE: false + } +})); + +vi.mock('gi://GObject', () => ({ + default: { + registerClass: (meta, cls) => { + const actualClass = cls || meta; + const wrapperClass = class extends actualClass { + constructor(...args) { + super(...args); + if (typeof this._init === 'function') { + this._init(...args); + } + } + }; + return wrapperClass; + } + } +})); + +vi.mock('resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js', () => ({ + ExtensionPreferences: class { + getSettings() { + return mockSettings; + } + } +})); + +vi.mock('../lib/editor/preview.js', () => ({ + LayoutPreviewPage: class extends BaseMockWidget { + _init(settings) { + this.settings = settings; + this.title = 'Layouts'; + this.groups = []; + } + add(group) { + this.groups.push(group); + } + } +})); + +vi.mock('../lib/editor/editor.js', () => ({ + LayoutEditorPage: class extends BaseMockWidget { + _init(settings) { + this.settings = settings; + this.title = 'Base JSON'; + } + } +})); + +// Import WorkflowTilingPreferences (relative to tests/ dir) +import WorkflowTilingPreferences from '../prefs.js'; + +describe('WorkflowTilingPreferences', () => { + let prefs; + let mockWindow; + let addedPages; + + beforeEach(() => { + // Reset settings + mockSettingsStore['enable-gaps'] = true; + mockSettingsStore['inner-gaps'] = 6; + mockSettingsStore['outer-gaps'] = 4; + mockSettingsStore['keybindings-mode'] = 'default'; + mockSettingsStore['focus-window-mode'] = 'default'; + mockSettingsStore['shortcut-close-monitor'] = []; + mockSettingsStore['close-monitor-include-minimized'] = false; + + Object.keys(mockListeners).forEach(k => delete mockListeners[k]); + + prefs = new WorkflowTilingPreferences(); + addedPages = []; + mockWindow = { + add: vi.fn((page) => { + addedPages.push(page); + }), + remove: vi.fn((page) => { + const idx = addedPages.indexOf(page); + if (idx > -1) addedPages.splice(idx, 1); + }) + }; + }); + + it('should create and add expected pages to preferences window', () => { + prefs.fillPreferencesWindow(mockWindow); + + expect(mockWindow.add).toHaveBeenCalled(); + const titles = addedPages.map(p => p.title); + expect(titles).toContain('General'); + expect(titles).toContain('Keyboard Shortcuts'); + expect(titles).toContain('Layouts'); + }); + + it('should place only Gaps group in General page', () => { + prefs.fillPreferencesWindow(mockWindow); + const generalPage = addedPages.find(p => p.title === 'General'); + + expect(generalPage).toBeDefined(); + expect(generalPage.groups).toHaveLength(1); + expect(generalPage.groups[0].title).toBe('Gaps'); + }); + + it('should place correct shortcut groups in Keyboard Shortcuts page', () => { + prefs.fillPreferencesWindow(mockWindow); + const shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); + + expect(shortcutsPage).toBeDefined(); + expect(shortcutsPage.groups).toHaveLength(3); + expect(shortcutsPage.groups[0].title).toBe('Window Position Swapping'); + expect(shortcutsPage.groups[1].title).toBe('Focus Navigation'); + expect(shortcutsPage.groups[2].title).toBe('Workspace & Monitor Actions'); + }); + + it('should toggle JSON Layout Editor page dynamically when active notify fires', () => { + prefs.fillPreferencesWindow(mockWindow); + const layoutsPage = addedPages.find(p => p.title === 'Layouts'); + const advancedGroup = layoutsPage.groups[0]; + const jsonToggle = advancedGroup.rows.find(r => r.title === 'Edit Base JSON Instead'); + + expect(jsonToggle).toBeDefined(); + expect(addedPages.some(p => p.title === 'Base JSON')).toBe(false); + + // Activate toggle and emit notify + jsonToggle.active = true; + jsonToggle.emit('notify::active'); + + expect(addedPages.some(p => p.title === 'Base JSON')).toBe(true); + + // Deactivate toggle and emit notify + jsonToggle.active = false; + jsonToggle.emit('notify::active'); + + expect(addedPages.some(p => p.title === 'Base JSON')).toBe(false); + }); + + describe('ShortcutRow & Key Recording', () => { + let shortcutsPage; + let swapGroup; + let testRow; + + beforeEach(() => { + prefs.fillPreferencesWindow(mockWindow); + shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); + swapGroup = shortcutsPage.groups.find(g => g.title === 'Window Position Swapping'); + testRow = swapGroup.rows.find(r => r.keyName === 'custom-move-window-left'); + }); + + it('should initialize ShortcutRow with accelerator from settings', () => { + mockSettingsStore['custom-move-window-left'] = ['Left']; + const row = new testRow.constructor(mockSettings, 'custom-move-window-left', 'Test Title'); + expect(row._getAccelerator()).toBe('Left'); + }); + + it('should handle empty accelerator settings', () => { + mockSettingsStore['custom-move-window-left'] = []; + const row = new testRow.constructor(mockSettings, 'custom-move-window-left', 'Test Title'); + expect(row._getAccelerator()).toBe(''); + }); + + it('should present shortcut dialog and handle key presses', () => { + testRow.emit('activated'); + + const dialog = mockAdw.lastAlertDialog; + expect(dialog).toBeDefined(); + expect(dialog.presented).toBe(true); + expect(dialog.window).toBeDefined(); + + expect(dialog.controllers).toHaveLength(1); + const controller = dialog.controllers[0]; + expect(controller.signal).toBe('key-pressed'); + + // Test modifier key press: Shift_L (0xffe1), modifiers = 0 + const propagateResult = controller.callback(controller, 0xffe1, 0, 0); + expect(propagateResult).toBe(false); // Gdk.EVENT_PROPAGATE is false + expect(mockSettingsStore['custom-move-window-left']).toEqual([]); + + // Test valid accelerator press: e.g. keyval = 65 ('a'), modifiers = 4 (Control) + const stopResult = controller.callback(controller, 65, 0, 4); + expect(stopResult).toBe(true); // Gdk.EVENT_STOP is true + expect(mockSettingsStore['custom-move-window-left']).toEqual(['mock-accel']); + expect(dialog.closed).toBe(true); + }); + + it('should clear shortcut when Backspace is pressed without modifiers', () => { + mockSettingsStore['custom-move-window-left'] = ['some-shortcut']; + testRow.emit('activated'); + + const dialog = mockAdw.lastAlertDialog; + const controller = dialog.controllers[0]; + + // Backspace keyval = 8, modifiers = 0 + const result = controller.callback(controller, 8, 0, 0); + expect(result).toBe(true); // Gdk.EVENT_STOP + expect(mockSettingsStore['custom-move-window-left']).toEqual([]); + expect(dialog.closed).toBe(true); + }); + + it('should propagate key event when accelerator is falsy', () => { + testRow.emit('activated'); + + const dialog = mockAdw.lastAlertDialog; + const controller = dialog.controllers[0]; + + // Spy on Gtk.accelerator_name to return empty string + vi.spyOn(Gtk, 'accelerator_name').mockReturnValueOnce(''); + + // Key press with keyval = 65, modifiers = 4 + const result = controller.callback(controller, 65, 0, 4); + expect(result).toBe(false); // Gdk.EVENT_PROPAGATE is false + expect(dialog.closed).toBeUndefined(); // Should not close + }); + + it('should handle missing surface or inhibit method without throwing', () => { + vi.spyOn(testRow, 'get_root').mockReturnValue({ + get_surface() { return null; } + }); + + expect(() => testRow.emit('activated')).not.toThrow(); + expect(mockAdw.lastAlertDialog.presented).toBe(true); + }); + + it('should handle surface inhibit/restore throwing errors without crashing', () => { + vi.spyOn(testRow, 'get_root').mockReturnValue({ + get_surface() { + return { + inhibit_system_shortcuts() { throw new Error('Inhibit failed'); }, + restore_system_shortcuts() { throw new Error('Restore failed'); } + }; + } + }); + + expect(() => testRow.emit('activated')).not.toThrow(); + const dialog = mockAdw.lastAlertDialog; + + // Trigger cleanup by emitting response on dialog + expect(() => dialog.connect).toBeDefined(); + + // Re-simulate a response event trigger + dialog.emit('response'); + }); + }); + + describe('Mode Switching and Visibility Stress Tests', () => { + let shortcutsPage; + let swapGroup; + let testRow; + + beforeEach(() => { + prefs.fillPreferencesWindow(mockWindow); + shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); + swapGroup = shortcutsPage.groups.find(g => g.title === 'Window Position Swapping'); + testRow = swapGroup.rows.find(r => r.keyName === 'custom-move-window-left'); + }); + + it('should update keybinding row visibilities when mode changes', () => { + const modeRow = swapGroup.rows.find(r => r instanceof mockAdw.ComboRow); + const moveRows = swapGroup.rows.filter(r => r.keyName !== undefined); // ShortcutRows + + // Initial: default mode. Move rows should be hidden + expect(moveRows.every(r => r.visible === false)).toBe(true); + expect(modeRow.subtitle).toContain('Default:'); + + // Switch to custom (notify selected = 1) + modeRow.selected = 1; + modeRow.emit('notify::selected'); + expect(mockSettingsStore['keybindings-mode']).toBe('custom'); + expect(moveRows.every(r => r.visible === true)).toBe(true); + expect(modeRow.subtitle).toBe(''); + + // Switch to disabled (notify selected = 2) + modeRow.selected = 2; + modeRow.emit('notify::selected'); + expect(mockSettingsStore['keybindings-mode']).toBe('disabled'); + expect(moveRows.every(r => r.visible === false)).toBe(true); + expect(modeRow.subtitle).toBe(''); + }); + + it('should handle external settings modifications for keybindings mode visibility', () => { + const moveRows = swapGroup.rows.filter(r => r.keyName !== undefined); + + // Set via settings directly + mockSettings.set_string('keybindings-mode', 'custom'); + expect(moveRows.every(r => r.visible === true)).toBe(true); + + mockSettings.set_string('keybindings-mode', 'disabled'); + expect(moveRows.every(r => r.visible === false)).toBe(true); + }); + + it('should handle invalid keybindings mode values gracefully', () => { + const moveRows = swapGroup.rows.filter(r => r.keyName !== undefined); + + // Set to invalid value + mockSettings.set_string('keybindings-mode', 'invalid-mode-value'); + + // Should hide custom rows and not crash + expect(moveRows.every(r => r.visible === false)).toBe(true); + }); + + it('should toggle gaps visibility when enable-gaps setting is changed', () => { + const generalPage = addedPages.find(p => p.title === 'General'); + const gapsGroup = generalPage.groups[0]; + const innerGapsRow = gapsGroup.rows.find(r => r.title.includes('Inner Gaps')); + const outerGapsRow = gapsGroup.rows.find(r => r.title.includes('Outer Gaps')); + + expect(innerGapsRow.visible).toBe(true); + expect(outerGapsRow.visible).toBe(true); + + // Disable gaps using setter on bound property + const enableGapsRow = gapsGroup.rows.find(r => r.title === 'Enable Gaps'); + enableGapsRow.active = false; + + expect(innerGapsRow.visible).toBe(false); + expect(outerGapsRow.visible).toBe(false); + }); + + it('should handle rapid toggling of JSON editor page', () => { + const layoutsPage = addedPages.find(p => p.title === 'Layouts'); + const advancedGroup = layoutsPage.groups[0]; + const jsonToggle = advancedGroup.rows.find(r => r.title === 'Edit Base JSON Instead'); + + for (let i = 0; i < 100; i++) { + jsonToggle.active = !jsonToggle.active; + jsonToggle.emit('notify::active'); + } + + // Since it was toggled 100 times starting from false, it ends at false (removed) + expect(addedPages.some(p => p.title === 'Base JSON')).toBe(false); + }); + }); +}); + diff --git a/vitest.config.js b/vitest.config.js index aa56510..5d26a09 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { setupFiles: ['./tests/setup.js'], + exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp,agents}/**', '**/{karma,rollup,webpack,vite,vitest}.config.*'], }, }); From cc091860ced3a8390f5ee66ea018c247a1c5f265 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Thu, 11 Jun 2026 17:05:48 +0200 Subject: [PATCH 21/33] feat: phase 2 - integrate native shortcuts and refine UI --- prefs.js | 303 ++++++++++++++---- schemas/gschemas.compiled | Bin 2652 -> 2796 bytes ...ell.extensions.workflow-tiling.gschema.xml | 12 + tests/prefs.test.js | 41 ++- 4 files changed, 291 insertions(+), 65 deletions(-) diff --git a/prefs.js b/prefs.js index 3a58796..a8c06e5 100644 --- a/prefs.js +++ b/prefs.js @@ -75,10 +75,30 @@ const ShortcutRowMixin = { const ShortcutRow = GObject.registerClass( class ShortcutRow extends Adw.ActionRow { - _init(settings, keyName, title) { + _init(settings, keyName, title, origin = '') { super._init({ title }); this.settings = settings; this.keyName = keyName; + this._onChangeCallback = null; + + if (origin === 'System') { + const badgeBox = new Gtk.Box({ orientation: 0, spacing: 4, valign: 3 }); // 0: HORIZONTAL, 3: CENTER + const icon = new Gtk.Image({ icon_name: 'preferences-system-symbolic' }); + icon.add_css_class('dim-label'); + const lbl = new Gtk.Label({ label: origin, css_classes: ['dim-label', 'caption'] }); + badgeBox.append(icon); + badgeBox.append(lbl); + badgeBox.margin_end = 12; + this.add_suffix(badgeBox); + } + + this.warningIcon = new Gtk.Image({ + icon_name: 'dialog-warning-symbolic', + valign: Gtk.Align.CENTER, + visible: false + }); + this.warningIcon.add_css_class('warning'); + this.add_suffix(this.warningIcon); this.shortcutLabel = new Gtk.ShortcutLabel({ disabled_text: 'Disabled', @@ -92,9 +112,19 @@ class ShortcutRow extends Adw.ActionRow { this.settings.connect(`changed::${this.keyName}`, () => { this.shortcutLabel.accelerator = this._getAccelerator(); + if (this._onChangeCallback) this._onChangeCallback(); }); } + setWarning(isWarning, tooltip = '') { + this.warningIcon.visible = isWarning; + this.warningIcon.tooltip_text = tooltip; + } + + setOnChange(cb) { + this._onChangeCallback = cb; + } + _getAccelerator() { const strv = this.settings.get_strv(this.keyName); return strv.length > 0 ? strv[0] : ''; @@ -151,11 +181,102 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { transitionGroup.add(transitionRow); page.add(transitionGroup); + const wmSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.wm.keybindings' }); + const allRows = []; + const updateConflicts = () => { + const accelMap = {}; + const addAccel = (accel, source) => { + if (!accel) return; + if (!accelMap[accel]) accelMap[accel] = []; + accelMap[accel].push(source); + }; + + for (const row of allRows) { + if (!row.visible && row.keyName && row.keyName.startsWith('custom-')) { + continue; + } + const accel = row._getAccelerator(); + if (accel === 'disabled') continue; + addAccel(accel, row); + } + + const focusMode = settings.get_string('focus-window-mode'); + if (focusMode === 'default') { + ['focus-window-left', 'focus-window-right', 'focus-window-up', 'focus-window-down'].forEach(k => { + const val = settings.get_strv(k); + if (val.length > 0) addAccel(val[0], 'focus-default'); + }); + } + + const swapMode = settings.get_string('keybindings-mode'); + if (swapMode === 'default') { + ['move-window-left', 'move-window-right', 'move-window-up', 'move-window-down'].forEach(k => { + const val = settings.get_strv(k); + if (val.length > 0) addAccel(val[0], 'swap-default'); + }); + } + + for (const row of allRows) { + if (!row.visible) { + row.setWarning(false); + continue; + } + const accel = row._getAccelerator(); + if (accel && accel !== 'disabled' && accelMap[accel] && accelMap[accel].length > 1) { + row.setWarning(true, 'Shortcut conflicts with another active shortcut'); + } else { + row.setWarning(false); + } + } + }; + + const createRow = (st, id, label, origin = '') => { + const row = new ShortcutRow(st, id, label, origin); + row.setOnChange(updateConflicts); + allRows.push(row); + return row; + }; + + // --- Focus & Position Group --- + const focusPositionGroup = new Adw.PreferencesGroup({ title: 'Window Focus & Position' }); + + const focusModeRow = new Adw.ComboRow({ + title: 'Focus Mode', + subtitle: '', + model: Gtk.StringList.new(['Default', 'Custom', 'Disabled']) + }); + const focusMode = settings.get_string('focus-window-mode'); + focusModeRow.selected = focusMode === 'custom' ? 1 : (focusMode === 'disabled' ? 2 : 0); + focusModeRow.connect('notify::selected', () => { + let mode = 'default'; + if (focusModeRow.selected === 1) mode = 'custom'; + if (focusModeRow.selected === 2) mode = 'disabled'; + settings.set_string('focus-window-mode', mode); + }); + focusPositionGroup.add(focusModeRow); + + const focusRows = [ + { id: 'custom-focus-window-left', label: ' ↳ Focus Window Left' }, + { id: 'custom-focus-window-right', label: ' ↳ Focus Window Right' }, + { id: 'custom-focus-window-up', label: ' ↳ Focus Window Up' }, + { id: 'custom-focus-window-down', label: ' ↳ Focus Window Down' } + ].map(s => { + const row = createRow(settings, s.id, s.label); + focusPositionGroup.add(row); + return row; + }); + + const updateFocusVisibility = () => { + const mode = settings.get_string('focus-window-mode'); + focusModeRow.subtitle = mode === 'default' ? 'Default: + + ' : ''; + const showCustom = mode === 'custom'; + focusRows.forEach(r => r.visible = showCustom); + }; + settings.connect('changed::focus-window-mode', () => { updateFocusVisibility(); updateConflicts(); }); + updateFocusVisibility(); - // --- Core Keybindings Group --- - const keysGroup = new Adw.PreferencesGroup({ title: 'Window Position Swapping' }); const modeRow = new Adw.ComboRow({ - title: 'Mode', + title: 'Swap Mode', subtitle: '', model: Gtk.StringList.new(['Default', 'Custom', 'Disabled']) }); @@ -167,16 +288,16 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { if (modeRow.selected === 2) mode = 'disabled'; settings.set_string('keybindings-mode', mode); }); - keysGroup.add(modeRow); + focusPositionGroup.add(modeRow); const moveRows = [ - { id: 'custom-move-window-left', label: ' ↳ Move Window Left' }, - { id: 'custom-move-window-right', label: ' ↳ Move Window Right' }, - { id: 'custom-move-window-up', label: ' ↳ Move Window Up' }, - { id: 'custom-move-window-down', label: ' ↳ Move Window Down' } + { id: 'custom-move-window-left', label: ' ↳ Swap Window Left' }, + { id: 'custom-move-window-right', label: ' ↳ Swap Window Right' }, + { id: 'custom-move-window-up', label: ' ↳ Swap Window Up' }, + { id: 'custom-move-window-down', label: ' ↳ Swap Window Down' } ].map(s => { - const row = new ShortcutRow(settings, s.id, s.label); - keysGroup.add(row); + const row = createRow(settings, s.id, s.label); + focusPositionGroup.add(row); return row; }); @@ -186,56 +307,127 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { const showCustom = mode === 'custom'; moveRows.forEach(r => r.visible = showCustom); }; - settings.connect('changed::keybindings-mode', updateVisibility); + settings.connect('changed::keybindings-mode', () => { updateVisibility(); updateConflicts(); }); updateVisibility(); - shortcutsPage.add(keysGroup); + shortcutsPage.add(new Adw.PreferencesGroup()); + shortcutsPage.add(focusPositionGroup); - // --- Focus Window Group --- - const focusGroup = new Adw.PreferencesGroup({ title: 'Focus Navigation' }); + // --- Window State --- + const stateGroup = new Adw.PreferencesGroup({ title: 'Window State' }); + [ + { id: 'close', label: 'Close Window', origin: 'System', st: wmSettings }, + { id: 'minimize', label: 'Minimize Window', origin: 'System', st: wmSettings }, + { id: 'maximize', label: 'Maximize Window', origin: 'System', st: wmSettings }, + { id: 'unmaximize', label: 'Unmaximize Window', origin: 'System', st: wmSettings }, + { id: 'toggle-fullscreen', label: 'Toggle Fullscreen', origin: 'System', st: wmSettings } + ].forEach(s => stateGroup.add(createRow(s.st, s.id, s.label, s.origin))); + shortcutsPage.add(stateGroup); + + // --- Workspace Operations --- + const wsOpsGroup = new Adw.PreferencesGroup({ title: 'Workspace Operations' }); + wsOpsGroup.add(createRow(settings, 'shortcut-close-workspace', 'Close Workspace Windows')); + wsOpsGroup.add(createRow(settings, 'shortcut-unminimize-workspace', 'Unminimize Workspace')); + shortcutsPage.add(wsOpsGroup); + + // --- Workspace Switching --- + const wsSwitchGroup = new Adw.PreferencesGroup({ title: 'Workspace Switching' }); + wsSwitchGroup.add(createRow(wmSettings, 'switch-to-workspace-left', 'Switch to Workspace Left', 'System')); + wsSwitchGroup.add(createRow(wmSettings, 'switch-to-workspace-right', 'Switch to Workspace Right', 'System')); - const focusModeRow = new Adw.ComboRow({ - title: 'Mode', + const wsSwitchModeRow = new Adw.ComboRow({ + title: 'Numbered Workspaces', subtitle: '', - model: Gtk.StringList.new(['Default', 'Custom', 'Disabled']) + model: Gtk.StringList.new(['System Default', 'Edit', 'Disabled']) }); - const focusMode = settings.get_string('focus-window-mode'); - focusModeRow.selected = focusMode === 'custom' ? 1 : (focusMode === 'disabled' ? 2 : 0); - focusModeRow.connect('notify::selected', () => { + const wsSwitchModeStr = settings.get_string('workspace-switch-mode'); + wsSwitchModeRow.selected = wsSwitchModeStr === 'custom' ? 1 : (wsSwitchModeStr === 'disabled' ? 2 : 0); + + const wsSwitchKeys = []; + for (let i = 1; i <= 4; i++) wsSwitchKeys.push(`switch-to-workspace-${i}`); + + wsSwitchModeRow.connect('notify::selected', () => { let mode = 'default'; - if (focusModeRow.selected === 1) mode = 'custom'; - if (focusModeRow.selected === 2) mode = 'disabled'; - settings.set_string('focus-window-mode', mode); + if (wsSwitchModeRow.selected === 1) mode = 'custom'; + if (wsSwitchModeRow.selected === 2) mode = 'disabled'; + settings.set_string('workspace-switch-mode', mode); + + if (mode === 'default') { + wsSwitchKeys.forEach(k => wmSettings.reset(k)); + } else if (mode === 'disabled') { + wsSwitchKeys.forEach(k => wmSettings.set_strv(k, ['disabled'])); + } }); - focusGroup.add(focusModeRow); + wsSwitchGroup.add(wsSwitchModeRow); - const focusRows = [ - { id: 'custom-focus-window-left', label: ' ↳ Focus Window Left' }, - { id: 'custom-focus-window-right', label: ' ↳ Focus Window Right' }, - { id: 'custom-focus-window-up', label: ' ↳ Focus Window Up' }, - { id: 'custom-focus-window-down', label: ' ↳ Focus Window Down' } - ].map(s => { - const row = new ShortcutRow(settings, s.id, s.label); - focusGroup.add(row); - return row; - }); + const wsSwitchRows = []; + for (let i = 1; i <= 4; i++) { + wsSwitchRows.push(createRow(wmSettings, `switch-to-workspace-${i}`, ` ↳ Switch to Workspace ${i}`, 'System')); + } + wsSwitchRows.forEach(r => wsSwitchGroup.add(r)); + shortcutsPage.add(wsSwitchGroup); - const updateFocusVisibility = () => { - const mode = settings.get_string('focus-window-mode'); - focusModeRow.subtitle = mode === 'default' ? 'Default: + + ' : ''; + const updateWsSwitchVisibility = () => { + const mode = settings.get_string('workspace-switch-mode'); + wsSwitchModeRow.subtitle = mode === 'default' ? 'System default keybindings' : ''; const showCustom = mode === 'custom'; - focusRows.forEach(r => r.visible = showCustom); + wsSwitchRows.forEach(r => r.visible = showCustom); + updateConflicts(); }; - settings.connect('changed::focus-window-mode', updateFocusVisibility); - updateFocusVisibility(); + settings.connect('changed::workspace-switch-mode', updateWsSwitchVisibility); + updateWsSwitchVisibility(); - shortcutsPage.add(focusGroup); + // --- Moving to Workspace --- + const wsMoveGroup = new Adw.PreferencesGroup({ title: 'Moving to Workspace' }); + wsMoveGroup.add(createRow(wmSettings, 'move-to-workspace-left', 'Move Window to Workspace Left', 'System')); + wsMoveGroup.add(createRow(wmSettings, 'move-to-workspace-right', 'Move Window to Workspace Right', 'System')); - // --- Additional Batch Shortcuts Group --- - const batchKeysGroup = new Adw.PreferencesGroup({ title: 'Workspace & Monitor Actions' }); + const wsMoveModeRow = new Adw.ComboRow({ + title: 'Numbered Workspaces', + subtitle: '', + model: Gtk.StringList.new(['System Default', 'Edit', 'Disabled']) + }); + const wsMoveModeStr = settings.get_string('workspace-move-mode'); + wsMoveModeRow.selected = wsMoveModeStr === 'custom' ? 1 : (wsMoveModeStr === 'disabled' ? 2 : 0); - const closeMonitorRow = new ShortcutRow(settings, 'shortcut-close-monitor', 'Close Monitor Windows'); - batchKeysGroup.add(closeMonitorRow); + const wsMoveKeys = []; + for (let i = 1; i <= 4; i++) wsMoveKeys.push(`move-to-workspace-${i}`); + + wsMoveModeRow.connect('notify::selected', () => { + let mode = 'default'; + if (wsMoveModeRow.selected === 1) mode = 'custom'; + if (wsMoveModeRow.selected === 2) mode = 'disabled'; + settings.set_string('workspace-move-mode', mode); + + if (mode === 'default') { + wsMoveKeys.forEach(k => wmSettings.reset(k)); + } else if (mode === 'disabled') { + wsMoveKeys.forEach(k => wmSettings.set_strv(k, ['disabled'])); + } + }); + wsMoveGroup.add(wsMoveModeRow); + + const wsMoveRows = []; + for (let i = 1; i <= 4; i++) { + wsMoveRows.push(createRow(wmSettings, `move-to-workspace-${i}`, ` ↳ Move Window to Workspace ${i}`, 'System')); + } + wsMoveRows.forEach(r => wsMoveGroup.add(r)); + shortcutsPage.add(wsMoveGroup); + + const updateWsMoveVisibility = () => { + const mode = settings.get_string('workspace-move-mode'); + wsMoveModeRow.subtitle = mode === 'default' ? 'System default keybindings' : ''; + const showCustom = mode === 'custom'; + wsMoveRows.forEach(r => r.visible = showCustom); + updateConflicts(); + }; + settings.connect('changed::workspace-move-mode', updateWsMoveVisibility); + updateWsMoveVisibility(); + + // --- Monitor Actions --- + const monitorGroup = new Adw.PreferencesGroup({ title: 'Monitor Actions' }); + const closeMonitorRow = createRow(settings, 'shortcut-close-monitor', 'Close Monitor Windows'); + monitorGroup.add(closeMonitorRow); const closeMinRow = new Adw.SwitchRow({ title: ' ↳ Include Minimized', subtitle: ' Also close minimized windows on monitor' }); settings.bind('close-monitor-include-minimized', closeMinRow, 'active', Gio.SettingsBindFlags.DEFAULT); @@ -245,17 +437,18 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { }; settings.connect('changed::shortcut-close-monitor', updateCloseMinRowVisibility); updateCloseMinRowVisibility(); - batchKeysGroup.add(closeMinRow); + monitorGroup.add(closeMinRow); [ - { id: 'shortcut-close-workspace', label: 'Close Workspace Windows' }, - { id: 'shortcut-switch-monitor', label: 'Switch Monitors' }, - { id: 'shortcut-port-monitor-left', label: 'Port Monitor to Left Workspace' }, - { id: 'shortcut-port-monitor-right', label: 'Port Monitor to Right Workspace' }, - { id: 'shortcut-unminimize-workspace', label: 'Unminimize Workspace' } - ].forEach(s => batchKeysGroup.add(new ShortcutRow(settings, s.id, s.label))); - - shortcutsPage.add(batchKeysGroup); + { id: 'shortcut-switch-monitor', label: 'Switch Monitors', origin: '', st: settings }, + { id: 'shortcut-port-monitor-left', label: 'Port Monitor to Left Workspace', origin: '', st: settings }, + { id: 'shortcut-port-monitor-right', label: 'Port Monitor to Right Workspace', origin: '', st: settings } + ].forEach(s => monitorGroup.add(createRow(s.st, s.id, s.label, s.origin))); + + shortcutsPage.add(monitorGroup); + + // Initial conflicts check + updateConflicts(); window.add(page); window.add(shortcutsPage); diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index 21efeafd8aa21da450c041196a1a8a6cc086dde2..3544718ff6b655bbd6d26113a7d63abbaf2f0367 100644 GIT binary patch literal 2796 zcmbtWU1%It6rQN5u}N*(ra!T*jVt1&WOnmIwZsrYs2H%3V$l|*u#=tH-7&K>>&$F6 zF|6dFN@@`bw$Mt^D7943Qi=sdkVP~P3O3XhE3GAu1tX^9!TO}%H+yF~`{R=XU%r!X z?w)(kx%Zwq&%LbJx?;MLc(#BKmhI?$LTdoqGe2G-^W9EqL^=#kJRnJp8zt!)gA^_JxEIsx5Guyz7ah)_F zO@faCyQVv2>a^EGp96mh+?e0?Hg(z?p)Y_Jftz1^)=r)FTIkE*CIqtb`|u0YX>Wxd z1W&q415AOb>Qp2sMFpA{Q~$B z@XH4u9idKp3-kr>ZQ$yAXID|Dy#spFD&!UT;lQyT>a;gN?*MlJ-<>g6sMDtZ5pV*y z+3@Mp)MzM?<1Kj#>{tu3) z{V?>C;G<2*?eOJqsMBtTehyp${%kwr#Y>ynu#KB#h9X&-q zv>DeB_*vj;$$XbOZR!cI1GF!x-uh{C{j=cr0sr37=Dj-&?!di!>&g*tzi9Iu?gsAx zTC$Cw(?4zIXCL?=aPs1DjXLc%=s9o!7=Heo7Z+{DH4B~thEA7Ga6IitpqIek11o)_ zXQ|U>-mZgL3$oUPCKq&5#eKK+bjB|<1N-wi%^rNwlh#O4#pz60$-GlNc3_{aXBB&D zaA1E%Pq~8zZXE|>sB2~Au={)t^9#Q(&$NJa1-oQ_t#eAT=n4PEJX3 ztoKyRlQb+xtC%Jnd_}^LU=t3i<9p4|SJ;!Rn@J2>Dy^I9=jz&n$73VIr&N?Sl z%uN?!{9dl69G4hxC{tG6b*3Xdk^WbwBa@N-?yks`_qPz~@9D;W8Ns>=qj;z*(pzun zu9}fuo;&J+zv@A~bl3NP-O(!-*ITTZk}_ftbNYk&iie8-xm!WWsNOHX4pf}4;MCOa zuYV6>ImOK&|8X=Zaj!v%dksqbUV{>^YEa^+#KFxk-^k#iVBz(Pk-zXdbuN5#f)i1D z9$2T#y$*XC_Au;q&~Bl=vR1SHmU|@jJM3>d0QN%co!B?A2jY2M3v2+`_wZq#^C++b zz-P(piP-<3HNm&4++(q~VviI=DhD;Mrmo?<`q#tr!TaH46zqnk%AuMFchYouuPe5V fNvPgm|2_tZuc*sG+RFJ4XH*mCwn%1UmY}}_!$?UcX)evI{L9x(OilrLRLMA)2J7Z>N*7?ac zhDaVn;)5typ%s5nYKfpCqM#IH5ecGTi?DR~iuR!qr*@k!6QGqc^@Y@QrAd%it; z_ulXK&f`z1uBqC-h@PF`vCvN45ot|eckaiZ$^31fI3!*Ir|uG>dAkt*;yJzvf*2KJ z;Aa8H&8PFWQ_$0%p<7m3Kk4hXXF9f*E;;UG&T>kMZ(63E=l?|N0ZF$%cs8~d%~jY4 z`<@6quC)qnu(w3m3MTH202jrM2)n>BI$_@r>;<|a?7RHoD~yw|?G#gD7(7-LCv6!g z_!6MC2P0fhy9Mi)z`q06-&_2XI_=HSSHU+B%wO$iAEHisD|E2|=KvROm9x}ocS7$6 zKLV6zUu0D2{9r`@@HK23k z!Z*}ucR*hNmw|t7{c@E$?M=`xg0BFrh2~GF)299hcp3P1Zg`10ZN{5GGjsx%%J!?& zY17Ys@L}q2oZCR1_Fm{kZ~!cRIc`&@oq#?MehWA_)1^?Sy$$+j;O~KJUwqs_o%R;! zSHR1_<*&=9sMBU1Hli`xfZ?~xZ&Ihd3wjEy06$&VmZ{U`xx?U5pyQ?{{h>{L9P9!s zeMio5J#F^S9QbwM=7Y~aOr19CxBxy6Tx9Gi~N)6`a8ETwUDr zGWVgq9eN7f51e`W{NL1RvmcIvIosL5^PPf{b6_YX)7CJ*mY(wmkGf_-b*G00j~Qmp zA2N`=l>gk@kN`OE zT|f)KI}2@rI)wLyZmScPuH@CCCz?cRqC0ZlQm383_pnNcndE`wz%w(+lgWYoJ;`bL zSxOEZ*pF{U#HI#g_C!yzx6#srYgYEi?WkAwt6$X1?fU-jcHEWD_0HBDNf|LjXU2>A zW>3swj=k=h+M#|n^H5*E=c4*~hU5C#z*hphVA^KEd_h-uaeGBItMiX6a=NJc2EPcA z!G)iZ7k)-w_?g=mex~li&j{gFwlCrsrQq16@3@L<=8gDG#QmeX6`Uy?9`;qqKws(n|*>)6K& zS((rcb;@*Ht`NGHRV~#=iP13)@*kHdit9;Pu{K$NFf;K1rk%9{O;_UX(d72{z&ti6 zXtdvY^^A{Q)30$JYO0Sy*5_+Y9let2XN|b^;1>F=aaV?=?IC)n9 diff --git a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml index 6f67ffe..a762eb0 100644 --- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml +++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml @@ -68,6 +68,18 @@ Custom swap active window with the one below it. + + 'default' + Workspace Switch Mode + Mode for numbered workspace switching (default or disabled). + + + + 'default' + Workspace Move Mode + Mode for numbered workspace moving (default or disabled). + + 'default' Focus Window Keybindings Mode diff --git a/tests/prefs.test.js b/tests/prefs.test.js index ada1dd1..ae67d1e 100644 --- a/tests/prefs.test.js +++ b/tests/prefs.test.js @@ -198,6 +198,24 @@ vi.mock('gi://Gtk', () => ({ Object.assign(this, params); } }, + Label: class { + constructor(params) { + Object.assign(this, params); + } + add_css_class(cls) {} + }, + Image: class { + constructor(params) { + Object.assign(this, params); + } + add_css_class(cls) {} + }, + Box: class { + constructor(params) { + Object.assign(this, params); + } + append() {} + }, StringList: { new(strings) { return { strings }; @@ -340,10 +358,13 @@ describe('WorkflowTilingPreferences', () => { const shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); expect(shortcutsPage).toBeDefined(); - expect(shortcutsPage.groups).toHaveLength(3); - expect(shortcutsPage.groups[0].title).toBe('Window Position Swapping'); - expect(shortcutsPage.groups[1].title).toBe('Focus Navigation'); - expect(shortcutsPage.groups[2].title).toBe('Workspace & Monitor Actions'); + expect(shortcutsPage.groups).toHaveLength(7); + expect(shortcutsPage.groups[1].title).toBe('Window Focus & Position'); + expect(shortcutsPage.groups[2].title).toBe('Window State'); + expect(shortcutsPage.groups[3].title).toBe('Workspace Operations'); + expect(shortcutsPage.groups[4].title).toBe('Workspace Switching'); + expect(shortcutsPage.groups[5].title).toBe('Moving to Workspace'); + expect(shortcutsPage.groups[6].title).toBe('Monitor Actions'); }); it('should toggle JSON Layout Editor page dynamically when active notify fires', () => { @@ -376,7 +397,7 @@ describe('WorkflowTilingPreferences', () => { beforeEach(() => { prefs.fillPreferencesWindow(mockWindow); shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); - swapGroup = shortcutsPage.groups.find(g => g.title === 'Window Position Swapping'); + swapGroup = shortcutsPage.groups.find(g => g.title === 'Window Focus & Position'); testRow = swapGroup.rows.find(r => r.keyName === 'custom-move-window-left'); }); @@ -483,13 +504,13 @@ describe('WorkflowTilingPreferences', () => { beforeEach(() => { prefs.fillPreferencesWindow(mockWindow); shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); - swapGroup = shortcutsPage.groups.find(g => g.title === 'Window Position Swapping'); + swapGroup = shortcutsPage.groups.find(g => g.title === 'Window Focus & Position'); testRow = swapGroup.rows.find(r => r.keyName === 'custom-move-window-left'); }); it('should update keybinding row visibilities when mode changes', () => { - const modeRow = swapGroup.rows.find(r => r instanceof mockAdw.ComboRow); - const moveRows = swapGroup.rows.filter(r => r.keyName !== undefined); // ShortcutRows + const modeRow = swapGroup.rows.find(r => r instanceof mockAdw.ComboRow && r.title === 'Swap Mode'); + const moveRows = swapGroup.rows.filter(r => r.keyName && r.keyName.startsWith('custom-move-window')); // ShortcutRows // Initial: default mode. Move rows should be hidden expect(moveRows.every(r => r.visible === false)).toBe(true); @@ -511,7 +532,7 @@ describe('WorkflowTilingPreferences', () => { }); it('should handle external settings modifications for keybindings mode visibility', () => { - const moveRows = swapGroup.rows.filter(r => r.keyName !== undefined); + const moveRows = swapGroup.rows.filter(r => r.keyName && r.keyName.startsWith('custom-move-window')); // Set via settings directly mockSettings.set_string('keybindings-mode', 'custom'); @@ -522,7 +543,7 @@ describe('WorkflowTilingPreferences', () => { }); it('should handle invalid keybindings mode values gracefully', () => { - const moveRows = swapGroup.rows.filter(r => r.keyName !== undefined); + const moveRows = swapGroup.rows.filter(r => r.keyName && r.keyName.startsWith('custom-move-window')); // Set to invalid value mockSettings.set_string('keybindings-mode', 'invalid-mode-value'); From 1471a0f58ab019aecec6d0a3e6cb8e0ee674897e Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Fri, 19 Jun 2026 16:20:26 +0200 Subject: [PATCH 22/33] fix: tests --- tests/prefs.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/prefs.test.js b/tests/prefs.test.js index ae67d1e..274c558 100644 --- a/tests/prefs.test.js +++ b/tests/prefs.test.js @@ -344,13 +344,14 @@ describe('WorkflowTilingPreferences', () => { expect(titles).toContain('Layouts'); }); - it('should place only Gaps group in General page', () => { + it('should place Gaps and Monitor Transition groups in General page', () => { prefs.fillPreferencesWindow(mockWindow); const generalPage = addedPages.find(p => p.title === 'General'); expect(generalPage).toBeDefined(); - expect(generalPage.groups).toHaveLength(1); + expect(generalPage.groups).toHaveLength(2); expect(generalPage.groups[0].title).toBe('Gaps'); + expect(generalPage.groups[1].title).toBe('Monitor Transition'); }); it('should place correct shortcut groups in Keyboard Shortcuts page', () => { From ae4b97251266698edc66d17c78d5fdcbe1511fd1 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Mon, 8 Jun 2026 20:34:57 +0200 Subject: [PATCH 23/33] first working iteration maximisation rework --- extension.js | 35 ++++++++++++++++ lib/controller.js | 39 ++++++++++++++++++ lib/keybindings.js | 4 +- lib/settings.js | 4 +- lib/signals.js | 1 + lib/window.js | 19 ++++++++- prefs.js | 4 +- schemas/gschemas.compiled | Bin 2796 -> 2940 bytes ...ell.extensions.workflow-tiling.gschema.xml | 11 +++++ 9 files changed, 113 insertions(+), 4 deletions(-) diff --git a/extension.js b/extension.js index f91cbe1..989d445 100644 --- a/extension.js +++ b/extension.js @@ -1,4 +1,5 @@ import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; +import Gio from 'gi://Gio'; import { TilingController } from './lib/controller.js'; import { SignalListener } from './lib/signals.js'; import { SettingsManager } from './lib/settings.js'; @@ -22,6 +23,20 @@ export default class WorkflowTilingExtension extends Extension { this._isActive = false; this._wasSuspended = false; + this._isActive = false; + this._wasSuspended = false; + + try { + this._wmSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.wm.preferences' }); + let currentLayout = this._wmSettings.get_string('button-layout'); + if (currentLayout.includes('maximize')) { + let newLayout = currentLayout.replace(/maximize,?/g, '').replace(/,maximize/g, ''); + this._wmSettings.set_string('button-layout', newLayout); + } + } catch (e) { + Logger.warn('Failed to hide maximize button', e); + } + this._settings.onSettingsChanged = () => { if (this._applyCustomLayouts()) { this._controller.hydrate(); @@ -84,5 +99,25 @@ export default class WorkflowTilingExtension extends Extension { this._controller = null; this._isActive = false; this._wasSuspended = false; + + if (this._wmSettings) { + try { + let layout = this._wmSettings.get_string('button-layout'); + if (!layout.includes('maximize')) { + if (layout.includes('minimize,close')) { + layout = layout.replace('minimize,close', 'minimize,maximize,close'); + } else if (layout.includes('close')) { + layout = layout.replace('close', 'maximize,close'); + } else { + layout += ',maximize'; + } + this._wmSettings.set_string('button-layout', layout); + Gio.Settings.sync(); + } + } catch (e) { + Logger.error('Failed to restore maximize button', e); + } + this._wmSettings = null; + } } } diff --git a/lib/controller.js b/lib/controller.js index 748192c..1784822 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -34,6 +34,7 @@ export class TilingController { this.monitorManager = new MonitorManager(this); this.workspaceManager = new WorkspaceManager(this); this.dragManager = new DragManager(this); + this._authorizedOverrides = new Set(); /** * When true, layout re-evaluations are deferred. @@ -79,6 +80,7 @@ export class TilingController { Logger.debug(`tilingRequest: Initiating for window ID ${window.get_id ? window.get_id() : 'unknown'} ("${window.get_title ? window.get_title() : 'unknown'}")`); + const isNewWindow = !this._windowWrappers.has(window); let wrapper = this._ensureWrapper(window); if (!wrapper) { Logger.debug(`tilingRequest: Aborted. Wrapper creation rejected window.`); @@ -101,6 +103,11 @@ export class TilingController { } const { workspace, monitorIndex, monitorId, isRestoring, preferredSlot } = context; + + if (isNewWindow && !isRestoring) { + this._clearOverridesOnMonitor(monitorIndex); + } + Logger.debug(`tilingRequest: Context resolved -> Workspace: ${workspace.index ? workspace.index() : 'unknown'}, MonitorIndex: ${monitorIndex}, MonitorID: ${monitorId}, Restoring: ${isRestoring}`); if (this.monitorManager.checkEvacuation(window, wrapper, monitorId, workspace)) { @@ -240,6 +247,7 @@ export class TilingController { wrapper.destroy(); const { workspace, monitorIndex, monitorId } = wrapper; this._windowWrappers.delete(window); + this._authorizedOverrides.delete(window); try { if (workspace) { @@ -422,7 +430,37 @@ export class TilingController { this.workspaceManager.unminimizeWorkspace(workspace); } + toggleOverrideActiveWindow(type) { + const targetWindow = global.display.get_focus_window(); + if (!targetWindow || targetWindow.unmanaged) return; + + const isActive = (targetWindow.maximized_horizontally && targetWindow.maximized_vertically) || (targetWindow.is_fullscreen && targetWindow.is_fullscreen()); + if (isActive) { + this._authorizedOverrides.delete(targetWindow); + if (targetWindow.is_fullscreen && targetWindow.is_fullscreen()) targetWindow.unmake_fullscreen(); + if (targetWindow.maximized_horizontally && targetWindow.maximized_vertically) targetWindow.unmaximize(3); + } else { + this._authorizedOverrides.add(targetWindow); + if (type === 'maximize') targetWindow.maximize(3); + if (type === 'fullscreen') targetWindow.make_fullscreen(); + } + } + + _clearOverridesOnMonitor(monitorIndex) { + const activeWorkspace = global.workspace_manager.get_active_workspace(); + this._windowWrappers.forEach((wrapper, window) => { + if (wrapper.monitorIndex === monitorIndex && wrapper.workspace === activeWorkspace) { + this._authorizedOverrides.delete(window); + if (window.maximized_horizontally && window.maximized_vertically) { + window.unmaximize(3); + } + if (window.is_fullscreen && window.is_fullscreen()) { + window.unmake_fullscreen(); + } + } + }); + } clear() { this._retileTimeouts.forEach(id => global.compositor.get_laters().remove(id)); @@ -433,6 +471,7 @@ export class TilingController { this.workspaceManager.clearLayouts(); this._windowWrappers.clear(); this._restoringWindows.clear(); + this._authorizedOverrides.clear(); this.monitorManager.clear(); if (TilingController.activeInstance === this) { diff --git a/lib/keybindings.js b/lib/keybindings.js index d49889a..1dbbe0e 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -59,7 +59,9 @@ export class KeybindingManager { 'shortcut-switch-monitor': (c, win) => c.switchMonitors(win ? win.get_monitor() : global.display.get_current_monitor()), 'shortcut-port-monitor-left': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), 'shortcut-port-monitor-right': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), - 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) + 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()), + 'shortcut-toggle-maximize': (c) => c.toggleOverrideActiveWindow('maximize'), + 'shortcut-toggle-fullscreen': (c) => c.toggleOverrideActiveWindow('fullscreen') }; for (const [key, action] of Object.entries(utilities)) { diff --git a/lib/settings.js b/lib/settings.js index 2787d52..4806e14 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -48,7 +48,9 @@ export class SettingsManager { 'shortcut-switch-monitor', 'shortcut-port-monitor-left', 'shortcut-port-monitor-right', - 'shortcut-unminimize-workspace' + 'shortcut-unminimize-workspace', + 'shortcut-toggle-maximize', + 'shortcut-toggle-fullscreen' ]; kbKeys.forEach(key => { this._changedIds.push(this.settings.connect(`changed::${key}`, () => { diff --git a/lib/signals.js b/lib/signals.js index 516f319..163f3dc 100644 --- a/lib/signals.js +++ b/lib/signals.js @@ -84,6 +84,7 @@ export class SignalListener { this.controller.hydrate(); } + _addWindow(window) { if (!window) return; const sourceId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { diff --git a/lib/window.js b/lib/window.js index ee6883c..eb64bb5 100644 --- a/lib/window.js +++ b/lib/window.js @@ -152,14 +152,26 @@ export class WindowWrapper { } } + if (this.isOverrideActive()) { + return; + } + if (this.window.maximized_horizontally || this.window.maximized_vertically) { - this.window.unmaximize(); + this.window.unmaximize(3); const laterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { if (this.unmanaged) return false; this._doResize(rect); return false; }); this._pendingLaters.push(laterId); + } else if (this.window.is_fullscreen()) { + this.window.unmake_fullscreen(); + const laterId2 = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { + if (this.unmanaged) return false; + this._doResize(rect); + return false; + }); + this._pendingLaters.push(laterId2); } else { this._doResize(rect); } @@ -168,6 +180,11 @@ export class WindowWrapper { } } + isOverrideActive() { + if (this.unmanaged) return false; + return this.controller._authorizedOverrides && this.controller._authorizedOverrides.has(this.window); + } + _doResize(rect) { try { this._isResizing = true; diff --git a/prefs.js b/prefs.js index a8c06e5..ece2fa9 100644 --- a/prefs.js +++ b/prefs.js @@ -320,7 +320,9 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { { id: 'minimize', label: 'Minimize Window', origin: 'System', st: wmSettings }, { id: 'maximize', label: 'Maximize Window', origin: 'System', st: wmSettings }, { id: 'unmaximize', label: 'Unmaximize Window', origin: 'System', st: wmSettings }, - { id: 'toggle-fullscreen', label: 'Toggle Fullscreen', origin: 'System', st: wmSettings } + { id: 'toggle-fullscreen', label: 'Toggle Fullscreen', origin: 'System', st: wmSettings }, + { id: 'shortcut-toggle-maximize', label: 'Toggle Maximize Override (Active)', origin: '', st: settings }, + { id: 'shortcut-toggle-fullscreen', label: 'Toggle Fullscreen Override (Active)', origin: '', st: settings } ].forEach(s => stateGroup.add(createRow(s.st, s.id, s.label, s.origin))); shortcutsPage.add(stateGroup); diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index 3544718ff6b655bbd6d26113a7d63abbaf2f0367..f56dc879c0c64f123ec6f506f817c5a1685afe65 100644 GIT binary patch literal 2940 zcmbtWZD?Cn7(P`y=UTUJ>pnVH*F^`}>}}T8sdS~(B3f`0amA(&st%roBmue+)c*o;-Pb z&U?PzbDnrswRP2WMex}U9`JSaw!o_a+Y>)tBJ;O>qF=lUp1ebd+ATu-gHNswhBzeh z;Fkc)PPL^>E2FhJY0WU&w7jdCj&7MwTh6jal7^L2T;0&k6#q{|_ek>ppjlBHU=4U} zfV;qzV7K+a-N1Ta6VM1CEwL?xCTL;@uoGwwunSDI0J{T3z{GvPQs?2b4PcISy%-he zz+VFU=FgmUFnH^DE0F9W}R@R7`un(Zut*J4sn9sBw( z##3*F-vZtPT>Eh55Bk&`?<3#`?i8Ztx5NZ}>TU4*!N-9_qt{2Dx(O*c@sr?b zVE(J(N&3{xa~Av)P@k#&ls@%3_?N*8K=#4mx9C%I{<*={0T*r-B+erW*;9@zf27&w^h8{%o9*c~Z+f!PCIf?|n}(p86idUj)wsKOB2Ut}pdw z_}9U=fMVh0I-WD?TKE`*hyj1!{N)PUq2{=H!TrF`*Ak2LshQ6JI04L@ACh@e)6aw7 z1m@m7yN2=9yWmfQ&jVL$K9>1YGyhre9B^f3=j)88=002mV*`k*UwqO;pLzrQ25=Pk zcA|HIJ~hYN4?YS^etuG;Pu&PV3uZ0JSfiSf)6E3xgqIbf`-qp-?4F}pj54Y^d*hzt zS{Ws2L6qVQE$MbYZR;7;9_#5olGc-MPa3weU5 zd$NY-l(!r1-qurTH|YNvY80EYCbMW-$ylcDTDHO@IR3JTxM4XM4ho->RpVOe_hmzI za=IH&qmhLC5o@I-RnI_6(bdN)lR`U{<28cut~NiIOS3Zm`O5k6Qw>HFj3a2wP{%CK zb(p;vkF-a+o*R$kBVGGjBV+P!F4EP$AK%ge>j{X$U~8mfwa^1qk*%^h`pfNA26eEx z&i~sSTV-+`g|aSbV+=u`rJ&BjU?IE=;jK|x?B)G=h^t>O7hf~gVQjRNn&phui0JU% zvfb*hVC8!J*ZRqGP_e&9g7ph$RoWX|r&ac?yzfx%kdqKBtGqXPe76EPdg69~cOmap zz6Uk}EdcLW-ixLCllSAj0Pooi06&|7`vH_GUVQ@BpuBfW_cGrUyqkFs<7yFQQ?fFh zRr|W?950_3dF=f2hTW;z{*1F<5!I{CebO@wCvIz+$p9f9Z*NDAWrx)>GrB2@L-@op zBsd4*x#pQ-JHb3Z#;^YRhoe>o7Q}`2N!bjPnyVUr)j2III-wP3o-1`lCc&x_t|x6- z)lD;Ld}4U0#pFXRCLe0Cm4{la;-MB3!qH8$G{gS?a}7DqBih(7sx{toj;vzg{=zd+ z8esK&l@%UEH{*tvK%EYjBH?$(&MMB4Uo*q~t+Gr!I`R|ryL^w7%|4>&$F6 zF|6dFN@@`bw$Mt^D7943Qi=sdkVP~P3O3XhE3GAu1tX^9!TO}%H+yF~`{R=XU%r!X z?w)(kx%Zwq&%LbJx?;MLc(#BKmhI?$LTdoqGe2G-^W9EqL^=#kJRnJp8zt!)gA^_JxEIsx5Guyz7ah)_F zO@faCyQVv2>a^EGp96mh+?e0?Hg(z?p)Y_Jftz1^)=r)FTIkE*CIqtb`|u0YX>Wxd z1W&q415AOb>Qp2sMFpA{Q~$B z@XH4u9idKp3-kr>ZQ$yAXID|Dy#spFD&!UT;lQyT>a;gN?*MlJ-<>g6sMDtZ5pV*y z+3@Mp)MzM?<1Kj#>{tu3) z{V?>C;G<2*?eOJqsMBtTehyp${%kwr#Y>ynu#KB#h9X&-q zv>DeB_*vj;$$XbOZR!cI1GF!x-uh{C{j=cr0sr37=Dj-&?!di!>&g*tzi9Iu?gsAx zTC$Cw(?4zIXCL?=aPs1DjXLc%=s9o!7=Heo7Z+{DH4B~thEA7Ga6IitpqIek11o)_ zXQ|U>-mZgL3$oUPCKq&5#eKK+bjB|<1N-wi%^rNwlh#O4#pz60$-GlNc3_{aXBB&D zaA1E%Pq~8zZXE|>sB2~Au={)t^9#Q(&$NJa1-oQ_t#eAT=n4PEJX3 ztoKyRlQb+xtC%Jnd_}^LU=t3i<9p4|SJ;!Rn@J2>Dy^I9=jz&n$73VIr&N?Sl z%uN?!{9dl69G4hxC{tG6b*3Xdk^WbwBa@N-?yks`_qPz~@9D;W8Ns>=qj;z*(pzun zu9}fuo;&J+zv@A~bl3NP-O(!-*ITTZk}_ftbNYk&iie8-xm!WWsNOHX4pf}4;MCOa zuYV6>ImOK&|8X=Zaj!v%dksqbUV{>^YEa^+#KFxk-^k#iVBz(Pk-zXdbuN5#f)i1D z9$2T#y$*XC_Au;q&~Bl=vR1SHmU|@jJM3>d0QN%co!B?A2jY2M3v2+`_wZq#^C++b zz-P(piP-<3HNm&4++(q~VviI=DhD;Mrmo?<`q#tr!TaH46zqnk%AuMFchYouuPe5V fNvPgm|2_tZuc*sG+RFJ4XH*mCustom Layouts JSON JSON string defining custom window layouts. + + plus']]]> + Toggle Maximize Override + Persistent maximize for active window. + + + + + Toggle Fullscreen Override + Persistent fullscreen for active window. + From 265873a38022ba2ba338b235e4c2943000e08854 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 10 Jun 2026 18:14:59 +0200 Subject: [PATCH 24/33] test: add unit tests for override features and fix mocks --- tests/controller.test.js | 58 +++++++++++++++++++++++++++++++++++++++- tests/window.test.js | 27 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/tests/controller.test.js b/tests/controller.test.js index 7ecafb2..2110329 100644 --- a/tests/controller.test.js +++ b/tests/controller.test.js @@ -46,6 +46,7 @@ describe('TilingController', () => { unmaximize: vi.fn(), maximized_horizontally: false, maximized_vertically: false, + is_fullscreen: vi.fn(() => false), minimized: false, connect: vi.fn(() => 123), disconnect: vi.fn(), @@ -344,7 +345,7 @@ describe('TilingController', () => { it('should gracefully handle errors in tilingRequest', () => { const win = createMockWindow(1, null, 0); // Force an error by mocking global.workspace_manager to throw - vi.mocked(global.workspace_manager.get_active_workspace).mockImplementation(() => { + vi.mocked(global.workspace_manager.get_active_workspace).mockImplementationOnce(() => { throw new Error('test error'); }); @@ -544,5 +545,60 @@ describe('TilingController', () => { expect(controller.dragManager._activeDrag.lastHoveredSlot).toBe(-1); }); }); + + describe('Overrides', () => { + it('should toggle override for maximize', () => { + const win = createMockWindow(1, { id: 'ws1' }, 0); + global.display.get_focus_window = vi.fn(() => win); + win.maximize = vi.fn(); + + controller.toggleOverrideActiveWindow('maximize'); + expect(controller._authorizedOverrides.has(win)).toBe(true); + expect(win.maximize).toHaveBeenCalledWith(3); + + // Toggle off + win.maximized_horizontally = true; + win.maximized_vertically = true; + controller.toggleOverrideActiveWindow('maximize'); + expect(controller._authorizedOverrides.has(win)).toBe(false); + expect(win.unmaximize).toHaveBeenCalledWith(3); + }); + + it('should toggle override for fullscreen', () => { + const win = createMockWindow(1, { id: 'ws1' }, 0); + global.display.get_focus_window = vi.fn(() => win); + win.make_fullscreen = vi.fn(); + win.unmake_fullscreen = vi.fn(); + + controller.toggleOverrideActiveWindow('fullscreen'); + expect(controller._authorizedOverrides.has(win)).toBe(true); + expect(win.make_fullscreen).toHaveBeenCalled(); + + // Toggle off + win.is_fullscreen = vi.fn(() => true); + controller.toggleOverrideActiveWindow('fullscreen'); + expect(controller._authorizedOverrides.has(win)).toBe(false); + expect(win.unmake_fullscreen).toHaveBeenCalled(); + }); + + it('should clear overrides on monitor', () => { + const ws = { id: 'ws1' }; + global.workspace_manager.get_active_workspace = vi.fn(() => ws); + const win = createMockWindow(1, ws, 0); + win.maximized_horizontally = true; + win.maximized_vertically = true; + win.is_fullscreen = vi.fn(() => true); + win.unmake_fullscreen = vi.fn(); + + controller.tilingRequest(win); + controller._authorizedOverrides.add(win); + + controller._clearOverridesOnMonitor(0); + + expect(controller._authorizedOverrides.has(win)).toBe(false); + expect(win.unmaximize).toHaveBeenCalledWith(3); + expect(win.unmake_fullscreen).toHaveBeenCalled(); + }); + }); }); diff --git a/tests/window.test.js b/tests/window.test.js index 88c52d2..94ce935 100644 --- a/tests/window.test.js +++ b/tests/window.test.js @@ -19,6 +19,7 @@ describe('WindowWrapper', () => { unmaximize: vi.fn(), maximized_horizontally: false, maximized_vertically: false, + is_fullscreen: vi.fn(() => false), }; mockController = { @@ -146,4 +147,30 @@ describe('WindowWrapper', () => { expect(() => wrapper.destroy()).not.toThrow(); }); }); + + it('should correctly identify active override', () => { + mockController._authorizedOverrides = new Set([mockWindow]); + const wrapper = new WindowWrapper(mockWindow, mockController); + expect(wrapper.isOverrideActive()).toBe(true); + + mockController._authorizedOverrides = new Set(); + expect(wrapper.isOverrideActive()).toBe(false); + }); + + it('should skip applyGeometry if override is active', () => { + mockController._authorizedOverrides = new Set([mockWindow]); + const wrapper = new WindowWrapper(mockWindow, mockController); + wrapper.applyGeometry({ x: 10, y: 10, width: 100, height: 100 }); + expect(mockWindow.move_resize_frame).not.toHaveBeenCalled(); + }); + + it('should unmake fullscreen before applying geometry if fullscreen', () => { + mockWindow.is_fullscreen = vi.fn(() => true); + mockWindow.unmake_fullscreen = vi.fn(); + const wrapper = new WindowWrapper(mockWindow, mockController); + wrapper.applyGeometry({ x: 10, y: 10, width: 100, height: 100 }); + + expect(mockWindow.unmake_fullscreen).toHaveBeenCalled(); + expect(mockWindow.move_resize_frame).toHaveBeenCalledWith(false, 10, 10, 100, 100); + }); }); From db367e0b1e75a452de6c81d2217ea8d3ffafc3fe Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 17 Jun 2026 08:02:18 +0200 Subject: [PATCH 25/33] Fix window.test.js test failures due to mock leaks --- tests/window.test.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/window.test.js b/tests/window.test.js index 94ce935..4b23c9a 100644 --- a/tests/window.test.js +++ b/tests/window.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { WindowWrapper } from '../lib/window.js'; describe('WindowWrapper', () => { @@ -121,6 +121,14 @@ describe('WindowWrapper', () => { }); describe('_pendingLaters tracking', () => { + let originalGetLaters; + beforeEach(() => { + originalGetLaters = global.compositor.get_laters; + }); + afterEach(() => { + global.compositor.get_laters = originalGetLaters; + }); + it('should track and remove compositor laters on destroy', () => { const wrapper = new WindowWrapper(mockWindow, mockController); const mockLaters = { add: vi.fn(() => 42), remove: vi.fn() }; From 02bf3cfc5133a7998dbb81189587890399a72afd Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Fri, 19 Jun 2026 16:37:01 +0200 Subject: [PATCH 26/33] bugfix: missing singal window wrapper --- lib/window.js | 6 ++++++ tests/window.test.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/window.js b/lib/window.js index eb64bb5..e9d422b 100644 --- a/lib/window.js +++ b/lib/window.js @@ -110,6 +110,12 @@ export class WindowWrapper { this.controller.tilingRequest(this.window); })); } + if (!this.signals.has('position-changed')) { + this.signals.set('position-changed', this.window.connect('position-changed', () => { + if (this._isResizing) return; + this.controller.tilingRequest(this.window); + })); + } } disconnectSignal(name) { diff --git a/tests/window.test.js b/tests/window.test.js index 4b23c9a..3287193 100644 --- a/tests/window.test.js +++ b/tests/window.test.js @@ -81,7 +81,7 @@ describe('WindowWrapper', () => { wrapper.bindSizeChanged(); wrapper.destroy(); - expect(mockWindow.disconnect).toHaveBeenCalledTimes(8); + expect(mockWindow.disconnect).toHaveBeenCalledTimes(9); }); it('should apply geometry skipping unmanaged', () => { From e5de076e87e41bf35ddeb4b5ea46d7c570b15c09 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Fri, 19 Jun 2026 16:52:13 +0200 Subject: [PATCH 27/33] bugfix: cross monitor window transition with max escalator overtiling blocked --- lib/controller.js | 7 +++++++ lib/drag.js | 6 +++++- lib/workspace.js | 8 ++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/controller.js b/lib/controller.js index 1784822..a789062 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -140,6 +140,13 @@ export class TilingController { const result = layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + if (result && result.aborted) { + // Restore window to previous monitor physically + if (window.move_to_monitor) window.move_to_monitor(sourceMonitorIndex); + this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + return; + } + this.updateWindowWrapperMonitor(window, monitorId, monitorIndex); if (result && result.swappedWindow) { diff --git a/lib/drag.js b/lib/drag.js index 359708d..bb4e4fd 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -424,7 +424,11 @@ export class DragManager { } else { layout.untrackWindow(window, sourceMonitorId); - layout.trackWindow(window, targetMonitorId, targetSlot !== -1 ? targetSlot : undefined); + + const maxCount = layout.escalator.getMaxCount(); + if (behavior === 'swap' || layout.getWindowCount(targetMonitorId) < maxCount) { + layout.trackWindow(window, targetMonitorId, targetSlot !== -1 ? targetSlot : undefined); + } this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); diff --git a/lib/workspace.js b/lib/workspace.js index d78a635..c02910e 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -158,6 +158,10 @@ export class WorkspaceLayout { const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + if (behavior !== 'swap' && targetTracker.size >= this.escalator.getMaxCount()) { + return { aborted: true }; + } + if (behavior === 'swap' && targetTracker.size > 0) { const targetLayout = this.escalator.getLayoutForCount(targetTracker.size); if (targetLayout) { @@ -240,6 +244,10 @@ export class WorkspaceLayout { const result = this.handleMonitorTransition(window, monitorId, targetMonitorId, enteringEdge, slot); + if (result && result.aborted) { + return false; + } + // Update wrappers and physical monitors this.controller.updateWindowWrapperMonitor(window, targetMonitorId, adjacentMonitorIndex); if (window.move_to_monitor) window.move_to_monitor(adjacentMonitorIndex); From 127f9e9d3ec701e0e50902e9e7411b06be5ecd78 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Fri, 19 Jun 2026 17:08:18 +0200 Subject: [PATCH 28/33] UI simplification: Maximisation Shortcuts --- lib/keybindings.js | 32 ++++++++++++++---- prefs.js | 7 ++-- schemas/gschemas.compiled | Bin 2940 -> 2796 bytes ...ell.extensions.workflow-tiling.gschema.xml | 12 ------- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/lib/keybindings.js b/lib/keybindings.js index 1dbbe0e..f9d9198 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -6,9 +6,7 @@ import { ShadowManager } from './shadows.js'; const NATIVE_CONFLICTS = { 'move-window-left': 'toggle-tiled-left', - 'move-window-right': 'toggle-tiled-right', - 'move-window-up': 'maximize', - 'move-window-down': 'unmaximize' + 'move-window-right': 'toggle-tiled-right' }; export class KeybindingManager { @@ -59,9 +57,7 @@ export class KeybindingManager { 'shortcut-switch-monitor': (c, win) => c.switchMonitors(win ? win.get_monitor() : global.display.get_current_monitor()), 'shortcut-port-monitor-left': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), 'shortcut-port-monitor-right': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), - 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()), - 'shortcut-toggle-maximize': (c) => c.toggleOverrideActiveWindow('maximize'), - 'shortcut-toggle-fullscreen': (c) => c.toggleOverrideActiveWindow('fullscreen') + 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) }; for (const [key, action] of Object.entries(utilities)) { @@ -75,6 +71,23 @@ export class KeybindingManager { }); } + const nativeHijacks = { + 'maximize': (c) => c.toggleOverrideActiveWindow('maximize'), + 'unmaximize': (c) => c.toggleOverrideActiveWindow('maximize'), + 'toggle-fullscreen': (c) => c.toggleOverrideActiveWindow('fullscreen') + }; + + for (const [key, action] of Object.entries(nativeHijacks)) { + defs.push({ + isNativeHijack: true, + conflict: key, + action: (c, win) => { + Logger.debug(`Action triggered: native hijack ${key}`); + action(c, win); + } + }); + } + return defs; } @@ -85,6 +98,11 @@ export class KeybindingManager { const keysToShadow = []; for (const def of this._definitions) { + if (def.isNativeHijack) { + conflictsToHijack.push(def.conflict); + continue; + } + const { active, keyToBind, isCustom } = this._resolveBinding(def); if (!active) { Logger.debug(`Binding ${def.defaultKey} is inactive.`); @@ -107,6 +125,8 @@ export class KeybindingManager { // Bind extension shortcuts for (const def of this._definitions) { + if (def.isNativeHijack) continue; + const { active, keyToBind, isCustom } = this._resolveBinding(def); if (active && !(!isCustom && def.conflict)) { this._bindExtensionShortcut(def, keyToBind); diff --git a/prefs.js b/prefs.js index ece2fa9..9bfca5d 100644 --- a/prefs.js +++ b/prefs.js @@ -318,11 +318,8 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { [ { id: 'close', label: 'Close Window', origin: 'System', st: wmSettings }, { id: 'minimize', label: 'Minimize Window', origin: 'System', st: wmSettings }, - { id: 'maximize', label: 'Maximize Window', origin: 'System', st: wmSettings }, - { id: 'unmaximize', label: 'Unmaximize Window', origin: 'System', st: wmSettings }, - { id: 'toggle-fullscreen', label: 'Toggle Fullscreen', origin: 'System', st: wmSettings }, - { id: 'shortcut-toggle-maximize', label: 'Toggle Maximize Override (Active)', origin: '', st: settings }, - { id: 'shortcut-toggle-fullscreen', label: 'Toggle Fullscreen Override (Active)', origin: '', st: settings } + { id: 'maximize', label: 'Un-/ Maximise Window', origin: '', st: wmSettings }, + { id: 'toggle-fullscreen', label: 'Toggle Fullscreen', origin: '', st: wmSettings } ].forEach(s => stateGroup.add(createRow(s.st, s.id, s.label, s.origin))); shortcutsPage.add(stateGroup); diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index f56dc879c0c64f123ec6f506f817c5a1685afe65..3544718ff6b655bbd6d26113a7d63abbaf2f0367 100644 GIT binary patch literal 2796 zcmbtWU1%It6rQN5u}N*(ra!T*jVt1&WOnmIwZsrYs2H%3V$l|*u#=tH-7&K>>&$F6 zF|6dFN@@`bw$Mt^D7943Qi=sdkVP~P3O3XhE3GAu1tX^9!TO}%H+yF~`{R=XU%r!X z?w)(kx%Zwq&%LbJx?;MLc(#BKmhI?$LTdoqGe2G-^W9EqL^=#kJRnJp8zt!)gA^_JxEIsx5Guyz7ah)_F zO@faCyQVv2>a^EGp96mh+?e0?Hg(z?p)Y_Jftz1^)=r)FTIkE*CIqtb`|u0YX>Wxd z1W&q415AOb>Qp2sMFpA{Q~$B z@XH4u9idKp3-kr>ZQ$yAXID|Dy#spFD&!UT;lQyT>a;gN?*MlJ-<>g6sMDtZ5pV*y z+3@Mp)MzM?<1Kj#>{tu3) z{V?>C;G<2*?eOJqsMBtTehyp${%kwr#Y>ynu#KB#h9X&-q zv>DeB_*vj;$$XbOZR!cI1GF!x-uh{C{j=cr0sr37=Dj-&?!di!>&g*tzi9Iu?gsAx zTC$Cw(?4zIXCL?=aPs1DjXLc%=s9o!7=Heo7Z+{DH4B~thEA7Ga6IitpqIek11o)_ zXQ|U>-mZgL3$oUPCKq&5#eKK+bjB|<1N-wi%^rNwlh#O4#pz60$-GlNc3_{aXBB&D zaA1E%Pq~8zZXE|>sB2~Au={)t^9#Q(&$NJa1-oQ_t#eAT=n4PEJX3 ztoKyRlQb+xtC%Jnd_}^LU=t3i<9p4|SJ;!Rn@J2>Dy^I9=jz&n$73VIr&N?Sl z%uN?!{9dl69G4hxC{tG6b*3Xdk^WbwBa@N-?yks`_qPz~@9D;W8Ns>=qj;z*(pzun zu9}fuo;&J+zv@A~bl3NP-O(!-*ITTZk}_ftbNYk&iie8-xm!WWsNOHX4pf}4;MCOa zuYV6>ImOK&|8X=Zaj!v%dksqbUV{>^YEa^+#KFxk-^k#iVBz(Pk-zXdbuN5#f)i1D z9$2T#y$*XC_Au;q&~Bl=vR1SHmU|@jJM3>d0QN%co!B?A2jY2M3v2+`_wZq#^C++b zz-P(piP-<3HNm&4++(q~VviI=DhD;Mrmo?<`q#tr!TaH46zqnk%AuMFchYouuPe5V fNvPgm|2_tZuc*sG+RFJ4XH*mpnVH*F^`}>}}T8sdS~(B3f`0amA(&st%roBmue+)c*o;-Pb z&U?PzbDnrswRP2WMex}U9`JSaw!o_a+Y>)tBJ;O>qF=lUp1ebd+ATu-gHNswhBzeh z;Fkc)PPL^>E2FhJY0WU&w7jdCj&7MwTh6jal7^L2T;0&k6#q{|_ek>ppjlBHU=4U} zfV;qzV7K+a-N1Ta6VM1CEwL?xCTL;@uoGwwunSDI0J{T3z{GvPQs?2b4PcISy%-he zz+VFU=FgmUFnH^DE0F9W}R@R7`un(Zut*J4sn9sBw( z##3*F-vZtPT>Eh55Bk&`?<3#`?i8Ztx5NZ}>TU4*!N-9_qt{2Dx(O*c@sr?b zVE(J(N&3{xa~Av)P@k#&ls@%3_?N*8K=#4mx9C%I{<*={0T*r-B+erW*;9@zf27&w^h8{%o9*c~Z+f!PCIf?|n}(p86idUj)wsKOB2Ut}pdw z_}9U=fMVh0I-WD?TKE`*hyj1!{N)PUq2{=H!TrF`*Ak2LshQ6JI04L@ACh@e)6aw7 z1m@m7yN2=9yWmfQ&jVL$K9>1YGyhre9B^f3=j)88=002mV*`k*UwqO;pLzrQ25=Pk zcA|HIJ~hYN4?YS^etuG;Pu&PV3uZ0JSfiSf)6E3xgqIbf`-qp-?4F}pj54Y^d*hzt zS{Ws2L6qVQE$MbYZR;7;9_#5olGc-MPa3weU5 zd$NY-l(!r1-qurTH|YNvY80EYCbMW-$ylcDTDHO@IR3JTxM4XM4ho->RpVOe_hmzI za=IH&qmhLC5o@I-RnI_6(bdN)lR`U{<28cut~NiIOS3Zm`O5k6Qw>HFj3a2wP{%CK zb(p;vkF-a+o*R$kBVGGjBV+P!F4EP$AK%ge>j{X$U~8mfwa^1qk*%^h`pfNA26eEx z&i~sSTV-+`g|aSbV+=u`rJ&BjU?IE=;jK|x?B)G=h^t>O7hf~gVQjRNn&phui0JU% zvfb*hVC8!J*ZRqGP_e&9g7ph$RoWX|r&ac?yzfx%kdqKBtGqXPe76EPdg69~cOmap zz6Uk}EdcLW-ixLCllSAj0Pooi06&|7`vH_GUVQ@BpuBfW_cGrUyqkFs<7yFQQ?fFh zRr|W?950_3dF=f2hTW;z{*1F<5!I{CebO@wCvIz+$p9f9Z*NDAWrx)>GrB2@L-@op zBsd4*x#pQ-JHb3Z#;^YRhoe>o7Q}`2N!bjPnyVUr)j2III-wP3o-1`lCc&x_t|x6- z)lD;Ld}4U0#pFXRCLe0Cm4{la;-MB3!qH8$G{gS?a}7DqBih(7sx{toj;vzg{=zd+ z8esK&l@%UEH{*tvK%EYjBH?$(&MMB4Uo*q~t+Gr!I`R|ryL^w7%|4Custom Layouts JSON JSON string defining custom window layouts. - - plus']]]> - Toggle Maximize Override - Persistent maximize for active window. - - - - - Toggle Fullscreen Override - Persistent fullscreen for active window. - - Shadowed Native Keybindings From 7771cf9f3230f8105f3f5e0033a083b50a6e9664 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Fri, 19 Jun 2026 17:19:29 +0200 Subject: [PATCH 29/33] bugfix: Floating windows dropped outside layout slots properly remain floating without causing de-escalations. --- lib/drag.js | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/lib/drag.js b/lib/drag.js index bb4e4fd..903e078 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -36,8 +36,10 @@ export class DragManager { const workspace = wrapper.workspace; const layout = this.controller.workspaceManager.getLayout(workspace); const windowCount = layout.getWindowCount(wrapper.monitorId); - const originalSlot = layout.getWindowSlot(wrapper.monitorId, window); - if (originalSlot === undefined) return; + let originalSlot = layout.getWindowSlot(wrapper.monitorId, window); + if (originalSlot === undefined) { + originalSlot = -1; + } const matrix = layout.escalator.getLayoutForCount(windowCount); // We do not require a valid matrix or originalSlot < matrix.size here. @@ -113,7 +115,8 @@ export class DragManager { }; } else { const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; - const matrixCount = (monitorId === wrapper.monitorId || behavior === 'swap') ? targetWindowCount : (targetWindowCount + 1); + const isTracked = originalSlot !== -1; + const matrixCount = (monitorId === wrapper.monitorId && isTracked || behavior === 'swap') ? targetWindowCount : (targetWindowCount + 1); if (matrixCount > layout.escalator.getMaxCount()) { hoveredSlot = -1; @@ -394,7 +397,8 @@ export class DragManager { // Pointer fallback cross-monitor drop this._commitCrossMonitorTransfer(window, wrapper, layout, pointerMonitorId, -1, activeDrag.originalSlot, pointerMonitorIndex); } else { - this._commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect); + const targetSlot = lastHoveredMonitorId === wrapper.monitorId ? lastHoveredSlot : -1; + this._commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect, activeDrag.originalSlot, targetSlot); } } } @@ -442,8 +446,29 @@ export class DragManager { this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); } - _commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect) { - const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); + _commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect, originalSlot, targetSlot) { + let swapped = false; + + if (originalSlot === -1) { + if (targetSlot !== -1) { + const targetWindowCount = layout.getWindowCount(wrapper.monitorId); + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + + if (behavior === 'swap' && targetWindowCount > 0) { + const targetWindows = layout.getWindowsForMonitor(wrapper.monitorId); + const targetWindow = targetWindows.find(w => layout.getWindowSlot(wrapper.monitorId, w) === targetSlot); + if (targetWindow) { + layout.replaceWindow(targetWindow, window, wrapper.monitorId); + swapped = true; + } + } else if (targetWindowCount < layout.escalator.getMaxCount()) { + layout.trackWindow(window, wrapper.monitorId, targetSlot); + swapped = true; + } + } + } else { + swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); + } const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; From 95056c7a522d4dd262e43668314ca2d2d869291b Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Mon, 22 Jun 2026 16:47:27 +0200 Subject: [PATCH 30/33] refactor: Fixing highest prio potential bugs --- lib/controller.js | 16 ++++++++--- lib/drag.js | 69 ++++++++++++++++++++++++++++++++++++----------- lib/monitor.js | 7 ++++- lib/signals.js | 7 +++++ lib/workspace.js | 16 +++++++++++ 5 files changed, 95 insertions(+), 20 deletions(-) diff --git a/lib/controller.js b/lib/controller.js index a789062..864c298 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -133,8 +133,11 @@ export class TilingController { const slot = layout.getWindowSlot(sourceMonitorId, window); const sourceSlot = slot !== undefined ? slot : 0; - const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); - const targetRect = global.display.get_monitor_geometry(monitorIndex); + const numM = global.display.get_n_monitors(); + const safeSource = (sourceMonitorIndex >= 0 && sourceMonitorIndex < numM) ? sourceMonitorIndex : 0; + const safeTarget = (monitorIndex >= 0 && monitorIndex < numM) ? monitorIndex : 0; + const sourceRect = global.display.get_monitor_geometry(safeSource); + const targetRect = global.display.get_monitor_geometry(safeTarget); const enteringEdge = getEnteringEdge(sourceRect, targetRect); @@ -251,6 +254,10 @@ export class TilingController { const wrapper = this._windowWrappers.get(window); if (!wrapper) return; + if (this.dragManager && this.dragManager._activeDrag && this.dragManager._activeDrag.window === window) { + this.dragManager.forceCleanup(); + } + wrapper.destroy(); const { workspace, monitorIndex, monitorId } = wrapper; this._windowWrappers.delete(window); @@ -297,7 +304,9 @@ export class TilingController { return false; } - const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); + const numM = global.display.get_n_monitors(); + const safeMonitor = (monitorIndex >= 0 && monitorIndex < numM) ? monitorIndex : 0; + const monitorRect = workspace.get_work_area_for_monitor(safeMonitor); const layout = this.workspaceManager.getLayout(workspace); const gaps = this.settings ? this.settings.getGaps() : { inner: 6, outer: 4 }; const operations = layout.getRetileOperations(monitorId, monitorRect, gaps); @@ -470,6 +479,7 @@ export class TilingController { } clear() { + if (this.dragManager) this.dragManager.forceCleanup(); this._retileTimeouts.forEach(id => global.compositor.get_laters().remove(id)); this._retileTimeouts.clear(); this._windowWrappers.forEach((wrapper, win) => { diff --git a/lib/drag.js b/lib/drag.js index 903e078..17873a4 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -10,6 +10,29 @@ export class DragManager { constructor(controller) { this.controller = controller; this._activeDrag = null; // { window, originalSlot, indicator, signalId, lastHoveredSlot, lastHoveredMonitorId, origRect } + this._pendingIdles = new Set(); + } + + /** + * Unconditional teardown — disconnects signal, destroys indicator, clears + * deferred retiles. Safe to call on finalized GObjects. + */ + forceCleanup() { + if (this._pendingIdles) { + this._pendingIdles.forEach(id => GLib.source_remove(id)); + this._pendingIdles.clear(); + } + if (!this._activeDrag) return; + try { + this._activeDrag.window.disconnect(this._activeDrag.signalId); + } catch (e) { + Logger.warn('DragManager: Failed to disconnect drag signal during cleanup', e); + } + if (this._activeDrag.indicator) { + this._activeDrag.indicator.destroy(); + } + this._activeDrag = null; + this._deferredRetiles = []; } isWindowInDragPreview(window) { @@ -100,7 +123,9 @@ export class DragManager { const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); const targetWindowCount = layout.getWindowCount(monitorId); - const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); + const numM1 = global.display.get_n_monitors(); + const safe1 = (monitorIndex >= 0 && monitorIndex < numM1) ? monitorIndex : 0; + const monitorRect = workspace.get_work_area_for_monitor(safe1); let hoveredSlot = -1; let targetRect = null; @@ -186,7 +211,10 @@ export class DragManager { // Revert previous hover const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); - const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); + if (sourceMonitorIndex === -1) return; + const numM2 = global.display.get_n_monitors(); + const safe2 = (sourceMonitorIndex >= 0 && sourceMonitorIndex < numM2) ? sourceMonitorIndex : 0; + const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(safe2); const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; @@ -221,8 +249,7 @@ export class DragManager { if (behavior === 'swap' && targetWindowCount > 0) { const sourceMatrix = layout.escalator.getLayoutForCount(layout.getWindowCount(wrapper.monitorId)); const sourceEstate = sourceMatrix ? sourceMatrix.getEstate(this._activeDrag.originalSlot) : null; - const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); - const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); + const sourceMonitorRect = sourceMonitorIndex !== -1 ? wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex) : { x: 0, y: 0, width: 0, height: 0 }; const windows = layout.getWindowsForMonitor(monitorId); for (const win of windows) { @@ -278,7 +305,9 @@ export class DragManager { const lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); if (lastMonitorIndex !== -1) { const workspace = wrapper ? wrapper.workspace : layout.workspace; - const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); + const numM3 = global.display.get_n_monitors(); + const safe3 = (lastMonitorIndex >= 0 && lastMonitorIndex < numM3) ? lastMonitorIndex : 0; + const lastMonitorRect = workspace.get_work_area_for_monitor(safe3); this._restoreTrackerGeometries(lastMonId, layout, lastMonitorRect, gaps); } } @@ -287,7 +316,9 @@ export class DragManager { const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); if (sourceMonitorIndex !== -1) { const workspace = wrapper ? wrapper.workspace : layout.workspace; - const sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); + const numM4 = global.display.get_n_monitors(); + const safe4 = (sourceMonitorIndex >= 0 && sourceMonitorIndex < numM4) ? sourceMonitorIndex : 0; + const sourceMonitorRect = workspace.get_work_area_for_monitor(safe4); this._restoreTrackerGeometries(sourceMonId, layout, sourceMonitorRect, gaps); } } @@ -342,28 +373,28 @@ export class DragManager { const wrapper = this.controller._windowWrappers.get(window); if (!wrapper || !wrapper.workspace || !wrapper.monitorId) { - window.disconnect(activeDrag.signalId); - if (activeDrag.indicator) activeDrag.indicator.destroy(); - this._activeDrag = null; + this.forceCleanup(); return; } const workspace = wrapper.workspace; if (!workspace.get_work_area_for_monitor) { - window.disconnect(activeDrag.signalId); - if (activeDrag.indicator) activeDrag.indicator.destroy(); - this._activeDrag = null; + this.forceCleanup(); return; } - const monitorRect = workspace.get_work_area_for_monitor(wrapper.monitorIndex); + const numM5 = global.display.get_n_monitors(); + const safe5 = (wrapper.monitorIndex >= 0 && wrapper.monitorIndex < numM5) ? wrapper.monitorIndex : 0; + const monitorRect = workspace.get_work_area_for_monitor(safe5); const gaps = this.controller.settings ? this.controller.settings.getGaps() : { inner: 6, outer: 4 }; const layout = this.controller.workspaceManager.getLayout(workspace); // Revert temporary visual swaps before performing final tracking and retile this._revertVisualSwap(layout, gaps, false); - window.disconnect(activeDrag.signalId); + try { + window.disconnect(activeDrag.signalId); + } catch(e) {} if (activeDrag.indicator) { activeDrag.indicator.destroy(); } @@ -420,11 +451,14 @@ export class DragManager { this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); this.controller.updateWindowWrapperMonitor(targetWindow, sourceMonitorId, sourceMonitorIndex); - GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + let sourceId; + sourceId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (sourceId !== undefined) this._pendingIdles.delete(sourceId); if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); if (sourceMonitorIndex !== -1 && targetWindow.move_to_monitor) targetWindow.move_to_monitor(sourceMonitorIndex); return GLib.SOURCE_REMOVE; }); + this._pendingIdles.add(sourceId); } else { layout.untrackWindow(window, sourceMonitorId); @@ -436,10 +470,13 @@ export class DragManager { this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); - GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + let sourceId; + sourceId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (sourceId !== undefined) this._pendingIdles.delete(sourceId); if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); return GLib.SOURCE_REMOVE; }); + this._pendingIdles.add(sourceId); } this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); diff --git a/lib/monitor.js b/lib/monitor.js index 8448473..d3f46c7 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -129,7 +129,8 @@ export class MonitorManager { Logger.info(`Topology Change. Count: ${this._lastMonitorCount}->${currentMonitorCount}`); - global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { + this._pendingLaterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { + this._pendingLaterId = 0; this._monitorsChangedPending = false; try { this.controller.setBatchMode(false); @@ -185,6 +186,10 @@ export class MonitorManager { } clear() { + if (this._pendingLaterId) { + global.compositor.get_laters().remove(this._pendingLaterId); + this._pendingLaterId = 0; + } this._evacuatedWindows.clear(); this._monitorsChangedPending = false; } diff --git a/lib/signals.js b/lib/signals.js index 163f3dc..dce51a9 100644 --- a/lib/signals.js +++ b/lib/signals.js @@ -49,6 +49,13 @@ export class SignalListener { this.controller.tilingRequest(window); }); + // Listen for workspace removals + connect(global.workspace_manager, 'workspace-removed', () => { + if (this.controller.workspaceManager) { + this.controller.workspaceManager.pruneLayouts(); + } + }); + // Monitor Hotplugging. try { const manager = global.backend.get_monitor_manager(); diff --git a/lib/workspace.js b/lib/workspace.js index c02910e..358d2f9 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -356,6 +356,22 @@ export class WorkspaceManager { this.layouts.clear(); } + pruneLayouts() { + const activeWorkspaces = new Set(); + const numWorkspaces = global.workspace_manager.n_workspaces; + for (let i = 0; i < numWorkspaces; i++) { + const ws = global.workspace_manager.get_workspace_by_index(i); + if (ws) activeWorkspaces.add(ws); + } + + for (const [workspace, layout] of this.layouts.entries()) { + if (!activeWorkspaces.has(workspace)) { + this.layouts.delete(workspace); + Logger.info('WorkspaceManager: Pruned dead workspace from layouts map'); + } + } + } + closeWorkspaceWindows(workspace) { if (!workspace) return; this.controller.setBatchMode(true); From 7313ea7643efb510d5352cf72c003dbcaff06ad6 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Mon, 22 Jun 2026 16:57:47 +0200 Subject: [PATCH 31/33] refactor: Shadow restore and workspace transactions --- lib/controller.js | 13 +++++-------- lib/shadows.js | 28 ++++++++++++++++++---------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/lib/controller.js b/lib/controller.js index 864c298..5851877 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -61,11 +61,12 @@ export class TilingController { this._restoringWindows.set(window, slot); } - updateWindowWrapperMonitor(window, monitorId, monitorIndex) { + updateWindowWrapperMonitor(window, monitorId, monitorIndex, workspace = undefined) { const wrapper = this._windowWrappers.get(window); if (wrapper) { wrapper.monitorId = monitorId; wrapper.monitorIndex = monitorIndex; + if (workspace !== undefined) wrapper.workspace = workspace; } } @@ -112,7 +113,7 @@ export class TilingController { if (this.monitorManager.checkEvacuation(window, wrapper, monitorId, workspace)) { Logger.debug(`tilingRequest: Window evacuated. Updating cache and returning.`); - this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); + this.updateWindowWrapperMonitor(window, monitorId, monitorIndex, workspace); return; } @@ -165,7 +166,7 @@ export class TilingController { _handleNormalTilingRequest(window, wrapper, workspace, monitorId, monitorIndex, isRestoring, preferredSlot) { const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); - this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); + this.updateWindowWrapperMonitor(window, monitorId, monitorIndex, workspace); this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); Logger.debug(`tilingRequest: State applied. Scheduling retile.`); @@ -229,11 +230,7 @@ export class TilingController { return oldSlot; } - _updateWrapperCache(wrapper, workspace, monitorIndex, monitorId) { - wrapper.workspace = workspace; - wrapper.monitorIndex = monitorIndex; - wrapper.monitorId = monitorId; - } + _applyTrackingState(window, monitorId, workspace, isRestoring, preferredSlot) { const layout = this.workspaceManager.getLayout(workspace); diff --git a/lib/shadows.js b/lib/shadows.js index 8903574..20b90e9 100644 --- a/lib/shadows.js +++ b/lib/shadows.js @@ -113,21 +113,29 @@ export class ShadowManager { if (Object.keys(state).length === 0) return; + const remaining = {}; + let hasRemaining = false; + for (const settings of this._nativeSchemas) { const schemaId = settings.schema_id; - if (state[schemaId]) { - for (const [key, originalAccels] of Object.entries(state[schemaId])) { - try { - settings.set_strv(key, originalAccels); - Logger.debug(`ShadowManager: Restored native shortcut ${schemaId}.${key} -> [${originalAccels.join(', ')}]`); - } catch (e) { - Logger.warn(`ShadowManager: Failed to restore ${schemaId}.${key}`, e); - } + if (!state[schemaId]) continue; + + for (const [key, originalAccels] of Object.entries(state[schemaId])) { + try { + settings.set_strv(key, originalAccels); + Logger.debug(`ShadowManager: Restored ${schemaId}.${key}`); + } catch (e) { + Logger.warn(`ShadowManager: Failed to restore ${schemaId}.${key}`, e); + if (!remaining[schemaId]) remaining[schemaId] = {}; + remaining[schemaId][key] = originalAccels; + hasRemaining = true; } } } - // Clear the state - this.extensionSettings.set_string('shadowed-keybindings', '{}'); + this.extensionSettings.set_string( + 'shadowed-keybindings', + hasRemaining ? JSON.stringify(remaining) : '{}' + ); } } From 3f4c43a8ae16f7ce26cd9c627582b1bd8676ba2b Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Mon, 22 Jun 2026 17:27:31 +0200 Subject: [PATCH 32/33] refactor: deleted dead code and minor refactorings --- lib/controller.js | 10 +-- lib/editor/editor.js | 8 ++ lib/editor/preview.js | 8 ++ lib/monitor.js | 80 ------------------ lib/state.js | 13 +-- lib/window.js | 2 +- prefs.js | 13 ++- schemas/gschemas.compiled | Bin 2796 -> 2836 bytes ...ell.extensions.workflow-tiling.gschema.xml | 5 ++ tests/controller.test.js | 6 +- 10 files changed, 43 insertions(+), 102 deletions(-) diff --git a/lib/controller.js b/lib/controller.js index 5851877..1342dde 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -282,7 +282,7 @@ export class TilingController { return; } - const key = `${workspace}-${monitorId}`; + const key = `${workspace.index ? workspace.index() : workspace}-${monitorId}`; if (this._retileTimeouts.has(key)) { global.compositor.get_laters().remove(this._retileTimeouts.get(key)); @@ -452,10 +452,10 @@ export class TilingController { if (isActive) { this._authorizedOverrides.delete(targetWindow); if (targetWindow.is_fullscreen && targetWindow.is_fullscreen()) targetWindow.unmake_fullscreen(); - if (targetWindow.maximized_horizontally && targetWindow.maximized_vertically) targetWindow.unmaximize(3); + if (targetWindow.maximized_horizontally && targetWindow.maximized_vertically) targetWindow.unmaximize(); } else { this._authorizedOverrides.add(targetWindow); - if (type === 'maximize') targetWindow.maximize(3); + if (type === 'maximize') targetWindow.maximize(); if (type === 'fullscreen') targetWindow.make_fullscreen(); } } @@ -463,10 +463,10 @@ export class TilingController { _clearOverridesOnMonitor(monitorIndex) { const activeWorkspace = global.workspace_manager.get_active_workspace(); this._windowWrappers.forEach((wrapper, window) => { - if (wrapper.monitorIndex === monitorIndex && wrapper.workspace === activeWorkspace) { + if (wrapper.monitorIndex === monitorIndex && wrapper.workspace === activeWorkspace && !window.unmanaged) { this._authorizedOverrides.delete(window); if (window.maximized_horizontally && window.maximized_vertically) { - window.unmaximize(3); + window.unmaximize(); } if (window.is_fullscreen && window.is_fullscreen()) { window.unmake_fullscreen(); diff --git a/lib/editor/editor.js b/lib/editor/editor.js index b71b906..a0487ff 100644 --- a/lib/editor/editor.js +++ b/lib/editor/editor.js @@ -52,4 +52,12 @@ class LayoutEditorPage extends Adw.PreferencesPage { }); layoutGroup.add(saveButton); } + + vfunc_dispose() { + if (this._changedId) { + this.settings.disconnect(this._changedId); + this._changedId = null; + } + super.vfunc_dispose(); + } }); diff --git a/lib/editor/preview.js b/lib/editor/preview.js index 1daa905..cd948b7 100644 --- a/lib/editor/preview.js +++ b/lib/editor/preview.js @@ -1160,4 +1160,12 @@ class LayoutPreviewPage extends Adw.PreferencesPage { }); previewContainer.append(addBtn); } + + vfunc_dispose() { + if (this._changedId) { + this.settings.disconnect(this._changedId); + this._changedId = null; + } + super.vfunc_dispose(); + } }); diff --git a/lib/monitor.js b/lib/monitor.js index d3f46c7..27a86b8 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -194,87 +194,7 @@ export class MonitorManager { this._monitorsChangedPending = false; } - closeMonitorWindows(monitorIndex, includeMinimized) { - const workspace = global.workspace_manager.get_active_workspace(); - if (!workspace) return; - this.controller.setBatchMode(true); - const windows = workspace.list_windows(); - windows.forEach(w => { - const wrapper = this.controller._windowWrappers.get(w); - const m = wrapper ? wrapper.effectiveMonitorIndex : w.get_monitor(); - if (m === monitorIndex && (!w.minimized || includeMinimized)) { - w.delete(global.get_current_time()); - } - }); - this.controller.setBatchMode(false); - this.controller.hydrate(workspace); - } - - switchMonitors(activeMonitorIndex) { - const workspace = global.workspace_manager.get_active_workspace(); - if (!workspace) return; - - const manager = global.backend.get_monitor_manager(); - const numMonitors = manager.get_logical_monitors().length; - if (numMonitors < 2) return; - - let targetMonitorIndex; - if (numMonitors === 2) { - targetMonitorIndex = activeMonitorIndex === 0 ? 1 : 0; - } else { - const primaryIndex = global.display.get_primary_monitor(); - if (activeMonitorIndex === primaryIndex) return; - targetMonitorIndex = primaryIndex; - } - - this.controller.setBatchMode(true); - const windows = workspace.list_windows(); - windows.forEach(w => { - const wrapper = this.controller._windowWrappers.get(w); - const m = wrapper ? wrapper.effectiveMonitorIndex : w.get_monitor(); - if (m === activeMonitorIndex) { - if (wrapper) wrapper._expectedMonitorIndex = targetMonitorIndex; - w.move_to_monitor(targetMonitorIndex); - } else if (m === targetMonitorIndex) { - if (wrapper) wrapper._expectedMonitorIndex = activeMonitorIndex; - w.move_to_monitor(activeMonitorIndex); - } - }); - this.controller.setBatchMode(false); - this.controller.hydrate(workspace); - } - portMonitorToWorkspace(monitorIndex, direction) { - const activeWorkspaceIndex = global.workspace_manager.get_active_workspace_index(); - const numWorkspaces = global.workspace_manager.n_workspaces; - let targetIndex = activeWorkspaceIndex; - - if (direction === 'left' && activeWorkspaceIndex > 0) { - targetIndex--; - } else if (direction === 'right' && activeWorkspaceIndex < numWorkspaces - 1) { - targetIndex++; - } - - if (targetIndex === activeWorkspaceIndex) return; - - const targetWorkspace = global.workspace_manager.get_workspace_by_index(targetIndex); - const activeWorkspace = global.workspace_manager.get_active_workspace(); - - this.controller.setBatchMode(true); - const windows = activeWorkspace.list_windows(); - windows.forEach(w => { - const wrapper = this.controller._windowWrappers.get(w); - const m = wrapper ? wrapper.effectiveMonitorIndex : w.get_monitor(); - if (m === monitorIndex) { - if (wrapper) wrapper._expectedWorkspace = targetWorkspace; - w.change_workspace(targetWorkspace); - } - }); - this.controller.setBatchMode(false); - - this.controller.hydrate(activeWorkspace); - this.controller.hydrate(targetWorkspace); - } getMonitorInDirection(currentMonitorIndex, direction) { try { diff --git a/lib/state.js b/lib/state.js index 680a5d7..a571bbe 100644 --- a/lib/state.js +++ b/lib/state.js @@ -6,13 +6,11 @@ import { Logger } from './logger.js'; export class StateTracker { constructor() { this._windowToSlot = new Map(); - this._originalGeometries = new Map(); + } track(window, index) { - if (!this._originalGeometries.has(window)) { - const rect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; - this._originalGeometries.set(window, rect); + if (true) { Logger.debug(`StateTracker: Tracked new window ID ${window.get_id ? window.get_id() : 'unknown'} ("${window.get_title ? window.get_title() : 'unknown'}") to slot ${index}`); } else { Logger.debug(`StateTracker: Updated window ID ${window.get_id ? window.get_id() : 'unknown'} to slot ${index}`); @@ -21,12 +19,9 @@ export class StateTracker { } untrack(window) { - const original = this._originalGeometries.get(window); const slot = this._windowToSlot.get(window); this._windowToSlot.delete(window); - this._originalGeometries.delete(window); Logger.debug(`StateTracker: Untracked window ID ${window.get_id ? window.get_id() : 'unknown'} ("${window.get_title ? window.get_title() : 'unknown'}") from slot ${slot}`); - return original; } swapWindows(win1, win2) { @@ -54,15 +49,11 @@ export class StateTracker { clear() { this._windowToSlot.clear(); - this._originalGeometries.clear(); } swapWith(otherTracker) { const tempWindowToSlot = this._windowToSlot; - const tempOriginalGeometries = this._originalGeometries; this._windowToSlot = otherTracker._windowToSlot; - this._originalGeometries = otherTracker._originalGeometries; otherTracker._windowToSlot = tempWindowToSlot; - otherTracker._originalGeometries = tempOriginalGeometries; } } diff --git a/lib/window.js b/lib/window.js index e9d422b..26d0134 100644 --- a/lib/window.js +++ b/lib/window.js @@ -163,7 +163,7 @@ export class WindowWrapper { } if (this.window.maximized_horizontally || this.window.maximized_vertically) { - this.window.unmaximize(3); + this.window.unmaximize(); const laterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { if (this.unmanaged) return false; this._doResize(rect); diff --git a/prefs.js b/prefs.js index 9bfca5d..10ce43b 100644 --- a/prefs.js +++ b/prefs.js @@ -467,8 +467,11 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { subtitle: 'Only if you know what you are doing. This will not save you from bad decisions' }); + settings.bind('show-advanced-json', jsonToggle, 'active', Gio.SettingsBindFlags.DEFAULT); + let jsonPageAdded = false; - jsonToggle.connect('notify::active', () => { + + const updateLayoutVisibility = () => { if (jsonToggle.active && !jsonPageAdded) { window.add(layoutPage); jsonPageAdded = true; @@ -476,7 +479,13 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { window.remove(layoutPage); jsonPageAdded = false; } - }); + }; + + jsonToggle.connect('notify::active', updateLayoutVisibility); + + // Initial state application + updateLayoutVisibility(); + advancedGroup.add(jsonToggle); previewPage.add(advancedGroup); } diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index 3544718ff6b655bbd6d26113a7d63abbaf2f0367..c2caa6da362578c2dab87ce17292388456df1658 100644 GIT binary patch literal 2836 zcmbtWTWB0r7(P)`W7A98rcKkNwyr*GN+z2N)e>U}L9l2m(xMGWA(Nd+cAD9lb!Il3 z7^8Wx(moUxY@r45MlBK42T_O$vWNz;VoPmMX)S#$SZPZXFE9Fi=ge$&H&Gv4`11Yx z<=_9DIp@F3BTwm`r8|KLKbye=%8uU_YE@uw`s@WV|F=Wz7B7OIyF-ZTjY9l|PhBkp zu}6^W0oNO88*$vU+2*HA+io+*1Jm&>*YVqOt~WYtyE!eeY|9zp??n7QNkVW9u6uh3 zEFo3^cZRqNOsovC8oUNrTY@^+>wxt@eF$t*Yy$XvL?h4yYz6KHwgLBq;9iK!AH37Z zxO~pFVoc=0Gr-{sM?0v~-Uz(_{u+4i&B1Bvv>Tvb1OEwJu6p|c>a^EEud5cK8L0Zf zkp8skzYF{*aO%J(ztNxeCg_7;6S(r)+|Sf$uYo=e9>wA0zdZW_b=vEpPlFEwtJ2l) zQm4HVdLDcd_-wlGDs|dT&}YG407iYbpE~VY=;y)nK;J8c6Vz#MfqoPGD{%7t<0f_5 z_0a2ZNe#fx$?Y0-+T2e&csKCXfxJ97+8dw`f^8tmA==C#@-M(QSB#sCL%R223)`YnFp!Urau#L190)>Gb^am-UfXa_#t4&{97^(ZN?b@XMlySy=UZp zVDlcQ!Fk})T=PrRX)~{8z_UPM>iJsM80~841@H~vV!=5^f7*;+gQ92x{y5o+msjt$P!_J^NGE zaL_x742tW13!8?Y(UYd;=d2)^($cPB!rw55^{kEd757^ZEpXHL&zKoSMPx_C-Ji{v zUhl*FzDhkr}t9X9O^@3zJ(2}<6o7jb81+FKT3O8o!6K*!} z<$fk(9kK4GCu8HW?)KK$g#4R}b$7JmTPkGBfH8HjHP*SD>&_*ftuh?-V!TSHPKN9H zzu_2_o9mn^Taq%KAzU+Z>Y6$@C9ekul+$!$Om~u|p&jyFbu;my7MzOdX(M{Z7if`i zwpzmD(*jS&*a$E`L4k6AcUeoQ~9+j@W$z{Q)6K4hCRK$Sqe;#FjDCDBc2>h73K zeo9ATU=yR}#1PUCQ%K#45R+=JWy#@LBdKUWR_3R@BtK>PEt^jb*f-}8=g++W=M!3| zoOgWgW?&0|@<4x4vx{?(Gman5D$X{PyPR2^SDa~_WoSvrL-q&uR`zcpwgWMMvyH8l zbCUD5>&$F6 zF|6dFN@@`bw$Mt^D7943Qi=sdkVP~P3O3XhE3GAu1tX^9!TO}%H+yF~`{R=XU%r!X z?w)(kx%Zwq&%LbJx?;MLc(#BKmhI?$LTdoqGe2G-^W9EqL^=#kJRnJp8zt!)gA^_JxEIsx5Guyz7ah)_F zO@faCyQVv2>a^EGp96mh+?e0?Hg(z?p)Y_Jftz1^)=r)FTIkE*CIqtb`|u0YX>Wxd z1W&q415AOb>Qp2sMFpA{Q~$B z@XH4u9idKp3-kr>ZQ$yAXID|Dy#spFD&!UT;lQyT>a;gN?*MlJ-<>g6sMDtZ5pV*y z+3@Mp)MzM?<1Kj#>{tu3) z{V?>C;G<2*?eOJqsMBtTehyp${%kwr#Y>ynu#KB#h9X&-q zv>DeB_*vj;$$XbOZR!cI1GF!x-uh{C{j=cr0sr37=Dj-&?!di!>&g*tzi9Iu?gsAx zTC$Cw(?4zIXCL?=aPs1DjXLc%=s9o!7=Heo7Z+{DH4B~thEA7Ga6IitpqIek11o)_ zXQ|U>-mZgL3$oUPCKq&5#eKK+bjB|<1N-wi%^rNwlh#O4#pz60$-GlNc3_{aXBB&D zaA1E%Pq~8zZXE|>sB2~Au={)t^9#Q(&$NJa1-oQ_t#eAT=n4PEJX3 ztoKyRlQb+xtC%Jnd_}^LU=t3i<9p4|SJ;!Rn@J2>Dy^I9=jz&n$73VIr&N?Sl z%uN?!{9dl69G4hxC{tG6b*3Xdk^WbwBa@N-?yks`_qPz~@9D;W8Ns>=qj;z*(pzun zu9}fuo;&J+zv@A~bl3NP-O(!-*ITTZk}_ftbNYk&iie8-xm!WWsNOHX4pf}4;MCOa zuYV6>ImOK&|8X=Zaj!v%dksqbUV{>^YEa^+#KFxk-^k#iVBz(Pk-zXdbuN5#f)i1D z9$2T#y$*XC_Au;q&~Bl=vR1SHmU|@jJM3>d0QN%co!B?A2jY2M3v2+`_wZq#^C++b zz-P(piP-<3HNm&4++(q~VviI=DhD;Mrmo?<`q#tr!TaH46zqnk%AuMFchYouuPe5V fNvPgm|2_tZuc*sG+RFJ4XH*mEnable Gaps Toggle gaps on or off globally. + + false + Show Advanced JSON + Persist visibility of the advanced JSON editor tab. + 'escalate' Monitor Transition Behavior diff --git a/tests/controller.test.js b/tests/controller.test.js index 2110329..a2e5152 100644 --- a/tests/controller.test.js +++ b/tests/controller.test.js @@ -554,14 +554,14 @@ describe('TilingController', () => { controller.toggleOverrideActiveWindow('maximize'); expect(controller._authorizedOverrides.has(win)).toBe(true); - expect(win.maximize).toHaveBeenCalledWith(3); + expect(win.maximize).toHaveBeenCalled(); // Toggle off win.maximized_horizontally = true; win.maximized_vertically = true; controller.toggleOverrideActiveWindow('maximize'); expect(controller._authorizedOverrides.has(win)).toBe(false); - expect(win.unmaximize).toHaveBeenCalledWith(3); + expect(win.unmaximize).toHaveBeenCalled(); }); it('should toggle override for fullscreen', () => { @@ -596,7 +596,7 @@ describe('TilingController', () => { controller._clearOverridesOnMonitor(0); expect(controller._authorizedOverrides.has(win)).toBe(false); - expect(win.unmaximize).toHaveBeenCalledWith(3); + expect(win.unmaximize).toHaveBeenCalled(); expect(win.unmake_fullscreen).toHaveBeenCalled(); }); }); From f9e28fa33a553f1bc30d04a0499fa10d4f5aa8f1 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Mon, 22 Jun 2026 17:37:06 +0200 Subject: [PATCH 33/33] bugfix: switching monitors leading to layout scramble --- lib/controller.js | 8 ++++++++ lib/workspace.js | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/controller.js b/lib/controller.js index 1342dde..47af81e 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -97,6 +97,14 @@ export class TilingController { return; } + if (wrapper.switchingMonitorsUntil && Date.now() < wrapper.switchingMonitorsUntil) { + if (window.get_monitor() !== wrapper.monitorIndex) { + return; // Ignore async signals while window is physically moving between monitors + } else { + wrapper.switchingMonitorsUntil = 0; // Arrived early + } + } + const context = this._resolveTilingContext(window, wrapper); if (!context) { Logger.debug(`tilingRequest: Aborted. No context resolved.`); diff --git a/lib/workspace.js b/lib/workspace.js index 358d2f9..b9d0d6c 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -430,14 +430,19 @@ export class WorkspaceManager { const trackerB = layout._getTracker(targetMonitorId); trackerA.swapWith(trackerB); - // Update window wrapper cache + // Update window wrapper cache and set transit flag const windows = workspace.list_windows(); + const transitExpiry = Date.now() + 2000; windows.forEach(w => { const m = w.get_monitor(); if (m === activeMonitorIndex) { this.controller.updateWindowWrapperMonitor(w, targetMonitorId, targetMonitorIndex); + const wrapper = this.controller._windowWrappers.get(w); + if (wrapper) wrapper.switchingMonitorsUntil = transitExpiry; } else if (m === targetMonitorIndex) { this.controller.updateWindowWrapperMonitor(w, activeMonitorId, activeMonitorIndex); + const wrapper = this.controller._windowWrappers.get(w); + if (wrapper) wrapper.switchingMonitorsUntil = transitExpiry; } });