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/docs/architecture.md b/docs/architecture.md index a760552..9a172b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,6 +14,7 @@ Tracks monitor topology. Detects hotplug events. Identifies stable monitor IDs. Manages window evacuation during monitor removal. +Provides directional monitor lookup via `getMonitorInDirection()` for cross-monitor transitions. ## SignalListener (`lib/signals.js`) Binds GNOME Shell signals. @@ -40,9 +41,11 @@ Binds single-shot `size-changed` signals to detect external resizing. ## WorkspaceManager & WorkspaceLayout (`lib/workspace.js`) `WorkspaceManager` tracks multiple layouts across GNOME workspaces. +`WorkspaceManager` provides batch monitor operations (close, switch, port to workspace). `WorkspaceLayout` tracks windows per workspace and monitor. Calculates window slots based on insertion order. Provides window displacement and swapping logic. +Handles cross-monitor window transitions via configurable swap or escalate behavior. ## StateTracker (`lib/state.js`) Maintains stable ordered list of windows. @@ -53,10 +56,12 @@ Swaps window positions. Tracks window drag-and-drop operations. Renders visual swap indicators. Triggers geometric swapping based on pointer intersections. +Handles cross-monitor drag transitions with visual previews and deferred retiles. ## SettingsManager (`lib/settings.js`) Loads configuration preferences. Parses layout JSON into valid escalator transitions. +Exposes monitor transition behavior configuration. ## Logger (`lib/logger.js`) Provides debug and trace logging. @@ -65,6 +70,7 @@ Configurable output verbosity. ## Escalator (`lib/layout.js`) Generates tile geometries. Provides geometric estates based on current window count. +Provides edge-adjacent slot lookup via `getEdgingSlot()` for directional transitions. ## Execution Flow Signal triggers event. @@ -73,3 +79,9 @@ Controller updates WorkspaceLayout state. Controller schedules deferred retile. Retile queries Escalator for layouts. Retile invokes WindowWrapper to apply geometries. + +## Cross-Monitor Flow +Keyboard/drag triggers direction detection. +Controller/DragManager delegates to WorkspaceLayout. +WorkspaceLayout escalates or swaps window between monitor trackers. +Controller schedules retile on both monitors. diff --git a/docs/vision.md b/docs/vision.md index 9b6a7e1..c39a2bf 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -21,7 +21,7 @@ Layout transitions are predictable, minimal, and fully customizable. The system - **Error Shielding**: Every Mutter API interaction is wrapped in defensive checks and error handling to prevent Shell crashes. ### Scaling & Isolation -- **Multi-Monitor Support**: Each monitor maintains an independent tiling state and respects its own work area/resolution. +- **Multi-Monitor Support**: Each monitor maintains an independent tiling state and respects its own work area/resolution. Monitors support cross-monitor window transitions via keyboard and drag, with configurable swap/escalate behavior. - **Workspace Isolation**: Tiling is scoped per GNOME workspace. ## Future Roadmap diff --git a/lib/controller.js b/lib/controller.js index 8ce7d5e..748192c 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -10,6 +10,7 @@ import { Logger } from './logger.js'; import { MonitorManager } from './monitor.js'; import { DragManager } from './drag.js'; import { TILABLE_WINDOW_TYPES } from './signals.js'; +import { getEnteringEdge } from './utils/geometry.js'; /** * TilingController: The central orchestration layer. @@ -88,6 +89,11 @@ export class TilingController { wrapper.bindSizeChanged(); try { + if (this.dragManager && this.dragManager.isWindowInDragPreview(window)) { + Logger.debug(`tilingRequest: Ignored for window in active drag preview.`); + return; + } + const context = this._resolveTilingContext(window, wrapper); if (!context) { Logger.debug(`tilingRequest: Aborted. No context resolved.`); @@ -103,18 +109,52 @@ export class TilingController { return; } - const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); - const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); - this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); - this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); - - Logger.debug(`tilingRequest: State applied. Scheduling retile.`); - this._scheduleRetile(workspace, monitorId, monitorIndex); + const layout = this.workspaceManager.getLayout(workspace); + const isMonitorChange = wrapper.workspace && wrapper.workspace === workspace && wrapper.monitorId && wrapper.monitorId !== monitorId; + + if (isMonitorChange) { + 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 sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); + const targetRect = global.display.get_monitor_geometry(monitorIndex); + + const enteringEdge = getEnteringEdge(sourceRect, targetRect); + + const result = layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + + this.updateWindowWrapperMonitor(window, monitorId, monitorIndex); + + if (result && result.swappedWindow) { + this.updateWindowWrapperMonitor(result.swappedWindow, sourceMonitorId, sourceMonitorIndex); + if (result.swappedWindow.move_to_monitor) result.swappedWindow.move_to_monitor(sourceMonitorIndex); + } + + Logger.debug(`tilingRequest: Monitor transition handled. Scheduling retile.`); + this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } + + _handleNormalTilingRequest(window, wrapper, workspace, monitorId, monitorIndex, isRestoring, preferredSlot) { + const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); + const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); + this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); + this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); + + Logger.debug(`tilingRequest: State applied. Scheduling retile.`); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } + _ensureWrapper(window) { const type = window.get_window_type ? window.get_window_type() : Meta.WindowType.NORMAL; const skipTaskbar = window.is_skip_taskbar ? window.is_skip_taskbar() : false; @@ -363,7 +403,7 @@ export class TilingController { } closeMonitorWindows(monitorIndex, includeMinimized) { - this.monitorManager.closeMonitorWindows(monitorIndex, includeMinimized); + this.workspaceManager.closeMonitorWindows(monitorIndex, includeMinimized); } closeWorkspaceWindows(workspace) { @@ -371,11 +411,11 @@ export class TilingController { } switchMonitors(activeMonitorIndex) { - this.monitorManager.switchMonitors(activeMonitorIndex); + this.workspaceManager.switchMonitors(activeMonitorIndex); } portMonitorToWorkspace(monitorIndex, direction) { - this.monitorManager.portMonitorToWorkspace(monitorIndex, direction); + this.workspaceManager.portMonitorToWorkspace(monitorIndex, direction); } unminimizeWorkspace(workspace) { diff --git a/lib/drag.js b/lib/drag.js index ca57b45..359708d 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,23 @@ 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 } + } + + 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 +35,23 @@ export class DragManager { const workspace = wrapper.workspace; const layout = this.controller.workspaceManager.getLayout(workspace); - const tracker = layout._getTracker(wrapper.monitorId); - const originalSlot = tracker.getSlot(window); + const windowCount = layout.getWindowCount(wrapper.monitorId); + const originalSlot = layout.getWindowSlot(wrapper.monitorId, window); if (originalSlot === undefined) return; - const matrix = layout.escalator.getLayoutForCount(tracker.size); - 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 +68,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,30 +84,72 @@ 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 monitorRect = workspace.get_work_area_for_monitor(monitorIndex); - if (hoveredSlot !== -1 && hoveredSlot !== originalSlot) { - const matrix = layout.escalator.getLayoutForCount(tracker.size); - if (!matrix || hoveredSlot >= matrix.size || originalSlot >= matrix.size) return; - const targetRect = matrix.getEstate(hoveredSlot).toAbsolute(monitorRect, gaps); + 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 matrixCount = (monitorId === wrapper.monitorId || 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); } } @@ -95,51 +157,214 @@ 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; - const matrix = layout.escalator.getLayoutForCount(tracker.size); + this._activeDrag.lastHoveredSlot = hoveredSlot; + this._activeDrag.lastHoveredMonitorId = sourceMonId; + + this._restoreWindowGeometry(monitorId, layout, matrix, hoveredSlot, originalSlot, monitorRect, gaps); + } + + _applyCrossMonitorVisualSwap(wrapper, targetWindowCount, layout, monitorId, hoveredSlot, monitorRect, gaps) { + if (!this._activeDrag) return; + if (this._activeDrag.lastHoveredMonitorId === monitorId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; // Revert previous hover - 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); + const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); + + 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 sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); + const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); + + 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 lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); + 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 sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); + 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) { - if (!matrix || targetEstateSlot >= matrix.size) return; - const win = tracker.windows.find(w => tracker.getSlot(w) === slotToFind); + _restoreWindowGeometry(monitorId, layout, matrix, slotToFind, targetEstateSlot, monitorRect, gaps) { + const win = layout.getWindowsForMonitor(monitorId).find(w => layout.getWindowSlot(monitorId, w) === slotToFind); if (!win) return; const wrap = this.controller._windowWrappers.get(win); if (wrap) { - const targetRect = matrix.getEstate(targetEstateSlot).toAbsolute(monitorRect, gaps); - wrap.applyGeometry(targetRect); + const estate = matrix.getEstate(targetEstateSlot); + if (estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } } } endDragTracking(window) { if (!this._activeDrag || this._activeDrag.window !== window) return; - window.disconnect(this._activeDrag.signalId); - if (this._activeDrag.indicator) { - this._activeDrag.indicator.destroy(); + const activeDrag = this._activeDrag; + const origRect = activeDrag.origRect; + const lastHoveredSlot = activeDrag.lastHoveredSlot; + const lastHoveredMonitorId = activeDrag.lastHoveredMonitorId; + + const wrapper = this.controller._windowWrappers.get(window); + if (!wrapper || !wrapper.workspace || !wrapper.monitorId) { + window.disconnect(activeDrag.signalId); + if (activeDrag.indicator) activeDrag.indicator.destroy(); + this._activeDrag = null; + return; + } + + const workspace = wrapper.workspace; + if (!workspace.get_work_area_for_monitor) { + window.disconnect(activeDrag.signalId); + if (activeDrag.indicator) activeDrag.indicator.destroy(); + this._activeDrag = null; + return; + } + + const monitorRect = workspace.get_work_area_for_monitor(wrapper.monitorIndex); + const gaps = this.controller.settings ? this.controller.settings.getGaps() : { inner: 6, outer: 4 }; + const layout = this.controller.workspaceManager.getLayout(workspace); + + // Revert temporary visual swaps before performing final tracking and retile + this._revertVisualSwap(layout, gaps, false); + + window.disconnect(activeDrag.signalId); + if (activeDrag.indicator) { + activeDrag.indicator.destroy(); } - const origRect = this._activeDrag.origRect; this._activeDrag = null; if (this._deferredRetiles && this._deferredRetiles.length > 0) { @@ -147,18 +372,73 @@ 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 { + this._commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect); + } + } + } + + _commitCrossMonitorTransfer(window, wrapper, layout, targetMonitorId, targetSlot, sourceSlot, targetMonitorIndexOverride = -1) { + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; const workspace = wrapper.workspace; - 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); - - const [x, y] = global.get_pointer(); + 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); + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + if (sourceMonitorIndex !== -1 && targetWindow.move_to_monitor) targetWindow.move_to_monitor(sourceMonitorIndex); + return GLib.SOURCE_REMOVE; + }); + + } else { + layout.untrackWindow(window, sourceMonitorId); + layout.trackWindow(window, targetMonitorId, targetSlot !== -1 ? targetSlot : undefined); + + this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + return GLib.SOURCE_REMOVE; + }); + } + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); + } + + _commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect) { const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; diff --git a/lib/keybindings.js b/lib/keybindings.js index f66c802..d49889a 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -56,7 +56,7 @@ export class KeybindingManager { const utilities = { 'shortcut-close-monitor': (c) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), 'shortcut-close-workspace': (c) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), - 'shortcut-switch-monitor': (c) => c.switchMonitors(global.display.get_current_monitor()), + 'shortcut-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()) @@ -65,9 +65,9 @@ export class KeybindingManager { for (const [key, action] of Object.entries(utilities)) { defs.push({ defaultKey: key, - action: (c) => { + action: (c, win) => { Logger.debug(`Action triggered: ${key}`); - action(c); + action(c, win); }, conflict: null }); 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 65a717f..8448473 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; }); @@ -268,4 +270,28 @@ export class MonitorManager { this.controller.hydrate(activeWorkspace); this.controller.hydrate(targetWorkspace); } + + getMonitorInDirection(currentMonitorIndex, direction) { + try { + const manager = global.backend.get_monitor_manager(); + const logicalMonitors = manager.get_logical_monitors(); + + const 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'); + } + return index; + } catch (e) { + Logger.error(`Failed to get monitor in direction ${direction}`, e); + return -1; + } + } } diff --git a/lib/settings.js b/lib/settings.js index a29ab4a..2787d52 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -12,6 +12,7 @@ export class SettingsManager { this._gaps = { inner: 6, outer: 4 }; this._customLayouts = ''; + this._monitorTransitionBehavior = 'escalate'; if (this.settings) { this._load(); @@ -31,6 +32,10 @@ export class SettingsManager { this._load(); if (this.onSettingsChanged) this.onSettingsChanged(); })); + this._changedIds.push(this.settings.connect('changed::monitor-transition-behavior', () => { + this._load(); + if (this.onSettingsChanged) this.onSettingsChanged(); + })); const kbKeys = [ 'keybindings-mode', 'move-window-left', 'move-window-right', 'move-window-up', 'move-window-down', @@ -63,6 +68,7 @@ export class SettingsManager { this._gaps.outer = 0; } this._customLayouts = this.settings.get_string('custom-layouts'); + this._monitorTransitionBehavior = this.settings.get_string('monitor-transition-behavior') || 'escalate'; } getGaps() { @@ -73,6 +79,10 @@ export class SettingsManager { return this._customLayouts; } + getMonitorTransitionBehavior() { + return this._monitorTransitionBehavior; + } + destroy() { if (this.settings) { this._changedIds.forEach(id => this.settings.disconnect(id)); diff --git a/lib/state.js b/lib/state.js index cdb924b..680a5d7 100644 --- a/lib/state.js +++ b/lib/state.js @@ -56,4 +56,13 @@ export class StateTracker { this._windowToSlot.clear(); this._originalGeometries.clear(); } + + swapWith(otherTracker) { + const tempWindowToSlot = this._windowToSlot; + const tempOriginalGeometries = this._originalGeometries; + this._windowToSlot = otherTracker._windowToSlot; + this._originalGeometries = otherTracker._originalGeometries; + otherTracker._windowToSlot = tempWindowToSlot; + otherTracker._originalGeometries = tempOriginalGeometries; + } } diff --git a/lib/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 a2c5791..ee6883c 100644 --- a/lib/window.js +++ b/lib/window.js @@ -35,9 +35,16 @@ export class WindowWrapper { let m = this.window.get_monitor ? this.window.get_monitor() : -1; if (this._expectedMonitorIndex !== undefined) { if (m !== this._expectedMonitorIndex) { - m = this._expectedMonitorIndex; + if (!this._monitorWaitCycles) this._monitorWaitCycles = 0; + if (++this._monitorWaitCycles > 15) { + delete this._expectedMonitorIndex; + this._monitorWaitCycles = 0; + } else { + m = this._expectedMonitorIndex; + } } else { delete this._expectedMonitorIndex; + this._monitorWaitCycles = 0; } } return m; @@ -48,9 +55,16 @@ export class WindowWrapper { let w = this.window.get_workspace ? this.window.get_workspace() : null; if (this._expectedWorkspace !== undefined) { if (w !== this._expectedWorkspace) { - w = this._expectedWorkspace; + if (!this._workspaceWaitCycles) this._workspaceWaitCycles = 0; + if (++this._workspaceWaitCycles > 15) { + delete this._expectedWorkspace; + this._workspaceWaitCycles = 0; + } else { + w = this._expectedWorkspace; + } } else { delete this._expectedWorkspace; + this._workspaceWaitCycles = 0; } } return w; @@ -66,6 +80,9 @@ export class WindowWrapper { if (!this.signals.has('notify::minimized')) { this.signals.set('notify::minimized', this.window.connect('notify::minimized', () => this.controller.tilingRequest(this.window))); } + if (!this.signals.has('notify::monitor')) { + this.signals.set('notify::monitor', this.window.connect('notify::monitor', () => this.controller.tilingRequest(this.window))); + } if (!this.signals.has('notify::maximized-horizontally')) { this.signals.set('notify::maximized-horizontally', this.window.connect('notify::maximized-horizontally', () => { if (this.window.maximized_horizontally || this.window.maximized_vertically) { diff --git a/lib/workspace.js b/lib/workspace.js index d0db0a2..d78a635 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,79 @@ 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; + } + + 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'; - return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; + 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 +197,69 @@ 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); + + // 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 +272,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 +306,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 +339,7 @@ export class WorkspaceManager { getLayout(workspace) { if (!this.layouts.has(workspace)) { - this.layouts.set(workspace, new WorkspaceLayout(workspace, this.controller.escalator)); + this.layouts.set(workspace, new WorkspaceLayout(workspace, this.controller)); } return this.layouts.get(workspace); } @@ -254,4 +371,99 @@ 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 + const windows = workspace.list_windows(); + windows.forEach(w => { + const m = w.get_monitor(); + if (m === activeMonitorIndex) { + this.controller.updateWindowWrapperMonitor(w, targetMonitorId, targetMonitorIndex); + } else if (m === targetMonitorIndex) { + this.controller.updateWindowWrapperMonitor(w, activeMonitorId, activeMonitorIndex); + } + }); + + // 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..063961d 100644 --- a/prefs.js +++ b/prefs.js @@ -131,6 +131,26 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { page.add(gapsGroup); + // --- Monitor Transition Group --- + const transitionGroup = new Adw.PreferencesGroup({ title: 'Monitor Transition' }); + const transitionRow = new Adw.SwitchRow({ + title: 'Swap Windows', + subtitle: 'Swap windows across monitors instead of escalating/de-escalating' + }); + transitionRow.active = settings.get_string('monitor-transition-behavior') === 'swap'; + transitionRow.connect('notify::active', () => { + settings.set_string('monitor-transition-behavior', transitionRow.active ? 'swap' : 'escalate'); + }); + settings.connect('changed::monitor-transition-behavior', () => { + const active = settings.get_string('monitor-transition-behavior') === 'swap'; + if (transitionRow.active !== active) { + transitionRow.active = active; + } + }); + transitionGroup.add(transitionRow); + page.add(transitionGroup); + + // --- Core Keybindings Group --- const keysGroup = new Adw.PreferencesGroup({ title: 'Keybindings' }); const modeRow = new Adw.ComboRow({ diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index a700a82..21efeaf 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 3db5d1e..6f67ffe 100644 --- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml +++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml @@ -6,6 +6,12 @@ Enable Gaps Toggle gaps on or off globally. + + 'escalate' + Monitor Transition Behavior + Behavior when window crosses monitor boundary ('escalate' or 'swap'). + + 'default' Keybindings Mode diff --git a/tests/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..7ecafb2 100644 --- a/tests/controller.test.js +++ b/tests/controller.test.js @@ -183,7 +183,7 @@ describe('TilingController', () => { // Window should have been minimized by our controller because monitor-1 is gone expect(win.minimize).toHaveBeenCalled(); - expect(controller._batchMode).toBe(true); + expect(controller._batchMode).toBe(false); // Window tracked for restoration (keyed by window reference) expect(controller.monitorManager._evacuatedWindows.has(win)).toBe(true); diff --git a/tests/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/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 89a5887..2464f3c 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -76,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() }; @@ -131,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/window.test.js b/tests/window.test.js index 5a7d08f..88c52d2 100644 --- a/tests/window.test.js +++ b/tests/window.test.js @@ -48,7 +48,7 @@ describe('WindowWrapper', () => { const wrapper = new WindowWrapper(mockWindow, mockController); wrapper.bindSignals(); wrapper.bindSignals(); // should not connect again - expect(mockWindow.connect).toHaveBeenCalledTimes(6); + expect(mockWindow.connect).toHaveBeenCalledTimes(7); }); it('should bind size changed and respect _isResizing', () => { @@ -80,7 +80,7 @@ describe('WindowWrapper', () => { wrapper.bindSizeChanged(); wrapper.destroy(); - expect(mockWindow.disconnect).toHaveBeenCalledTimes(7); + expect(mockWindow.disconnect).toHaveBeenCalledTimes(8); }); it('should apply geometry skipping unmanaged', () => { 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); + }); });