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);
+ });
});