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/ 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..45d9fa6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# 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. +* **`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`. + +## 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. +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! 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: 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 62% rename from architecture.md rename to docs/architecture.md index 6f92df2..9a172b7 100644 --- a/architecture.md +++ b/docs/architecture.md @@ -14,14 +14,26 @@ 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. 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. @@ -29,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. @@ -42,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. @@ -54,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. @@ -62,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/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/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 89% rename from vision.md rename to docs/vision.md index 9b6a7e1..c39a2bf 100644 --- a/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/extension.js b/extension.js index 1f9f0bc..989d445 100644 --- a/extension.js +++ b/extension.js @@ -1,8 +1,10 @@ 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'; import { Logger } from './lib/logger.js'; +import { KeybindingManager } from './lib/keybindings.js'; /** * Main extension class. Manages controller and signals. @@ -16,10 +18,25 @@ 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; + 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(); @@ -27,7 +44,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 +63,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 +75,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,13 +87,37 @@ 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; 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 c345d2c..47af81e 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. @@ -33,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. @@ -59,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; } } @@ -78,6 +81,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.`); @@ -88,6 +92,19 @@ export class TilingController { wrapper.bindSizeChanged(); try { + if (this.dragManager && this.dragManager.isWindowInDragPreview(window)) { + Logger.debug(`tilingRequest: Ignored for window in active drag preview.`); + 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.`); @@ -95,26 +112,75 @@ 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)) { Logger.debug(`tilingRequest: Window evacuated. Updating cache and returning.`); - this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); + this.updateWindowWrapperMonitor(window, monitorId, monitorIndex, workspace); 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) { + this._handleMonitorTransitionChange(window, wrapper, layout, wrapper.monitorIndex, wrapper.monitorId, monitorIndex, monitorId, workspace); + } else { + 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 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); + + 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) { + 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.updateWindowWrapperMonitor(window, monitorId, monitorIndex, workspace); + 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; @@ -134,8 +200,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 +212,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); } @@ -172,11 +238,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); @@ -197,9 +259,14 @@ 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); + this._authorizedOverrides.delete(window); try { if (workspace) { @@ -223,7 +290,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)); @@ -242,7 +309,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); @@ -324,10 +393,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 +408,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); @@ -361,7 +432,7 @@ export class TilingController { } closeMonitorWindows(monitorIndex, includeMinimized) { - this.monitorManager.closeMonitorWindows(monitorIndex, includeMinimized); + this.workspaceManager.closeMonitorWindows(monitorIndex, includeMinimized); } closeWorkspaceWindows(workspace) { @@ -369,20 +440,51 @@ 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) { 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(); + } else { + this._authorizedOverrides.add(targetWindow); + if (type === 'maximize') targetWindow.maximize(); + 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 && !window.unmanaged) { + this._authorizedOverrides.delete(window); + if (window.maximized_horizontally && window.maximized_vertically) { + window.unmaximize(); + } + if (window.is_fullscreen && window.is_fullscreen()) { + window.unmake_fullscreen(); + } + } + }); + } 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) => { @@ -391,6 +493,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/drag.js b/lib/drag.js index afffd72..17873a4 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -1,5 +1,7 @@ -import Gio from 'gi://Gio'; +import Meta from 'gi://Meta'; import St from 'gi://St'; +import GLib from 'gi://GLib'; +import { Logger } from './logger.js'; /** * DragManager: Manages pointer drag tracking and visual drop indicators. @@ -7,7 +9,46 @@ 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 } + 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) { + 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) { @@ -17,21 +58,25 @@ 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); - if (originalSlot === undefined) return; + const windowCount = layout.getWindowCount(wrapper.monitorId); + let originalSlot = layout.getWindowSlot(wrapper.monitorId, window); + if (originalSlot === undefined) { + originalSlot = -1; + } - const matrix = layout.escalator.getLayoutForCount(tracker.size); - if (!matrix || originalSlot >= matrix.size) return; + const matrix = layout.escalator.getLayoutForCount(windowCount); + // 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(); 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 }; - this._activeDrag = { window, originalSlot, indicator, signalId, lastHoveredSlot: -1, origRect }; + this._activeDrag = { window, originalSlot, indicator, signalId, lastHoveredSlot: -1, lastHoveredMonitorId: null, origRect }; } /** @@ -48,7 +93,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 @@ -64,29 +109,75 @@ 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; - 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; + } + + const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); + const targetWindowCount = layout.getWindowCount(monitorId); + const numM1 = global.display.get_n_monitors(); + const safe1 = (monitorIndex >= 0 && monitorIndex < numM1) ? monitorIndex : 0; + const monitorRect = workspace.get_work_area_for_monitor(safe1); - if (hoveredSlot !== -1 && hoveredSlot !== originalSlot) { - const matrix = layout.escalator.getLayoutForCount(tracker.size); - const targetRect = matrix.getEstate(hoveredSlot).toAbsolute(monitorRect, gaps); + let hoveredSlot = -1; + let targetRect = null; + + if (targetWindowCount === 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 { + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + const isTracked = originalSlot !== -1; + const matrixCount = (monitorId === wrapper.monitorId && isTracked || behavior === 'swap') ? targetWindowCount : (targetWindowCount + 1); + if (matrixCount > layout.escalator.getMaxCount()) { + hoveredSlot = -1; + } else { + hoveredSlot = layout.getSlotAtPointer(monitorId, x, y, monitorRect, gaps, matrixCount); + } + + if (hoveredSlot !== -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, targetWindowCount, layout, monitorId, hoveredSlot, monitorRect, gaps); + } else { + this._applyVisualSwap(monitorId, layout, originalSlot, hoveredSlot, monitorRect, gaps); + } } else { indicator.hide(); - this._revertVisualSwap(tracker, layout, monitorRect, gaps); + this._revertVisualSwap(layout, gaps); } } @@ -94,50 +185,220 @@ 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) { - if (!this._activeDrag || this._activeDrag.lastHoveredSlot === hoveredSlot) return; + _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); + + 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); + } - const matrix = layout.escalator.getLayoutForCount(tracker.size); + _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 - if (this._activeDrag.lastHoveredSlot !== -1) { - this._restoreWindowGeometry(tracker, matrix, this._activeDrag.lastHoveredSlot, this._activeDrag.lastHoveredSlot, monitorRect, gaps); + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); + 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'; + + if (behavior === 'escalate') { + const sourceCount = layout.getWindowCount(wrapper.monitorId); + const sourceMatrix = layout.escalator.getLayoutForCount(sourceCount > 0 ? sourceCount - 1 : 0); + if (sourceMatrix) { + const origSlot = this._activeDrag.originalSlot; + const windows = layout.getWindowsForMonitor(wrapper.monitorId); + for (const win of windows) { + if (win === this._activeDrag.window) continue; + const slot = layout.getWindowSlot(wrapper.monitorId, 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(layout, gaps); + } + + // Apply new cross-monitor visual swap preview using size N+1 matrix + const matrix = layout.escalator.getLayoutForCount(behavior === 'swap' && targetWindowCount > 0 ? targetWindowCount : targetWindowCount + 1); + + if (matrix) { + if (behavior === 'swap' && targetWindowCount > 0) { + const sourceMatrix = layout.escalator.getLayoutForCount(layout.getWindowCount(wrapper.monitorId)); + const sourceEstate = sourceMatrix ? sourceMatrix.getEstate(this._activeDrag.originalSlot) : null; + 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) { + const slot = layout.getWindowSlot(monitorId, 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 { + 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); + 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; } /** * 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 matrix = layout.escalator.getLayoutForCount(tracker.size); - this._restoreWindowGeometry(tracker, matrix, this._activeDrag.lastHoveredSlot, this._activeDrag.lastHoveredSlot, monitorRect, gaps); - this._activeDrag.lastHoveredSlot = -1; + + 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 lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); + if (lastMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + 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); + } + } + + if (sourceMonId) { + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); + if (sourceMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + 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); + } + } + + if (clearState) { + this._activeDrag.lastHoveredSlot = -1; + this._activeDrag.lastHoveredMonitorId = null; + } + } + + _restoreTrackerGeometries(monitorId, layout, monitorRect, gaps) { + const matrix = layout.escalator.getLayoutForCount(layout.getWindowCount(monitorId)); + if (!matrix) return; + const draggedWindow = this._activeDrag ? this._activeDrag.window : null; + const windows = layout.getWindowsForMonitor(monitorId); + for (const win of windows) { + if (win === draggedWindow) continue; + const slot = layout.getWindowSlot(monitorId, 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) { - 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) { - 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) { + this.forceCleanup(); + return; + } + + const workspace = wrapper.workspace; + if (!workspace.get_work_area_for_monitor) { + this.forceCleanup(); + return; + } + + 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); + + try { + window.disconnect(activeDrag.signalId); + } catch(e) {} + if (activeDrag.indicator) { + activeDrag.indicator.destroy(); } - const origRect = this._activeDrag.origRect; this._activeDrag = null; if (this._deferredRetiles && this._deferredRetiles.length > 0) { @@ -145,19 +406,106 @@ export class DragManager { this._deferredRetiles = []; } - const wrapper = this.controller._windowWrappers.get(window); - if (!wrapper || !wrapper.workspace || !wrapper.monitorId) return; + if (lastHoveredMonitorId && lastHoveredMonitorId !== wrapper.monitorId) { + this._commitCrossMonitorTransfer(window, wrapper, layout, lastHoveredMonitorId, lastHoveredSlot, activeDrag.originalSlot); + } else { + const [x, y] = global.get_pointer(); + + 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) { + // Pointer fallback cross-monitor drop + this._commitCrossMonitorTransfer(window, wrapper, layout, pointerMonitorId, -1, activeDrag.originalSlot, pointerMonitorIndex); + } else { + const targetSlot = lastHoveredMonitorId === wrapper.monitorId ? lastHoveredSlot : -1; + this._commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect, activeDrag.originalSlot, targetSlot); + } + } + } + _commitCrossMonitorTransfer(window, wrapper, layout, targetMonitorId, targetSlot, sourceSlot, targetMonitorIndexOverride = -1) { + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; const workspace = wrapper.workspace; - if (!workspace.get_work_area_for_monitor) return; + let targetMonitorIndex = targetMonitorIndexOverride !== -1 ? targetMonitorIndexOverride : this.controller.monitorManager.getMonitorIndex(targetMonitorId); - const monitorRect = workspace.get_work_area_for_monitor(wrapper.monitorIndex); - const gaps = this.controller.settings ? this.controller.settings.getGaps() : { inner: 6, outer: 4 }; + 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); - const layout = this.controller.workspaceManager.getLayout(workspace); + 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); + + 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); + + 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); + + 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); + this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); + } + + _commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect, originalSlot, targetSlot) { + let swapped = false; - const [x, y] = global.get_pointer(); - const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); + 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; 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/keybindings.js b/lib/keybindings.js new file mode 100644 index 0000000..f9d9198 --- /dev/null +++ b/lib/keybindings.js @@ -0,0 +1,225 @@ +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' +}; + +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, 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()) + }; + + for (const [key, action] of Object.entries(utilities)) { + defs.push({ + defaultKey: key, + action: (c, win) => { + Logger.debug(`Action triggered: ${key}`); + action(c, win); + }, + conflict: null + }); + } + + 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; + } + + bindAll() { + if (!this.settings) return; + + const conflictsToHijack = []; + 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.`); + 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) { + if (def.isNativeHijack) continue; + + 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/layout.js b/lib/layout.js index 21293c9..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). @@ -81,8 +81,13 @@ export class Layout { get size() { return this.estates.length; } + + getEdgingSlot(direction) { + return getEdgingSlotForEstates(this.estates, direction); + } } + /** * Escalator class. Maps window count to layout. */ @@ -99,6 +104,10 @@ export class LayoutEscalator { } return this._layouts.get(windowCount) || null; } + + getMaxCount() { + return this._maxCount; + } } diff --git a/lib/monitor.js b/lib/monitor.js index cf6f5be..27a86b8 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. @@ -97,6 +98,7 @@ export class MonitorManager { if (window && !window.unmanaged && !window.minimized) { window.minimize(); } + this.controller.setBatchMode(false); return GLib.SOURCE_REMOVE; }); @@ -127,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); @@ -183,81 +186,37 @@ export class MonitorManager { } clear() { + if (this._pendingLaterId) { + global.compositor.get_laters().remove(this._pendingLaterId); + this._pendingLaterId = 0; + } this._evacuatedWindows.clear(); 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 => { - 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); + getMonitorInDirection(currentMonitorIndex, direction) { + try { + const manager = global.backend.get_monitor_manager(); + const logicalMonitors = manager.get_logical_monitors(); + + 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'); } - }); - 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++; + return index; + } catch (e) { + Logger.error(`Failed to get monitor in direction ${direction}`, e); + return -1; } - - 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/lib/settings.js b/lib/settings.js index a29ab4a..4806e14 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', @@ -43,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}`, () => { @@ -63,6 +70,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 +81,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/shadows.js b/lib/shadows.js new file mode 100644 index 0000000..20b90e9 --- /dev/null +++ b/lib/shadows.js @@ -0,0 +1,141 @@ +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; + + const remaining = {}; + let hasRemaining = false; + + for (const settings of this._nativeSchemas) { + const schemaId = settings.schema_id; + 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; + } + } + } + + this.extensionSettings.set_string( + 'shadowed-keybindings', + hasRemaining ? JSON.stringify(remaining) : '{}' + ); + } +} diff --git a/lib/signals.js b/lib/signals.js index d314cec..dce51a9 100644 --- a/lib/signals.js +++ b/lib/signals.js @@ -24,7 +24,7 @@ export class SignalListener { this.controller = controller; this._signals = []; - this._keybindings = []; + this._pendingIdles = new Set(); } bind() { @@ -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(); @@ -80,129 +87,28 @@ 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, () => { + 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; }); - } - - rebindKeybindings() { - this._unbindKeybindings(); - this._bindKeybindings(); + this._pendingIdles.add(sourceId); } unbind() { + this._pendingIdles.forEach(id => GLib.source_remove(id)); + this._pendingIdles.clear(); + this._signals.forEach(({ obj, id }) => { try { if (obj) { @@ -214,24 +120,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/lib/state.js b/lib/state.js index cdb924b..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,6 +49,11 @@ export class StateTracker { clear() { this._windowToSlot.clear(); - this._originalGeometries.clear(); + } + + swapWith(otherTracker) { + const tempWindowToSlot = this._windowToSlot; + this._windowToSlot = otherTracker._windowToSlot; + otherTracker._windowToSlot = tempWindowToSlot; } } diff --git a/lib/utils/geometry.js b/lib/utils/geometry.js new file mode 100644 index 0000000..a00ab85 --- /dev/null +++ b/lib/utils/geometry.js @@ -0,0 +1,239 @@ +/** + * 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)); + } + if (overlap > 0) { + 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, 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; + } 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/window.js b/lib/window.js index 36b84a2..26d0134 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() { @@ -29,6 +30,46 @@ 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) { + 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; + } + + // 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) { + 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; + } + bindSignals() { if (!this.signals.has('unmanaged')) { this.signals.set('unmanaged', this.window.connect('unmanaged', () => this.controller.untile(this.window))); @@ -39,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) { @@ -66,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) { @@ -81,6 +131,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); @@ -90,6 +146,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) { @@ -97,14 +158,26 @@ export class WindowWrapper { } } + if (this.isOverrideActive()) { + return; + } + 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 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); } @@ -113,6 +186,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; @@ -123,10 +201,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/lib/workspace.js b/lib/workspace.js index d0db0a2..b9d0d6c 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -1,12 +1,15 @@ 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. */ export class WorkspaceLayout { - constructor(workspace, escalator) { + constructor(workspace, controller) { this.workspace = workspace; - this.escalator = escalator; + this.controller = controller; + this.escalator = controller.escalator; this.monitors = new Map(); } @@ -38,6 +41,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. */ @@ -62,6 +92,19 @@ 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 }; + candidates.push({ win, rect: targetRect }); + } + + return findClosestBoundaryWindow(candidates, direction, sourceRect); + } + + _getTargetWindowInDirection(monitorId, window, direction) { const tracker = this._getTracker(monitorId); const slot = tracker.getSlot(window); @@ -74,10 +117,83 @@ export class WorkspaceLayout { const estate = layout.getEstate(slot); if (!estate) return null; - const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); - if (targetSlot === -1) return null; + const targetSlot = findTargetSlotInDirection(layout, slot, estate, direction); + 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; + if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { + currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); + } + + Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + + if (currentMonitorIndex === -1) return null; + + if (this.controller && this.controller.monitorManager) { + Logger.debug(`calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + 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); + 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; + } + + /** + * 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 null; + const sourceTracker = this._getTracker(sourceMonitorId); + const targetTracker = this._getTracker(targetMonitorId); + + 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) { + 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); + + return { swappedWindow: targetWindow, behavior: 'swap' }; + } + } + } + } + + 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); + return { swappedWindow: null, behavior: 'escalate' }; } /** @@ -85,15 +201,73 @@ 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 = 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; + if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { + currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); + } + + if (currentMonitorIndex === -1) return false; + + if (this.controller && this.controller.monitorManager) { + Logger.debug(`calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + Logger.debug(`getMonitorInDirection returned ${adjacentMonitorIndex}`); + if (adjacentMonitorIndex !== -1) { + const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); + const enteringEdgeMap = { + 'left': 'right', + 'right': 'left', + 'up': 'bottom', + 'down': 'top' + }; + const enteringEdge = enteringEdgeMap[direction]; + + 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); + + 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); + this.controller._scheduleRetile(this.workspace, targetMonitorId, adjacentMonitorIndex); + } + return true; + } } return false; } + /** * Finds the nearest window in the specified geometric direction and activates it. */ @@ -106,67 +280,15 @@ 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. * 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; @@ -192,6 +314,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) { @@ -222,7 +347,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); } @@ -231,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); @@ -254,4 +395,104 @@ 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; + + 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}`; + + const layout = this.getLayout(workspace); + + // Swap layout trackers + const trackerA = layout._getTracker(activeMonitorId); + const trackerB = layout._getTracker(targetMonitorId); + trackerA.swapWith(trackerB); + + // 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; + } + }); + + // Move windows + this.controller.setBatchMode(true); + 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); + + // Schedule retile rather than hydrate + if (this.controller && this.controller._scheduleRetile) { + this.controller._scheduleRetile(workspace, activeMonitorId, activeMonitorIndex); + this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); + } else { + 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/prefs.js b/prefs.js index 55ebb59..10ce43b 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] : ''; @@ -108,6 +138,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' }); @@ -131,50 +162,86 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { page.add(gapsGroup); - // --- Core Keybindings Group --- - const keysGroup = new Adw.PreferencesGroup({ title: 'Keybindings' }); - const modeRow = new Adw.ComboRow({ - title: 'Mode', - subtitle: '', - model: Gtk.StringList.new(['Default', 'Custom', 'Disabled']) + // --- 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' }); - const currentMode = settings.get_string('keybindings-mode'); - modeRow.selected = currentMode === 'custom' ? 1 : (currentMode === 'disabled' ? 2 : 0); - modeRow.connect('notify::selected', () => { - let mode = 'default'; - if (modeRow.selected === 1) mode = 'custom'; - if (modeRow.selected === 2) mode = 'disabled'; - settings.set_string('keybindings-mode', mode); + transitionRow.active = settings.get_string('monitor-transition-behavior') === 'swap'; + transitionRow.connect('notify::active', () => { + settings.set_string('monitor-transition-behavior', transitionRow.active ? 'swap' : 'escalate'); }); - keysGroup.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' } - ].map(s => { - const row = new ShortcutRow(settings, s.id, s.label); - keysGroup.add(row); - return row; + 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); + + 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 updateVisibility = () => { - const mode = settings.get_string('keybindings-mode'); - modeRow.subtitle = mode === 'default' ? 'Default: + ' : ''; - const showCustom = mode === 'custom'; - moveRows.forEach(r => r.visible = showCustom); + 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); + } + } }; - settings.connect('changed::keybindings-mode', updateVisibility); - updateVisibility(); - page.add(keysGroup); + const createRow = (st, id, label, origin = '') => { + const row = new ShortcutRow(st, id, label, origin); + row.setOnChange(updateConflicts); + allRows.push(row); + return row; + }; - // --- Focus Window Group --- - const focusGroup = new Adw.PreferencesGroup({ title: 'Focus Keybindings' }); + // --- Focus & Position Group --- + const focusPositionGroup = new Adw.PreferencesGroup({ title: 'Window Focus & Position' }); const focusModeRow = new Adw.ComboRow({ - title: 'Mode', + title: 'Focus Mode', subtitle: '', model: Gtk.StringList.new(['Default', 'Custom', 'Disabled']) }); @@ -186,7 +253,7 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { if (focusModeRow.selected === 2) mode = 'disabled'; settings.set_string('focus-window-mode', mode); }); - focusGroup.add(focusModeRow); + focusPositionGroup.add(focusModeRow); const focusRows = [ { id: 'custom-focus-window-left', label: ' ↳ Focus Window Left' }, @@ -194,8 +261,8 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { { 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); + const row = createRow(settings, s.id, s.label); + focusPositionGroup.add(row); return row; }); @@ -205,16 +272,161 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { const showCustom = mode === 'custom'; focusRows.forEach(r => r.visible = showCustom); }; - settings.connect('changed::focus-window-mode', updateFocusVisibility); + settings.connect('changed::focus-window-mode', () => { updateFocusVisibility(); updateConflicts(); }); updateFocusVisibility(); - page.add(focusGroup); + const modeRow = new Adw.ComboRow({ + title: 'Swap Mode', + subtitle: '', + model: Gtk.StringList.new(['Default', 'Custom', 'Disabled']) + }); + const currentMode = settings.get_string('keybindings-mode'); + modeRow.selected = currentMode === 'custom' ? 1 : (currentMode === 'disabled' ? 2 : 0); + modeRow.connect('notify::selected', () => { + let mode = 'default'; + if (modeRow.selected === 1) mode = 'custom'; + if (modeRow.selected === 2) mode = 'disabled'; + settings.set_string('keybindings-mode', mode); + }); + focusPositionGroup.add(modeRow); + + const moveRows = [ + { 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 = createRow(settings, s.id, s.label); + focusPositionGroup.add(row); + return row; + }); + + const updateVisibility = () => { + const mode = settings.get_string('keybindings-mode'); + modeRow.subtitle = mode === 'default' ? 'Default: + ' : ''; + const showCustom = mode === 'custom'; + moveRows.forEach(r => r.visible = showCustom); + }; + settings.connect('changed::keybindings-mode', () => { updateVisibility(); updateConflicts(); }); + updateVisibility(); + + shortcutsPage.add(new Adw.PreferencesGroup()); + shortcutsPage.add(focusPositionGroup); - // --- Additional Batch Shortcuts Group --- - const batchKeysGroup = new Adw.PreferencesGroup({ title: 'Batch Operations' }); + // --- 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: '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); + + // --- 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 wsSwitchModeRow = new Adw.ComboRow({ + title: 'Numbered Workspaces', + subtitle: '', + model: Gtk.StringList.new(['System Default', 'Edit', 'Disabled']) + }); + 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}`); - const closeMonitorRow = new ShortcutRow(settings, 'shortcut-close-monitor', 'Close Monitor Windows'); - batchKeysGroup.add(closeMonitorRow); + wsSwitchModeRow.connect('notify::selected', () => { + let mode = 'default'; + 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'])); + } + }); + wsSwitchGroup.add(wsSwitchModeRow); + + 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 updateWsSwitchVisibility = () => { + const mode = settings.get_string('workspace-switch-mode'); + wsSwitchModeRow.subtitle = mode === 'default' ? 'System default keybindings' : ''; + const showCustom = mode === 'custom'; + wsSwitchRows.forEach(r => r.visible = showCustom); + updateConflicts(); + }; + settings.connect('changed::workspace-switch-mode', updateWsSwitchVisibility); + updateWsSwitchVisibility(); + + // --- 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')); + + 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 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); @@ -224,19 +436,21 @@ 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))); + { 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))); - page.add(batchKeysGroup); + shortcutsPage.add(monitorGroup); + + // Initial conflicts check + updateConflicts(); window.add(page); + window.add(shortcutsPage); // --- Custom Layouts (JSON debug) Page --- const layoutPage = new LayoutEditorPage(settings); @@ -253,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; @@ -262,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 082b794..c2caa6d 100644 Binary files a/schemas/gschemas.compiled and b/schemas/gschemas.compiled differ diff --git a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml index 7bdfd23..4ba4d85 100644 --- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml +++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml @@ -6,6 +6,17 @@ Enable Gaps Toggle gaps on or off globally. + + false + Show Advanced JSON + Persist visibility of the advanced JSON editor tab. + + + 'escalate' + Monitor Transition Behavior + Behavior when window crosses monitor boundary ('escalate' or 'swap'). + + 'default' Keybindings Mode @@ -62,6 +73,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 @@ -161,6 +184,11 @@ Custom Layouts JSON 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. + 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 diff --git a/tests/adversarial.test.js b/tests/adversarial.test.js new file mode 100644 index 0000000..dd2a541 --- /dev/null +++ b/tests/adversarial.test.js @@ -0,0 +1,232 @@ +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, + get effectiveWorkspace() { return ws; }, + monitorIndex: 0, + get effectiveMonitorIndex() { return win.get_monitor(); }, + 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/controller.test.js b/tests/controller.test.js index 89c026d..a2e5152 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(), @@ -183,7 +184,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); @@ -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).toHaveBeenCalled(); + + // Toggle off + win.maximized_horizontally = true; + win.maximized_vertically = true; + controller.toggleOverrideActiveWindow('maximize'); + expect(controller._authorizedOverrides.has(win)).toBe(false); + expect(win.unmaximize).toHaveBeenCalled(); + }); + + 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).toHaveBeenCalled(); + expect(win.unmake_fullscreen).toHaveBeenCalled(); + }); + }); }); 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-transition.test.js b/tests/monitor-transition.test.js new file mode 100644 index 0000000..5d67cad --- /dev/null +++ b/tests/monitor-transition.test.js @@ -0,0 +1,300 @@ +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(), + updateWindowWrapperMonitor: function(win, id, idx) { + const w = this._windowWrappers.get(win); + if (w) { w.monitorId = id; w.monitorIndex = idx; } + } + }; + }); + + 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/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/prefs.test.js b/tests/prefs.test.js new file mode 100644 index 0000000..274c558 --- /dev/null +++ b/tests/prefs.test.js @@ -0,0 +1,588 @@ +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); + } + }, + 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 }; + } + }, + 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 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(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', () => { + prefs.fillPreferencesWindow(mockWindow); + const shortcutsPage = addedPages.find(p => p.title === 'Keyboard Shortcuts'); + + expect(shortcutsPage).toBeDefined(); + 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', () => { + 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 Focus & Position'); + 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 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 && 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); + 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 && r.keyName.startsWith('custom-move-window')); + + // 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 && r.keyName.startsWith('custom-move-window')); + + // 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/tests/regressions.test.js b/tests/regressions.test.js new file mode 100644 index 0000000..411e990 --- /dev/null +++ b/tests/regressions.test.js @@ -0,0 +1,333 @@ +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, + 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 7df516f..2464f3c 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: { @@ -70,6 +76,8 @@ global.display = { get_current_monitor: vi.fn(() => 0), get_focus_window: vi.fn(() => null), list_all_windows: vi.fn(() => []), + 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() }; @@ -78,11 +86,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() })) @@ -123,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() + } } })); 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..3287193 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', () => { @@ -19,6 +19,7 @@ describe('WindowWrapper', () => { unmaximize: vi.fn(), maximized_horizontally: false, maximized_vertically: false, + is_fullscreen: vi.fn(() => false), }; mockController = { @@ -48,7 +49,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 +81,7 @@ describe('WindowWrapper', () => { wrapper.bindSizeChanged(); wrapper.destroy(); - expect(mockWindow.disconnect).toHaveBeenCalledTimes(7); + expect(mockWindow.disconnect).toHaveBeenCalledTimes(9); }); it('should apply geometry skipping unmanaged', () => { @@ -118,4 +119,66 @@ describe('WindowWrapper', () => { const wrapper = new WindowWrapper(mockWindow, mockController); wrapper.applyGeometry({ x: 10, y: 10, width: 100, height: 100 }); // should not throw }); + + 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() }; + 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(); + }); + }); + + 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); + }); }); diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 02e6622..e173917 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 }) }; @@ -216,11 +217,21 @@ 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(), 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); @@ -259,4 +270,185 @@ 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); + const controller = { escalator }; + + describe('_findClosestBoundaryWindow', () => { + it('should choose the window with highest overlap on adjacent edge', () => { + const layout = new WorkspaceLayout({}, controller); + const targetTracker = { + size: 2, + windows: [ + { 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[1]); + }); + + it('should resolve ties using top-most/right-most tie breakers', () => { + const layout = new WorkspaceLayout({}, controller); + 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(), + 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); + + 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); + }); }); 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.*'], }, });