From af2adaaf13ab456fb639c46d87f5d056fcdf1d13 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Thu, 11 Jun 2026 12:31:18 +0200 Subject: [PATCH 01/10] premature: changes, should amend this commit once working --- lib/controller.js | 6 +- lib/drag.js | 249 +++++++++++++++--- lib/monitor.js | 73 ++++++ lib/workspace.js | 214 ++++++++++++++- multi_monitor_refactoring_plan.md | 423 ++++++++++++++++++++++++++++++ tests/drag.test.js | 217 +++++++++++++++ tests/monitor.test.js | 122 ++++++--- tests/setup.js | 1 + tests/workspace.test.js | 182 +++++++++++++ 9 files changed, 1391 insertions(+), 96 deletions(-) create mode 100644 multi_monitor_refactoring_plan.md create mode 100644 tests/drag.test.js diff --git a/lib/controller.js b/lib/controller.js index 8ce7d5e..1fbfd92 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -363,7 +363,7 @@ export class TilingController { } closeMonitorWindows(monitorIndex, includeMinimized) { - this.monitorManager.closeMonitorWindows(monitorIndex, includeMinimized); + this.workspaceManager.closeMonitorWindows(monitorIndex, includeMinimized); } closeWorkspaceWindows(workspace) { @@ -371,11 +371,11 @@ export class TilingController { } switchMonitors(activeMonitorIndex) { - this.monitorManager.switchMonitors(activeMonitorIndex); + this.workspaceManager.switchMonitors(activeMonitorIndex); } portMonitorToWorkspace(monitorIndex, direction) { - this.monitorManager.portMonitorToWorkspace(monitorIndex, direction); + this.workspaceManager.portMonitorToWorkspace(monitorIndex, direction); } unminimizeWorkspace(workspace) { diff --git a/lib/drag.js b/lib/drag.js index ca57b45..5416092 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -7,7 +7,7 @@ import St from 'gi://St'; export class DragManager { constructor(controller) { this.controller = controller; - this._activeDrag = null; // { window, originalSlot, indicator, signalId, lastHoveredSlot } + this._activeDrag = null; // { window, originalSlot, indicator, signalId, lastHoveredSlot, lastHoveredMonitorId, origRect } } startDragTracking(window) { @@ -31,7 +31,7 @@ export class DragManager { }); const origRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; - this._activeDrag = { window, originalSlot, indicator, signalId, lastHoveredSlot: -1, origRect }; + this._activeDrag = { window, originalSlot, indicator, signalId, lastHoveredSlot: -1, lastHoveredMonitorId: null, origRect }; } /** @@ -40,7 +40,7 @@ export class DragManager { _createIndicator() { const indicator = new St.Widget({ style: ` - border: 2px solid -st-accent-color; + border: 2px solid var(--accent-color, #3584e4); border-radius: 8px; `, visible: false @@ -48,10 +48,9 @@ export class DragManager { const bg = new St.Widget({ style: ` - background-color: -st-accent-color; + background-color: var(--accent-bg-color, rgba(53, 132, 228, 0.3)); border-radius: 6px; - `, - opacity: 76 + ` }); indicator.add_child(bg); indicator._bg = bg; @@ -68,23 +67,58 @@ export class DragManager { const workspace = wrapper.workspace; if (!workspace.get_work_area_for_monitor) return; - const monitorRect = workspace.get_work_area_for_monitor(wrapper.monitorIndex); const gaps = this.controller.settings ? this.controller.settings.getGaps() : { inner: 6, outer: 4 }; const [x, y] = global.get_pointer(); - const hoveredSlot = layout.getSlotAtPointer(wrapper.monitorId, x, y, monitorRect, gaps); + let monitorIndex = global.display.get_current_monitor(); + if (monitorIndex === -1) { + monitorIndex = wrapper.monitorIndex; + } - if (hoveredSlot !== -1 && hoveredSlot !== originalSlot) { - const matrix = layout.escalator.getLayoutForCount(tracker.size); - if (!matrix || hoveredSlot >= matrix.size || originalSlot >= matrix.size) return; - const targetRect = matrix.getEstate(hoveredSlot).toAbsolute(monitorRect, gaps); - + const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); + const targetTracker = layout._getTracker(monitorId); + const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); + + let hoveredSlot = -1; + let targetRect = null; + + if (targetTracker.size === 0) { + hoveredSlot = 0; + targetRect = { + x: monitorRect.x + gaps.outer, + y: monitorRect.y + gaps.outer, + width: monitorRect.width - (gaps.outer * 2), + height: monitorRect.height - (gaps.outer * 2) + }; + } else { + hoveredSlot = layout.getSlotAtPointer(monitorId, x, y, monitorRect, gaps); + if (hoveredSlot !== -1) { + const matrixCount = (monitorId === wrapper.monitorId) ? targetTracker.size : (targetTracker.size + 1); + const matrix = layout.escalator.getLayoutForCount(matrixCount); + if (matrix) { + const estate = matrix.getEstate(hoveredSlot); + if (estate) { + targetRect = estate.toAbsolute(monitorRect, gaps); + } else { + hoveredSlot = -1; + } + } else { + hoveredSlot = -1; + } + } + } + + if (hoveredSlot !== -1 && targetRect) { indicator.set_position(targetRect.x, targetRect.y); indicator.set_size(targetRect.width, targetRect.height); if (indicator._bg) indicator._bg.set_size(targetRect.width, targetRect.height); indicator.show(); - this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); + if (monitorId !== wrapper.monitorId) { + this._applyCrossMonitorVisualSwap(wrapper, targetTracker, layout, monitorId, hoveredSlot, monitorRect, gaps); + } else { + this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); + } } else { indicator.hide(); this._revertVisualSwap(tracker, layout, monitorRect, gaps); @@ -96,18 +130,53 @@ export class DragManager { * previously hovered window and shifting the newly hovered window. */ _applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps) { - if (!this._activeDrag || this._activeDrag.lastHoveredSlot === hoveredSlot) return; + if (!this._activeDrag) return; + const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); + const sourceMonId = wrapper ? wrapper.monitorId : null; + if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; + + // Revert previous hover + this._revertVisualSwap(tracker, layout, monitorRect, gaps); const matrix = layout.escalator.getLayoutForCount(tracker.size); + if (matrix) { + // Apply new hover (move hovered window to dragged window's original slot) + this._restoreWindowGeometry(tracker, matrix, hoveredSlot, originalSlot, monitorRect, gaps); + } + + this._activeDrag.lastHoveredSlot = hoveredSlot; + this._activeDrag.lastHoveredMonitorId = sourceMonId; + } + + _applyCrossMonitorVisualSwap(wrapper, targetTracker, layout, monitorId, hoveredSlot, monitorRect, gaps) { + if (!this._activeDrag) return; + if (this._activeDrag.lastHoveredMonitorId === monitorId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; // Revert previous hover - if (this._activeDrag.lastHoveredSlot !== -1) { - this._restoreWindowGeometry(tracker, matrix, this._activeDrag.lastHoveredSlot, this._activeDrag.lastHoveredSlot, monitorRect, gaps); + const sourceTracker = layout._getTracker(wrapper.monitorId); + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); + const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); + this._revertVisualSwap(sourceTracker, layout, sourceMonitorRect, gaps); + + // Apply new cross-monitor visual swap preview using size N+1 matrix + const matrix = layout.escalator.getLayoutForCount(targetTracker.size + 1); + if (matrix) { + for (const win of targetTracker.windows) { + const slot = targetTracker.getSlot(win); + if (slot !== undefined) { + const targetEstateSlot = (slot >= hoveredSlot) ? (slot + 1) : slot; + const wrap = this.controller._windowWrappers.get(win); + const estate = matrix.getEstate(targetEstateSlot); + if (wrap && estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } + } + } } - // Apply new hover (move hovered window to dragged window's original slot) - this._restoreWindowGeometry(tracker, matrix, hoveredSlot, originalSlot, monitorRect, gaps); this._activeDrag.lastHoveredSlot = hoveredSlot; + this._activeDrag.lastHoveredMonitorId = monitorId; } /** @@ -115,9 +184,65 @@ export class DragManager { */ _revertVisualSwap(tracker, layout, monitorRect, gaps) { if (!this._activeDrag || this._activeDrag.lastHoveredSlot === -1) return; - const matrix = layout.escalator.getLayoutForCount(tracker.size); - this._restoreWindowGeometry(tracker, matrix, this._activeDrag.lastHoveredSlot, this._activeDrag.lastHoveredSlot, monitorRect, gaps); + + const lastMonId = this._activeDrag.lastHoveredMonitorId; + const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); + const sourceMonId = wrapper ? wrapper.monitorId : null; + + if (lastMonId && lastMonId !== sourceMonId) { + const lastTracker = layout._getTracker(lastMonId); + const lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); + if (lastMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); + this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); + } + } else { + this._restoreTrackerGeometries(tracker, layout, monitorRect, gaps); + } + this._activeDrag.lastHoveredSlot = -1; + this._activeDrag.lastHoveredMonitorId = null; + } + + _revertVisualSwapForEnd(tracker, layout, monitorRect, gaps) { + if (!this._activeDrag || this._activeDrag.lastHoveredSlot === -1) return; + + const lastMonId = this._activeDrag.lastHoveredMonitorId; + const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); + const sourceMonId = wrapper ? wrapper.monitorId : null; + + if (lastMonId && lastMonId !== sourceMonId) { + const lastTracker = layout._getTracker(lastMonId); + const lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); + if (lastMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); + this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); + } + } else { + this._restoreTrackerGeometries(tracker, layout, monitorRect, gaps); + } + } + + _restoreTrackerGeometries(tracker, layout, monitorRect, gaps) { + const matrix = layout.escalator.getLayoutForCount(tracker.size); + if (!matrix) return; + const draggedWindow = this._activeDrag ? this._activeDrag.window : null; + for (const win of tracker.windows) { + if (win === draggedWindow) continue; + const slot = tracker.getSlot(win); + if (slot !== undefined) { + const wrap = this.controller._windowWrappers.get(win); + if (wrap) { + const estate = matrix.getEstate(slot); + if (estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } + } + } + } } _restoreWindowGeometry(tracker, matrix, slotToFind, targetEstateSlot, monitorRect, gaps) { @@ -126,20 +251,51 @@ export class DragManager { if (!win) return; const wrap = this.controller._windowWrappers.get(win); if (wrap) { - const targetRect = matrix.getEstate(targetEstateSlot).toAbsolute(monitorRect, gaps); - wrap.applyGeometry(targetRect); + const estate = matrix.getEstate(targetEstateSlot); + if (estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } } } endDragTracking(window) { if (!this._activeDrag || this._activeDrag.window !== window) return; - window.disconnect(this._activeDrag.signalId); - if (this._activeDrag.indicator) { - this._activeDrag.indicator.destroy(); + const activeDrag = this._activeDrag; + const origRect = activeDrag.origRect; + const lastHoveredSlot = activeDrag.lastHoveredSlot; + const lastHoveredMonitorId = activeDrag.lastHoveredMonitorId; + + const wrapper = this.controller._windowWrappers.get(window); + if (!wrapper || !wrapper.workspace || !wrapper.monitorId) { + window.disconnect(activeDrag.signalId); + if (activeDrag.indicator) activeDrag.indicator.destroy(); + this._activeDrag = null; + return; + } + + const workspace = wrapper.workspace; + if (!workspace.get_work_area_for_monitor) { + window.disconnect(activeDrag.signalId); + if (activeDrag.indicator) activeDrag.indicator.destroy(); + this._activeDrag = null; + return; + } + + const monitorRect = workspace.get_work_area_for_monitor(wrapper.monitorIndex); + const gaps = this.controller.settings ? this.controller.settings.getGaps() : { inner: 6, outer: 4 }; + const layout = this.controller.workspaceManager.getLayout(workspace); + + // Revert temporary visual swaps before performing final tracking and retile + const sourceTracker = layout._getTracker(wrapper.monitorId); + this._revertVisualSwapForEnd(sourceTracker, layout, monitorRect, gaps); + + window.disconnect(activeDrag.signalId); + if (activeDrag.indicator) { + activeDrag.indicator.destroy(); } - const origRect = this._activeDrag.origRect; this._activeDrag = null; if (this._deferredRetiles && this._deferredRetiles.length > 0) { @@ -147,25 +303,32 @@ export class DragManager { this._deferredRetiles = []; } - const wrapper = this.controller._windowWrappers.get(window); - if (!wrapper || !wrapper.workspace || !wrapper.monitorId) return; + if (lastHoveredMonitorId && lastHoveredMonitorId !== wrapper.monitorId) { + const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastHoveredMonitorId); + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; - const workspace = wrapper.workspace; - if (!workspace.get_work_area_for_monitor) return; - - const monitorRect = workspace.get_work_area_for_monitor(wrapper.monitorIndex); - const gaps = this.controller.settings ? this.controller.settings.getGaps() : { inner: 6, outer: 4 }; + layout.untrackWindow(window, sourceMonitorId); + if (targetMonitorIndex !== -1) { + window.move_to_monitor(targetMonitorIndex); + } + layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); - const layout = this.controller.workspaceManager.getLayout(workspace); - - const [x, y] = global.get_pointer(); - const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); - - const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; - const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + } else { + const [x, y] = global.get_pointer(); + const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); + + const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; + const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; - if (swapped || rectChanged) { - this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + if (swapped || rectChanged) { + this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + } } } } diff --git a/lib/monitor.js b/lib/monitor.js index 65a717f..b70bf34 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -268,4 +268,77 @@ export class MonitorManager { this.controller.hydrate(activeWorkspace); this.controller.hydrate(targetWorkspace); } + + getMonitorInDirection(currentMonitorIndex, direction) { + try { + const manager = global.backend.get_monitor_manager(); + const logicalMonitors = manager.get_logical_monitors(); + const sourceMonitor = logicalMonitors[currentMonitorIndex]; + if (!sourceMonitor) { + Logger.info(`getMonitorInDirection: invalid sourceMonitor for index ${currentMonitorIndex}`); + return -1; + } + + const sRect = global.display.get_monitor_geometry(currentMonitorIndex); + Logger.info(`[DEBUG] getMonitorInDirection: source monitor ${currentMonitorIndex} rect: ${sRect.x}, ${sRect.y}, ${sRect.width}, ${sRect.height}`); + + let candidates = []; + const eps = 1; + + for (let i = 0; i < logicalMonitors.length; i++) { + if (i === currentMonitorIndex) continue; + const cRect = global.display.get_monitor_geometry(i); + + let inDirection = false; + let dist = Infinity; + + if (direction === 'left') { + inDirection = cRect.x + cRect.width <= sRect.x + eps; + dist = sRect.x - (cRect.x + cRect.width); + } else if (direction === 'right') { + inDirection = cRect.x >= sRect.x + sRect.width - eps; + dist = cRect.x - (sRect.x + sRect.width); + } else if (direction === 'up') { + inDirection = cRect.y + cRect.height <= sRect.y + eps; + dist = sRect.y - (cRect.y + cRect.height); + } else if (direction === 'down') { + inDirection = cRect.y >= sRect.y + sRect.height - eps; + dist = cRect.y - (sRect.y + sRect.height); + } + + Logger.info(`getMonitorInDirection: checking monitor ${i} (${cRect.x},${cRect.y},${cRect.width},${cRect.height}) for direction ${direction}: inDirection=${inDirection}`); + + if (inDirection) { + let overlap = 0; + if (direction === 'left' || direction === 'right') { + overlap = Math.max(0, Math.min(cRect.y + cRect.height, sRect.y + sRect.height) - Math.max(cRect.y, sRect.y)); + } else { + overlap = Math.max(0, Math.min(cRect.x + cRect.width, sRect.x + sRect.width) - Math.max(cRect.x, sRect.x)); + } + candidates.push({ index: i, dist, overlap, rect: cRect }); + } + } + + if (candidates.length === 0) { + Logger.info('getMonitorInDirection: no candidates found'); + return -1; + } + + candidates.sort((a, b) => { + if (Math.abs(a.dist - b.dist) > eps) { + return a.dist - b.dist; + } + if (Math.abs(a.overlap - b.overlap) > eps) { + return b.overlap - a.overlap; + } + return a.index - b.index; + }); + + Logger.info(`getMonitorInDirection: best candidate is ${candidates[0].index}`); + return candidates[0].index; + } catch (e) { + Logger.error(`Failed to get monitor in direction ${direction}`, e); + return -1; + } + } } diff --git a/lib/workspace.js b/lib/workspace.js index d0db0a2..324a060 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -1,12 +1,14 @@ import { StateTracker } from './state.js'; +import { Logger } from './logger.js'; /** * WorkspaceLayout: Manages internal state for a specific Meta.Workspace. */ export class WorkspaceLayout { - constructor(workspace, escalator) { + constructor(workspace, controller) { this.workspace = workspace; - this.escalator = escalator; + this.controller = controller; + this.escalator = (controller && controller.escalator) ? controller.escalator : controller; this.monitors = new Map(); } @@ -62,6 +64,44 @@ export class WorkspaceLayout { }).filter(op => op !== null); } + _findClosestBoundaryWindow(targetTracker, direction, sourceRect) { + if (!targetTracker || targetTracker.size === 0) return null; + let candidates = []; + for (const win of targetTracker.windows) { + if (!win) continue; + const targetRect = win.get_frame_rect ? win.get_frame_rect() : { x: 0, y: 0, width: 100, height: 100 }; + + let overlap = 0; + if (direction === 'left' || direction === 'right') { + overlap = Math.min(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height) - Math.max(sourceRect.y, targetRect.y); + } else if (direction === 'up' || direction === 'down') { + overlap = Math.min(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width) - Math.max(sourceRect.x, targetRect.x); + } + candidates.push({ win, rect: targetRect, overlap }); + } + + if (candidates.length === 0) return null; + + candidates.sort((a, b) => { + if (Math.abs(b.overlap - a.overlap) > 0.001) { + return b.overlap - a.overlap; + } + if (direction === 'left' || direction === 'right') { + if (a.rect.y !== b.rect.y) { + return a.rect.y - b.rect.y; + } + return b.rect.x - a.rect.x; + } else { + if (b.rect.x !== a.rect.x) { + return b.rect.x - a.rect.x; + } + return a.rect.y - b.rect.y; + } + }); + + return candidates[0].win; + } + _getTargetWindowInDirection(monitorId, window, direction) { const tracker = this._getTracker(monitorId); const slot = tracker.getSlot(window); @@ -75,9 +115,33 @@ export class WorkspaceLayout { if (!estate) return null; const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); - if (targetSlot === -1) return null; + if (targetSlot !== -1) { + return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; + } - return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; + let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; + Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { + currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); + } + + Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + + if (currentMonitorIndex === -1) return null; + + if (this.controller && this.controller.monitorManager) { + Logger.info(`[DEBUG] calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + Logger.info(`[DEBUG] getMonitorInDirection returned ${adjacentMonitorIndex}`); + Logger.info(`[DEBUG] _getTargetWindowInDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); + if (adjacentMonitorIndex !== -1) { + const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); + const targetTracker = this._getTracker(targetMonitorId); + const sourceRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 100, height: 100 }; + return this._findClosestBoundaryWindow(targetTracker, direction, sourceRect); + } + } + return null; } /** @@ -85,11 +149,67 @@ export class WorkspaceLayout { * Computes orthogonal overlap and distance to determine the best candidate. */ moveWindowDirection(monitorId, window, direction) { - const targetWindow = this._getTargetWindowInDirection(monitorId, window, direction); - if (targetWindow) { - const tracker = this._getTracker(monitorId); - tracker.swapWindows(window, targetWindow); - return true; + const tracker = this._getTracker(monitorId); + const slot = tracker.getSlot(window); + if (slot === undefined) return false; + + const windowCount = tracker.size; + const layout = this.escalator.getLayoutForCount(windowCount); + if (!layout) return false; + + const estate = layout.getEstate(slot); + if (!estate) return false; + + const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + if (targetSlot !== -1) { + const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); + if (targetWindow) { + tracker.swapWindows(window, targetWindow); + return true; + } + } + + let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; + Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { + currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); + } + + Logger.info(`[DEBUG] moveWindowDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + + if (currentMonitorIndex === -1) return false; + + if (this.controller && this.controller.monitorManager) { + Logger.info(`[DEBUG] calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + Logger.info(`[DEBUG] getMonitorInDirection returned ${adjacentMonitorIndex}`); + Logger.info(`[DEBUG] moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); + if (adjacentMonitorIndex !== -1) { + const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); + + tracker.untrack(window); + + const targetTracker = this._getTracker(targetMonitorId); + targetTracker.track(window, targetTracker.size); + + if (this.controller._windowWrappers) { + const wrapper = this.controller._windowWrappers.get(window); + if (wrapper) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = adjacentMonitorIndex; + } + } + + if (window.move_to_monitor) { + window.move_to_monitor(adjacentMonitorIndex); + } + + if (this.controller._scheduleRetile) { + this.controller._scheduleRetile(this.workspace, monitorId, currentMonitorIndex); + this.controller._scheduleRetile(this.workspace, targetMonitorId, adjacentMonitorIndex); + } + return true; + } } return false; } @@ -222,7 +342,7 @@ export class WorkspaceManager { getLayout(workspace) { if (!this.layouts.has(workspace)) { - this.layouts.set(workspace, new WorkspaceLayout(workspace, this.controller.escalator)); + this.layouts.set(workspace, new WorkspaceLayout(workspace, this.controller)); } return this.layouts.get(workspace); } @@ -254,4 +374,78 @@ export class WorkspaceManager { windows[0].activate(global.get_current_time()); } } + + closeMonitorWindows(monitorIndex, includeMinimized) { + const workspace = global.workspace_manager.get_active_workspace(); + if (!workspace) return; + this.controller.setBatchMode(true); + const windows = workspace.list_windows(); + windows.forEach(w => { + if (w.get_monitor() === monitorIndex && (!w.minimized || includeMinimized)) { + w.delete(global.get_current_time()); + } + }); + this.controller.setBatchMode(false); + this.controller.hydrate(workspace); + } + + switchMonitors(activeMonitorIndex) { + const workspace = global.workspace_manager.get_active_workspace(); + if (!workspace) return; + + const manager = global.backend.get_monitor_manager(); + const numMonitors = manager.get_logical_monitors().length; + if (numMonitors < 2) return; + + let targetMonitorIndex; + if (numMonitors === 2) { + targetMonitorIndex = activeMonitorIndex === 0 ? 1 : 0; + } else { + const primaryIndex = global.display.get_primary_monitor(); + if (activeMonitorIndex === primaryIndex) return; + targetMonitorIndex = primaryIndex; + } + + this.controller.setBatchMode(true); + const windows = workspace.list_windows(); + windows.forEach(w => { + const m = w.get_monitor(); + if (m === activeMonitorIndex) { + w.move_to_monitor(targetMonitorIndex); + } else if (m === targetMonitorIndex) { + w.move_to_monitor(activeMonitorIndex); + } + }); + this.controller.setBatchMode(false); + this.controller.hydrate(workspace); + } + + portMonitorToWorkspace(monitorIndex, direction) { + const activeWorkspaceIndex = global.workspace_manager.get_active_workspace_index(); + const numWorkspaces = global.workspace_manager.n_workspaces; + let targetIndex = activeWorkspaceIndex; + + if (direction === 'left' && activeWorkspaceIndex > 0) { + targetIndex--; + } else if (direction === 'right' && activeWorkspaceIndex < numWorkspaces - 1) { + targetIndex++; + } + + if (targetIndex === activeWorkspaceIndex) return; + + const targetWorkspace = global.workspace_manager.get_workspace_by_index(targetIndex); + const activeWorkspace = global.workspace_manager.get_active_workspace(); + + this.controller.setBatchMode(true); + const windows = activeWorkspace.list_windows(); + windows.forEach(w => { + if (w.get_monitor() === monitorIndex) { + w.change_workspace(targetWorkspace); + } + }); + this.controller.setBatchMode(false); + + this.controller.hydrate(activeWorkspace); + this.controller.hydrate(targetWorkspace); + } } diff --git a/multi_monitor_refactoring_plan.md b/multi_monitor_refactoring_plan.md new file mode 100644 index 0000000..f130473 --- /dev/null +++ b/multi_monitor_refactoring_plan.md @@ -0,0 +1,423 @@ +# Multi-Monitor Refactoring Plan + +## Architecture Analysis & Responsibility Shifts + +This section details updates to files and classes to support workspaces spanning multiple displays. + +| File Path | Target Class | Role & Proposed Architecture Updates | +|---|---|---| +| `lib/workspace.js` | `WorkspaceLayout` | Allocates slots per workspace and monitor. Handles cross-monitor actions (like switching monitors, closing monitor windows, and workspace porting) and integrates cross-monitor navigation fallback by querying adjacent monitor state trackers when spatial boundaries are reached. | +| `lib/drag.js` | `DragManager` | Tracks drag positions. Implements cross-monitor pointer-to-slot mapping, renders visual indicators on target monitors, and coordinates visual swaps across monitor boundaries. | +| `lib/keybindings.js` | `KeybindingManager` | Resolves and binds keyboard shortcuts. Delegates focus-switching and window-movement actions to the controller, supporting boundary traversal to adjacent displays. | +| `lib/monitor.js` | `MonitorManager` | Tracks display topologies. Listens to hardware changes via backend signals, and manages transient storage for windows evicted by monitor unplug events (leaving cross-monitor actions to Workspace). | +| `lib/controller.js` | `TilingController` | Orchestrates operations. Coordinates window state changes between workspace layouts, coordinates drag state initialization/termination, and dispatches batch updates to avoid layout thrashing. | + +### Responsibility Changes + +* `WorkspaceLayout` / `WorkspaceManager`: Tracks logical state mapped per monitor ID. Spatial search fallbacks to adjacent monitors when keyboard navigation hits display edges. Handles cross-monitor actions (switching monitors, closing monitor windows, workspace porting). +* `DragManager`: Evaluates pointer location globally. Renders indicators relative to target displays and manages preview transitions across screens. +* `MonitorManager`: Listens to topology updates, matching logical monitors to stable physical IDs. Manages transient storage/metadata for windows evicted by monitor unplug events (uses existing evacuation logic, does not destroy windows, preserves state). +* `TilingController`: Dispatches coordinates and sizes to the correct target workspace layout based on pointer position or active focus. + +--- + +## Core Multi-Monitor Scenarios + +### Dynamic Handling of Monitor Hotplug Events + +* Proposed GNOME Shell Signals: + * `monitors-changed`: Connected via `global.backend.get_monitor_manager()` to detect display hardware hotplug and configuration updates. + * `size-changed`: Connected on tracked windows or workspace boundaries to trigger retiling upon size changes or display resolution alterations. +* Evacuation Logic: + * Detects removed displays by comparing active stable monitor IDs against cached IDs. + * Use existing logic; do not destroy windows, preserve state. Specifically, minimize windows instead of deleting/destroying them, untrack them, and record their original monitor, workspace, and slot in `MonitorManager._evacuatedWindows` for later restoration. +* Restoration Logic: + * Restores minimized windows to their original slots when matching displays reconnect. + * Triggers workspace hydration to update tiling allocations on target displays. + +### Cross-Monitor Drag-and-Drop + +* Pointer Intersection Tracking: + * Resolves absolute coordinates from `global.get_pointer()` during window drag. + * Queries `global.display.get_monitor_index_for_rect` to identify the monitor containing the pointer. +* Visual Drop Feedback: + * Renders a `St.Widget` backdrop overlay within the resolved slot geometry on the target display. + * Applies visual preview layout updates on the target display by shifting target windows out of the hovered slot. + +### Keyboard Shortcuts for Cross-Monitor Focus and Movement + +* Focus Navigation Fallback: + * Triggers when intra-monitor focus search returns no window in the requested direction. + * Locates the adjacent monitor index and targets the corresponding slot tracker. + * Focuses the boundary window on the adjacent display. + * Goalslot fallback: Resolve via adjacent edge. If multiple windows intersect, pick the one with highest overlap. If equal overlap, pick the top/right one. +* Window Transference: + * Moves the active window to the adjacent monitor when moving past the monitor border. + * Registers the window with the target monitor's state tracker and triggers retiling on both screens. + * Shift cross-monitor actions (like moving windows across monitors, switching monitors, closing monitor windows, workspace porting) from Monitor to Workspace. + +--- + +## Mermaid Diagrams + +### Hotplug Event Handling Sequence + +```mermaid +sequenceDiagram + participant MM as global.backend.get_monitor_manager() + participant MonM as MonitorManager + participant TC as TilingController + participant WL as WorkspaceLayout + participant WW as WindowWrapper + + MM->>MonM: monitors-changed + activate MonM + MonM->>MonM: Detect monitor addition/removal + alt Monitor Removed + MonM->>WW: Evacuate window (minimize/store metadata) + MonM->>WL: Untrack window + else Monitor Added + MonM->>WW: Restore window (unminimize/update monitor index) + MonM->>WL: Track window on target monitor + end + MonM->>TC: hydrate() + deactivate MonM + activate TC + TC->>WL: getRetileOperations() + WL-->>TC: Return window rectangles + TC->>WW: applyGeometry() + deactivate TC +``` + +### Cross-Monitor Drag-and-Drop Sequence + +```mermaid +sequenceDiagram + actor User + participant Win as Meta.Window + participant DM as DragManager + participant TC as TilingController + participant WL as WorkspaceLayout + participant Ind as DragIndicator + + User->>Win: Starts dragging window + DM->>Win: Connect position-changed signal + loop Every position-changed event + Win->>DM: position-changed + activate DM + DM->>DM: Resolve pointer coordinates (global.get_pointer()) + DM->>DM: Determine active monitor under pointer + DM->>WL: getSlotAtPointer(monitorId, x, y) + WL-->>DM: Target slot index + alt Valid slot on target monitor + DM->>Ind: Position and size indicator to slot boundaries + DM->>Ind: show() + DM->>DM: Apply visual swap preview + else Out of bounds + DM->>Ind: hide() + DM->>DM: Revert visual swap preview + end + deactivate DM + end + User->>Win: Releases window + activate DM + DM->>Win: Disconnect position-changed signal + DM->>Ind: destroy() + DM->>WL: swapWindowByPointer() or trackWindow() on target monitor + DM->>TC: _scheduleRetile() for source and target monitors + deactivate DM +``` + +--- + +## Pseudo-code + +### Pointer-to-Slot Mapping and Indicator Rendering + +```javascript +// Located in lib/drag.js +_handlePositionChanged(wrapper, layout, tracker, originalSlot, indicator) { + const [pointerX, pointerY] = global.get_pointer(); + const gaps = this.controller.settings.getGaps(); + const workspace = wrapper.workspace; + + // Identify monitor matching pointer coordinates + const monitorIndex = global.display.get_monitor_index_for_rect({ + x: pointerX, + y: pointerY, + width: 1, + height: 1 + }); + + // Guard against out-of-bounds monitor index + if (monitorIndex === -1) { + indicator.hide(); + const fallbackMonitorIndex = global.display.get_current_monitor(); + const fallbackRect = workspace.get_work_area_for_monitor(fallbackMonitorIndex); + this._revertVisualSwap(tracker, layout, fallbackRect, gaps); + return; + } + + const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); + const targetLayout = this.controller.workspaceManager.getLayout(workspace); + const targetTracker = targetLayout._getTracker(monitorId); + const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); + + let targetRect = null; + let hoveredSlot = -1; + + // Handle empty monitor drop target explicitly + if (targetTracker.size === 0) { + hoveredSlot = 0; + targetRect = { + x: monitorRect.x + gaps.outer, + y: monitorRect.y + gaps.outer, + width: monitorRect.width - (gaps.outer * 2), + height: monitorRect.height - (gaps.outer * 2) + }; + } else { + hoveredSlot = targetLayout.getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps); + if (hoveredSlot !== -1) { + const matrix = targetLayout.escalator.getLayoutForCount(targetTracker.size); + const estate = matrix.getEstate(hoveredSlot); + if (estate) { + targetRect = estate.toAbsolute(monitorRect, gaps); + } else { + hoveredSlot = -1; + } + } + } + + if (hoveredSlot !== -1 && targetRect) { + // Update indicator layout coordinates and display + indicator.set_position(targetRect.x, targetRect.y); + indicator.set_size(targetRect.width, targetRect.height); + if (indicator._bg) { + indicator._bg.set_size(targetRect.width, targetRect.height); + } + indicator.show(); + + // Apply preview layout modifications + if (monitorId !== wrapper.monitorId) { + this._applyCrossMonitorVisualSwap(wrapper, targetTracker, targetLayout, hoveredSlot, monitorRect, gaps); + } else { + this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); + } + } else { + indicator.hide(); + this._revertVisualSwap(tracker, layout, monitorRect, gaps); + } +} +``` + +### Cross-Monitor Keyboard Navigation Fallback + +```javascript +// Located in lib/workspace.js +_getTargetWindowInDirection(monitorId, window, direction) { + const tracker = this._getTracker(monitorId); + const slot = tracker.getSlot(window); + if (slot === undefined) return null; + + const windowCount = tracker.size; + const layout = this.escalator.getLayoutForCount(windowCount); + if (!layout) return null; + + const estate = layout.getEstate(slot); + if (!estate) return null; + + // Evaluate spatial targets within same monitor + const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + if (targetSlot !== -1) { + return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; + } + + // Fetch boundary adjacent display + const currentMonitorIndex = this.workspace.get_display().get_monitor_index_for_rect(window.get_frame_rect()); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + if (adjacentMonitorIndex === -1) return null; + + const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); + const targetTracker = this._getTracker(targetMonitorId); + if (targetTracker.size === 0) return null; + + // Match coordinate boundary overlap on adjacent display via adjacent edge + return this._findClosestBoundaryWindow(targetTracker, direction, window.get_frame_rect()); +} + +_findClosestBoundaryWindow(targetTracker, direction, sourceRect) { + let candidates = []; + for (const win of targetTracker.windows) { + if (!win || win.unmanaged) continue; + const targetRect = win.get_frame_rect(); + + let overlap = 0; + if (direction === 'left' || direction === 'right') { + // Overlap along Y axis + overlap = Math.max(0, Math.min(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height) - Math.max(sourceRect.y, targetRect.y)); + } else if (direction === 'up' || direction === 'down') { + // Overlap along X axis + overlap = Math.max(0, Math.min(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width) - Math.max(sourceRect.x, targetRect.x)); + } + + if (overlap > 0) { + candidates.push({ win, rect: targetRect, overlap }); + } + } + + if (candidates.length === 0) return null; + + candidates.sort((a, b) => { + if (b.overlap !== a.overlap) { + return b.overlap - a.overlap; // Highest overlap first + } + // If equal overlap, pick the top/right one + if (direction === 'left' || direction === 'right') { + return a.rect.y - b.rect.y; // Top-most (smaller Y) + } else { + return b.rect.x - a.rect.x; // Right-most (larger X) + } + }); + + return candidates[0].win; +} +``` + +### Cross-Monitor Keyboard Window Movement + +```javascript +// Located in lib/workspace.js +moveWindowDirection(monitorId, window, direction) { + const tracker = this._getTracker(monitorId); + const slot = tracker.getSlot(window); + if (slot === undefined) return false; + + const windowCount = tracker.size; + const layout = this.escalator.getLayoutForCount(windowCount); + if (!layout) return false; + + const estate = layout.getEstate(slot); + if (!estate) return false; + + // Evaluate spatial targets within same monitor + const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + if (targetSlot !== -1) { + const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); + if (targetWindow) { + tracker.swapWindows(window, targetWindow); + this.controller.scheduleRetile(this.workspace, monitorId); + return true; + } + } + + // Fallback: cross-monitor window movement + const currentMonitorIndex = this.workspace.get_display().get_monitor_index_for_rect(window.get_frame_rect()); + const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); + if (adjacentMonitorIndex === -1) return false; + + const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); + + // Untrack from source monitor tracker + tracker.untrack(window); + + // Track on target monitor tracker + const targetTracker = this._getTracker(targetMonitorId); + targetTracker.track(window, targetTracker.size); + + // Update window wrapper cache/metadata + const wrapper = this.controller.getWindowWrapper(window); + if (wrapper) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = adjacentMonitorIndex; + } + + // Physical transfer using GNOME Shell API + window.move_to_monitor(adjacentMonitorIndex); + + // Schedule retiles on both source and target monitors + this.controller.scheduleRetile(this.workspace, monitorId); + this.controller.scheduleRetile(this.workspace, targetMonitorId); + + return true; +} +``` + +### Cross-Monitor Workspace Actions (Shifted from MonitorManager) + +```javascript +// Located in lib/workspace.js (WorkspaceLayout / WorkspaceManager) +closeMonitorWindows(monitorIndex, includeMinimized) { + const workspace = this.workspace || global.workspace_manager.get_active_workspace(); + if (!workspace) return; + this.controller.setBatchMode(true); + const windows = workspace.list_windows(); + windows.forEach(w => { + if (w.get_monitor() === monitorIndex && (!w.minimized || includeMinimized)) { + w.delete(global.get_current_time()); + } + }); + this.controller.setBatchMode(false); + this.controller.hydrate(workspace); +} + +switchMonitors(activeMonitorIndex) { + const workspace = this.workspace || global.workspace_manager.get_active_workspace(); + if (!workspace) return; + + const manager = global.backend.get_monitor_manager(); + const numMonitors = manager.get_logical_monitors().length; + if (numMonitors < 2) return; + + let targetMonitorIndex; + if (numMonitors === 2) { + targetMonitorIndex = activeMonitorIndex === 0 ? 1 : 0; + } else { + const primaryIndex = global.display.get_primary_monitor(); + if (activeMonitorIndex === primaryIndex) return; + targetMonitorIndex = primaryIndex; + } + + this.controller.setBatchMode(true); + const windows = workspace.list_windows(); + windows.forEach(w => { + const m = w.get_monitor(); + if (m === activeMonitorIndex) { + w.move_to_monitor(targetMonitorIndex); + } else if (m === targetMonitorIndex) { + w.move_to_monitor(activeMonitorIndex); + } + }); + this.controller.setBatchMode(false); + this.controller.hydrate(workspace); +} + +portMonitorToWorkspace(monitorIndex, direction) { + const activeWorkspaceIndex = global.workspace_manager.get_active_workspace_index(); + const numWorkspaces = global.workspace_manager.n_workspaces; + let targetIndex = activeWorkspaceIndex; + + if (direction === 'left' && activeWorkspaceIndex > 0) { + targetIndex--; + } else if (direction === 'right' && activeWorkspaceIndex < numWorkspaces - 1) { + targetIndex++; + } + + if (targetIndex === activeWorkspaceIndex) return; + + const targetWorkspace = global.workspace_manager.get_workspace_by_index(targetIndex); + const activeWorkspace = this.workspace || global.workspace_manager.get_active_workspace(); + + this.controller.setBatchMode(true); + const windows = activeWorkspace.list_windows(); + windows.forEach(w => { + if (w.get_monitor() === monitorIndex) { + w.change_workspace(targetWorkspace); + } + }); + this.controller.setBatchMode(false); + + this.controller.hydrate(activeWorkspace); + this.controller.hydrate(targetWorkspace); +} +``` + diff --git a/tests/drag.test.js b/tests/drag.test.js new file mode 100644 index 0000000..a6c8fd3 --- /dev/null +++ b/tests/drag.test.js @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TilingController } from '../lib/controller.js'; +import { LayoutParser } from '../lib/layout.js'; +import Meta from 'gi://Meta'; + +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; + +describe('DragManager Cross-Monitor', () => { + let controller; + let dragManager; + let ws; + + beforeEach(() => { + vi.clearAllMocks(); + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + vi.mocked(manager.get_primary_monitor).mockReturnValue(0); + + global.display.get_primary_monitor = () => manager.get_primary_monitor(); + global.display.get_current_monitor = vi.fn(() => { + const [x, y] = global.get_pointer ? global.get_pointer() : [0, 0]; + if (x >= 1000) return 1; + return 0; + }); + + TilingController.activeInstance = null; + controller = new TilingController(); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + controller.monitorManager.initializeMonitorState(); + dragManager = controller.dragManager; + + ws = { + id: 'ws1', + get_work_area_for_monitor: vi.fn((idx) => { + if (idx === 1) { + return { x: 1000, y: 0, width: 1000, height: 1000 }; + } + return { x: 0, y: 0, width: 1000, height: 1000 }; + }), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }) + }; + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const createMockWindow = (id, workspace, initialMonitor) => { + let monitor = initialMonitor; + return { + id, + get_workspace: () => workspace, + get_monitor: vi.fn(() => monitor), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }), + get_frame_rect: () => ({ + x: monitor === 1 ? 1010 : 10, + y: 10, + width: 100, + height: 100 + }), + move_resize_frame: vi.fn(), + move_to_monitor: vi.fn((m) => { monitor = m; }), + get_title: () => `Window ${id}`, + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false, + connect: vi.fn(() => 123), + disconnect: vi.fn(), + handler_is_connected: vi.fn(() => true), + minimize: vi.fn(function() { this.minimized = true; }), + unminimize: vi.fn(function() { this.minimized = false; }), + delete: vi.fn() + }; + }; + + it('should initialize and cleanup active drag state', () => { + const win = createMockWindow(1, ws, 0); + controller.tilingRequest(win); + + dragManager.startDragTracking(win); + expect(dragManager._activeDrag).toBeDefined(); + expect(dragManager._activeDrag.window).toBe(win); + expect(dragManager._activeDrag.lastHoveredSlot).toBe(-1); + expect(dragManager._activeDrag.lastHoveredMonitorId).toBeNull(); + + dragManager.endDragTracking(win); + expect(dragManager._activeDrag).toBeNull(); + }); + + it('should map pointer to slot and position indicator on same monitor', () => { + const win1 = createMockWindow(1, ws, 0); + const win2 = createMockWindow(2, ws, 0); + controller.tilingRequest(win1); + controller.tilingRequest(win2); + + let posChangedCb; + win1.connect = vi.fn((event, cb) => { + if (event === 'position-changed') posChangedCb = cb; + return 123; + }); + + dragManager.startDragTracking(win1); + expect(posChangedCb).toBeDefined(); + + // Hover over slot 1 on DP-1 (same monitor) + global.get_pointer = vi.fn(() => [600, 500]); // right side of monitor-0 + + posChangedCb(); + + expect(dragManager._activeDrag.lastHoveredSlot).toBe(1); + expect(dragManager._activeDrag.lastHoveredMonitorId).toBe('monitor-0'); + expect(dragManager._activeDrag.indicator).toBeDefined(); + }); + + it('should map pointer to slot and position indicator on different monitor', () => { + const win1 = createMockWindow(1, ws, 0); // on monitor-0 + const win2 = createMockWindow(2, ws, 1); // on monitor-1 + controller.tilingRequest(win1); + controller.tilingRequest(win2); + + let posChangedCb; + win1.connect = vi.fn((event, cb) => { + if (event === 'position-changed') posChangedCb = cb; + return 123; + }); + + dragManager.startDragTracking(win1); + + // Hover on different monitor (HDMI-1) at slot 0 (which has win2) + global.get_pointer = vi.fn(() => [1200, 500]); // coordinates on monitor-1 + + posChangedCb(); + + expect(dragManager._activeDrag.lastHoveredSlot).toBe(0); + expect(dragManager._activeDrag.lastHoveredMonitorId).toBe('monitor-1'); + }); + + it('should handle cross-monitor drop behavior', () => { + const win1 = createMockWindow(1, ws, 0); // monitor-0 + const win2 = createMockWindow(2, ws, 1); // monitor-1 + controller.tilingRequest(win1); + controller.tilingRequest(win2); + + let posChangedCb; + win1.connect = vi.fn((event, cb) => { + if (event === 'position-changed') posChangedCb = cb; + return 123; + }); + + dragManager.startDragTracking(win1); + + // Hover on different monitor (HDMI-1) at slot 0 + global.get_pointer = vi.fn(() => [1200, 500]); + posChangedCb(); + + vi.spyOn(controller, '_scheduleRetile'); + + dragManager.endDragTracking(win1); + + // Verify window physical movement + expect(win1.move_to_monitor).toHaveBeenCalledWith(1); + + // Verify window tracked on target and untracked on source + const layout = controller.workspaceManager.getLayout(ws); + const sourceTracker = layout._getTracker('monitor-0'); + const targetTracker = layout._getTracker('monitor-1'); + + expect(sourceTracker.getSlot(win1)).toBeUndefined(); + expect(targetTracker.getSlot(win1)).toBe(0); // target slot preferred slot 0 + + // Verify wrapper monitor details updated + const wrapper = controller._windowWrappers.get(win1); + expect(wrapper.monitorId).toBe('monitor-1'); + expect(wrapper.monitorIndex).toBe(1); + + // Verify schedule retiles called on both + expect(controller._scheduleRetile).toHaveBeenCalledWith(ws, 'monitor-0', 0); + expect(controller._scheduleRetile).toHaveBeenCalledWith(ws, 'monitor-1', 1); + }); + + it('should handle empty monitor target drop', () => { + const win1 = createMockWindow(1, ws, 0); // monitor-0 + controller.tilingRequest(win1); + + // target monitor-1 starts empty + + let posChangedCb; + win1.connect = vi.fn((event, cb) => { + if (event === 'position-changed') posChangedCb = cb; + return 123; + }); + + dragManager.startDragTracking(win1); + + // Hover on empty monitor-1 + global.get_pointer = vi.fn(() => [1200, 500]); + posChangedCb(); + + // Check active drag preview mapping + expect(dragManager._activeDrag.lastHoveredSlot).toBe(0); + expect(dragManager._activeDrag.lastHoveredMonitorId).toBe('monitor-1'); + + dragManager.endDragTracking(win1); + + // Verify window tracked on target monitor-1 slot 0 + const layout = controller.workspaceManager.getLayout(ws); + const targetTracker = layout._getTracker('monitor-1'); + expect(targetTracker.getSlot(win1)).toBe(0); + }); +}); diff --git a/tests/monitor.test.js b/tests/monitor.test.js index 2965feb..b669388 100644 --- a/tests/monitor.test.js +++ b/tests/monitor.test.js @@ -30,6 +30,7 @@ describe('MonitorManager', () => { _windowWrappers: new Map(), _restoringWindows: new Set(), updateWindowWrapperMonitor: vi.fn(), + addRestoringWindow: vi.fn(), tilingRequest: vi.fn() }; @@ -44,53 +45,94 @@ describe('MonitorManager', () => { monitorManager.initializeMonitorState(); }); - it('should close all windows on a monitor', () => { - const win1 = { delete: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false, minimized: false }; - const win2 = { delete: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false, minimized: false }; - const win3 = { delete: vi.fn(), get_monitor: () => 1, is_skip_taskbar: () => false, minimized: false }; - const ws = { list_windows: () => [win1, win2, win3] }; - global.workspace_manager.get_active_workspace.mockReturnValue(ws); - monitorManager.closeMonitorWindows(0, false); - - expect(controller.setBatchMode).toHaveBeenCalledWith(true); - expect(controller.setBatchMode).toHaveBeenCalledWith(false); - expect(win1.delete).toHaveBeenCalled(); - expect(win2.delete).toHaveBeenCalled(); - expect(win3.delete).not.toHaveBeenCalled(); - expect(controller.hydrate).toHaveBeenCalled(); + + it('should evacuate window when its monitor is disconnected', () => { + const mockWin = { + unmanaged: false, + minimized: false, + minimize: vi.fn() + }; + const mockWorkspace = { index: () => 0 }; + const mockWrapper = { + title: 'Test Window', + monitorId: 'monitor-1', + workspace: mockWorkspace + }; + + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] } + ]); + + const mockTracker = { + getSlot: vi.fn().mockReturnValue(2) + }; + const mockGrid = { + _getTracker: vi.fn().mockReturnValue(mockTracker), + untrackWindow: vi.fn() + }; + controller.workspaceManager.getLayout.mockReturnValue(mockGrid); + + const evacuated = monitorManager.checkEvacuation(mockWin, mockWrapper, 'monitor-0', mockWorkspace); + + expect(evacuated).toBe(true); + expect(mockWin.minimize).toHaveBeenCalled(); + expect(mockGrid._getTracker).toHaveBeenCalledWith('monitor-1'); + expect(mockGrid.untrackWindow).toHaveBeenCalledWith(mockWin, 'monitor-1'); + expect(monitorManager.isEvacuated(mockWin)).toBe(true); }); - it('should switch monitors for all windows', () => { - const win1 = { move_to_monitor: vi.fn(), get_monitor: () => 0, minimized: false, is_skip_taskbar: () => false }; - const win2 = { move_to_monitor: vi.fn(), get_monitor: () => 1, minimized: false, is_skip_taskbar: () => false }; - const ws = { list_windows: () => [win1, win2] }; - global.workspace_manager.get_active_workspace.mockReturnValue(ws); + it('should restore evacuated window when its monitor is reconnected', () => { + const mockWin = { + unmanaged: false, + minimized: true, + unminimize: vi.fn(), + move_to_monitor: vi.fn() + }; + const mockWorkspace = { index: () => 0 }; + const mockInfo = { + monitorId: 'monitor-1', + workspace: mockWorkspace, + slot: 2 + }; + monitorManager._evacuatedWindows.set(mockWin, mockInfo); - monitorManager.switchMonitors(0, 1); - - expect(controller.setBatchMode).toHaveBeenCalledWith(true); - expect(controller.setBatchMode).toHaveBeenCalledWith(false); - expect(win1.move_to_monitor).toHaveBeenCalledWith(1); - expect(win2.move_to_monitor).toHaveBeenCalledWith(0); + monitorManager._lastMonitorCount = 1; + monitorManager._knownMonitorIds = new Set(['monitor-0']); + + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + + monitorManager.handleMonitorsChanged(); + + expect(mockWin.move_to_monitor).toHaveBeenCalledWith(1); + expect(mockWin.unminimize).toHaveBeenCalled(); + expect(controller.updateWindowWrapperMonitor).toHaveBeenCalledWith(mockWin, 'monitor-1', 1); + expect(controller.addRestoringWindow).toHaveBeenCalledWith(mockWin, 2); + expect(monitorManager.isEvacuated(mockWin)).toBe(false); expect(controller.hydrate).toHaveBeenCalled(); + expect(monitorManager._lastMonitorCount).toBe(2); + expect(monitorManager._knownMonitorIds.has('monitor-1')).toBe(true); }); - it('should port all monitor windows to another workspace', () => { - const win1 = { change_workspace: vi.fn(), get_monitor: () => 0, minimized: false, is_skip_taskbar: () => false }; - const win2 = { change_workspace: vi.fn(), get_monitor: () => 1, minimized: false, is_skip_taskbar: () => false }; - const sourceWorkspace = { list_windows: () => [win1, win2] }; - const targetWorkspace = { list_windows: () => [] }; - - global.workspace_manager.get_active_workspace.mockReturnValue(sourceWorkspace); - global.workspace_manager.get_workspace_by_index.mockReturnValue(targetWorkspace); + it('should find adjacent monitor in direction using logical geometries', () => { + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { rect: { x: 0, y: 0, width: 1920, height: 1080 }, get_monitors: () => [] }, + { rect: { x: 1920, y: 0, width: 1920, height: 1080 }, get_monitors: () => [] } + ]); - monitorManager.portMonitorToWorkspace(0, 'right'); - - expect(controller.setBatchMode).toHaveBeenCalledWith(true); - expect(controller.setBatchMode).toHaveBeenCalledWith(false); - expect(win1.change_workspace).toHaveBeenCalledWith(targetWorkspace); - expect(win2.change_workspace).not.toHaveBeenCalled(); - expect(controller.hydrate).toHaveBeenCalled(); + const targetRight = monitorManager.getMonitorInDirection(0, 'right'); + expect(targetRight).toBe(1); + + const targetLeft = monitorManager.getMonitorInDirection(1, 'left'); + expect(targetLeft).toBe(0); + + const targetUp = monitorManager.getMonitorInDirection(0, 'up'); + expect(targetUp).toBe(-1); }); }); diff --git a/tests/setup.js b/tests/setup.js index 89a5887..af4c822 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -76,6 +76,7 @@ global.display = { get_current_monitor: vi.fn(() => 0), get_focus_window: vi.fn(() => null), list_all_windows: vi.fn(() => []), + get_monitor_index_for_rect: vi.fn(() => -1), connect: vi.fn(), disconnect: vi.fn() }; diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 02e6622..08883eb 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -216,6 +216,12 @@ describe('WorkspaceManager', () => { beforeEach(() => { global.get_current_time = vi.fn(() => 1234); + global.workspace_manager = { + get_active_workspace: vi.fn(), + get_active_workspace_index: vi.fn(() => 0), + get_workspace_by_index: vi.fn(), + n_workspaces: 4 + }; controller = { setBatchMode: vi.fn(), retileAll: vi.fn(), @@ -259,4 +265,180 @@ describe('WorkspaceManager', () => { expect(controller.hydrate).toHaveBeenCalledWith(ws); expect(win1.activate).toHaveBeenCalledWith(1234); }); + + it('should close all windows on a monitor', () => { + const win1 = { delete: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false, minimized: false }; + const win2 = { delete: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false, minimized: false }; + const win3 = { delete: vi.fn(), get_monitor: () => 1, is_skip_taskbar: () => false, minimized: false }; + const ws = { list_windows: () => [win1, win2, win3] }; + global.workspace_manager.get_active_workspace.mockReturnValue(ws); + + manager.closeMonitorWindows(0, false); + + expect(controller.setBatchMode).toHaveBeenCalledWith(true); + expect(controller.setBatchMode).toHaveBeenCalledWith(false); + expect(win1.delete).toHaveBeenCalled(); + expect(win2.delete).toHaveBeenCalled(); + expect(win3.delete).not.toHaveBeenCalled(); + expect(controller.hydrate).toHaveBeenCalledWith(ws); + }); + + it('should switch monitors for all windows', () => { + const win1 = { move_to_monitor: vi.fn(), get_monitor: () => 0, minimized: false, is_skip_taskbar: () => false }; + const win2 = { move_to_monitor: vi.fn(), get_monitor: () => 1, minimized: false, is_skip_taskbar: () => false }; + const ws = { list_windows: () => [win1, win2] }; + global.workspace_manager.get_active_workspace.mockReturnValue(ws); + + const mockManager = { + get_logical_monitors: () => [{}, {}] + }; + global.backend = { + get_monitor_manager: () => mockManager + }; + + manager.switchMonitors(0); + + expect(controller.setBatchMode).toHaveBeenCalledWith(true); + expect(controller.setBatchMode).toHaveBeenCalledWith(false); + expect(win1.move_to_monitor).toHaveBeenCalledWith(1); + expect(win2.move_to_monitor).toHaveBeenCalledWith(0); + expect(controller.hydrate).toHaveBeenCalledWith(ws); + }); + + it('should port all monitor windows to another workspace', () => { + const win1 = { change_workspace: vi.fn(), get_monitor: () => 0, minimized: false, is_skip_taskbar: () => false }; + const win2 = { change_workspace: vi.fn(), get_monitor: () => 1, minimized: false, is_skip_taskbar: () => false }; + const sourceWorkspace = { list_windows: () => [win1, win2] }; + const targetWorkspace = { list_windows: () => [] }; + + global.workspace_manager.get_active_workspace.mockReturnValue(sourceWorkspace); + global.workspace_manager.get_workspace_by_index.mockReturnValue(targetWorkspace); + + manager.portMonitorToWorkspace(0, 'right'); + + expect(controller.setBatchMode).toHaveBeenCalledWith(true); + expect(controller.setBatchMode).toHaveBeenCalledWith(false); + expect(win1.change_workspace).toHaveBeenCalledWith(targetWorkspace); + expect(win2.change_workspace).not.toHaveBeenCalled(); + expect(controller.hydrate).toHaveBeenCalledWith(sourceWorkspace); + expect(controller.hydrate).toHaveBeenCalledWith(targetWorkspace); + }); +}); + +describe('WorkspaceLayout Cross-Monitor Fallback', () => { + const defaultJson = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; + const escalator = LayoutParser.parse(defaultJson); + + describe('_findClosestBoundaryWindow', () => { + it('should choose the window with highest overlap on adjacent edge', () => { + const layout = new WorkspaceLayout({}, escalator); + const targetTracker = { + size: 2, + windows: [ + { get_frame_rect: () => ({ x: 1000, y: 0, width: 500, height: 400 }) }, + { get_frame_rect: () => ({ x: 1000, y: 300, width: 500, height: 700 }) } + ] + }; + const sourceRect = { x: 0, y: 100, width: 1000, height: 400 }; + + const best = layout._findClosestBoundaryWindow(targetTracker, 'right', sourceRect); + expect(best).toBe(targetTracker.windows[0]); + }); + + it('should resolve ties using top-most/right-most tie breakers', () => { + const layout = new WorkspaceLayout({}, escalator); + const targetTrackerY = { + size: 2, + windows: [ + { get_frame_rect: () => ({ x: 1000, y: 200, width: 500, height: 300 }) }, + { get_frame_rect: () => ({ x: 1000, y: 100, width: 500, height: 300 }) } + ] + }; + const sourceRectY = { x: 0, y: 200, width: 1000, height: 200 }; + const bestY = layout._findClosestBoundaryWindow(targetTrackerY, 'right', sourceRectY); + expect(bestY).toBe(targetTrackerY.windows[1]); + + const targetTrackerX = { + size: 2, + windows: [ + { get_frame_rect: () => ({ x: 100, y: 1000, width: 300, height: 500 }) }, + { get_frame_rect: () => ({ x: 200, y: 1000, width: 300, height: 500 }) } + ] + }; + const sourceRectX = { x: 200, y: 0, width: 200, height: 1000 }; + const bestX = layout._findClosestBoundaryWindow(targetTrackerX, 'down', sourceRectX); + expect(bestX).toBe(targetTrackerX.windows[1]); + }); + }); + + it('should fall back to cross-monitor focus when intra-monitor search fails', () => { + const mockMonitorManager = { + getMonitorIndex: vi.fn(id => id === 'monitor-0' ? 0 : 1), + getMonitorInDirection: vi.fn((idx, dir) => idx === 0 && dir === 'right' ? 1 : -1), + getMonitorId: vi.fn(idx => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + const controller = { + escalator: escalator, + monitorManager: mockMonitorManager + }; + const layout = new WorkspaceLayout({}, controller); + + const win0 = { + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 0, width: 1000, height: 1000 }) + }; + const win1 = { + get_monitor: () => 1, + get_frame_rect: () => ({ x: 1000, y: 0, width: 1000, height: 1000 }), + activate: vi.fn() + }; + + layout.trackWindow(win0, 'monitor-0'); + layout.trackWindow(win1, 'monitor-1'); + + const result = layout.focusWindowDirection('monitor-0', win0, 'right'); + expect(result).toBe(true); + expect(win1.activate).toHaveBeenCalled(); + }); + + it('should fall back to cross-monitor movement when intra-monitor swap fails', () => { + const mockMonitorManager = { + getMonitorIndex: vi.fn(id => id === 'monitor-0' ? 0 : 1), + getMonitorInDirection: vi.fn((idx, dir) => idx === 0 && dir === 'right' ? 1 : -1), + getMonitorId: vi.fn(idx => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + const controller = { + escalator: escalator, + monitorManager: mockMonitorManager, + _windowWrappers: new Map(), + _scheduleRetile: vi.fn() + }; + const ws = { index: () => 0 }; + const layout = new WorkspaceLayout(ws, controller); + + const win0 = { + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 0, width: 1000, height: 1000 }), + move_to_monitor: vi.fn() + }; + const wrapper0 = { monitorId: 'monitor-0', monitorIndex: 0 }; + controller._windowWrappers.set(win0, wrapper0); + + layout.trackWindow(win0, 'monitor-0'); + + const result = layout.moveWindowDirection('monitor-0', win0, 'right'); + expect(result).toBe(true); + + const tracker0 = layout._getTracker('monitor-0'); + const tracker1 = layout._getTracker('monitor-1'); + expect(tracker0.getSlot(win0)).toBeUndefined(); + expect(tracker1.getSlot(win0)).toBe(0); + + expect(wrapper0.monitorId).toBe('monitor-1'); + expect(wrapper0.monitorIndex).toBe(1); + expect(win0.move_to_monitor).toHaveBeenCalledWith(1); + + expect(controller._scheduleRetile).toHaveBeenCalledWith(ws, 'monitor-0', 0); + expect(controller._scheduleRetile).toHaveBeenCalledWith(ws, 'monitor-1', 1); + }); }); From 7fabc70e11417d45efe68c03b7862930914f3356 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Fri, 12 Jun 2026 22:17:34 +0200 Subject: [PATCH 02/10] feat: Implement robust cross-monitor drag and tiling transitions - first fully working version - Added cross-monitor dragging with dynamic visual N+1 and N-1 matrix previews - Introduced 'Monitor Transition Behavior' settings ('Escalate' vs 'Swap') - Implemented floating fallback when attempting to drag over a monitor at maximum layout capacity - Resolved cross-monitor preview bugs by cleanly reverting both source and target tracker geometries - Prevented drag-and-drop desync bugs during rapid movements by calculating monitor indices using absolute pointer geometry instead of global.display.get_current_monitor() - Updated keybinding logic and restored missing dragging indicator styles - Extensive unit tests covering cross-monitor transitions and regressions --- lib/controller.js | 49 ++- lib/drag.js | 234 ++++++++++-- lib/keybindings.js | 36 +- lib/layout.js | 49 +++ lib/settings.js | 10 + lib/workspace.js | 180 ++++++++-- prefs.js | 20 ++ schemas/gschemas.compiled | Bin 2600 -> 2652 bytes ...ell.extensions.workflow-tiling.gschema.xml | 6 + tests/monitor-transition.test.js | 296 ++++++++++++++++ tests/regressions.test.js | 334 ++++++++++++++++++ tests/setup.js | 9 +- 12 files changed, 1122 insertions(+), 101 deletions(-) create mode 100644 tests/monitor-transition.test.js create mode 100644 tests/regressions.test.js diff --git a/lib/controller.js b/lib/controller.js index 1fbfd92..faf90a4 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -88,6 +88,11 @@ export class TilingController { wrapper.bindSizeChanged(); try { + if (this.dragManager && this.dragManager.isWindowInDragPreview(window)) { + Logger.debug(`tilingRequest: Ignored for window in active drag preview.`); + return; + } + const context = this._resolveTilingContext(window, wrapper); if (!context) { Logger.debug(`tilingRequest: Aborted. No context resolved.`); @@ -103,13 +108,43 @@ export class TilingController { return; } - const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); - const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); - this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); - this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); - - Logger.debug(`tilingRequest: State applied. Scheduling retile.`); - this._scheduleRetile(workspace, monitorId, monitorIndex); + const layout = this.workspaceManager.getLayout(workspace); + const isMonitorChange = wrapper.workspace && wrapper.workspace === workspace && wrapper.monitorId && wrapper.monitorId !== monitorId; + + if (isMonitorChange) { + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; + const sourceTracker = layout._getTracker(sourceMonitorId); + const sourceSlot = sourceTracker.getSlot(window) !== undefined ? sourceTracker.getSlot(window) : 0; + + const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); + const targetRect = global.display.get_monitor_geometry(monitorIndex); + + let enteringEdge = 'left'; + if (targetRect.x > sourceRect.x) { + enteringEdge = 'left'; + } else if (targetRect.x < sourceRect.x) { + enteringEdge = 'right'; + } else if (targetRect.y > sourceRect.y) { + enteringEdge = 'top'; + } else if (targetRect.y < sourceRect.y) { + enteringEdge = 'bottom'; + } + + layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + + Logger.debug(`tilingRequest: Monitor transition handled. Scheduling retile.`); + this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } else { + const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); + const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); + this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); + this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); + + Logger.debug(`tilingRequest: State applied. Scheduling retile.`); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } } catch (e) { Logger.warn(`Tiling attempt failed for "${wrapper ? wrapper.title : 'unknown'}"`, e); } diff --git a/lib/drag.js b/lib/drag.js index 5416092..22de3f0 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -1,5 +1,6 @@ import Gio from 'gi://Gio'; import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; /** * DragManager: Manages pointer drag tracking and visual drop indicators. @@ -10,6 +11,22 @@ export class DragManager { this._activeDrag = null; // { window, originalSlot, indicator, signalId, lastHoveredSlot, lastHoveredMonitorId, origRect } } + isWindowInDragPreview(window) { + if (!this._activeDrag) return false; + if (this._activeDrag.window === window) return true; + + const wrapper = this.controller._windowWrappers.get(window); + if (!wrapper) return false; + + const draggedWrapper = this.controller._windowWrappers.get(this._activeDrag.window); + const sourceMonitorId = draggedWrapper ? draggedWrapper.monitorId : null; + + if (wrapper.monitorId === sourceMonitorId) return true; + if (this._activeDrag.lastHoveredMonitorId && wrapper.monitorId === this._activeDrag.lastHoveredMonitorId) return true; + + return false; + } + startDragTracking(window) { if (this._activeDrag) this.endDragTracking(this._activeDrag.window); const wrapper = this.controller._windowWrappers.get(window); @@ -40,7 +57,7 @@ export class DragManager { _createIndicator() { const indicator = new St.Widget({ style: ` - border: 2px solid var(--accent-color, #3584e4); + border: 2px solid -st-accent-color; border-radius: 8px; `, visible: false @@ -48,9 +65,10 @@ export class DragManager { const bg = new St.Widget({ style: ` - background-color: var(--accent-bg-color, rgba(53, 132, 228, 0.3)); + background-color: -st-accent-color; border-radius: 6px; - ` + `, + opacity: 76 }); indicator.add_child(bg); indicator._bg = bg; @@ -91,9 +109,16 @@ export class DragManager { height: monitorRect.height - (gaps.outer * 2) }; } else { - hoveredSlot = layout.getSlotAtPointer(monitorId, x, y, monitorRect, gaps); + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + const matrixCount = (monitorId === wrapper.monitorId || behavior === 'swap') ? targetTracker.size : (targetTracker.size + 1); + + if (matrixCount > layout.escalator.getMaxCount()) { + hoveredSlot = -1; + } else { + hoveredSlot = layout.getSlotAtPointer(monitorId, x, y, monitorRect, gaps, matrixCount); + } + if (hoveredSlot !== -1) { - const matrixCount = (monitorId === wrapper.monitorId) ? targetTracker.size : (targetTracker.size + 1); const matrix = layout.escalator.getLayoutForCount(matrixCount); if (matrix) { const estate = matrix.getEstate(hoveredSlot); @@ -156,20 +181,73 @@ export class DragManager { const sourceTracker = layout._getTracker(wrapper.monitorId); const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); - this._revertVisualSwap(sourceTracker, layout, sourceMonitorRect, gaps); + + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + + if (behavior === 'escalate') { + const sourceMatrix = layout.escalator.getLayoutForCount(sourceTracker.size > 0 ? sourceTracker.size - 1 : 0); + if (sourceMatrix) { + const origSlot = this._activeDrag.originalSlot; + for (const win of sourceTracker.windows) { + if (win === this._activeDrag.window) continue; + const slot = sourceTracker.getSlot(win); + if (slot !== undefined) { + const targetSlot = (slot > origSlot) ? (slot - 1) : slot; + const wrap = this.controller._windowWrappers.get(win); + const estate = sourceMatrix.getEstate(targetSlot); + if (wrap && estate) { + const rect = estate.toAbsolute(sourceMonitorRect, gaps); + wrap.applyGeometry(rect); + } + } + } + } + } else { + this._revertVisualSwap(sourceTracker, layout, sourceMonitorRect, gaps); + } // Apply new cross-monitor visual swap preview using size N+1 matrix - const matrix = layout.escalator.getLayoutForCount(targetTracker.size + 1); + const matrix = layout.escalator.getLayoutForCount(behavior === 'swap' && targetTracker.size > 0 ? targetTracker.size : targetTracker.size + 1); + if (matrix) { - for (const win of targetTracker.windows) { - const slot = targetTracker.getSlot(win); - if (slot !== undefined) { - const targetEstateSlot = (slot >= hoveredSlot) ? (slot + 1) : slot; - const wrap = this.controller._windowWrappers.get(win); - const estate = matrix.getEstate(targetEstateSlot); - if (wrap && estate) { - const targetRect = estate.toAbsolute(monitorRect, gaps); - wrap.applyGeometry(targetRect); + if (behavior === 'swap' && targetTracker.size > 0) { + // In swap mode, we visually move the hovered target window to the source window's slot + const sourceTracker = layout._getTracker(wrapper.monitorId); + const sourceMatrix = layout.escalator.getLayoutForCount(sourceTracker.size); + const sourceEstate = sourceMatrix ? sourceMatrix.getEstate(this._activeDrag.originalSlot) : null; + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); + const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); + + for (const win of targetTracker.windows) { + const slot = targetTracker.getSlot(win); + if (slot !== undefined) { + const wrap = this.controller._windowWrappers.get(win); + if (wrap) { + if (slot === hoveredSlot && sourceEstate) { + const targetRect = sourceEstate.toAbsolute(sourceMonitorRect, gaps); + wrap.applyGeometry(targetRect); + } else { + const estate = matrix.getEstate(slot); + if (estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } + } + } + } + } + } else { + // Escalate N+1 preview + for (const win of targetTracker.windows) { + const slot = targetTracker.getSlot(win); + if (slot !== undefined) { + const targetEstateSlot = (slot >= hoveredSlot) ? (slot + 1) : slot; + const wrap = this.controller._windowWrappers.get(win); + const estate = matrix.getEstate(targetEstateSlot); + if (wrap && estate) { + const targetRect = estate.toAbsolute(monitorRect, gaps); + wrap.applyGeometry(targetRect); + } } } } @@ -197,8 +275,16 @@ export class DragManager { const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); } - } else { - this._restoreTrackerGeometries(tracker, layout, monitorRect, gaps); + } + + if (sourceMonId) { + const sourceTracker = layout._getTracker(sourceMonId); + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); + if (sourceMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + const sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); + this._restoreTrackerGeometries(sourceTracker, layout, sourceMonitorRect, gaps); + } } this._activeDrag.lastHoveredSlot = -1; @@ -220,8 +306,16 @@ export class DragManager { const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); } - } else { - this._restoreTrackerGeometries(tracker, layout, monitorRect, gaps); + } + + if (sourceMonId) { + const sourceTracker = layout._getTracker(sourceMonId); + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); + if (sourceMonitorIndex !== -1) { + const workspace = wrapper ? wrapper.workspace : layout.workspace; + const sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); + this._restoreTrackerGeometries(sourceTracker, layout, sourceMonitorRect, gaps); + } } } @@ -307,27 +401,99 @@ export class DragManager { const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastHoveredMonitorId); const sourceMonitorId = wrapper.monitorId; const sourceMonitorIndex = wrapper.monitorIndex; + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + + if (behavior === 'swap') { + const targetTracker = layout._getTracker(lastHoveredMonitorId); + const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === lastHoveredSlot); + + if (targetWindow) { + layout.untrackWindow(window, sourceMonitorId); + layout.untrackWindow(targetWindow, lastHoveredMonitorId); + + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + + const targetWrapper = this.controller._windowWrappers.get(targetWindow); + if (targetWrapper) { + targetWrapper.monitorId = sourceMonitorId; + targetWrapper.monitorIndex = sourceMonitorIndex; + } - layout.untrackWindow(window, sourceMonitorId); - if (targetMonitorIndex !== -1) { - window.move_to_monitor(targetMonitorIndex); - } - layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); + targetTracker.track(window, lastHoveredSlot); + const sourceTracker = layout._getTracker(sourceMonitorId); + sourceTracker.track(targetWindow, activeDrag.originalSlot); - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; + if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); + if (sourceMonitorIndex !== -1) targetWindow.move_to_monitor(sourceMonitorIndex); - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + } else { + layout.untrackWindow(window, sourceMonitorId); + + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + + layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); + + if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + } + } else { + layout.untrackWindow(window, sourceMonitorId); + + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + + layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); + + if (targetMonitorIndex !== -1) { + window.move_to_monitor(targetMonitorIndex); + } + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); + } } else { const [x, y] = global.get_pointer(); - const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); - const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; - const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; + let pointerMonitorIndex = -1; + const numMonitors = global.display.get_n_monitors(); + for (let i = 0; i < numMonitors; i++) { + const rect = global.display.get_monitor_geometry(i); + if (x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height) { + pointerMonitorIndex = i; + break; + } + } + + if (pointerMonitorIndex === -1) pointerMonitorIndex = wrapper.monitorIndex; + const pointerMonitorId = this.controller.monitorManager.getMonitorId(pointerMonitorIndex); + + if (pointerMonitorId && pointerMonitorId !== wrapper.monitorId) { + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; + + layout.untrackWindow(window, sourceMonitorId); + + wrapper.monitorId = pointerMonitorId; + wrapper.monitorIndex = pointerMonitorIndex; + + if (pointerMonitorIndex !== -1) window.move_to_monitor(pointerMonitorIndex); + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + } else { + const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); + + const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; + const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; - if (swapped || rectChanged) { - this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + if (swapped || rectChanged) { + this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + } } } } diff --git a/lib/keybindings.js b/lib/keybindings.js index f66c802..a011a9b 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -54,20 +54,20 @@ export class KeybindingManager { // Batch Utilities const utilities = { - 'shortcut-close-monitor': (c) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), - 'shortcut-close-workspace': (c) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), - 'shortcut-switch-monitor': (c) => c.switchMonitors(global.display.get_current_monitor()), - 'shortcut-port-monitor-left': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), - 'shortcut-port-monitor-right': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), - 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) + 'shortcut-close-monitor': (c, win) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), + 'shortcut-close-workspace': (c, win) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), + 'shortcut-switch-monitor': (c, win) => c.switchMonitors(win ? win.get_monitor() : global.display.get_current_monitor()), + 'shortcut-port-monitor-left': (c, win) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), + 'shortcut-port-monitor-right': (c, win) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), + 'shortcut-unminimize-workspace': (c, win) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) }; for (const [key, action] of Object.entries(utilities)) { defs.push({ defaultKey: key, - action: (c) => { + action: (c, win) => { Logger.debug(`Action triggered: ${key}`); - action(c); + action(c, win); }, conflict: null }); @@ -83,20 +83,14 @@ export class KeybindingManager { const keysToShadow = []; for (const def of this._definitions) { - const { active, keyToBind, isCustom } = this._resolveBinding(def); + const { active, keyToBind } = this._resolveBinding(def); if (!active) { Logger.debug(`Binding ${def.defaultKey} is inactive.`); continue; } - Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, isCustom=${isCustom}, conflict=${def.conflict}`); - - if (!isCustom && def.conflict) { - Logger.debug(`Will hijack native conflict ${def.conflict} instead of binding extension shortcut.`); - conflictsToHijack.push(def.conflict); - } else { - keysToShadow.push(keyToBind); - } + Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, conflict=${def.conflict}`); + keysToShadow.push(keyToBind); } if (keysToShadow.length > 0) { @@ -105,13 +99,11 @@ export class KeybindingManager { // Bind extension shortcuts for (const def of this._definitions) { - const { active, keyToBind, isCustom } = this._resolveBinding(def); - if (active && !(!isCustom && def.conflict)) { + const { active, keyToBind } = this._resolveBinding(def); + if (active) { this._bindExtensionShortcut(def, keyToBind); } } - - conflictsToHijack.forEach(conflictKey => this._hijackNativeShortcut(conflictKey)); } _resolveBinding(def) { @@ -128,7 +120,7 @@ export class KeybindingManager { } } - return { active, keyToBind, isCustom }; + return { active, keyToBind }; } _bindExtensionShortcut(def, keyToBind) { diff --git a/lib/layout.js b/lib/layout.js index 21293c9..5fe73f7 100644 --- a/lib/layout.js +++ b/lib/layout.js @@ -81,8 +81,53 @@ export class Layout { get size() { return this.estates.length; } + + getEdgingSlot(direction) { + const eps = 0.01; + let candidates = []; + + this.estates.forEach((estate, index) => { + let touches = false; + if (direction === 'left') { + touches = estate.pct_x <= eps; + } else if (direction === 'right') { + touches = (estate.pct_x + estate.pct_w) >= (100 - eps); + } else if (direction === 'top') { + touches = estate.pct_y <= eps; + } else if (direction === 'bottom') { + touches = (estate.pct_y + estate.pct_h) >= (100 - eps); + } + + if (touches) { + candidates.push({ estate, index }); + } + }); + + if (candidates.length === 0) { + return -1; + } + + candidates.sort((a, b) => { + if (direction === 'left' || direction === 'right') { + const diffHeight = b.estate.pct_h - a.estate.pct_h; + if (Math.abs(diffHeight) > eps) { + return diffHeight; + } + return a.estate.pct_y - b.estate.pct_y; + } else { + const diffWidth = b.estate.pct_w - a.estate.pct_w; + if (Math.abs(diffWidth) > eps) { + return diffWidth; + } + return b.estate.pct_x - a.estate.pct_x; + } + }); + + return candidates[0].index; + } } + /** * Escalator class. Maps window count to layout. */ @@ -99,6 +144,10 @@ export class LayoutEscalator { } return this._layouts.get(windowCount) || null; } + + getMaxCount() { + return this._maxCount; + } } diff --git a/lib/settings.js b/lib/settings.js index a29ab4a..2787d52 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -12,6 +12,7 @@ export class SettingsManager { this._gaps = { inner: 6, outer: 4 }; this._customLayouts = ''; + this._monitorTransitionBehavior = 'escalate'; if (this.settings) { this._load(); @@ -31,6 +32,10 @@ export class SettingsManager { this._load(); if (this.onSettingsChanged) this.onSettingsChanged(); })); + this._changedIds.push(this.settings.connect('changed::monitor-transition-behavior', () => { + this._load(); + if (this.onSettingsChanged) this.onSettingsChanged(); + })); const kbKeys = [ 'keybindings-mode', 'move-window-left', 'move-window-right', 'move-window-up', 'move-window-down', @@ -63,6 +68,7 @@ export class SettingsManager { this._gaps.outer = 0; } this._customLayouts = this.settings.get_string('custom-layouts'); + this._monitorTransitionBehavior = this.settings.get_string('monitor-transition-behavior') || 'escalate'; } getGaps() { @@ -73,6 +79,10 @@ export class SettingsManager { return this._customLayouts; } + getMonitorTransitionBehavior() { + return this._monitorTransitionBehavior; + } + destroy() { if (this.settings) { this._changedIds.forEach(id => this.settings.disconnect(id)); diff --git a/lib/workspace.js b/lib/workspace.js index 324a060..ccb4c6a 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -70,38 +70,40 @@ export class WorkspaceLayout { for (const win of targetTracker.windows) { if (!win) continue; const targetRect = win.get_frame_rect ? win.get_frame_rect() : { x: 0, y: 0, width: 100, height: 100 }; - - let overlap = 0; - if (direction === 'left' || direction === 'right') { - overlap = Math.min(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height) - Math.max(sourceRect.y, targetRect.y); - } else if (direction === 'up' || direction === 'down') { - overlap = Math.min(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width) - Math.max(sourceRect.x, targetRect.x); - } - candidates.push({ win, rect: targetRect, overlap }); + candidates.push({ win, rect: targetRect }); } if (candidates.length === 0) return null; candidates.sort((a, b) => { - if (Math.abs(b.overlap - a.overlap) > 0.001) { - return b.overlap - a.overlap; - } - if (direction === 'left' || direction === 'right') { + if (direction === 'left') { + if (a.rect.x !== b.rect.x) { + return b.rect.x - a.rect.x; + } + return a.rect.y - b.rect.y; + } else if (direction === 'right') { + if (a.rect.x !== b.rect.x) { + return a.rect.x - b.rect.x; + } + return a.rect.y - b.rect.y; + } else if (direction === 'up') { if (a.rect.y !== b.rect.y) { - return a.rect.y - b.rect.y; + return b.rect.y - a.rect.y; } return b.rect.x - a.rect.x; - } else { - if (b.rect.x !== a.rect.x) { - return b.rect.x - a.rect.x; + } else if (direction === 'down') { + if (a.rect.y !== b.rect.y) { + return a.rect.y - b.rect.y; } - return a.rect.y - b.rect.y; + return b.rect.x - a.rect.x; } + return 0; }); return candidates[0].win; } + _getTargetWindowInDirection(monitorId, window, direction) { const tracker = this._getTracker(monitorId); const slot = tracker.getSlot(window); @@ -144,6 +146,79 @@ export class WorkspaceLayout { return null; } + /** + * Finds the nearest window in the specified geometric direction and swaps slots. + * Computes orthogonal overlap and distance to determine the best candidate. + */ + handleMonitorTransition(window, sourceMonitorId, targetMonitorId, enteringEdge, sourceSlot) { + const sourceTracker = this._getTracker(sourceMonitorId); + const targetTracker = this._getTracker(targetMonitorId); + + const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonitorId); + const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(targetMonitorId); + + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + + if (behavior === 'swap' && targetTracker.size > 0) { + const targetLayout = this.escalator.getLayoutForCount(targetTracker.size); + if (targetLayout) { + const targetEdgingSlot = targetLayout.getEdgingSlot(enteringEdge); + if (targetEdgingSlot !== -1) { + const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === targetEdgingSlot); + if (targetWindow) { + sourceTracker.untrack(window); + targetTracker.untrack(targetWindow); + + targetTracker.track(window, targetEdgingSlot); + sourceTracker.track(targetWindow, sourceSlot); + + const wrapper = this.controller._windowWrappers.get(window); + if (wrapper) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + } + const targetWrapper = this.controller._windowWrappers.get(targetWindow); + if (targetWrapper) { + targetWrapper.monitorId = sourceMonitorId; + targetWrapper.monitorIndex = sourceMonitorIndex; + } + + if (window.move_to_monitor) { + window.move_to_monitor(targetMonitorIndex); + } + if (targetWindow.move_to_monitor) { + targetWindow.move_to_monitor(sourceMonitorIndex); + } + return; + } + } + } + } + + sourceTracker.untrack(window); + + const targetLayout = this.escalator.getLayoutForCount(targetTracker.size + 1); + let preferredSlot = targetTracker.size; + if (targetLayout) { + const edgingSlot = targetLayout.getEdgingSlot(enteringEdge); + if (edgingSlot !== -1) { + preferredSlot = edgingSlot; + } + } + + this.trackWindow(window, targetMonitorId, preferredSlot); + + const wrapper = this.controller._windowWrappers.get(window); + if (wrapper) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + } + + if (window.move_to_monitor) { + window.move_to_monitor(targetMonitorIndex); + } + } + /** * Finds the nearest window in the specified geometric direction and swaps slots. * Computes orthogonal overlap and distance to determine the best candidate. @@ -186,23 +261,15 @@ export class WorkspaceLayout { Logger.info(`[DEBUG] moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); if (adjacentMonitorIndex !== -1) { const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); - - tracker.untrack(window); - - const targetTracker = this._getTracker(targetMonitorId); - targetTracker.track(window, targetTracker.size); - - if (this.controller._windowWrappers) { - const wrapper = this.controller._windowWrappers.get(window); - if (wrapper) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = adjacentMonitorIndex; - } - } + const enteringEdgeMap = { + 'left': 'right', + 'right': 'left', + 'up': 'bottom', + 'down': 'top' + }; + const enteringEdge = enteringEdgeMap[direction]; - if (window.move_to_monitor) { - window.move_to_monitor(adjacentMonitorIndex); - } + this.handleMonitorTransition(window, monitorId, targetMonitorId, enteringEdge, slot); if (this.controller._scheduleRetile) { this.controller._scheduleRetile(this.workspace, monitorId, currentMonitorIndex); @@ -214,6 +281,7 @@ export class WorkspaceLayout { return false; } + /** * Finds the nearest window in the specified geometric direction and activates it. */ @@ -284,9 +352,9 @@ export class WorkspaceLayout { * Resolves absolute pointer coordinates to a window layout slot index. * Returns -1 if pointer does not intersect any calculated slot estate. */ - getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps) { + getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps, customCount = null) { const tracker = this._getTracker(monitorId); - const windowCount = tracker.size; + const windowCount = customCount !== null ? customCount : tracker.size; const layout = this.escalator.getLayoutForCount(windowCount); if (!layout) return -1; @@ -406,8 +474,39 @@ export class WorkspaceManager { targetMonitorIndex = primaryIndex; } - this.controller.setBatchMode(true); + const activeMonitorId = this.controller.monitorManager ? this.controller.monitorManager.getMonitorId(activeMonitorIndex) : `monitor-${activeMonitorIndex}`; + const targetMonitorId = this.controller.monitorManager ? this.controller.monitorManager.getMonitorId(targetMonitorIndex) : `monitor-${targetMonitorIndex}`; + + const layout = this.getLayout(workspace); + + // Swap layout trackers + const trackerA = layout._getTracker(activeMonitorId); + const trackerB = layout._getTracker(targetMonitorId); + const tempWindowToSlot = trackerA._windowToSlot; + const tempOriginalGeometries = trackerA._originalGeometries; + trackerA._windowToSlot = trackerB._windowToSlot; + trackerA._originalGeometries = trackerB._originalGeometries; + trackerB._windowToSlot = tempWindowToSlot; + trackerB._originalGeometries = tempOriginalGeometries; + + // Update window wrapper cache const windows = workspace.list_windows(); + windows.forEach(w => { + const m = w.get_monitor(); + const wrapper = this.controller._windowWrappers.get(w); + if (wrapper) { + if (m === activeMonitorIndex) { + wrapper.monitorId = targetMonitorId; + wrapper.monitorIndex = targetMonitorIndex; + } else if (m === targetMonitorIndex) { + wrapper.monitorId = activeMonitorId; + wrapper.monitorIndex = activeMonitorIndex; + } + } + }); + + // Move windows + this.controller.setBatchMode(true); windows.forEach(w => { const m = w.get_monitor(); if (m === activeMonitorIndex) { @@ -417,7 +516,14 @@ export class WorkspaceManager { } }); this.controller.setBatchMode(false); - this.controller.hydrate(workspace); + + // Schedule retile rather than hydrate + if (this.controller._scheduleRetile) { + this.controller._scheduleRetile(workspace, activeMonitorId, activeMonitorIndex); + this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); + } else { + this.controller.hydrate(workspace); + } } portMonitorToWorkspace(monitorIndex, direction) { diff --git a/prefs.js b/prefs.js index 55ebb59..063961d 100644 --- a/prefs.js +++ b/prefs.js @@ -131,6 +131,26 @@ export default class WorkflowTilingPreferences extends ExtensionPreferences { page.add(gapsGroup); + // --- Monitor Transition Group --- + const transitionGroup = new Adw.PreferencesGroup({ title: 'Monitor Transition' }); + const transitionRow = new Adw.SwitchRow({ + title: 'Swap Windows', + subtitle: 'Swap windows across monitors instead of escalating/de-escalating' + }); + transitionRow.active = settings.get_string('monitor-transition-behavior') === 'swap'; + transitionRow.connect('notify::active', () => { + settings.set_string('monitor-transition-behavior', transitionRow.active ? 'swap' : 'escalate'); + }); + settings.connect('changed::monitor-transition-behavior', () => { + const active = settings.get_string('monitor-transition-behavior') === 'swap'; + if (transitionRow.active !== active) { + transitionRow.active = active; + } + }); + transitionGroup.add(transitionRow); + page.add(transitionGroup); + + // --- Core Keybindings Group --- const keysGroup = new Adw.PreferencesGroup({ title: 'Keybindings' }); const modeRow = new Adw.ComboRow({ diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled index a700a828b54016afad677e008f1432dfabf88666..21efeafd8aa21da450c041196a1a8a6cc086dde2 100644 GIT binary patch literal 2652 zcmbtVU1%It7`;(bYm>Cwn%1UmY}}_!$?UcX)evI{L9x(OilrLRLMA)2J7Z>N*7?ac zhDaVn;)5typ%s5nYKfpCqM#IH5ecGTi?DR~iuR!qr*@k!6QGqc^@Y@QrAd%it; z_ulXK&f`z1uBqC-h@PF`vCvN45ot|eckaiZ$^31fI3!*Ir|uG>dAkt*;yJzvf*2KJ z;Aa8H&8PFWQ_$0%p<7m3Kk4hXXF9f*E;;UG&T>kMZ(63E=l?|N0ZF$%cs8~d%~jY4 z`<@6quC)qnu(w3m3MTH202jrM2)n>BI$_@r>;<|a?7RHoD~yw|?G#gD7(7-LCv6!g z_!6MC2P0fhy9Mi)z`q06-&_2XI_=HSSHU+B%wO$iAEHisD|E2|=KvROm9x}ocS7$6 zKLV6zUu0D2{9r`@@HK23k z!Z*}ucR*hNmw|t7{c@E$?M=`xg0BFrh2~GF)299hcp3P1Zg`10ZN{5GGjsx%%J!?& zY17Ys@L}q2oZCR1_Fm{kZ~!cRIc`&@oq#?MehWA_)1^?Sy$$+j;O~KJUwqs_o%R;! zSHR1_<*&=9sMBU1Hli`xfZ?~xZ&Ihd3wjEy06$&VmZ{U`xx?U5pyQ?{{h>{L9P9!s zeMio5J#F^S9QbwM=7Y~aOr19CxBxy6Tx9Gi~N)6`a8ETwUDr zGWVgq9eN7f51e`W{NL1RvmcIvIosL5^PPf{b6_YX)7CJ*mY(wmkGf_-b*G00j~Qmp zA2N`=l>gk@kN`OE zT|f)KI}2@rI)wLyZmScPuH@CCCz?cRqC0ZlQm383_pnNcndE`wz%w(+lgWYoJ;`bL zSxOEZ*pF{U#HI#g_C!yzx6#srYgYEi?WkAwt6$X1?fU-jcHEWD_0HBDNf|LjXU2>A zW>3swj=k=h+M#|n^H5*E=c4*~hU5C#z*hphVA^KEd_h-uaeGBItMiX6a=NJc2EPcA z!G)iZ7k)-w_?g=mex~li&j{gFwlCrsrQq16@3@L<=8gDG#QmeX6`Uy?9`;qqKws(n|*>)6K& zS((rcb;@*Ht`NGHRV~#=iP13)@*kHdit9;Pu{K$NFf;K1rk%9{O;_UX(d72{z&ti6 zXtdvY^^A{Q)30$JYO0Sy*5_+Y9let2XN|b^;1>F=aaV?=?IC)n9 literal 2600 zcmbtUU1%It7`;(bYm>HVO`A=B?6N9sYi4(wP?Z=$2to@sQd-o46gt_R-I-=~W}TUB z8kdzkh(xeYR!G4glv+|y31XrCAp0PpDA=MFkxI#9z=|z-P@nXiyEB{F*~J$ZPR^IJ z-+uSr@9*IAs$-~@C;U$ge7tTa@A0(`urvGJ4`hC~PaG1jg1he%qH~)N|KRiR1_YWDea7>S*~GQZfe?grt+pet$2oMSOxx0B;%5VxEGADqSMFy z;Pt=*5%4&o+s7twPXt@>e=o4zM<1B)+v#H$_#t2q5C>}2SGxEX`7Uu<#Bh;=z}VZh zw|EcQJD}^}BCtF+ah^Ku81y;tS)k*W+$?q4eb5)cUjXCJEIdJ-_7>>Z!7IQw7pzt4 zw6{a&<6}Usw>(arb{F&{SOJ2Z(`L>y;00jm@=1&Sw0q!x1^g|r@5YC(Q>V>)u7Jfl zA#OkZ(o@uF^FA@~V?cMY^JD6?H$Z<9JO+I7!Abcp+I-h>@G0Q?Te&;*r_FO`!EXT9 z?$)x@Y43)<2)+#b(R)GWfi};*3SI_QhbAu4pY|r`cfbO|>Tl!EQm4HWdK~;H@Y9|R|i*s8=u$CQK!xO&x79q4pia_b=q5@FM_WEKfn9F%o}aS zSqA?F%zt`L14+9VdJh`35BU4;k2k5)X5NzE4A6f&C+k9+`56Nr1iZ-YJuo(F#IxFqvNn|WIVe?|SXkNW9Pdo%Rw;1yu^__eR8)9#199zzrd zZoRkkJ9XO3a|X=$O_fwnht5wL1DuuvoR$NeUK`+aWPsB`xUOzHUbgHhC9qPoEyJ@N z#nkeR#gbIry$!FdX}g-jIBrSJYCMqNXUnc<7xDAc_|mX)*bza9kz?hO=8PU@#BS-F zx8WXj6C+0)qo_JFqa(+3BkzsssAwp3(~rs}(c(Yl`)iguX=+MAExEj%=n%=ty;%*D)1bMOid^`=)&xfD9rWXd;Ykg;smvCIiDCj}G{P;(uv{9zeE_<32WQ6Y$ zNuyoKcsSE{lUhGnhQnmYTL^$zEg z-Q~Y4G?$HM)rZ{AT6hS}`RNf~eO&kUS~swe8bAnWrlDMMfG diff --git a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml index 3db5d1e..6f67ffe 100644 --- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml +++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml @@ -6,6 +6,12 @@ Enable Gaps Toggle gaps on or off globally. + + 'escalate' + Monitor Transition Behavior + Behavior when window crosses monitor boundary ('escalate' or 'swap'). + + 'default' Keybindings Mode diff --git a/tests/monitor-transition.test.js b/tests/monitor-transition.test.js new file mode 100644 index 0000000..7dcdd46 --- /dev/null +++ b/tests/monitor-transition.test.js @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Layout, ScreenEstate, LayoutParser } from '../lib/layout.js'; +import { WorkspaceLayout } from '../lib/workspace.js'; +import { SettingsManager } from '../lib/settings.js'; +import Meta from 'gi://Meta'; +import Gio from 'gi://Gio'; + +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; + +describe('Edging Tile Identification', () => { + it('should identify edging tile for layout of size 1', () => { + const estates = [new ScreenEstate(0, 0, 100, 100)]; + const layout = new Layout(estates); + expect(layout.getEdgingSlot('left')).toBe(0); + expect(layout.getEdgingSlot('right')).toBe(0); + expect(layout.getEdgingSlot('top')).toBe(0); + expect(layout.getEdgingSlot('bottom')).toBe(0); + }); + + it('should identify edging tile for layout of size 2 (tie-break right/upper)', () => { + // Vertical split: left slot 0, right slot 1 + const estates = [ + new ScreenEstate(0, 0, 50, 100), + new ScreenEstate(50, 0, 50, 100) + ]; + const layout = new Layout(estates); + expect(layout.getEdgingSlot('left')).toBe(0); + expect(layout.getEdgingSlot('right')).toBe(1); + // Tie breaker for top/bottom edge: right-most (slot 1) + expect(layout.getEdgingSlot('top')).toBe(1); + expect(layout.getEdgingSlot('bottom')).toBe(1); + }); + + it('should identify edging tile for layout of size 3 (escalator layout)', () => { + // Left slot 0, top-right slot 1, bottom-right slot 2 + const estates = [ + new ScreenEstate(0, 0, 50, 100), + new ScreenEstate(50, 0, 50, 50), + new ScreenEstate(50, 50, 50, 50) + ]; + const layout = new Layout(estates); + expect(layout.getEdgingSlot('left')).toBe(0); + // Tie breaker for right edge: upper (slot 1) + expect(layout.getEdgingSlot('right')).toBe(1); + // Tie breaker for top edge: right-most (slot 1) + expect(layout.getEdgingSlot('top')).toBe(1); + // Tie breaker for bottom edge: right-most (slot 2) + expect(layout.getEdgingSlot('bottom')).toBe(2); + }); + + it('should prioritize longest edge for edging tile identification', () => { + // Left slot 0 (30 width), top-right slot 1 (70 width, 40 height), bottom-right slot 2 (70 width, 60 height) + const estates = [ + new ScreenEstate(0, 0, 30, 100), + new ScreenEstate(30, 0, 70, 40), + new ScreenEstate(30, 40, 70, 60) + ]; + const layout = new Layout(estates); + expect(layout.getEdgingSlot('left')).toBe(0); + // Right edge: slot 2 has height 60, slot 1 has height 40 -> slot 2 is longer edge + expect(layout.getEdgingSlot('right')).toBe(2); + // Top edge: slot 1 has width 70, slot 0 has width 30 -> slot 1 is longer edge + expect(layout.getEdgingSlot('top')).toBe(1); + // Bottom edge: slot 2 has width 70, slot 0 has width 30 -> slot 2 is longer edge + expect(layout.getEdgingSlot('bottom')).toBe(2); + }); +}); + +describe('Monitor Transitions', () => { + let controller; + let mockMonitorManager; + let mockSettings; + const escalator = LayoutParser.parse(DEFAULT_JSON); + + const createMockWindow = (id, workspace, initialMonitorIndex) => { + let monitor = initialMonitorIndex; + return { + id, + get_workspace: () => workspace, + get_monitor: vi.fn(() => monitor), + get_frame_rect: () => ({ x: monitor * 1920 + 10, y: 10, width: 100, height: 100 }), + move_to_monitor: vi.fn((m) => { monitor = m; }), + get_title: () => `Window ${id}`, + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockMonitorManager = { + getMonitorIndex: vi.fn((id) => id === 'monitor-0' ? 0 : 1), + getMonitorInDirection: vi.fn((idx, dir) => idx === 0 && dir === 'right' ? 1 : (idx === 1 && dir === 'left' ? 0 : -1)), + getMonitorId: vi.fn((idx) => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + + mockSettings = { + getGaps: () => ({ inner: 6, outer: 4 }), + getMonitorTransitionBehavior: vi.fn(() => 'escalate') + }; + + controller = { + escalator: escalator, + monitorManager: mockMonitorManager, + settings: mockSettings, + _windowWrappers: new Map(), + _scheduleRetile: vi.fn() + }; + }); + + describe('Escalate/De-escalate monitor transition behavior', () => { + it('should de-escalate source and escalate target into edging slot', () => { + mockSettings.getMonitorTransitionBehavior.mockReturnValue('escalate'); + const ws = { index: () => 0 }; + const layout = new WorkspaceLayout(ws, controller); + + // Source: 2 windows. Target: 2 windows. + const winA = createMockWindow('A', ws, 0); + const winB = createMockWindow('B', ws, 0); + const winC = createMockWindow('C', ws, 1); + const winD = createMockWindow('D', ws, 1); + + controller._windowWrappers.set(winA, { monitorId: 'monitor-0', monitorIndex: 0 }); + controller._windowWrappers.set(winB, { monitorId: 'monitor-0', monitorIndex: 0 }); + controller._windowWrappers.set(winC, { monitorId: 'monitor-1', monitorIndex: 1 }); + controller._windowWrappers.set(winD, { monitorId: 'monitor-1', monitorIndex: 1 }); + + layout.trackWindow(winA, 'monitor-0'); + layout.trackWindow(winB, 'monitor-0'); + layout.trackWindow(winC, 'monitor-1'); + layout.trackWindow(winD, 'monitor-1'); + + // Move winB to right (cross-monitor movement DP-1 -> HDMI-1) + const result = layout.moveWindowDirection('monitor-0', winB, 'right'); + expect(result).toBe(true); + + const tracker0 = layout._getTracker('monitor-0'); + const tracker1 = layout._getTracker('monitor-1'); + + // Source de-escalates: winB untracked, winA remains at slot 0 + expect(tracker0.size).toBe(1); + expect(tracker0.getSlot(winA)).toBe(0); + expect(tracker0.getSlot(winB)).toBeUndefined(); + + // Target escalates: new layout size is 3. Entering edge is 'left'. + // size 3 'left' edging slot is 0. + // winB should be at slot 0, winC pushed to 1, winD pushed to 2. + expect(tracker1.size).toBe(3); + expect(tracker1.getSlot(winB)).toBe(0); + expect(tracker1.getSlot(winC)).toBe(1); + expect(tracker1.getSlot(winD)).toBe(2); + + expect(winB.move_to_monitor).toHaveBeenCalledWith(1); + expect(controller._windowWrappers.get(winB).monitorId).toBe('monitor-1'); + expect(controller._windowWrappers.get(winB).monitorIndex).toBe(1); + }); + }); + + describe('Swap monitor transition behavior', () => { + it('should swap moved window with target edged window without escalation/de-escalation', () => { + mockSettings.getMonitorTransitionBehavior.mockReturnValue('swap'); + const ws = { index: () => 0 }; + const layout = new WorkspaceLayout(ws, controller); + + // Source: 2 windows. Target: 2 windows. + const winA = createMockWindow('A', ws, 0); + const winB = createMockWindow('B', ws, 0); + const winC = createMockWindow('C', ws, 1); + const winD = createMockWindow('D', ws, 1); + + controller._windowWrappers.set(winA, { monitorId: 'monitor-0', monitorIndex: 0 }); + controller._windowWrappers.set(winB, { monitorId: 'monitor-0', monitorIndex: 0 }); + controller._windowWrappers.set(winC, { monitorId: 'monitor-1', monitorIndex: 1 }); + controller._windowWrappers.set(winD, { monitorId: 'monitor-1', monitorIndex: 1 }); + + layout.trackWindow(winA, 'monitor-0'); + layout.trackWindow(winB, 'monitor-0'); // slot 1 + layout.trackWindow(winC, 'monitor-1'); // slot 0 + layout.trackWindow(winD, 'monitor-1'); // slot 1 + + // Move winB to right + const result = layout.moveWindowDirection('monitor-0', winB, 'right'); + expect(result).toBe(true); + + const tracker0 = layout._getTracker('monitor-0'); + const tracker1 = layout._getTracker('monitor-1'); + + // No change in sizes + expect(tracker0.size).toBe(2); + expect(tracker1.size).toBe(2); + + // winB swaps with winC (edged window on target for entering edge 'left') + // winB gets slot 0 on monitor 1 + // winC gets slot 1 on monitor 0 (winB's old slot) + expect(tracker0.getSlot(winA)).toBe(0); + expect(tracker0.getSlot(winC)).toBe(1); + expect(tracker0.getSlot(winB)).toBeUndefined(); + + expect(tracker1.getSlot(winB)).toBe(0); + expect(tracker1.getSlot(winD)).toBe(1); + expect(tracker1.getSlot(winC)).toBeUndefined(); + + // Check physical movement + expect(winB.move_to_monitor).toHaveBeenCalledWith(1); + expect(winC.move_to_monitor).toHaveBeenCalledWith(0); + + // Check wrapper updates + expect(controller._windowWrappers.get(winB).monitorId).toBe('monitor-1'); + expect(controller._windowWrappers.get(winB).monitorIndex).toBe(1); + expect(controller._windowWrappers.get(winC).monitorId).toBe('monitor-0'); + expect(controller._windowWrappers.get(winC).monitorIndex).toBe(0); + }); + + it('should fall back to moving window if target monitor has no windows', () => { + mockSettings.getMonitorTransitionBehavior.mockReturnValue('swap'); + const ws = { index: () => 0 }; + const layout = new WorkspaceLayout(ws, controller); + + // Source: 1 window. Target: 0 windows. + const winA = createMockWindow('A', ws, 0); + + controller._windowWrappers.set(winA, { monitorId: 'monitor-0', monitorIndex: 0 }); + + layout.trackWindow(winA, 'monitor-0'); + + const result = layout.moveWindowDirection('monitor-0', winA, 'right'); + expect(result).toBe(true); + + const tracker0 = layout._getTracker('monitor-0'); + const tracker1 = layout._getTracker('monitor-1'); + + expect(tracker0.size).toBe(0); + expect(tracker1.size).toBe(1); + expect(tracker1.getSlot(winA)).toBe(0); + + expect(winA.move_to_monitor).toHaveBeenCalledWith(1); + expect(controller._windowWrappers.get(winA).monitorId).toBe('monitor-1'); + expect(controller._windowWrappers.get(winA).monitorIndex).toBe(1); + }); + }); +}); + +describe('Settings Binding Toggle Updates', () => { + let mockGioSettings; + let mockExtension; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGioSettings = { + connect: vi.fn((signal, cb) => { + if (signal.startsWith('changed::')) { + mockGioSettings._callbacks = mockGioSettings._callbacks || {}; + mockGioSettings._callbacks[signal] = cb; + } + return 123; + }), + disconnect: vi.fn(), + get_boolean: vi.fn(() => false), + get_int: vi.fn(() => 0), + get_string: vi.fn((key) => { + if (key === 'monitor-transition-behavior') return 'swap'; + return ''; + }) + }; + + mockExtension = { + getSettings: () => mockGioSettings + }; + }); + + it('should connect settings change signal for monitor-transition-behavior', () => { + const onSettingsChanged = vi.fn(); + const settingsManager = new SettingsManager(mockExtension, onSettingsChanged); + + // Verify connected change handler + expect(mockGioSettings.connect).toHaveBeenCalledWith( + 'changed::monitor-transition-behavior', + expect.any(Function) + ); + + // Verify loading of setting + expect(mockGioSettings.get_string).toHaveBeenCalledWith('monitor-transition-behavior'); + + // Trigger the signal callback + const callback = mockGioSettings._callbacks['changed::monitor-transition-behavior']; + expect(callback).toBeDefined(); + + callback(); + + expect(onSettingsChanged).toHaveBeenCalled(); + }); +}); diff --git a/tests/regressions.test.js b/tests/regressions.test.js new file mode 100644 index 0000000..a3a2fd4 --- /dev/null +++ b/tests/regressions.test.js @@ -0,0 +1,334 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TilingController } from '../lib/controller.js'; +import { LayoutParser } from '../lib/layout.js'; +import { WorkspaceLayout } from '../lib/workspace.js'; +import Meta from 'gi://Meta'; + +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; +const escalator = LayoutParser.parse(DEFAULT_JSON); + +describe('Regressions', () => { + beforeEach(() => { + global.get_current_time = vi.fn(() => 1234); + }); + + describe('R1: Focus Jumping over Monitor Boundary', () => { + it('should select the closest edge window on the target monitor when focusing right over boundary', () => { + const mockMonitorManager = { + getMonitorIndex: vi.fn(id => id === 'monitor-0' ? 0 : 1), + getMonitorInDirection: vi.fn((idx, dir) => idx === 0 && dir === 'right' ? 1 : -1), + getMonitorId: vi.fn(idx => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + const controller = { + escalator: escalator, + monitorManager: mockMonitorManager + }; + const layout = new WorkspaceLayout({}, controller); + + const winSource = { + id: 'source', + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 0, width: 1000, height: 1000 }), + activate: vi.fn() + }; + const winClose = { + id: 'close', + get_monitor: () => 1, + get_frame_rect: () => ({ x: 1000, y: 0, width: 500, height: 1000 }), + activate: vi.fn() + }; + const winFar = { + id: 'far', + get_monitor: () => 1, + get_frame_rect: () => ({ x: 1500, y: 0, width: 500, height: 1000 }), + activate: vi.fn() + }; + + layout.trackWindow(winSource, 'monitor-0'); + layout.trackWindow(winClose, 'monitor-1'); + layout.trackWindow(winFar, 'monitor-1'); + + const result = layout.focusWindowDirection('monitor-0', winSource, 'right'); + expect(result).toBe(true); + expect(winClose.activate).toHaveBeenCalled(); + expect(winFar.activate).not.toHaveBeenCalled(); + }); + + it('should select the closest edge window on the target monitor when focusing up over boundary', () => { + const mockMonitorManager = { + getMonitorIndex: vi.fn(id => id === 'monitor-1' ? 1 : 0), + getMonitorInDirection: vi.fn((idx, dir) => idx === 1 && dir === 'up' ? 0 : -1), + getMonitorId: vi.fn(idx => idx === 0 ? 'monitor-0' : 'monitor-1') + }; + const controller = { + escalator: escalator, + monitorManager: mockMonitorManager + }; + const layout = new WorkspaceLayout({}, controller); + + const winSource = { + id: 'source', + get_monitor: () => 1, + get_frame_rect: () => ({ x: 0, y: 1000, width: 1000, height: 1000 }), + activate: vi.fn() + }; + const winClose = { + id: 'close', + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 500, width: 1000, height: 500 }), + activate: vi.fn() + }; + const winFar = { + id: 'far', + get_monitor: () => 0, + get_frame_rect: () => ({ x: 0, y: 0, width: 1000, height: 500 }), + activate: vi.fn() + }; + + layout.trackWindow(winSource, 'monitor-1'); + layout.trackWindow(winClose, 'monitor-0'); + layout.trackWindow(winFar, 'monitor-0'); + + const result = layout.focusWindowDirection('monitor-1', winSource, 'up'); + expect(result).toBe(true); + expect(winClose.activate).toHaveBeenCalled(); + expect(winFar.activate).not.toHaveBeenCalled(); + }); + }); + + describe('R3: Drag-and-Drop Swap Slot Confusion', () => { + let controller; + let ws; + + beforeEach(() => { + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + vi.mocked(manager.get_primary_monitor).mockReturnValue(0); + + global.display.get_primary_monitor = () => manager.get_primary_monitor(); + global.display.get_current_monitor = vi.fn(() => 1); + + const mockSettings = { + getGaps: () => ({ inner: 6, outer: 4 }), + getMonitorTransitionBehavior: () => 'swap', + settings: { + get_boolean: () => false + } + }; + + TilingController.activeInstance = null; + controller = new TilingController(mockSettings); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + controller.monitorManager.initializeMonitorState(); + + ws = { + id: 'ws1', + get_work_area_for_monitor: vi.fn((idx) => { + if (idx === 1) return { x: 1000, y: 0, width: 1000, height: 1000 }; + return { x: 0, y: 0, width: 1000, height: 1000 }; + }), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }) + }; + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const createMockWindow = (id, workspace, initialMonitor) => { + let monitor = initialMonitor; + return { + id, + get_workspace: () => workspace, + get_monitor: vi.fn(() => monitor), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }), + get_frame_rect: () => ({ + x: monitor === 1 ? 1010 : 10, + y: 10, + width: 100, + height: 100 + }), + move_resize_frame: vi.fn(), + move_to_monitor: vi.fn((m) => { monitor = m; }), + get_title: () => `Window ${id}`, + get_id: () => id, + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false, + connect: vi.fn(() => 123), + disconnect: vi.fn(), + handler_is_connected: vi.fn(() => true), + minimize: vi.fn(function() { this.minimized = true; }), + unminimize: vi.fn(function() { this.minimized = false; }), + delete: vi.fn() + }; + }; + + it('should swap dragged window with target window exactly and not shift other windows', () => { + const winA = createMockWindow('A', ws, 0); + const winB = createMockWindow('B', ws, 0); + const winC = createMockWindow('C', ws, 1); + const winD = createMockWindow('D', ws, 1); + + controller.tilingRequest(winA); + controller.tilingRequest(winB); + controller.tilingRequest(winC); + controller.tilingRequest(winD); + + const layout = controller.workspaceManager.getLayout(ws); + const sourceTracker = layout._getTracker('monitor-0'); + const targetTracker = layout._getTracker('monitor-1'); + + expect(sourceTracker.getSlot(winA)).toBe(0); + expect(sourceTracker.getSlot(winB)).toBe(1); + expect(targetTracker.getSlot(winC)).toBe(0); + expect(targetTracker.getSlot(winD)).toBe(1); + + controller.dragManager.startDragTracking(winB); + + global.get_pointer = vi.fn(() => [1200, 500]); + + const dragInfo = controller.dragManager._activeDrag; + expect(dragInfo).toBeDefined(); + + controller.dragManager._handlePositionChanged( + controller._windowWrappers.get(winB), + layout, + sourceTracker, + 1, + dragInfo.indicator + ); + + expect(dragInfo.lastHoveredSlot).toBe(0); + expect(dragInfo.lastHoveredMonitorId).toBe('monitor-1'); + + const retileSpy = vi.spyOn(controller, '_scheduleRetile').mockImplementation(() => {}); + controller.dragManager.endDragTracking(winB); + + expect(targetTracker.getSlot(winB)).toBe(0); + expect(targetTracker.getSlot(winD)).toBe(1); + expect(sourceTracker.getSlot(winC)).toBe(1); + expect(sourceTracker.getSlot(winA)).toBe(0); + }); + }); + + describe('R4: Swap Monitor Keyboard Shortcut', () => { + let controller; + let ws; + + beforeEach(() => { + const manager = Meta.Backend.get_monitor_manager(); + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + vi.mocked(manager.get_primary_monitor).mockReturnValue(0); + + global.display.get_primary_monitor = () => manager.get_primary_monitor(); + global.display.get_current_monitor = vi.fn(() => 0); + + const mockSettings = { + getGaps: () => ({ inner: 6, outer: 4 }), + getMonitorTransitionBehavior: () => 'escalate', + settings: { + get_boolean: () => false + } + }; + + TilingController.activeInstance = null; + controller = new TilingController(mockSettings); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + controller.monitorManager.initializeMonitorState(); + + ws = { + id: 'ws1', + get_work_area_for_monitor: vi.fn((idx) => { + if (idx === 1) return { x: 1000, y: 0, width: 1000, height: 1000 }; + return { x: 0, y: 0, width: 1000, height: 1000 }; + }), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }), + list_windows: vi.fn() + }; + global.workspace_manager.get_active_workspace = () => ws; + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const createMockWindow = (id, workspace, initialMonitor) => { + let monitor = initialMonitor; + return { + id, + get_workspace: () => workspace, + get_monitor: vi.fn(() => monitor), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 2000, height: 1000 }), + get_frame_rect: () => ({ + x: monitor === 1 ? 1010 : 10, + y: 10, + width: 100, + height: 100 + }), + move_resize_frame: vi.fn(), + move_to_monitor: vi.fn((m) => { monitor = m; }), + get_title: () => `Window ${id}`, + get_id: () => id, + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false, + connect: vi.fn(() => 123), + disconnect: vi.fn(), + handler_is_connected: vi.fn(() => true), + minimize: vi.fn(function() { this.minimized = true; }), + unminimize: vi.fn(function() { this.minimized = false; }), + delete: vi.fn() + }; + }; + + it('should successfully swap all windows between monitors and preserve slots', () => { + const winA = createMockWindow(101, ws, 0); + const winB = createMockWindow(102, ws, 0); + const winC = createMockWindow(201, ws, 1); + const winD = createMockWindow(202, ws, 1); + + ws.list_windows.mockReturnValue([winA, winB, winC, winD]); + + controller.tilingRequest(winA); + controller.tilingRequest(winB); + controller.tilingRequest(winC); + controller.tilingRequest(winD); + + const layout = controller.workspaceManager.getLayout(ws); + const sourceTracker = layout._getTracker('monitor-0'); + const targetTracker = layout._getTracker('monitor-1'); + + expect(sourceTracker.getSlot(winA)).toBe(0); + expect(sourceTracker.getSlot(winB)).toBe(1); + expect(targetTracker.getSlot(winC)).toBe(0); + expect(targetTracker.getSlot(winD)).toBe(1); + + controller.switchMonitors(0); + + expect(sourceTracker.getSlot(winC)).toBe(0); + expect(sourceTracker.getSlot(winD)).toBe(1); + expect(targetTracker.getSlot(winA)).toBe(0); + expect(targetTracker.getSlot(winB)).toBe(1); + + expect(winA.get_monitor()).toBe(1); + expect(winB.get_monitor()).toBe(1); + expect(winC.get_monitor()).toBe(0); + expect(winD.get_monitor()).toBe(0); + }); + }); +}); diff --git a/tests/setup.js b/tests/setup.js index af4c822..2464f3c 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -76,7 +76,8 @@ global.display = { get_current_monitor: vi.fn(() => 0), get_focus_window: vi.fn(() => null), list_all_windows: vi.fn(() => []), - get_monitor_index_for_rect: vi.fn(() => -1), + get_n_monitors: vi.fn(() => 2), + get_monitor_geometry: vi.fn((index) => mockMonitorManager.get_logical_monitors()[index]?.rect || { x: 0, y: 0, width: 1920, height: 1080 }), connect: vi.fn(), disconnect: vi.fn() }; @@ -132,5 +133,11 @@ vi.mock('resource:///org/gnome/shell/ui/main.js', () => ({ wm: { addKeybinding: vi.fn(), removeKeybinding: vi.fn() + }, + layoutManager: { + uiGroup: { + add_child: vi.fn(), + remove_child: vi.fn() + } } })); From cd20743cb9618cd10f241c0f32e6b4739b728dc9 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 14 Jun 2026 08:46:53 +0200 Subject: [PATCH 03/10] gitignore .agents --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 07f3420..5351dc7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ CLAUDE.md CODEX.md CURSOR.md GEMINI.md +.agents coverage/ From 69f598750077639f439bd741a08c207eddd3fe93 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 14 Jun 2026 10:30:53 +0200 Subject: [PATCH 04/10] refactor: first iteration --- lib/controller.js | 14 +-- lib/drag.js | 105 +++++------------ lib/keybindings.js | 10 +- lib/monitor.js | 10 +- lib/workspace.js | 41 +++---- tests/adversarial.test.js | 230 ++++++++++++++++++++++++++++++++++++++ tests/workspace.test.js | 30 ++--- 7 files changed, 311 insertions(+), 129 deletions(-) create mode 100644 tests/adversarial.test.js diff --git a/lib/controller.js b/lib/controller.js index faf90a4..3fba26a 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -120,15 +120,13 @@ export class TilingController { const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); const targetRect = global.display.get_monitor_geometry(monitorIndex); + const dx = targetRect.x - sourceRect.x; + const dy = targetRect.y - sourceRect.y; let enteringEdge = 'left'; - if (targetRect.x > sourceRect.x) { - enteringEdge = 'left'; - } else if (targetRect.x < sourceRect.x) { - enteringEdge = 'right'; - } else if (targetRect.y > sourceRect.y) { - enteringEdge = 'top'; - } else if (targetRect.y < sourceRect.y) { - enteringEdge = 'bottom'; + if (Math.abs(dx) >= Math.abs(dy)) { + enteringEdge = dx > 0 ? 'left' : 'right'; + } else { + enteringEdge = dy > 0 ? 'top' : 'bottom'; } layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); diff --git a/lib/drag.js b/lib/drag.js index 22de3f0..2230ac1 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -146,7 +146,7 @@ export class DragManager { } } else { indicator.hide(); - this._revertVisualSwap(tracker, layout, monitorRect, gaps); + this._revertVisualSwap(layout, gaps); } } @@ -161,7 +161,7 @@ export class DragManager { if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; // Revert previous hover - this._revertVisualSwap(tracker, layout, monitorRect, gaps); + this._revertVisualSwap(layout, gaps); const matrix = layout.escalator.getLayoutForCount(tracker.size); if (matrix) { @@ -203,7 +203,7 @@ export class DragManager { } } } else { - this._revertVisualSwap(sourceTracker, layout, sourceMonitorRect, gaps); + this._revertVisualSwap(layout, gaps); } // Apply new cross-monitor visual swap preview using size N+1 matrix @@ -260,7 +260,7 @@ export class DragManager { /** * Restores window geometry to its original slot when the pointer leaves an active tile. */ - _revertVisualSwap(tracker, layout, monitorRect, gaps) { + _revertVisualSwap(layout, gaps, clearState = true) { if (!this._activeDrag || this._activeDrag.lastHoveredSlot === -1) return; const lastMonId = this._activeDrag.lastHoveredMonitorId; @@ -287,35 +287,9 @@ export class DragManager { } } - this._activeDrag.lastHoveredSlot = -1; - this._activeDrag.lastHoveredMonitorId = null; - } - - _revertVisualSwapForEnd(tracker, layout, monitorRect, gaps) { - if (!this._activeDrag || this._activeDrag.lastHoveredSlot === -1) return; - - const lastMonId = this._activeDrag.lastHoveredMonitorId; - const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); - const sourceMonId = wrapper ? wrapper.monitorId : null; - - if (lastMonId && lastMonId !== sourceMonId) { - const lastTracker = layout._getTracker(lastMonId); - const lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); - if (lastMonitorIndex !== -1) { - const workspace = wrapper ? wrapper.workspace : layout.workspace; - const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); - this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); - } - } - - if (sourceMonId) { - const sourceTracker = layout._getTracker(sourceMonId); - const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); - if (sourceMonitorIndex !== -1) { - const workspace = wrapper ? wrapper.workspace : layout.workspace; - const sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); - this._restoreTrackerGeometries(sourceTracker, layout, sourceMonitorRect, gaps); - } + if (clearState) { + this._activeDrag.lastHoveredSlot = -1; + this._activeDrag.lastHoveredMonitorId = null; } } @@ -382,8 +356,7 @@ export class DragManager { const layout = this.controller.workspaceManager.getLayout(workspace); // Revert temporary visual swaps before performing final tracking and retile - const sourceTracker = layout._getTracker(wrapper.monitorId); - this._revertVisualSwapForEnd(sourceTracker, layout, monitorRect, gaps); + this._revertVisualSwap(layout, gaps, false); window.disconnect(activeDrag.signalId); if (activeDrag.indicator) { @@ -403,45 +376,31 @@ export class DragManager { const sourceMonitorIndex = wrapper.monitorIndex; const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; - if (behavior === 'swap') { - const targetTracker = layout._getTracker(lastHoveredMonitorId); - const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === lastHoveredSlot); - - if (targetWindow) { - layout.untrackWindow(window, sourceMonitorId); - layout.untrackWindow(targetWindow, lastHoveredMonitorId); + const targetTracker = layout._getTracker(lastHoveredMonitorId); + const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === lastHoveredSlot); - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; + if (behavior === 'swap' && targetWindow) { + layout.untrackWindow(window, sourceMonitorId); + layout.untrackWindow(targetWindow, lastHoveredMonitorId); - const targetWrapper = this.controller._windowWrappers.get(targetWindow); - if (targetWrapper) { - targetWrapper.monitorId = sourceMonitorId; - targetWrapper.monitorIndex = sourceMonitorIndex; - } + wrapper.monitorId = lastHoveredMonitorId; + wrapper.monitorIndex = targetMonitorIndex; - targetTracker.track(window, lastHoveredSlot); - const sourceTracker = layout._getTracker(sourceMonitorId); - sourceTracker.track(targetWindow, activeDrag.originalSlot); + const targetWrapper = this.controller._windowWrappers.get(targetWindow); + if (targetWrapper) { + targetWrapper.monitorId = sourceMonitorId; + targetWrapper.monitorIndex = sourceMonitorIndex; + } - if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); - if (sourceMonitorIndex !== -1) targetWindow.move_to_monitor(sourceMonitorIndex); + targetTracker.track(window, lastHoveredSlot); + const sourceTracker = layout._getTracker(sourceMonitorId); + sourceTracker.track(targetWindow, activeDrag.originalSlot); - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); - } else { - layout.untrackWindow(window, sourceMonitorId); - - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - - layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); - - if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); - - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); - } + if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); + if (sourceMonitorIndex !== -1) targetWindow.move_to_monitor(sourceMonitorIndex); + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); } else { layout.untrackWindow(window, sourceMonitorId); @@ -449,11 +408,9 @@ export class DragManager { wrapper.monitorIndex = targetMonitorIndex; layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); - - if (targetMonitorIndex !== -1) { - window.move_to_monitor(targetMonitorIndex); - } - + + if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); } diff --git a/lib/keybindings.js b/lib/keybindings.js index a011a9b..02a183e 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -54,12 +54,12 @@ export class KeybindingManager { // Batch Utilities const utilities = { - 'shortcut-close-monitor': (c, win) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), - 'shortcut-close-workspace': (c, win) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), + 'shortcut-close-monitor': (c) => c.closeMonitorWindows(global.display.get_current_monitor(), c.settings.settings.get_boolean('close-monitor-include-minimized')), + 'shortcut-close-workspace': (c) => c.closeWorkspaceWindows(global.workspace_manager.get_active_workspace()), 'shortcut-switch-monitor': (c, win) => c.switchMonitors(win ? win.get_monitor() : global.display.get_current_monitor()), - 'shortcut-port-monitor-left': (c, win) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), - 'shortcut-port-monitor-right': (c, win) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), - 'shortcut-unminimize-workspace': (c, win) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) + 'shortcut-port-monitor-left': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'left'), + 'shortcut-port-monitor-right': (c) => c.portMonitorToWorkspace(global.display.get_current_monitor(), 'right'), + 'shortcut-unminimize-workspace': (c) => c.unminimizeWorkspace(global.workspace_manager.get_active_workspace()) }; for (const [key, action] of Object.entries(utilities)) { diff --git a/lib/monitor.js b/lib/monitor.js index b70bf34..b9d9ec3 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -275,12 +275,12 @@ export class MonitorManager { const logicalMonitors = manager.get_logical_monitors(); const sourceMonitor = logicalMonitors[currentMonitorIndex]; if (!sourceMonitor) { - Logger.info(`getMonitorInDirection: invalid sourceMonitor for index ${currentMonitorIndex}`); + Logger.debug(`getMonitorInDirection: invalid sourceMonitor for index ${currentMonitorIndex}`); return -1; } const sRect = global.display.get_monitor_geometry(currentMonitorIndex); - Logger.info(`[DEBUG] getMonitorInDirection: source monitor ${currentMonitorIndex} rect: ${sRect.x}, ${sRect.y}, ${sRect.width}, ${sRect.height}`); + Logger.debug(`getMonitorInDirection: source monitor ${currentMonitorIndex} rect: ${sRect.x}, ${sRect.y}, ${sRect.width}, ${sRect.height}`); let candidates = []; const eps = 1; @@ -306,7 +306,7 @@ export class MonitorManager { dist = cRect.y - (sRect.y + sRect.height); } - Logger.info(`getMonitorInDirection: checking monitor ${i} (${cRect.x},${cRect.y},${cRect.width},${cRect.height}) for direction ${direction}: inDirection=${inDirection}`); + Logger.debug(`getMonitorInDirection: checking monitor ${i} (${cRect.x},${cRect.y},${cRect.width},${cRect.height}) for direction ${direction}: inDirection=${inDirection}`); if (inDirection) { let overlap = 0; @@ -320,7 +320,7 @@ export class MonitorManager { } if (candidates.length === 0) { - Logger.info('getMonitorInDirection: no candidates found'); + Logger.debug('getMonitorInDirection: no candidates found'); return -1; } @@ -334,7 +334,7 @@ export class MonitorManager { return a.index - b.index; }); - Logger.info(`getMonitorInDirection: best candidate is ${candidates[0].index}`); + Logger.debug(`getMonitorInDirection: best candidate is ${candidates[0].index}`); return candidates[0].index; } catch (e) { Logger.error(`Failed to get monitor in direction ${direction}`, e); diff --git a/lib/workspace.js b/lib/workspace.js index ccb4c6a..aa5e07c 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -8,7 +8,7 @@ export class WorkspaceLayout { constructor(workspace, controller) { this.workspace = workspace; this.controller = controller; - this.escalator = (controller && controller.escalator) ? controller.escalator : controller; + this.escalator = controller.escalator; this.monitors = new Map(); } @@ -122,20 +122,20 @@ export class WorkspaceLayout { } let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; - Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); } - Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1) return null; if (this.controller && this.controller.monitorManager) { - Logger.info(`[DEBUG] calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); - Logger.info(`[DEBUG] getMonitorInDirection returned ${adjacentMonitorIndex}`); - Logger.info(`[DEBUG] _getTargetWindowInDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); + Logger.debug(`getMonitorInDirection returned ${adjacentMonitorIndex}`); + Logger.debug(`_getTargetWindowInDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); if (adjacentMonitorIndex !== -1) { const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); const targetTracker = this._getTracker(targetMonitorId); @@ -147,10 +147,12 @@ export class WorkspaceLayout { } /** - * Finds the nearest window in the specified geometric direction and swaps slots. - * Computes orthogonal overlap and distance to determine the best candidate. + * Handles the transition of a window moving between monitors. + * Depending on configuration, it either swaps the window with another window + * at the entering edge of the target monitor, or scales/escalates the layouts. */ handleMonitorTransition(window, sourceMonitorId, targetMonitorId, enteringEdge, sourceSlot) { + if (!this.controller) return; const sourceTracker = this._getTracker(sourceMonitorId); const targetTracker = this._getTracker(targetMonitorId); @@ -245,20 +247,20 @@ export class WorkspaceLayout { } let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; - Logger.info(`[DEBUG] _getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); } - Logger.info(`[DEBUG] moveWindowDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`moveWindowDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1) return false; if (this.controller && this.controller.monitorManager) { - Logger.info(`[DEBUG] calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); + Logger.debug(`calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); - Logger.info(`[DEBUG] getMonitorInDirection returned ${adjacentMonitorIndex}`); - Logger.info(`[DEBUG] moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); + Logger.debug(`getMonitorInDirection returned ${adjacentMonitorIndex}`); + Logger.debug(`moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); if (adjacentMonitorIndex !== -1) { const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); const enteringEdgeMap = { @@ -271,7 +273,7 @@ export class WorkspaceLayout { this.handleMonitorTransition(window, monitorId, targetMonitorId, enteringEdge, slot); - if (this.controller._scheduleRetile) { + if (this.controller && this.controller._scheduleRetile) { this.controller._scheduleRetile(this.workspace, monitorId, currentMonitorIndex); this.controller._scheduleRetile(this.workspace, targetMonitorId, adjacentMonitorIndex); } @@ -465,14 +467,7 @@ export class WorkspaceManager { const numMonitors = manager.get_logical_monitors().length; if (numMonitors < 2) return; - let targetMonitorIndex; - if (numMonitors === 2) { - targetMonitorIndex = activeMonitorIndex === 0 ? 1 : 0; - } else { - const primaryIndex = global.display.get_primary_monitor(); - if (activeMonitorIndex === primaryIndex) return; - targetMonitorIndex = primaryIndex; - } + const targetMonitorIndex = (activeMonitorIndex + 1) % numMonitors; const activeMonitorId = this.controller.monitorManager ? this.controller.monitorManager.getMonitorId(activeMonitorIndex) : `monitor-${activeMonitorIndex}`; const targetMonitorId = this.controller.monitorManager ? this.controller.monitorManager.getMonitorId(targetMonitorIndex) : `monitor-${targetMonitorIndex}`; @@ -518,7 +513,7 @@ export class WorkspaceManager { this.controller.setBatchMode(false); // Schedule retile rather than hydrate - if (this.controller._scheduleRetile) { + if (this.controller && this.controller._scheduleRetile) { this.controller._scheduleRetile(workspace, activeMonitorId, activeMonitorIndex); this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); } else { diff --git a/tests/adversarial.test.js b/tests/adversarial.test.js new file mode 100644 index 0000000..83e10d3 --- /dev/null +++ b/tests/adversarial.test.js @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TilingController } from '../lib/controller.js'; +import { LayoutParser } from '../lib/layout.js'; +import Meta from 'gi://Meta'; + +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; + +describe('Adversarial Tests', () => { + describe('enteringEdge calculation in tilingRequest', () => { + let controller; + let mockMonitorGeometries; + + beforeEach(() => { + vi.clearAllMocks(); + const manager = Meta.Backend.get_monitor_manager(); + + // Set up mock monitor list + vi.mocked(manager.get_logical_monitors).mockReturnValue([ + { get_monitors: () => [{ get_stable_id: () => 'monitor-0', get_connector: () => 'DP-1' }] }, + { get_monitors: () => [{ get_stable_id: () => 'monitor-1', get_connector: () => 'HDMI-1' }] } + ]); + vi.mocked(manager.get_primary_monitor).mockReturnValue(0); + global.display.get_primary_monitor = () => manager.get_primary_monitor(); + + TilingController.activeInstance = null; + controller = new TilingController(); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + controller.monitorManager.initializeMonitorState(); + + mockMonitorGeometries = { + 0: { x: 0, y: 0, width: 1920, height: 1080 }, + 1: { x: 0, y: 0, width: 1920, height: 1080 } + }; + + global.display.get_monitor_geometry = vi.fn(idx => mockMonitorGeometries[idx]); + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const testEnteringEdge = (sourceRect, targetRect) => { + mockMonitorGeometries[0] = sourceRect; + mockMonitorGeometries[1] = targetRect; + + const ws = { id: 'ws1', index: () => 0, get_work_area_for_monitor: () => ({ x: 0, y: 0, width: 1000, height: 1000 }) }; + const win = { + id: 'win1', + get_workspace: () => ws, + get_monitor: vi.fn(() => 1), + get_work_area_all_monitors: () => ({ x: 0, y: 0, width: 3840, height: 1080 }), + get_frame_rect: () => ({ x: 10, y: 10, width: 100, height: 100 }), + move_resize_frame: vi.fn(), + move_to_monitor: vi.fn(), + get_title: () => 'Window 1', + unmaximize: vi.fn(), + maximized_horizontally: false, + maximized_vertically: false, + minimized: false, + connect: vi.fn(() => 123), + disconnect: vi.fn(), + handler_is_connected: vi.fn(() => true) + }; + + // Setup wrapper to make it look like a monitor change + const wrapper = { + workspace: ws, + monitorIndex: 0, + monitorId: 'monitor-0', + applyGeometry: vi.fn(), + bindSignals: vi.fn(), + bindSizeChanged: vi.fn(), + destroy: vi.fn() + }; + controller._windowWrappers.set(win, wrapper); + + // Spy on handleMonitorTransition + const layout = controller.workspaceManager.getLayout(ws); + const spy = vi.spyOn(layout, 'handleMonitorTransition').mockImplementation(() => {}); + + controller.tilingRequest(win); + + expect(spy).toHaveBeenCalled(); + const calledEdge = spy.mock.calls[0][3]; + return calledEdge; + }; + + it('should detect top edge when target is below source', () => { + const edge = testEnteringEdge( + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 0, y: 1080, width: 1920, height: 1080 } + ); + expect(edge).toBe('top'); + }); + + it('should detect bottom edge when target is above source', () => { + const edge = testEnteringEdge( + { x: 0, y: 1080, width: 1920, height: 1080 }, + { x: 0, y: 0, width: 1920, height: 1080 } + ); + expect(edge).toBe('bottom'); + }); + + it('should detect left edge when target is right of source', () => { + const edge = testEnteringEdge( + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 1920, y: 0, width: 1920, height: 1080 } + ); + expect(edge).toBe('left'); + }); + + it('should detect right edge when target is left of source', () => { + const edge = testEnteringEdge( + { x: 1920, y: 0, width: 1920, height: 1080 }, + { x: 0, y: 0, width: 1920, height: 1080 } + ); + expect(edge).toBe('right'); + }); + + it('should handle vertical stack with slight horizontal misalignment', () => { + const edge = testEnteringEdge( + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 10, y: 1080, width: 1920, height: 1080 } + ); + expect(edge).toBe('top'); + }); + + it('should handle diagonal layout (45 degrees)', () => { + const edge = testEnteringEdge( + { x: 0, y: 0, width: 1000, height: 1000 }, + { x: 1000, y: 1000, width: 1000, height: 1000 } + ); + expect(edge).toBe('left'); // Math.abs(dx) >= Math.abs(dy) (1000 >= 1000) -> left + }); + }); + + describe('switchMonitors modulo arithmetic', () => { + let controller; + beforeEach(() => { + vi.clearAllMocks(); + TilingController.activeInstance = null; + controller = new TilingController(); + controller.setEscalator(LayoutParser.parse(DEFAULT_JSON)); + }); + + afterEach(() => { + if (controller) { + controller.clear(); + } + TilingController.activeInstance = null; + }); + + const setupMonitors = (num) => { + const manager = Meta.Backend.get_monitor_manager(); + const list = []; + for (let i = 0; i < num; i++) { + list.push({ + get_monitors: () => [{ + get_stable_id: () => `monitor-${i}`, + get_connector: () => `CONN-${i}` + }] + }); + } + vi.mocked(manager.get_logical_monitors).mockReturnValue(list); + controller.monitorManager.initializeMonitorState(); + }; + + it('should cycle correctly on 3 monitors', () => { + setupMonitors(3); + const ws = { + id: 'ws1', + list_windows: () => [win0, win1, win2], + get_work_area_for_monitor: () => ({ x: 0, y: 0, width: 1000, height: 1000 }) + }; + global.workspace_manager.get_active_workspace.mockReturnValue(ws); + + const win0 = { move_to_monitor: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false }; + const win1 = { move_to_monitor: vi.fn(), get_monitor: () => 1, is_skip_taskbar: () => false }; + const win2 = { move_to_monitor: vi.fn(), get_monitor: () => 2, is_skip_taskbar: () => false }; + + // Wrap them + controller._windowWrappers.set(win0, { monitorId: 'monitor-0', monitorIndex: 0, destroy: vi.fn() }); + controller._windowWrappers.set(win1, { monitorId: 'monitor-1', monitorIndex: 1, destroy: vi.fn() }); + controller._windowWrappers.set(win2, { monitorId: 'monitor-2', monitorIndex: 2, destroy: vi.fn() }); + + // Switch monitors with active index = 0 + // targetMonitorIndex = (0 + 1) % 3 = 1 + // Windows on 0 should move to 1, windows on 1 should move to 0. Win2 remains untouched. + controller.switchMonitors(0); + + expect(win0.move_to_monitor).toHaveBeenCalledWith(1); + expect(win1.move_to_monitor).toHaveBeenCalledWith(0); + expect(win2.move_to_monitor).not.toHaveBeenCalled(); + }); + + it('should cycle correctly on 4 monitors', () => { + setupMonitors(4); + const ws = { + id: 'ws1', + list_windows: () => [win0, win1, win2, win3], + get_work_area_for_monitor: () => ({ x: 0, y: 0, width: 1000, height: 1000 }) + }; + global.workspace_manager.get_active_workspace.mockReturnValue(ws); + + const win0 = { move_to_monitor: vi.fn(), get_monitor: () => 0, is_skip_taskbar: () => false }; + const win1 = { move_to_monitor: vi.fn(), get_monitor: () => 1, is_skip_taskbar: () => false }; + const win2 = { move_to_monitor: vi.fn(), get_monitor: () => 2, is_skip_taskbar: () => false }; + const win3 = { move_to_monitor: vi.fn(), get_monitor: () => 3, is_skip_taskbar: () => false }; + + // Wrap them + controller._windowWrappers.set(win0, { monitorId: 'monitor-0', monitorIndex: 0, destroy: vi.fn() }); + controller._windowWrappers.set(win1, { monitorId: 'monitor-1', monitorIndex: 1, destroy: vi.fn() }); + controller._windowWrappers.set(win2, { monitorId: 'monitor-2', monitorIndex: 2, destroy: vi.fn() }); + controller._windowWrappers.set(win3, { monitorId: 'monitor-3', monitorIndex: 3, destroy: vi.fn() }); + + // Switch monitors with active index = 2 + // targetMonitorIndex = (2 + 1) % 4 = 3 + // Windows on 2 should move to 3, windows on 3 should move to 2. Others untouched. + controller.switchMonitors(2); + + expect(win2.move_to_monitor).toHaveBeenCalledWith(3); + expect(win3.move_to_monitor).toHaveBeenCalledWith(2); + expect(win0.move_to_monitor).not.toHaveBeenCalled(); + expect(win1.move_to_monitor).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 08883eb..2f9b1bf 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -5,10 +5,11 @@ import { LayoutParser } from '../lib/layout.js'; describe('WorkspaceLayout', () => { const defaultJson = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; const escalator = LayoutParser.parse(defaultJson); + const controller = { escalator }; const monitorRect = { x: 0, y: 0, width: 1000, height: 1000 }; it('should track windows in sequence with gaps', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const win1 = { id: 1 }; const win2 = { id: 2 }; @@ -22,7 +23,7 @@ describe('WorkspaceLayout', () => { }); it('should handle multiple monitors independently', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const rectM0 = { x: 0, y: 0, width: 1920, height: 1080 }; const rectM1 = { x: 1920, y: 0, width: 1920, height: 1080 }; @@ -39,7 +40,7 @@ describe('WorkspaceLayout', () => { }); it('should handle different resolutions on different monitors', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const rect4K = { x: 0, y: 0, width: 3840, height: 2160 }; const rectHD = { x: 3840, y: 0, width: 1920, height: 1080 }; @@ -56,7 +57,7 @@ describe('WorkspaceLayout', () => { }); it('should provide retile operations when a window is removed', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; const w2 = { id: 2 }; const w3 = { id: 3 }; @@ -73,7 +74,7 @@ describe('WorkspaceLayout', () => { }); it('should maintain independent window counts across monitors', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); layout.trackWindow({ id: 1 }, 0); layout.trackWindow({ id: 2 }, 1); layout.trackWindow({ id: 3 }, 1); @@ -84,7 +85,7 @@ describe('WorkspaceLayout', () => { describe('moveWindowDirection', () => { it('should correctly swap windows left/right with 2 windows', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; const w2 = { id: 2 }; layout.trackWindow(w1, 0); @@ -103,7 +104,7 @@ describe('WorkspaceLayout', () => { }); it('should correctly prioritize older windows when moving left/right in 3-window layout', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; // left const w2 = { id: 2 }; // top right const w3 = { id: 3 }; // bottom right @@ -122,7 +123,7 @@ describe('WorkspaceLayout', () => { }); it('should swap up/down in 3-window layout', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; // left const w2 = { id: 2 }; // top right const w3 = { id: 3 }; // bottom right @@ -140,7 +141,7 @@ describe('WorkspaceLayout', () => { }); it('should not move if no window in that direction', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1 }; // left const w2 = { id: 2 }; // right @@ -160,7 +161,7 @@ describe('WorkspaceLayout', () => { const mockRect = { x: 0, y: 0, width: 1000, height: 1000 }; it('should swap windows if center is dropped over another window', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const w1 = { id: 1, get_frame_rect: () => ({ x: 700, y: 100, width: 100, height: 100 }) }; // dropped center at (750, 150), which is in the right half const w2 = { id: 2, get_frame_rect: () => ({ x: 500, y: 0, width: 500, height: 1000 }) }; @@ -177,7 +178,7 @@ describe('WorkspaceLayout', () => { }); it('should not swap if dropped outside of any other window', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); // Dropped way off screen (e.g. invalid drag or over a panel) const w1 = { id: 1, get_frame_rect: () => ({ x: 2000, y: 2000, width: 100, height: 100 }) }; const w2 = { id: 2, get_frame_rect: () => ({ x: 500, y: 0, width: 500, height: 1000 }) }; @@ -195,7 +196,7 @@ describe('WorkspaceLayout', () => { }); it('should not swap with itself if dropped in its own original area', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); // Dropped in the left half (its own area in 2-window layout) const w1 = { id: 1, get_frame_rect: () => ({ x: 100, y: 100, width: 100, height: 100 }) }; const w2 = { id: 2, get_frame_rect: () => ({ x: 500, y: 0, width: 500, height: 1000 }) }; @@ -328,10 +329,11 @@ describe('WorkspaceManager', () => { describe('WorkspaceLayout Cross-Monitor Fallback', () => { const defaultJson = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}'; const escalator = LayoutParser.parse(defaultJson); + const controller = { escalator }; describe('_findClosestBoundaryWindow', () => { it('should choose the window with highest overlap on adjacent edge', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const targetTracker = { size: 2, windows: [ @@ -346,7 +348,7 @@ describe('WorkspaceLayout Cross-Monitor Fallback', () => { }); it('should resolve ties using top-most/right-most tie breakers', () => { - const layout = new WorkspaceLayout({}, escalator); + const layout = new WorkspaceLayout({}, controller); const targetTrackerY = { size: 2, windows: [ From a587658765e33bb9984bed4dbb8c6a15872dc356 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 14 Jun 2026 11:01:20 +0200 Subject: [PATCH 05/10] refactor: 2 iteration --- docs/architecture.md | 12 + docs/vision.md | 2 +- lib/controller.js | 13 +- lib/drag.js | 207 +++++++-------- lib/keybindings.js | 20 +- lib/state.js | 9 + lib/workspace.js | 98 ++++--- multi_monitor_refactoring_plan.md | 423 ------------------------------ tests/monitor-transition.test.js | 6 +- tests/regressions.test.js | 1 - tests/workspace.test.js | 10 +- 11 files changed, 199 insertions(+), 602 deletions(-) delete mode 100644 multi_monitor_refactoring_plan.md diff --git a/docs/architecture.md b/docs/architecture.md index a760552..9a172b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,6 +14,7 @@ Tracks monitor topology. Detects hotplug events. Identifies stable monitor IDs. Manages window evacuation during monitor removal. +Provides directional monitor lookup via `getMonitorInDirection()` for cross-monitor transitions. ## SignalListener (`lib/signals.js`) Binds GNOME Shell signals. @@ -40,9 +41,11 @@ Binds single-shot `size-changed` signals to detect external resizing. ## WorkspaceManager & WorkspaceLayout (`lib/workspace.js`) `WorkspaceManager` tracks multiple layouts across GNOME workspaces. +`WorkspaceManager` provides batch monitor operations (close, switch, port to workspace). `WorkspaceLayout` tracks windows per workspace and monitor. Calculates window slots based on insertion order. Provides window displacement and swapping logic. +Handles cross-monitor window transitions via configurable swap or escalate behavior. ## StateTracker (`lib/state.js`) Maintains stable ordered list of windows. @@ -53,10 +56,12 @@ Swaps window positions. Tracks window drag-and-drop operations. Renders visual swap indicators. Triggers geometric swapping based on pointer intersections. +Handles cross-monitor drag transitions with visual previews and deferred retiles. ## SettingsManager (`lib/settings.js`) Loads configuration preferences. Parses layout JSON into valid escalator transitions. +Exposes monitor transition behavior configuration. ## Logger (`lib/logger.js`) Provides debug and trace logging. @@ -65,6 +70,7 @@ Configurable output verbosity. ## Escalator (`lib/layout.js`) Generates tile geometries. Provides geometric estates based on current window count. +Provides edge-adjacent slot lookup via `getEdgingSlot()` for directional transitions. ## Execution Flow Signal triggers event. @@ -73,3 +79,9 @@ Controller updates WorkspaceLayout state. Controller schedules deferred retile. Retile queries Escalator for layouts. Retile invokes WindowWrapper to apply geometries. + +## Cross-Monitor Flow +Keyboard/drag triggers direction detection. +Controller/DragManager delegates to WorkspaceLayout. +WorkspaceLayout escalates or swaps window between monitor trackers. +Controller schedules retile on both monitors. diff --git a/docs/vision.md b/docs/vision.md index 9b6a7e1..c39a2bf 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -21,7 +21,7 @@ Layout transitions are predictable, minimal, and fully customizable. The system - **Error Shielding**: Every Mutter API interaction is wrapped in defensive checks and error handling to prevent Shell crashes. ### Scaling & Isolation -- **Multi-Monitor Support**: Each monitor maintains an independent tiling state and respects its own work area/resolution. +- **Multi-Monitor Support**: Each monitor maintains an independent tiling state and respects its own work area/resolution. Monitors support cross-monitor window transitions via keyboard and drag, with configurable swap/escalate behavior. - **Workspace Isolation**: Tiling is scoped per GNOME workspace. ## Future Roadmap diff --git a/lib/controller.js b/lib/controller.js index 3fba26a..146aa85 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -114,8 +114,8 @@ export class TilingController { if (isMonitorChange) { const sourceMonitorId = wrapper.monitorId; const sourceMonitorIndex = wrapper.monitorIndex; - const sourceTracker = layout._getTracker(sourceMonitorId); - const sourceSlot = sourceTracker.getSlot(window) !== undefined ? sourceTracker.getSlot(window) : 0; + const slot = layout.getWindowSlot(sourceMonitorId, window); + const sourceSlot = slot !== undefined ? slot : 0; const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); const targetRect = global.display.get_monitor_geometry(monitorIndex); @@ -129,7 +129,14 @@ export class TilingController { enteringEdge = dy > 0 ? 'top' : 'bottom'; } - layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + const result = layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + + this.updateWindowWrapperMonitor(window, monitorId, monitorIndex); + + if (result && result.swappedWindow) { + this.updateWindowWrapperMonitor(result.swappedWindow, sourceMonitorId, sourceMonitorIndex); + if (result.swappedWindow.move_to_monitor) result.swappedWindow.move_to_monitor(sourceMonitorIndex); + } Logger.debug(`tilingRequest: Monitor transition handled. Scheduling retile.`); this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); diff --git a/lib/drag.js b/lib/drag.js index 2230ac1..81ab97b 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -1,6 +1,6 @@ -import Gio from 'gi://Gio'; +import Meta from 'gi://Meta'; import St from 'gi://St'; -import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import { Logger } from './logger.js'; /** * DragManager: Manages pointer drag tracking and visual drop indicators. @@ -34,17 +34,17 @@ export class DragManager { const workspace = wrapper.workspace; const layout = this.controller.workspaceManager.getLayout(workspace); - const tracker = layout._getTracker(wrapper.monitorId); - const originalSlot = tracker.getSlot(window); + const windowCount = layout.getWindowCount(wrapper.monitorId); + const originalSlot = layout.getWindowSlot(wrapper.monitorId, window); if (originalSlot === undefined) return; - const matrix = layout.escalator.getLayoutForCount(tracker.size); + const matrix = layout.escalator.getLayoutForCount(windowCount); if (!matrix || originalSlot >= matrix.size) return; const indicator = this._createIndicator(); const signalId = window.connect('position-changed', () => { - this._handlePositionChanged(wrapper, layout, tracker, originalSlot, indicator); + this._handlePositionChanged(wrapper, layout, originalSlot, indicator); }); const origRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; @@ -81,7 +81,7 @@ export class DragManager { * Continuously handles window pointer positioning during an active drag, * triggering visual slot swaps when the pointer crosses bounds. */ - _handlePositionChanged(wrapper, layout, tracker, originalSlot, indicator) { + _handlePositionChanged(wrapper, layout, originalSlot, indicator) { const workspace = wrapper.workspace; if (!workspace.get_work_area_for_monitor) return; @@ -94,13 +94,13 @@ export class DragManager { } const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); - const targetTracker = layout._getTracker(monitorId); + const targetWindowCount = layout.getWindowCount(monitorId); const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); let hoveredSlot = -1; let targetRect = null; - if (targetTracker.size === 0) { + if (targetWindowCount === 0) { hoveredSlot = 0; targetRect = { x: monitorRect.x + gaps.outer, @@ -110,7 +110,7 @@ export class DragManager { }; } else { const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; - const matrixCount = (monitorId === wrapper.monitorId || behavior === 'swap') ? targetTracker.size : (targetTracker.size + 1); + const matrixCount = (monitorId === wrapper.monitorId || behavior === 'swap') ? targetWindowCount : (targetWindowCount + 1); if (matrixCount > layout.escalator.getMaxCount()) { hoveredSlot = -1; @@ -140,9 +140,9 @@ export class DragManager { indicator.show(); if (monitorId !== wrapper.monitorId) { - this._applyCrossMonitorVisualSwap(wrapper, targetTracker, layout, monitorId, hoveredSlot, monitorRect, gaps); + this._applyCrossMonitorVisualSwap(wrapper, targetWindowCount, layout, monitorId, hoveredSlot, monitorRect, gaps); } else { - this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); + this._applyVisualSwap(monitorId, layout, originalSlot, hoveredSlot, monitorRect, gaps); } } else { indicator.hide(); @@ -154,43 +154,47 @@ export class DragManager { * Applies a temporary visual preview of window positions, reverting the * previously hovered window and shifting the newly hovered window. */ - _applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps) { + _applyVisualSwap(monitorId, layout, originalSlot, hoveredSlot, monitorRect, gaps) { if (!this._activeDrag) return; const wrapper = this.controller._windowWrappers.get(this._activeDrag.window); const sourceMonId = wrapper ? wrapper.monitorId : null; - if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; - // Revert previous hover - this._revertVisualSwap(layout, gaps); + if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; - const matrix = layout.escalator.getLayoutForCount(tracker.size); - if (matrix) { - // Apply new hover (move hovered window to dragged window's original slot) - this._restoreWindowGeometry(tracker, matrix, hoveredSlot, originalSlot, monitorRect, gaps); + // If we were hovering a different monitor previously, revert it first + if (this._activeDrag.lastHoveredMonitorId && this._activeDrag.lastHoveredMonitorId !== sourceMonId) { + this._revertVisualSwap(layout, gaps, true); } + const windowCount = layout.getWindowCount(monitorId); + const matrix = layout.escalator.getLayoutForCount(windowCount); + if (!matrix) return; + this._activeDrag.lastHoveredSlot = hoveredSlot; this._activeDrag.lastHoveredMonitorId = sourceMonId; + + this._restoreWindowGeometry(monitorId, layout, matrix, hoveredSlot, originalSlot, monitorRect, gaps); } - _applyCrossMonitorVisualSwap(wrapper, targetTracker, layout, monitorId, hoveredSlot, monitorRect, gaps) { + _applyCrossMonitorVisualSwap(wrapper, targetWindowCount, layout, monitorId, hoveredSlot, monitorRect, gaps) { if (!this._activeDrag) return; if (this._activeDrag.lastHoveredMonitorId === monitorId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; // Revert previous hover - const sourceTracker = layout._getTracker(wrapper.monitorId); const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; if (behavior === 'escalate') { - const sourceMatrix = layout.escalator.getLayoutForCount(sourceTracker.size > 0 ? sourceTracker.size - 1 : 0); + const sourceCount = layout.getWindowCount(wrapper.monitorId); + const sourceMatrix = layout.escalator.getLayoutForCount(sourceCount > 0 ? sourceCount - 1 : 0); if (sourceMatrix) { const origSlot = this._activeDrag.originalSlot; - for (const win of sourceTracker.windows) { + const windows = layout.getWindowsForMonitor(wrapper.monitorId); + for (const win of windows) { if (win === this._activeDrag.window) continue; - const slot = sourceTracker.getSlot(win); + const slot = layout.getWindowSlot(wrapper.monitorId, win); if (slot !== undefined) { const targetSlot = (slot > origSlot) ? (slot - 1) : slot; const wrap = this.controller._windowWrappers.get(win); @@ -207,19 +211,18 @@ export class DragManager { } // Apply new cross-monitor visual swap preview using size N+1 matrix - const matrix = layout.escalator.getLayoutForCount(behavior === 'swap' && targetTracker.size > 0 ? targetTracker.size : targetTracker.size + 1); + const matrix = layout.escalator.getLayoutForCount(behavior === 'swap' && targetWindowCount > 0 ? targetWindowCount : targetWindowCount + 1); if (matrix) { - if (behavior === 'swap' && targetTracker.size > 0) { - // In swap mode, we visually move the hovered target window to the source window's slot - const sourceTracker = layout._getTracker(wrapper.monitorId); - const sourceMatrix = layout.escalator.getLayoutForCount(sourceTracker.size); + if (behavior === 'swap' && targetWindowCount > 0) { + const sourceMatrix = layout.escalator.getLayoutForCount(layout.getWindowCount(wrapper.monitorId)); const sourceEstate = sourceMatrix ? sourceMatrix.getEstate(this._activeDrag.originalSlot) : null; const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(wrapper.monitorId); const sourceMonitorRect = wrapper.workspace.get_work_area_for_monitor(sourceMonitorIndex); - for (const win of targetTracker.windows) { - const slot = targetTracker.getSlot(win); + const windows = layout.getWindowsForMonitor(monitorId); + for (const win of windows) { + const slot = layout.getWindowSlot(monitorId, win); if (slot !== undefined) { const wrap = this.controller._windowWrappers.get(win); if (wrap) { @@ -237,9 +240,9 @@ export class DragManager { } } } else { - // Escalate N+1 preview - for (const win of targetTracker.windows) { - const slot = targetTracker.getSlot(win); + const windows = layout.getWindowsForMonitor(monitorId); + for (const win of windows) { + const slot = layout.getWindowSlot(monitorId, win); if (slot !== undefined) { const targetEstateSlot = (slot >= hoveredSlot) ? (slot + 1) : slot; const wrap = this.controller._windowWrappers.get(win); @@ -268,22 +271,20 @@ export class DragManager { const sourceMonId = wrapper ? wrapper.monitorId : null; if (lastMonId && lastMonId !== sourceMonId) { - const lastTracker = layout._getTracker(lastMonId); const lastMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastMonId); if (lastMonitorIndex !== -1) { const workspace = wrapper ? wrapper.workspace : layout.workspace; const lastMonitorRect = workspace.get_work_area_for_monitor(lastMonitorIndex); - this._restoreTrackerGeometries(lastTracker, layout, lastMonitorRect, gaps); + this._restoreTrackerGeometries(lastMonId, layout, lastMonitorRect, gaps); } } if (sourceMonId) { - const sourceTracker = layout._getTracker(sourceMonId); const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonId); if (sourceMonitorIndex !== -1) { const workspace = wrapper ? wrapper.workspace : layout.workspace; const sourceMonitorRect = workspace.get_work_area_for_monitor(sourceMonitorIndex); - this._restoreTrackerGeometries(sourceTracker, layout, sourceMonitorRect, gaps); + this._restoreTrackerGeometries(sourceMonId, layout, sourceMonitorRect, gaps); } } @@ -293,13 +294,14 @@ export class DragManager { } } - _restoreTrackerGeometries(tracker, layout, monitorRect, gaps) { - const matrix = layout.escalator.getLayoutForCount(tracker.size); + _restoreTrackerGeometries(monitorId, layout, monitorRect, gaps) { + const matrix = layout.escalator.getLayoutForCount(layout.getWindowCount(monitorId)); if (!matrix) return; const draggedWindow = this._activeDrag ? this._activeDrag.window : null; - for (const win of tracker.windows) { + const windows = layout.getWindowsForMonitor(monitorId); + for (const win of windows) { if (win === draggedWindow) continue; - const slot = tracker.getSlot(win); + const slot = layout.getWindowSlot(monitorId, win); if (slot !== undefined) { const wrap = this.controller._windowWrappers.get(win); if (wrap) { @@ -313,9 +315,8 @@ export class DragManager { } } - _restoreWindowGeometry(tracker, matrix, slotToFind, targetEstateSlot, monitorRect, gaps) { - if (!matrix || targetEstateSlot >= matrix.size) return; - const win = tracker.windows.find(w => tracker.getSlot(w) === slotToFind); + _restoreWindowGeometry(monitorId, layout, matrix, slotToFind, targetEstateSlot, monitorRect, gaps) { + const win = layout.getWindowsForMonitor(monitorId).find(w => layout.getWindowSlot(monitorId, w) === slotToFind); if (!win) return; const wrap = this.controller._windowWrappers.get(win); if (wrap) { @@ -365,55 +366,8 @@ export class DragManager { this._activeDrag = null; - if (this._deferredRetiles && this._deferredRetiles.length > 0) { - this._deferredRetiles.forEach(r => this.controller._scheduleRetile(r.workspace, r.monitorId, r.monitorIndex)); - this._deferredRetiles = []; - } - if (lastHoveredMonitorId && lastHoveredMonitorId !== wrapper.monitorId) { - const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(lastHoveredMonitorId); - const sourceMonitorId = wrapper.monitorId; - const sourceMonitorIndex = wrapper.monitorIndex; - const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; - - const targetTracker = layout._getTracker(lastHoveredMonitorId); - const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === lastHoveredSlot); - - if (behavior === 'swap' && targetWindow) { - layout.untrackWindow(window, sourceMonitorId); - layout.untrackWindow(targetWindow, lastHoveredMonitorId); - - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - - const targetWrapper = this.controller._windowWrappers.get(targetWindow); - if (targetWrapper) { - targetWrapper.monitorId = sourceMonitorId; - targetWrapper.monitorIndex = sourceMonitorIndex; - } - - targetTracker.track(window, lastHoveredSlot); - const sourceTracker = layout._getTracker(sourceMonitorId); - sourceTracker.track(targetWindow, activeDrag.originalSlot); - - if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); - if (sourceMonitorIndex !== -1) targetWindow.move_to_monitor(sourceMonitorIndex); - - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); - } else { - layout.untrackWindow(window, sourceMonitorId); - - wrapper.monitorId = lastHoveredMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - - layout.trackWindow(window, lastHoveredMonitorId, lastHoveredSlot !== -1 ? lastHoveredSlot : undefined); - - if (targetMonitorIndex !== -1) window.move_to_monitor(targetMonitorIndex); - - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this.controller._scheduleRetile(workspace, lastHoveredMonitorId, targetMonitorIndex); - } + this._commitCrossMonitorTransfer(window, wrapper, layout, lastHoveredMonitorId, lastHoveredSlot, activeDrag.originalSlot); } else { const [x, y] = global.get_pointer(); @@ -431,27 +385,54 @@ export class DragManager { const pointerMonitorId = this.controller.monitorManager.getMonitorId(pointerMonitorIndex); if (pointerMonitorId && pointerMonitorId !== wrapper.monitorId) { - const sourceMonitorId = wrapper.monitorId; - const sourceMonitorIndex = wrapper.monitorIndex; - - layout.untrackWindow(window, sourceMonitorId); - - wrapper.monitorId = pointerMonitorId; - wrapper.monitorIndex = pointerMonitorIndex; - - if (pointerMonitorIndex !== -1) window.move_to_monitor(pointerMonitorIndex); - - this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + // Pointer fallback cross-monitor drop + this._commitCrossMonitorTransfer(window, wrapper, layout, pointerMonitorId, -1, activeDrag.originalSlot, pointerMonitorIndex); } else { - const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); - - const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; - const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; - - if (swapped || rectChanged) { - this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); - } + this._commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect); } } } + + _commitCrossMonitorTransfer(window, wrapper, layout, targetMonitorId, targetSlot, sourceSlot, targetMonitorIndexOverride = -1) { + const sourceMonitorId = wrapper.monitorId; + const sourceMonitorIndex = wrapper.monitorIndex; + const workspace = wrapper.workspace; + let targetMonitorIndex = targetMonitorIndexOverride !== -1 ? targetMonitorIndexOverride : this.controller.monitorManager.getMonitorIndex(targetMonitorId); + + const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; + const targetWindows = layout.getWindowsForMonitor(targetMonitorId); + const targetWindow = targetWindows.find(w => layout.getWindowSlot(targetMonitorId, w) === targetSlot); + + if (behavior === 'swap' && targetWindow) { + layout.replaceWindow(targetWindow, window, targetMonitorId); + layout.replaceWindow(window, targetWindow, sourceMonitorId); + + this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); + this.controller.updateWindowWrapperMonitor(targetWindow, sourceMonitorId, sourceMonitorIndex); + + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + if (sourceMonitorIndex !== -1 && targetWindow.move_to_monitor) targetWindow.move_to_monitor(sourceMonitorIndex); + + } else { + layout.untrackWindow(window, sourceMonitorId); + layout.trackWindow(window, targetMonitorId, targetSlot !== -1 ? targetSlot : undefined); + + this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + } + + this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this.controller._scheduleRetile(workspace, targetMonitorId, targetMonitorIndex); + } + + _commitSameMonitorDrop(window, wrapper, layout, x, y, monitorRect, gaps, origRect) { + const swapped = layout.swapWindowByPointer(wrapper.monitorId, window, x, y, monitorRect, gaps); + + const currRect = window.get_frame_rect ? window.get_frame_rect() : { x: 0, y: 0, width: 0, height: 0 }; + const rectChanged = currRect.x !== origRect.x || currRect.y !== origRect.y || currRect.width !== origRect.width || currRect.height !== origRect.height; + + if (swapped || rectChanged) { + this.controller._scheduleRetile(wrapper.workspace, wrapper.monitorId, wrapper.monitorIndex); + } + } } diff --git a/lib/keybindings.js b/lib/keybindings.js index 02a183e..d49889a 100644 --- a/lib/keybindings.js +++ b/lib/keybindings.js @@ -83,14 +83,20 @@ export class KeybindingManager { const keysToShadow = []; for (const def of this._definitions) { - const { active, keyToBind } = this._resolveBinding(def); + const { active, keyToBind, isCustom } = this._resolveBinding(def); if (!active) { Logger.debug(`Binding ${def.defaultKey} is inactive.`); continue; } - Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, conflict=${def.conflict}`); - keysToShadow.push(keyToBind); + Logger.debug(`Resolved binding for ${def.defaultKey}: keyToBind=${keyToBind}, isCustom=${isCustom}, conflict=${def.conflict}`); + + if (!isCustom && def.conflict) { + Logger.debug(`Will hijack native conflict ${def.conflict} instead of binding extension shortcut.`); + conflictsToHijack.push(def.conflict); + } else { + keysToShadow.push(keyToBind); + } } if (keysToShadow.length > 0) { @@ -99,11 +105,13 @@ export class KeybindingManager { // Bind extension shortcuts for (const def of this._definitions) { - const { active, keyToBind } = this._resolveBinding(def); - if (active) { + const { active, keyToBind, isCustom } = this._resolveBinding(def); + if (active && !(!isCustom && def.conflict)) { this._bindExtensionShortcut(def, keyToBind); } } + + conflictsToHijack.forEach(conflictKey => this._hijackNativeShortcut(conflictKey)); } _resolveBinding(def) { @@ -120,7 +128,7 @@ export class KeybindingManager { } } - return { active, keyToBind }; + return { active, keyToBind, isCustom }; } _bindExtensionShortcut(def, keyToBind) { diff --git a/lib/state.js b/lib/state.js index cdb924b..680a5d7 100644 --- a/lib/state.js +++ b/lib/state.js @@ -56,4 +56,13 @@ export class StateTracker { this._windowToSlot.clear(); this._originalGeometries.clear(); } + + swapWith(otherTracker) { + const tempWindowToSlot = this._windowToSlot; + const tempOriginalGeometries = this._originalGeometries; + this._windowToSlot = otherTracker._windowToSlot; + this._originalGeometries = otherTracker._originalGeometries; + otherTracker._windowToSlot = tempWindowToSlot; + otherTracker._originalGeometries = tempOriginalGeometries; + } } diff --git a/lib/workspace.js b/lib/workspace.js index aa5e07c..3c1e5d5 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -40,6 +40,33 @@ export class WorkspaceLayout { tracker.untrack(window); } + /** + * Replaces an existing window with a new window in the exact same slot. + */ + replaceWindow(oldWindow, newWindow, monitorId) { + const tracker = this._getTracker(monitorId); + const slot = tracker.getSlot(oldWindow); + if (slot !== undefined) { + tracker.untrack(oldWindow); + tracker.track(newWindow, slot); + } + } + + /** + * Tracker proxy methods + */ + getWindowSlot(monitorId, window) { + return this._getTracker(monitorId).getSlot(window); + } + + getWindowsForMonitor(monitorId) { + return this._getTracker(monitorId).windows; + } + + getWindowCount(monitorId) { + return this._getTracker(monitorId).size; + } + /** * Calculates absolute rects for all currently tracked windows on a monitor. */ @@ -122,7 +149,6 @@ export class WorkspaceLayout { } let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; - Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); } @@ -152,13 +178,10 @@ export class WorkspaceLayout { * at the entering edge of the target monitor, or scales/escalates the layouts. */ handleMonitorTransition(window, sourceMonitorId, targetMonitorId, enteringEdge, sourceSlot) { - if (!this.controller) return; + if (!this.controller) return null; const sourceTracker = this._getTracker(sourceMonitorId); const targetTracker = this._getTracker(targetMonitorId); - const sourceMonitorIndex = this.controller.monitorManager.getMonitorIndex(sourceMonitorId); - const targetMonitorIndex = this.controller.monitorManager.getMonitorIndex(targetMonitorId); - const behavior = this.controller.settings ? this.controller.settings.getMonitorTransitionBehavior() : 'escalate'; if (behavior === 'swap' && targetTracker.size > 0) { @@ -174,24 +197,7 @@ export class WorkspaceLayout { targetTracker.track(window, targetEdgingSlot); sourceTracker.track(targetWindow, sourceSlot); - const wrapper = this.controller._windowWrappers.get(window); - if (wrapper) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - } - const targetWrapper = this.controller._windowWrappers.get(targetWindow); - if (targetWrapper) { - targetWrapper.monitorId = sourceMonitorId; - targetWrapper.monitorIndex = sourceMonitorIndex; - } - - if (window.move_to_monitor) { - window.move_to_monitor(targetMonitorIndex); - } - if (targetWindow.move_to_monitor) { - targetWindow.move_to_monitor(sourceMonitorIndex); - } - return; + return { swappedWindow: targetWindow, behavior: 'swap' }; } } } @@ -209,16 +215,7 @@ export class WorkspaceLayout { } this.trackWindow(window, targetMonitorId, preferredSlot); - - const wrapper = this.controller._windowWrappers.get(window); - if (wrapper) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - } - - if (window.move_to_monitor) { - window.move_to_monitor(targetMonitorIndex); - } + return { swappedWindow: null, behavior: 'escalate' }; } /** @@ -247,12 +244,9 @@ export class WorkspaceLayout { } let currentMonitorIndex = window.get_monitor ? window.get_monitor() : -1; - Logger.debug(`_getTargetWindowInDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1 && this.controller && this.controller.monitorManager) { currentMonitorIndex = this.controller.monitorManager.getMonitorIndex(monitorId); } - - Logger.debug(`moveWindowDirection: targetSlot=-1, currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); if (currentMonitorIndex === -1) return false; @@ -260,7 +254,6 @@ export class WorkspaceLayout { Logger.debug(`calling getMonitorInDirection with currentMonitorIndex=${currentMonitorIndex}, direction=${direction}`); const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); Logger.debug(`getMonitorInDirection returned ${adjacentMonitorIndex}`); - Logger.debug(`moveWindowDirection: adjacentMonitorIndex=${adjacentMonitorIndex}`); if (adjacentMonitorIndex !== -1) { const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); const enteringEdgeMap = { @@ -271,7 +264,16 @@ export class WorkspaceLayout { }; const enteringEdge = enteringEdgeMap[direction]; - this.handleMonitorTransition(window, monitorId, targetMonitorId, enteringEdge, slot); + const result = this.handleMonitorTransition(window, monitorId, targetMonitorId, enteringEdge, slot); + + // Update wrappers and physical monitors + this.controller.updateWindowWrapperMonitor(window, targetMonitorId, adjacentMonitorIndex); + if (window.move_to_monitor) window.move_to_monitor(adjacentMonitorIndex); + + if (result && result.swappedWindow) { + this.controller.updateWindowWrapperMonitor(result.swappedWindow, monitorId, currentMonitorIndex); + if (result.swappedWindow.move_to_monitor) result.swappedWindow.move_to_monitor(currentMonitorIndex); + } if (this.controller && this.controller._scheduleRetile) { this.controller._scheduleRetile(this.workspace, monitorId, currentMonitorIndex); @@ -477,26 +479,16 @@ export class WorkspaceManager { // Swap layout trackers const trackerA = layout._getTracker(activeMonitorId); const trackerB = layout._getTracker(targetMonitorId); - const tempWindowToSlot = trackerA._windowToSlot; - const tempOriginalGeometries = trackerA._originalGeometries; - trackerA._windowToSlot = trackerB._windowToSlot; - trackerA._originalGeometries = trackerB._originalGeometries; - trackerB._windowToSlot = tempWindowToSlot; - trackerB._originalGeometries = tempOriginalGeometries; + trackerA.swapWith(trackerB); // Update window wrapper cache const windows = workspace.list_windows(); windows.forEach(w => { const m = w.get_monitor(); - const wrapper = this.controller._windowWrappers.get(w); - if (wrapper) { - if (m === activeMonitorIndex) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = targetMonitorIndex; - } else if (m === targetMonitorIndex) { - wrapper.monitorId = activeMonitorId; - wrapper.monitorIndex = activeMonitorIndex; - } + if (m === activeMonitorIndex) { + this.controller.updateWindowWrapperMonitor(w, targetMonitorId, targetMonitorIndex); + } else if (m === targetMonitorIndex) { + this.controller.updateWindowWrapperMonitor(w, activeMonitorId, activeMonitorIndex); } }); diff --git a/multi_monitor_refactoring_plan.md b/multi_monitor_refactoring_plan.md deleted file mode 100644 index f130473..0000000 --- a/multi_monitor_refactoring_plan.md +++ /dev/null @@ -1,423 +0,0 @@ -# Multi-Monitor Refactoring Plan - -## Architecture Analysis & Responsibility Shifts - -This section details updates to files and classes to support workspaces spanning multiple displays. - -| File Path | Target Class | Role & Proposed Architecture Updates | -|---|---|---| -| `lib/workspace.js` | `WorkspaceLayout` | Allocates slots per workspace and monitor. Handles cross-monitor actions (like switching monitors, closing monitor windows, and workspace porting) and integrates cross-monitor navigation fallback by querying adjacent monitor state trackers when spatial boundaries are reached. | -| `lib/drag.js` | `DragManager` | Tracks drag positions. Implements cross-monitor pointer-to-slot mapping, renders visual indicators on target monitors, and coordinates visual swaps across monitor boundaries. | -| `lib/keybindings.js` | `KeybindingManager` | Resolves and binds keyboard shortcuts. Delegates focus-switching and window-movement actions to the controller, supporting boundary traversal to adjacent displays. | -| `lib/monitor.js` | `MonitorManager` | Tracks display topologies. Listens to hardware changes via backend signals, and manages transient storage for windows evicted by monitor unplug events (leaving cross-monitor actions to Workspace). | -| `lib/controller.js` | `TilingController` | Orchestrates operations. Coordinates window state changes between workspace layouts, coordinates drag state initialization/termination, and dispatches batch updates to avoid layout thrashing. | - -### Responsibility Changes - -* `WorkspaceLayout` / `WorkspaceManager`: Tracks logical state mapped per monitor ID. Spatial search fallbacks to adjacent monitors when keyboard navigation hits display edges. Handles cross-monitor actions (switching monitors, closing monitor windows, workspace porting). -* `DragManager`: Evaluates pointer location globally. Renders indicators relative to target displays and manages preview transitions across screens. -* `MonitorManager`: Listens to topology updates, matching logical monitors to stable physical IDs. Manages transient storage/metadata for windows evicted by monitor unplug events (uses existing evacuation logic, does not destroy windows, preserves state). -* `TilingController`: Dispatches coordinates and sizes to the correct target workspace layout based on pointer position or active focus. - ---- - -## Core Multi-Monitor Scenarios - -### Dynamic Handling of Monitor Hotplug Events - -* Proposed GNOME Shell Signals: - * `monitors-changed`: Connected via `global.backend.get_monitor_manager()` to detect display hardware hotplug and configuration updates. - * `size-changed`: Connected on tracked windows or workspace boundaries to trigger retiling upon size changes or display resolution alterations. -* Evacuation Logic: - * Detects removed displays by comparing active stable monitor IDs against cached IDs. - * Use existing logic; do not destroy windows, preserve state. Specifically, minimize windows instead of deleting/destroying them, untrack them, and record their original monitor, workspace, and slot in `MonitorManager._evacuatedWindows` for later restoration. -* Restoration Logic: - * Restores minimized windows to their original slots when matching displays reconnect. - * Triggers workspace hydration to update tiling allocations on target displays. - -### Cross-Monitor Drag-and-Drop - -* Pointer Intersection Tracking: - * Resolves absolute coordinates from `global.get_pointer()` during window drag. - * Queries `global.display.get_monitor_index_for_rect` to identify the monitor containing the pointer. -* Visual Drop Feedback: - * Renders a `St.Widget` backdrop overlay within the resolved slot geometry on the target display. - * Applies visual preview layout updates on the target display by shifting target windows out of the hovered slot. - -### Keyboard Shortcuts for Cross-Monitor Focus and Movement - -* Focus Navigation Fallback: - * Triggers when intra-monitor focus search returns no window in the requested direction. - * Locates the adjacent monitor index and targets the corresponding slot tracker. - * Focuses the boundary window on the adjacent display. - * Goalslot fallback: Resolve via adjacent edge. If multiple windows intersect, pick the one with highest overlap. If equal overlap, pick the top/right one. -* Window Transference: - * Moves the active window to the adjacent monitor when moving past the monitor border. - * Registers the window with the target monitor's state tracker and triggers retiling on both screens. - * Shift cross-monitor actions (like moving windows across monitors, switching monitors, closing monitor windows, workspace porting) from Monitor to Workspace. - ---- - -## Mermaid Diagrams - -### Hotplug Event Handling Sequence - -```mermaid -sequenceDiagram - participant MM as global.backend.get_monitor_manager() - participant MonM as MonitorManager - participant TC as TilingController - participant WL as WorkspaceLayout - participant WW as WindowWrapper - - MM->>MonM: monitors-changed - activate MonM - MonM->>MonM: Detect monitor addition/removal - alt Monitor Removed - MonM->>WW: Evacuate window (minimize/store metadata) - MonM->>WL: Untrack window - else Monitor Added - MonM->>WW: Restore window (unminimize/update monitor index) - MonM->>WL: Track window on target monitor - end - MonM->>TC: hydrate() - deactivate MonM - activate TC - TC->>WL: getRetileOperations() - WL-->>TC: Return window rectangles - TC->>WW: applyGeometry() - deactivate TC -``` - -### Cross-Monitor Drag-and-Drop Sequence - -```mermaid -sequenceDiagram - actor User - participant Win as Meta.Window - participant DM as DragManager - participant TC as TilingController - participant WL as WorkspaceLayout - participant Ind as DragIndicator - - User->>Win: Starts dragging window - DM->>Win: Connect position-changed signal - loop Every position-changed event - Win->>DM: position-changed - activate DM - DM->>DM: Resolve pointer coordinates (global.get_pointer()) - DM->>DM: Determine active monitor under pointer - DM->>WL: getSlotAtPointer(monitorId, x, y) - WL-->>DM: Target slot index - alt Valid slot on target monitor - DM->>Ind: Position and size indicator to slot boundaries - DM->>Ind: show() - DM->>DM: Apply visual swap preview - else Out of bounds - DM->>Ind: hide() - DM->>DM: Revert visual swap preview - end - deactivate DM - end - User->>Win: Releases window - activate DM - DM->>Win: Disconnect position-changed signal - DM->>Ind: destroy() - DM->>WL: swapWindowByPointer() or trackWindow() on target monitor - DM->>TC: _scheduleRetile() for source and target monitors - deactivate DM -``` - ---- - -## Pseudo-code - -### Pointer-to-Slot Mapping and Indicator Rendering - -```javascript -// Located in lib/drag.js -_handlePositionChanged(wrapper, layout, tracker, originalSlot, indicator) { - const [pointerX, pointerY] = global.get_pointer(); - const gaps = this.controller.settings.getGaps(); - const workspace = wrapper.workspace; - - // Identify monitor matching pointer coordinates - const monitorIndex = global.display.get_monitor_index_for_rect({ - x: pointerX, - y: pointerY, - width: 1, - height: 1 - }); - - // Guard against out-of-bounds monitor index - if (monitorIndex === -1) { - indicator.hide(); - const fallbackMonitorIndex = global.display.get_current_monitor(); - const fallbackRect = workspace.get_work_area_for_monitor(fallbackMonitorIndex); - this._revertVisualSwap(tracker, layout, fallbackRect, gaps); - return; - } - - const monitorId = this.controller.monitorManager.getMonitorId(monitorIndex); - const targetLayout = this.controller.workspaceManager.getLayout(workspace); - const targetTracker = targetLayout._getTracker(monitorId); - const monitorRect = workspace.get_work_area_for_monitor(monitorIndex); - - let targetRect = null; - let hoveredSlot = -1; - - // Handle empty monitor drop target explicitly - if (targetTracker.size === 0) { - hoveredSlot = 0; - targetRect = { - x: monitorRect.x + gaps.outer, - y: monitorRect.y + gaps.outer, - width: monitorRect.width - (gaps.outer * 2), - height: monitorRect.height - (gaps.outer * 2) - }; - } else { - hoveredSlot = targetLayout.getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps); - if (hoveredSlot !== -1) { - const matrix = targetLayout.escalator.getLayoutForCount(targetTracker.size); - const estate = matrix.getEstate(hoveredSlot); - if (estate) { - targetRect = estate.toAbsolute(monitorRect, gaps); - } else { - hoveredSlot = -1; - } - } - } - - if (hoveredSlot !== -1 && targetRect) { - // Update indicator layout coordinates and display - indicator.set_position(targetRect.x, targetRect.y); - indicator.set_size(targetRect.width, targetRect.height); - if (indicator._bg) { - indicator._bg.set_size(targetRect.width, targetRect.height); - } - indicator.show(); - - // Apply preview layout modifications - if (monitorId !== wrapper.monitorId) { - this._applyCrossMonitorVisualSwap(wrapper, targetTracker, targetLayout, hoveredSlot, monitorRect, gaps); - } else { - this._applyVisualSwap(tracker, layout, originalSlot, hoveredSlot, monitorRect, gaps); - } - } else { - indicator.hide(); - this._revertVisualSwap(tracker, layout, monitorRect, gaps); - } -} -``` - -### Cross-Monitor Keyboard Navigation Fallback - -```javascript -// Located in lib/workspace.js -_getTargetWindowInDirection(monitorId, window, direction) { - const tracker = this._getTracker(monitorId); - const slot = tracker.getSlot(window); - if (slot === undefined) return null; - - const windowCount = tracker.size; - const layout = this.escalator.getLayoutForCount(windowCount); - if (!layout) return null; - - const estate = layout.getEstate(slot); - if (!estate) return null; - - // Evaluate spatial targets within same monitor - const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); - if (targetSlot !== -1) { - return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; - } - - // Fetch boundary adjacent display - const currentMonitorIndex = this.workspace.get_display().get_monitor_index_for_rect(window.get_frame_rect()); - const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); - if (adjacentMonitorIndex === -1) return null; - - const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); - const targetTracker = this._getTracker(targetMonitorId); - if (targetTracker.size === 0) return null; - - // Match coordinate boundary overlap on adjacent display via adjacent edge - return this._findClosestBoundaryWindow(targetTracker, direction, window.get_frame_rect()); -} - -_findClosestBoundaryWindow(targetTracker, direction, sourceRect) { - let candidates = []; - for (const win of targetTracker.windows) { - if (!win || win.unmanaged) continue; - const targetRect = win.get_frame_rect(); - - let overlap = 0; - if (direction === 'left' || direction === 'right') { - // Overlap along Y axis - overlap = Math.max(0, Math.min(sourceRect.y + sourceRect.height, targetRect.y + targetRect.height) - Math.max(sourceRect.y, targetRect.y)); - } else if (direction === 'up' || direction === 'down') { - // Overlap along X axis - overlap = Math.max(0, Math.min(sourceRect.x + sourceRect.width, targetRect.x + targetRect.width) - Math.max(sourceRect.x, targetRect.x)); - } - - if (overlap > 0) { - candidates.push({ win, rect: targetRect, overlap }); - } - } - - if (candidates.length === 0) return null; - - candidates.sort((a, b) => { - if (b.overlap !== a.overlap) { - return b.overlap - a.overlap; // Highest overlap first - } - // If equal overlap, pick the top/right one - if (direction === 'left' || direction === 'right') { - return a.rect.y - b.rect.y; // Top-most (smaller Y) - } else { - return b.rect.x - a.rect.x; // Right-most (larger X) - } - }); - - return candidates[0].win; -} -``` - -### Cross-Monitor Keyboard Window Movement - -```javascript -// Located in lib/workspace.js -moveWindowDirection(monitorId, window, direction) { - const tracker = this._getTracker(monitorId); - const slot = tracker.getSlot(window); - if (slot === undefined) return false; - - const windowCount = tracker.size; - const layout = this.escalator.getLayoutForCount(windowCount); - if (!layout) return false; - - const estate = layout.getEstate(slot); - if (!estate) return false; - - // Evaluate spatial targets within same monitor - const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); - if (targetSlot !== -1) { - const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); - if (targetWindow) { - tracker.swapWindows(window, targetWindow); - this.controller.scheduleRetile(this.workspace, monitorId); - return true; - } - } - - // Fallback: cross-monitor window movement - const currentMonitorIndex = this.workspace.get_display().get_monitor_index_for_rect(window.get_frame_rect()); - const adjacentMonitorIndex = this.controller.monitorManager.getMonitorInDirection(currentMonitorIndex, direction); - if (adjacentMonitorIndex === -1) return false; - - const targetMonitorId = this.controller.monitorManager.getMonitorId(adjacentMonitorIndex); - - // Untrack from source monitor tracker - tracker.untrack(window); - - // Track on target monitor tracker - const targetTracker = this._getTracker(targetMonitorId); - targetTracker.track(window, targetTracker.size); - - // Update window wrapper cache/metadata - const wrapper = this.controller.getWindowWrapper(window); - if (wrapper) { - wrapper.monitorId = targetMonitorId; - wrapper.monitorIndex = adjacentMonitorIndex; - } - - // Physical transfer using GNOME Shell API - window.move_to_monitor(adjacentMonitorIndex); - - // Schedule retiles on both source and target monitors - this.controller.scheduleRetile(this.workspace, monitorId); - this.controller.scheduleRetile(this.workspace, targetMonitorId); - - return true; -} -``` - -### Cross-Monitor Workspace Actions (Shifted from MonitorManager) - -```javascript -// Located in lib/workspace.js (WorkspaceLayout / WorkspaceManager) -closeMonitorWindows(monitorIndex, includeMinimized) { - const workspace = this.workspace || global.workspace_manager.get_active_workspace(); - if (!workspace) return; - this.controller.setBatchMode(true); - const windows = workspace.list_windows(); - windows.forEach(w => { - if (w.get_monitor() === monitorIndex && (!w.minimized || includeMinimized)) { - w.delete(global.get_current_time()); - } - }); - this.controller.setBatchMode(false); - this.controller.hydrate(workspace); -} - -switchMonitors(activeMonitorIndex) { - const workspace = this.workspace || global.workspace_manager.get_active_workspace(); - if (!workspace) return; - - const manager = global.backend.get_monitor_manager(); - const numMonitors = manager.get_logical_monitors().length; - if (numMonitors < 2) return; - - let targetMonitorIndex; - if (numMonitors === 2) { - targetMonitorIndex = activeMonitorIndex === 0 ? 1 : 0; - } else { - const primaryIndex = global.display.get_primary_monitor(); - if (activeMonitorIndex === primaryIndex) return; - targetMonitorIndex = primaryIndex; - } - - this.controller.setBatchMode(true); - const windows = workspace.list_windows(); - windows.forEach(w => { - const m = w.get_monitor(); - if (m === activeMonitorIndex) { - w.move_to_monitor(targetMonitorIndex); - } else if (m === targetMonitorIndex) { - w.move_to_monitor(activeMonitorIndex); - } - }); - this.controller.setBatchMode(false); - this.controller.hydrate(workspace); -} - -portMonitorToWorkspace(monitorIndex, direction) { - const activeWorkspaceIndex = global.workspace_manager.get_active_workspace_index(); - const numWorkspaces = global.workspace_manager.n_workspaces; - let targetIndex = activeWorkspaceIndex; - - if (direction === 'left' && activeWorkspaceIndex > 0) { - targetIndex--; - } else if (direction === 'right' && activeWorkspaceIndex < numWorkspaces - 1) { - targetIndex++; - } - - if (targetIndex === activeWorkspaceIndex) return; - - const targetWorkspace = global.workspace_manager.get_workspace_by_index(targetIndex); - const activeWorkspace = this.workspace || global.workspace_manager.get_active_workspace(); - - this.controller.setBatchMode(true); - const windows = activeWorkspace.list_windows(); - windows.forEach(w => { - if (w.get_monitor() === monitorIndex) { - w.change_workspace(targetWorkspace); - } - }); - this.controller.setBatchMode(false); - - this.controller.hydrate(activeWorkspace); - this.controller.hydrate(targetWorkspace); -} -``` - diff --git a/tests/monitor-transition.test.js b/tests/monitor-transition.test.js index 7dcdd46..5d67cad 100644 --- a/tests/monitor-transition.test.js +++ b/tests/monitor-transition.test.js @@ -107,7 +107,11 @@ describe('Monitor Transitions', () => { monitorManager: mockMonitorManager, settings: mockSettings, _windowWrappers: new Map(), - _scheduleRetile: vi.fn() + _scheduleRetile: vi.fn(), + updateWindowWrapperMonitor: function(win, id, idx) { + const w = this._windowWrappers.get(win); + if (w) { w.monitorId = id; w.monitorIndex = idx; } + } }; }); diff --git a/tests/regressions.test.js b/tests/regressions.test.js index a3a2fd4..411e990 100644 --- a/tests/regressions.test.js +++ b/tests/regressions.test.js @@ -201,7 +201,6 @@ describe('Regressions', () => { controller.dragManager._handlePositionChanged( controller._windowWrappers.get(winB), layout, - sourceTracker, 1, dragInfo.indicator ); diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 2f9b1bf..462e3bc 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -228,6 +228,10 @@ describe('WorkspaceManager', () => { retileAll: vi.fn(), hydrate: vi.fn(), _windowWrappers: new Map(), + updateWindowWrapperMonitor: function(win, id, idx) { + const w = this._windowWrappers.get(win); + if (w) { w.monitorId = id; w.monitorIndex = idx; } + }, escalator: LayoutParser.parse('{"1":[{"x":0,"y":0,"w":100,"h":100,"id":1}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":100,"id":2}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":1},{"x":50,"y":0,"w":50,"h":50,"id":2},{"x":50,"y":50,"w":50,"h":50,"id":3}]}') }; manager = new WorkspaceManager(controller); @@ -413,7 +417,11 @@ describe('WorkspaceLayout Cross-Monitor Fallback', () => { escalator: escalator, monitorManager: mockMonitorManager, _windowWrappers: new Map(), - _scheduleRetile: vi.fn() + _scheduleRetile: vi.fn(), + updateWindowWrapperMonitor: function(win, id, idx) { + const w = this._windowWrappers.get(win); + if (w) { w.monitorId = id; w.monitorIndex = idx; } + } }; const ws = { index: () => 0 }; const layout = new WorkspaceLayout(ws, controller); From 77aaabdf9967d3c5c19f7eb7c77a1aa4075d1978 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Sun, 14 Jun 2026 13:55:41 +0200 Subject: [PATCH 06/10] bugfix: resolve floating window drag regressions and overtiling --- lib/drag.js | 17 +++++++++++------ lib/workspace.js | 3 +++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/drag.js b/lib/drag.js index 81ab97b..a83d81d 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -39,7 +39,9 @@ export class DragManager { if (originalSlot === undefined) return; const matrix = layout.escalator.getLayoutForCount(windowCount); - if (!matrix || originalSlot >= matrix.size) return; + // We do not require a valid matrix or originalSlot < matrix.size here. + // We must still track the drag so `isWindowInDragPreview` correctly suppresses + // monitor-changed retiles when the user drags this floating window to another monitor. const indicator = this._createIndicator(); @@ -65,7 +67,7 @@ export class DragManager { const bg = new St.Widget({ style: ` - background-color: -st-accent-color; + background-color: --st-accent-color; border-radius: 6px; `, opacity: 76 @@ -161,10 +163,8 @@ export class DragManager { if (this._activeDrag.lastHoveredMonitorId === sourceMonId && this._activeDrag.lastHoveredSlot === hoveredSlot) return; - // If we were hovering a different monitor previously, revert it first - if (this._activeDrag.lastHoveredMonitorId && this._activeDrag.lastHoveredMonitorId !== sourceMonId) { - this._revertVisualSwap(layout, gaps, true); - } + // Revert previous hover + this._revertVisualSwap(layout, gaps); const windowCount = layout.getWindowCount(monitorId); const matrix = layout.escalator.getLayoutForCount(windowCount); @@ -366,6 +366,11 @@ export class DragManager { this._activeDrag = null; + if (this._deferredRetiles && this._deferredRetiles.length > 0) { + this._deferredRetiles.forEach(r => this.controller._scheduleRetile(r.workspace, r.monitorId, r.monitorIndex)); + this._deferredRetiles = []; + } + if (lastHoveredMonitorId && lastHoveredMonitorId !== wrapper.monitorId) { this._commitCrossMonitorTransfer(window, wrapper, layout, lastHoveredMonitorId, lastHoveredSlot, activeDrag.originalSlot); } else { diff --git a/lib/workspace.js b/lib/workspace.js index 3c1e5d5..0a29eaa 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -384,6 +384,9 @@ export class WorkspaceLayout { const targetSlot = this.getSlotAtPointer(monitorId, pointerX, pointerY, monitorRect, gaps); + const maxCount = this.escalator.getMaxCount(); + if (slot >= maxCount) return false; + if (targetSlot !== -1 && targetSlot !== slot) { const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); if (targetWindow) { From 8ff2bfcaa1471679a4616df4582c75a14006e7db Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 17 Jun 2026 08:14:57 +0200 Subject: [PATCH 07/10] fix: adversarial tests mock wrappers --- tests/adversarial.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/adversarial.test.js b/tests/adversarial.test.js index 83e10d3..dd2a541 100644 --- a/tests/adversarial.test.js +++ b/tests/adversarial.test.js @@ -68,7 +68,9 @@ describe('Adversarial Tests', () => { // Setup wrapper to make it look like a monitor change const wrapper = { workspace: ws, + get effectiveWorkspace() { return ws; }, monitorIndex: 0, + get effectiveMonitorIndex() { return win.get_monitor(); }, monitorId: 'monitor-0', applyGeometry: vi.fn(), bindSignals: vi.fn(), From 1ff8ef1b7cd89964fe2d934caea642c6a38d7ada Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 17 Jun 2026 19:38:28 +0200 Subject: [PATCH 08/10] refactor: iteration 3 outsourcing and function unbloating --- lib/controller.js | 72 +++++++------- lib/layout.js | 44 +-------- lib/monitor.js | 76 +++----------- lib/utils/geometry.js | 224 ++++++++++++++++++++++++++++++++++++++++++ lib/workspace.js | 86 +--------------- 5 files changed, 280 insertions(+), 222 deletions(-) create mode 100644 lib/utils/geometry.js diff --git a/lib/controller.js b/lib/controller.js index 146aa85..748192c 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -10,6 +10,7 @@ import { Logger } from './logger.js'; import { MonitorManager } from './monitor.js'; import { DragManager } from './drag.js'; import { TILABLE_WINDOW_TYPES } from './signals.js'; +import { getEnteringEdge } from './utils/geometry.js'; /** * TilingController: The central orchestration layer. @@ -112,49 +113,48 @@ export class TilingController { const isMonitorChange = wrapper.workspace && wrapper.workspace === workspace && wrapper.monitorId && wrapper.monitorId !== monitorId; if (isMonitorChange) { - const sourceMonitorId = wrapper.monitorId; - const sourceMonitorIndex = wrapper.monitorIndex; - const slot = layout.getWindowSlot(sourceMonitorId, window); - const sourceSlot = slot !== undefined ? slot : 0; - - const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); - const targetRect = global.display.get_monitor_geometry(monitorIndex); - - const dx = targetRect.x - sourceRect.x; - const dy = targetRect.y - sourceRect.y; - let enteringEdge = 'left'; - if (Math.abs(dx) >= Math.abs(dy)) { - enteringEdge = dx > 0 ? 'left' : 'right'; - } else { - enteringEdge = dy > 0 ? 'top' : 'bottom'; - } - - const result = layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); - - this.updateWindowWrapperMonitor(window, monitorId, monitorIndex); - - if (result && result.swappedWindow) { - this.updateWindowWrapperMonitor(result.swappedWindow, sourceMonitorId, sourceMonitorIndex); - if (result.swappedWindow.move_to_monitor) result.swappedWindow.move_to_monitor(sourceMonitorIndex); - } - - Logger.debug(`tilingRequest: Monitor transition handled. Scheduling retile.`); - this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); - this._scheduleRetile(workspace, monitorId, monitorIndex); + this._handleMonitorTransitionChange(window, wrapper, layout, wrapper.monitorIndex, wrapper.monitorId, monitorIndex, monitorId, workspace); } else { - const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); - const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); - this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); - this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); - - Logger.debug(`tilingRequest: State applied. Scheduling retile.`); - this._scheduleRetile(workspace, monitorId, monitorIndex); + this._handleNormalTilingRequest(window, wrapper, workspace, monitorId, monitorIndex, isRestoring, preferredSlot); } } catch (e) { Logger.warn(`Tiling attempt failed for "${wrapper ? wrapper.title : 'unknown'}"`, e); } } + _handleMonitorTransitionChange(window, wrapper, layout, sourceMonitorIndex, sourceMonitorId, monitorIndex, monitorId, workspace) { + const slot = layout.getWindowSlot(sourceMonitorId, window); + const sourceSlot = slot !== undefined ? slot : 0; + + const sourceRect = global.display.get_monitor_geometry(sourceMonitorIndex); + const targetRect = global.display.get_monitor_geometry(monitorIndex); + + const enteringEdge = getEnteringEdge(sourceRect, targetRect); + + const result = layout.handleMonitorTransition(window, sourceMonitorId, monitorId, enteringEdge, sourceSlot); + + this.updateWindowWrapperMonitor(window, monitorId, monitorIndex); + + if (result && result.swappedWindow) { + this.updateWindowWrapperMonitor(result.swappedWindow, sourceMonitorId, sourceMonitorIndex); + if (result.swappedWindow.move_to_monitor) result.swappedWindow.move_to_monitor(sourceMonitorIndex); + } + + Logger.debug(`tilingRequest: Monitor transition handled. Scheduling retile.`); + this._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } + + _handleNormalTilingRequest(window, wrapper, workspace, monitorId, monitorIndex, isRestoring, preferredSlot) { + const oldSlot = this._handleWorkspaceChange(window, wrapper, workspace, monitorId); + const finalPreferredSlot = isRestoring ? preferredSlot : (oldSlot !== undefined ? oldSlot : undefined); + this._updateWrapperCache(wrapper, workspace, monitorIndex, monitorId); + this._applyTrackingState(window, monitorId, workspace, isRestoring, finalPreferredSlot); + + Logger.debug(`tilingRequest: State applied. Scheduling retile.`); + this._scheduleRetile(workspace, monitorId, monitorIndex); + } + _ensureWrapper(window) { const type = window.get_window_type ? window.get_window_type() : Meta.WindowType.NORMAL; const skipTaskbar = window.is_skip_taskbar ? window.is_skip_taskbar() : false; diff --git a/lib/layout.js b/lib/layout.js index 5fe73f7..ec09b51 100644 --- a/lib/layout.js +++ b/lib/layout.js @@ -1,4 +1,4 @@ - +import { getEdgingSlotForEstates } from './utils/geometry.js'; /** * ScreenEstate: Immutable data object holding percentages (0-100). @@ -83,47 +83,7 @@ export class Layout { } getEdgingSlot(direction) { - const eps = 0.01; - let candidates = []; - - this.estates.forEach((estate, index) => { - let touches = false; - if (direction === 'left') { - touches = estate.pct_x <= eps; - } else if (direction === 'right') { - touches = (estate.pct_x + estate.pct_w) >= (100 - eps); - } else if (direction === 'top') { - touches = estate.pct_y <= eps; - } else if (direction === 'bottom') { - touches = (estate.pct_y + estate.pct_h) >= (100 - eps); - } - - if (touches) { - candidates.push({ estate, index }); - } - }); - - if (candidates.length === 0) { - return -1; - } - - candidates.sort((a, b) => { - if (direction === 'left' || direction === 'right') { - const diffHeight = b.estate.pct_h - a.estate.pct_h; - if (Math.abs(diffHeight) > eps) { - return diffHeight; - } - return a.estate.pct_y - b.estate.pct_y; - } else { - const diffWidth = b.estate.pct_w - a.estate.pct_w; - if (Math.abs(diffWidth) > eps) { - return diffWidth; - } - return b.estate.pct_x - a.estate.pct_x; - } - }); - - return candidates[0].index; + return getEdgingSlotForEstates(this.estates, direction); } } diff --git a/lib/monitor.js b/lib/monitor.js index b9d9ec3..0383417 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -1,6 +1,7 @@ import Meta from 'gi://Meta'; import GLib from 'gi://GLib'; import { Logger } from './logger.js'; +import { findMonitorInDirection } from './utils/geometry.js'; /** * MonitorManager class. Tracks monitor changes. @@ -273,69 +274,20 @@ export class MonitorManager { try { const manager = global.backend.get_monitor_manager(); const logicalMonitors = manager.get_logical_monitors(); - const sourceMonitor = logicalMonitors[currentMonitorIndex]; - if (!sourceMonitor) { - Logger.debug(`getMonitorInDirection: invalid sourceMonitor for index ${currentMonitorIndex}`); - return -1; + + const index = findMonitorInDirection( + currentMonitorIndex, + direction, + logicalMonitors, + (i) => global.display.get_monitor_geometry(i) + ); + + if (index !== -1) { + Logger.debug(`getMonitorInDirection: best candidate is ${index}`); + } else { + Logger.debug('getMonitorInDirection: no candidates found or invalid source monitor'); } - - const sRect = global.display.get_monitor_geometry(currentMonitorIndex); - Logger.debug(`getMonitorInDirection: source monitor ${currentMonitorIndex} rect: ${sRect.x}, ${sRect.y}, ${sRect.width}, ${sRect.height}`); - - let candidates = []; - const eps = 1; - - for (let i = 0; i < logicalMonitors.length; i++) { - if (i === currentMonitorIndex) continue; - const cRect = global.display.get_monitor_geometry(i); - - let inDirection = false; - let dist = Infinity; - - if (direction === 'left') { - inDirection = cRect.x + cRect.width <= sRect.x + eps; - dist = sRect.x - (cRect.x + cRect.width); - } else if (direction === 'right') { - inDirection = cRect.x >= sRect.x + sRect.width - eps; - dist = cRect.x - (sRect.x + sRect.width); - } else if (direction === 'up') { - inDirection = cRect.y + cRect.height <= sRect.y + eps; - dist = sRect.y - (cRect.y + cRect.height); - } else if (direction === 'down') { - inDirection = cRect.y >= sRect.y + sRect.height - eps; - dist = cRect.y - (sRect.y + sRect.height); - } - - Logger.debug(`getMonitorInDirection: checking monitor ${i} (${cRect.x},${cRect.y},${cRect.width},${cRect.height}) for direction ${direction}: inDirection=${inDirection}`); - - if (inDirection) { - let overlap = 0; - if (direction === 'left' || direction === 'right') { - overlap = Math.max(0, Math.min(cRect.y + cRect.height, sRect.y + sRect.height) - Math.max(cRect.y, sRect.y)); - } else { - overlap = Math.max(0, Math.min(cRect.x + cRect.width, sRect.x + sRect.width) - Math.max(cRect.x, sRect.x)); - } - candidates.push({ index: i, dist, overlap, rect: cRect }); - } - } - - if (candidates.length === 0) { - Logger.debug('getMonitorInDirection: no candidates found'); - return -1; - } - - candidates.sort((a, b) => { - if (Math.abs(a.dist - b.dist) > eps) { - return a.dist - b.dist; - } - if (Math.abs(a.overlap - b.overlap) > eps) { - return b.overlap - a.overlap; - } - return a.index - b.index; - }); - - Logger.debug(`getMonitorInDirection: best candidate is ${candidates[0].index}`); - return candidates[0].index; + return index; } catch (e) { Logger.error(`Failed to get monitor in direction ${direction}`, e); return -1; diff --git a/lib/utils/geometry.js b/lib/utils/geometry.js new file mode 100644 index 0000000..99f324d --- /dev/null +++ b/lib/utils/geometry.js @@ -0,0 +1,224 @@ +/** + * geometry.js + * Utility functions for spatial and directional calculations. + */ + +/** + * Determines the entering edge when moving from a source rectangle to a target rectangle. + * @param {Object} sourceRect - The source geometry {x, y, width, height}. + * @param {Object} targetRect - The target geometry {x, y, width, height}. + * @returns {string} The entering edge ('left', 'right', 'top', 'bottom'). + */ +export function getEnteringEdge(sourceRect, targetRect) { + const dx = targetRect.x - sourceRect.x; + const dy = targetRect.y - sourceRect.y; + if (Math.abs(dx) >= Math.abs(dy)) { + return dx > 0 ? 'left' : 'right'; + } else { + return dy > 0 ? 'top' : 'bottom'; + } +} + +/** + * Finds the best monitor in a given direction from a source monitor. + * @param {number} currentMonitorIndex - The index of the source monitor. + * @param {string} direction - The direction to look ('left', 'right', 'up', 'down'). + * @param {Array} logicalMonitors - The array of logical monitors. + * @param {Function} getGeometryFn - Function that returns geometry given a monitor index. + * @returns {number} The index of the selected monitor, or -1 if none found. + */ +export function findMonitorInDirection(currentMonitorIndex, direction, logicalMonitors, getGeometryFn) { + const sourceMonitor = logicalMonitors[currentMonitorIndex]; + if (!sourceMonitor) return -1; + + const sRect = getGeometryFn(currentMonitorIndex); + let candidates = []; + const eps = 1; + + for (let i = 0; i < logicalMonitors.length; i++) { + if (i === currentMonitorIndex) continue; + const cRect = getGeometryFn(i); + + let inDirection = false; + let dist = Infinity; + + if (direction === 'left') { + inDirection = cRect.x + cRect.width <= sRect.x + eps; + dist = sRect.x - (cRect.x + cRect.width); + } else if (direction === 'right') { + inDirection = cRect.x >= sRect.x + sRect.width - eps; + dist = cRect.x - (sRect.x + sRect.width); + } else if (direction === 'up') { + inDirection = cRect.y + cRect.height <= sRect.y + eps; + dist = sRect.y - (cRect.y + cRect.height); + } else if (direction === 'down') { + inDirection = cRect.y >= sRect.y + sRect.height - eps; + dist = cRect.y - (sRect.y + sRect.height); + } + + if (inDirection) { + let overlap = 0; + if (direction === 'left' || direction === 'right') { + overlap = Math.max(0, Math.min(cRect.y + cRect.height, sRect.y + sRect.height) - Math.max(cRect.y, sRect.y)); + } else { + overlap = Math.max(0, Math.min(cRect.x + cRect.width, sRect.x + sRect.width) - Math.max(cRect.x, sRect.x)); + } + candidates.push({ index: i, dist, overlap, rect: cRect }); + } + } + + if (candidates.length === 0) { + return -1; + } + + candidates.sort((a, b) => { + if (Math.abs(a.dist - b.dist) > eps) { + return a.dist - b.dist; + } + if (Math.abs(a.overlap - b.overlap) > eps) { + return b.overlap - a.overlap; + } + return a.index - b.index; + }); + + return candidates[0].index; +} + +/** + * Gets the most appropriate estate index on a specific edge of the layout. + * @param {Array} estates - Array of layout estates. + * @param {string} direction - The edge direction ('left', 'right', 'top', 'bottom'). + * @returns {number} The index of the chosen estate, or -1 if none found. + */ +export function getEdgingSlotForEstates(estates, direction) { + const eps = 0.01; + let candidates = []; + + estates.forEach((estate, index) => { + let touches = false; + if (direction === 'left') { + touches = estate.pct_x <= eps; + } else if (direction === 'right') { + touches = (estate.pct_x + estate.pct_w) >= (100 - eps); + } else if (direction === 'top') { + touches = estate.pct_y <= eps; + } else if (direction === 'bottom') { + touches = (estate.pct_y + estate.pct_h) >= (100 - eps); + } + + if (touches) { + candidates.push({ estate, index }); + } + }); + + if (candidates.length === 0) { + return -1; + } + + candidates.sort((a, b) => { + if (direction === 'left' || direction === 'right') { + const diffHeight = b.estate.pct_h - a.estate.pct_h; + if (Math.abs(diffHeight) > eps) { + return diffHeight; + } + return a.estate.pct_y - b.estate.pct_y; + } else { + const diffWidth = b.estate.pct_w - a.estate.pct_w; + if (Math.abs(diffWidth) > eps) { + return diffWidth; + } + return b.estate.pct_x - a.estate.pct_x; + } + }); + + return candidates[0].index; +} + +/** + * Checks if a target slot estate is in a specific direction relative to a source estate. + */ +export function isSlotInDirection(estate, c, direction, eps = 0.01) { + let orthoOverlap = false; + + if (direction === 'left' || direction === 'right') { + orthoOverlap = Math.max(c.pct_y, estate.pct_y) < Math.min(c.pct_y + c.pct_h, estate.pct_y + estate.pct_h) - eps; + if (direction === 'left') { + return orthoOverlap && c.pct_x + c.pct_w <= estate.pct_x + eps; + } + return orthoOverlap && c.pct_x >= estate.pct_x + estate.pct_w - eps; + } else if (direction === 'up' || direction === 'down') { + orthoOverlap = Math.max(c.pct_x, estate.pct_x) < Math.min(c.pct_x + c.pct_w, estate.pct_x + estate.pct_w) - eps; + if (direction === 'up') { + return orthoOverlap && c.pct_y + c.pct_h <= estate.pct_y + eps; + } + return orthoOverlap && c.pct_y >= estate.pct_y + estate.pct_h - eps; + } + return false; +} + +/** + * Calculates distance between two slot estates in a specific direction. + */ +export function calculateSlotDistance(estate, c, direction) { + if (direction === 'left') return estate.pct_x - (c.pct_x + c.pct_w); + if (direction === 'right') return c.pct_x - (estate.pct_x + estate.pct_w); + if (direction === 'up') return estate.pct_y - (c.pct_y + c.pct_h); + if (direction === 'down') return c.pct_y - (estate.pct_y + estate.pct_h); + return 0; +} + +/** + * Finds the nearest slot index in a specific direction. + */ +export function findTargetSlotInDirection(layout, slot, estate, direction) { + const eps = 0.01; + let candidates = []; + + for (let i = 0; i < layout.size; i++) { + if (i === slot) continue; + const c = layout.getEstate(i); + + if (isSlotInDirection(estate, c, direction, eps)) { + candidates.push({ + index: i, + distance: calculateSlotDistance(estate, c, direction) + }); + } + } + + if (candidates.length === 0) return -1; + + candidates.sort((a, b) => a.distance - b.distance); + const minDist = candidates[0].distance; + + candidates = candidates.filter(c => c.distance <= minDist + eps); + candidates.sort((a, b) => a.index - b.index); + + return candidates[0].index; +} + +/** + * Finds the closest boundary window out of candidate windows in a given direction. + */ +export function findClosestBoundaryWindow(candidates, direction) { + if (!candidates || candidates.length === 0) return null; + + candidates.sort((a, b) => { + if (direction === 'left') { + if (a.rect.x !== b.rect.x) return b.rect.x - a.rect.x; + return a.rect.y - b.rect.y; + } else if (direction === 'right') { + if (a.rect.x !== b.rect.x) return a.rect.x - b.rect.x; + return a.rect.y - b.rect.y; + } else if (direction === 'up') { + if (a.rect.y !== b.rect.y) return b.rect.y - a.rect.y; + return b.rect.x - a.rect.x; + } else if (direction === 'down') { + if (a.rect.y !== b.rect.y) return a.rect.y - b.rect.y; + return b.rect.x - a.rect.x; + } + return 0; + }); + + return candidates[0].win; +} diff --git a/lib/workspace.js b/lib/workspace.js index 0a29eaa..28b635f 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -1,5 +1,6 @@ import { StateTracker } from './state.js'; import { Logger } from './logger.js'; +import { findTargetSlotInDirection, findClosestBoundaryWindow } from './utils/geometry.js'; /** * WorkspaceLayout: Manages internal state for a specific Meta.Workspace. @@ -100,34 +101,7 @@ export class WorkspaceLayout { candidates.push({ win, rect: targetRect }); } - if (candidates.length === 0) return null; - - candidates.sort((a, b) => { - if (direction === 'left') { - if (a.rect.x !== b.rect.x) { - return b.rect.x - a.rect.x; - } - return a.rect.y - b.rect.y; - } else if (direction === 'right') { - if (a.rect.x !== b.rect.x) { - return a.rect.x - b.rect.x; - } - return a.rect.y - b.rect.y; - } else if (direction === 'up') { - if (a.rect.y !== b.rect.y) { - return b.rect.y - a.rect.y; - } - return b.rect.x - a.rect.x; - } else if (direction === 'down') { - if (a.rect.y !== b.rect.y) { - return a.rect.y - b.rect.y; - } - return b.rect.x - a.rect.x; - } - return 0; - }); - - return candidates[0].win; + return findClosestBoundaryWindow(candidates, direction); } @@ -143,7 +117,7 @@ export class WorkspaceLayout { const estate = layout.getEstate(slot); if (!estate) return null; - const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + const targetSlot = findTargetSlotInDirection(layout, slot, estate, direction); if (targetSlot !== -1) { return tracker.windows.find(w => tracker.getSlot(w) === targetSlot) || null; } @@ -234,7 +208,7 @@ export class WorkspaceLayout { const estate = layout.getEstate(slot); if (!estate) return false; - const targetSlot = this._findTargetSlotInDirection(layout, slot, estate, direction); + const targetSlot = findTargetSlotInDirection(layout, slot, estate, direction); if (targetSlot !== -1) { const targetWindow = tracker.windows.find(w => tracker.getSlot(w) === targetSlot); if (targetWindow) { @@ -298,59 +272,7 @@ export class WorkspaceLayout { return false; } - _findTargetSlotInDirection(layout, slot, estate, direction) { - const eps = 0.01; - let candidates = []; - - for (let i = 0; i < layout.size; i++) { - if (i === slot) continue; - const c = layout.getEstate(i); - - if (this._isSlotInDirection(estate, c, direction, eps)) { - candidates.push({ - index: i, - distance: this._calculateSlotDistance(estate, c, direction) - }); - } - } - if (candidates.length === 0) return -1; - - candidates.sort((a, b) => a.distance - b.distance); - const minDist = candidates[0].distance; - - candidates = candidates.filter(c => c.distance <= minDist + eps); - candidates.sort((a, b) => a.index - b.index); - - return candidates[0].index; - } - - _isSlotInDirection(estate, c, direction, eps) { - let orthoOverlap = false; - - if (direction === 'left' || direction === 'right') { - orthoOverlap = Math.max(c.pct_y, estate.pct_y) < Math.min(c.pct_y + c.pct_h, estate.pct_y + estate.pct_h) - eps; - if (direction === 'left') { - return orthoOverlap && c.pct_x + c.pct_w <= estate.pct_x + eps; - } - return orthoOverlap && c.pct_x >= estate.pct_x + estate.pct_w - eps; - } else if (direction === 'up' || direction === 'down') { - orthoOverlap = Math.max(c.pct_x, estate.pct_x) < Math.min(c.pct_x + c.pct_w, estate.pct_x + estate.pct_w) - eps; - if (direction === 'up') { - return orthoOverlap && c.pct_y + c.pct_h <= estate.pct_y + eps; - } - return orthoOverlap && c.pct_y >= estate.pct_y + estate.pct_h - eps; - } - return false; - } - - _calculateSlotDistance(estate, c, direction) { - if (direction === 'left') return estate.pct_x - (c.pct_x + c.pct_w); - if (direction === 'right') return c.pct_x - (estate.pct_x + estate.pct_w); - if (direction === 'up') return estate.pct_y - (c.pct_y + c.pct_h); - if (direction === 'down') return c.pct_y - (estate.pct_y + estate.pct_h); - return 0; - } /** * Resolves absolute pointer coordinates to a window layout slot index. From 96dca90ec0b65a4539677346b304014da373e5ee Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Wed, 17 Jun 2026 20:38:20 +0200 Subject: [PATCH 09/10] fix: window move deferral, geometry overlap calculation, and add state wait timeouts --- lib/drag.js | 14 +++++++++++--- lib/utils/geometry.js | 19 +++++++++++++++++-- lib/window.js | 18 ++++++++++++++++-- lib/workspace.js | 2 +- tests/workspace.test.js | 6 +++--- 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/lib/drag.js b/lib/drag.js index a83d81d..359708d 100644 --- a/lib/drag.js +++ b/lib/drag.js @@ -1,5 +1,6 @@ import Meta from 'gi://Meta'; import St from 'gi://St'; +import GLib from 'gi://GLib'; import { Logger } from './logger.js'; /** @@ -415,15 +416,22 @@ export class DragManager { this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); this.controller.updateWindowWrapperMonitor(targetWindow, sourceMonitorId, sourceMonitorIndex); - if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); - if (sourceMonitorIndex !== -1 && targetWindow.move_to_monitor) targetWindow.move_to_monitor(sourceMonitorIndex); + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + if (sourceMonitorIndex !== -1 && targetWindow.move_to_monitor) targetWindow.move_to_monitor(sourceMonitorIndex); + return GLib.SOURCE_REMOVE; + }); } else { layout.untrackWindow(window, sourceMonitorId); layout.trackWindow(window, targetMonitorId, targetSlot !== -1 ? targetSlot : undefined); this.controller.updateWindowWrapperMonitor(window, targetMonitorId, targetMonitorIndex); - if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (targetMonitorIndex !== -1 && window.move_to_monitor) window.move_to_monitor(targetMonitorIndex); + return GLib.SOURCE_REMOVE; + }); } this.controller._scheduleRetile(workspace, sourceMonitorId, sourceMonitorIndex); diff --git a/lib/utils/geometry.js b/lib/utils/geometry.js index 99f324d..a00ab85 100644 --- a/lib/utils/geometry.js +++ b/lib/utils/geometry.js @@ -63,7 +63,9 @@ export function findMonitorInDirection(currentMonitorIndex, direction, logicalMo } else { overlap = Math.max(0, Math.min(cRect.x + cRect.width, sRect.x + sRect.width) - Math.max(cRect.x, sRect.x)); } - candidates.push({ index: i, dist, overlap, rect: cRect }); + if (overlap > 0) { + candidates.push({ index: i, dist, overlap, rect: cRect }); + } } } @@ -200,10 +202,23 @@ export function findTargetSlotInDirection(layout, slot, estate, direction) { /** * Finds the closest boundary window out of candidate windows in a given direction. */ -export function findClosestBoundaryWindow(candidates, direction) { +export function findClosestBoundaryWindow(candidates, direction, sourceRect) { if (!candidates || candidates.length === 0) return null; + if (!sourceRect) return candidates[0].win; candidates.sort((a, b) => { + let overA = 0, overB = 0; + + if (direction === 'left' || direction === 'right') { + overA = Math.max(0, Math.min(a.rect.y + a.rect.height, sourceRect.y + sourceRect.height) - Math.max(a.rect.y, sourceRect.y)); + overB = Math.max(0, Math.min(b.rect.y + b.rect.height, sourceRect.y + sourceRect.height) - Math.max(b.rect.y, sourceRect.y)); + } else { + overA = Math.max(0, Math.min(a.rect.x + a.rect.width, sourceRect.x + sourceRect.width) - Math.max(a.rect.x, sourceRect.x)); + overB = Math.max(0, Math.min(b.rect.x + b.rect.width, sourceRect.x + sourceRect.width) - Math.max(b.rect.x, sourceRect.x)); + } + + if (overA !== overB) return overB - overA; + if (direction === 'left') { if (a.rect.x !== b.rect.x) return b.rect.x - a.rect.x; return a.rect.y - b.rect.y; diff --git a/lib/window.js b/lib/window.js index a2c5791..6ddb637 100644 --- a/lib/window.js +++ b/lib/window.js @@ -35,9 +35,16 @@ export class WindowWrapper { let m = this.window.get_monitor ? this.window.get_monitor() : -1; if (this._expectedMonitorIndex !== undefined) { if (m !== this._expectedMonitorIndex) { - m = this._expectedMonitorIndex; + if (!this._monitorWaitCycles) this._monitorWaitCycles = 0; + if (++this._monitorWaitCycles > 15) { + delete this._expectedMonitorIndex; + this._monitorWaitCycles = 0; + } else { + m = this._expectedMonitorIndex; + } } else { delete this._expectedMonitorIndex; + this._monitorWaitCycles = 0; } } return m; @@ -48,9 +55,16 @@ export class WindowWrapper { let w = this.window.get_workspace ? this.window.get_workspace() : null; if (this._expectedWorkspace !== undefined) { if (w !== this._expectedWorkspace) { - w = this._expectedWorkspace; + if (!this._workspaceWaitCycles) this._workspaceWaitCycles = 0; + if (++this._workspaceWaitCycles > 15) { + delete this._expectedWorkspace; + this._workspaceWaitCycles = 0; + } else { + w = this._expectedWorkspace; + } } else { delete this._expectedWorkspace; + this._workspaceWaitCycles = 0; } } return w; diff --git a/lib/workspace.js b/lib/workspace.js index 28b635f..d78a635 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -101,7 +101,7 @@ export class WorkspaceLayout { candidates.push({ win, rect: targetRect }); } - return findClosestBoundaryWindow(candidates, direction); + return findClosestBoundaryWindow(candidates, direction, sourceRect); } diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 462e3bc..e173917 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -341,14 +341,14 @@ describe('WorkspaceLayout Cross-Monitor Fallback', () => { const targetTracker = { size: 2, windows: [ - { get_frame_rect: () => ({ x: 1000, y: 0, width: 500, height: 400 }) }, - { get_frame_rect: () => ({ x: 1000, y: 300, width: 500, height: 700 }) } + { get_frame_rect: () => ({ x: 1000, y: 0, width: 500, height: 150 }) }, + { get_frame_rect: () => ({ x: 1000, y: 150, width: 500, height: 850 }) } ] }; const sourceRect = { x: 0, y: 100, width: 1000, height: 400 }; const best = layout._findClosestBoundaryWindow(targetTracker, 'right', sourceRect); - expect(best).toBe(targetTracker.windows[0]); + expect(best).toBe(targetTracker.windows[1]); }); it('should resolve ties using top-most/right-most tie breakers', () => { From 11b4b8cb929efcdaa0b68e019bf54830ce853894 Mon Sep 17 00:00:00 2001 From: Konstantin Merkel Date: Fri, 19 Jun 2026 16:11:58 +0200 Subject: [PATCH 10/10] bugfix: missing monitor notification --- lib/monitor.js | 1 + lib/window.js | 3 +++ tests/controller.test.js | 2 +- tests/window.test.js | 4 ++-- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/monitor.js b/lib/monitor.js index 0383417..8448473 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -98,6 +98,7 @@ export class MonitorManager { if (window && !window.unmanaged && !window.minimized) { window.minimize(); } + this.controller.setBatchMode(false); return GLib.SOURCE_REMOVE; }); diff --git a/lib/window.js b/lib/window.js index 6ddb637..ee6883c 100644 --- a/lib/window.js +++ b/lib/window.js @@ -80,6 +80,9 @@ export class WindowWrapper { if (!this.signals.has('notify::minimized')) { this.signals.set('notify::minimized', this.window.connect('notify::minimized', () => this.controller.tilingRequest(this.window))); } + if (!this.signals.has('notify::monitor')) { + this.signals.set('notify::monitor', this.window.connect('notify::monitor', () => this.controller.tilingRequest(this.window))); + } if (!this.signals.has('notify::maximized-horizontally')) { this.signals.set('notify::maximized-horizontally', this.window.connect('notify::maximized-horizontally', () => { if (this.window.maximized_horizontally || this.window.maximized_vertically) { diff --git a/tests/controller.test.js b/tests/controller.test.js index 89c026d..7ecafb2 100644 --- a/tests/controller.test.js +++ b/tests/controller.test.js @@ -183,7 +183,7 @@ describe('TilingController', () => { // Window should have been minimized by our controller because monitor-1 is gone expect(win.minimize).toHaveBeenCalled(); - expect(controller._batchMode).toBe(true); + expect(controller._batchMode).toBe(false); // Window tracked for restoration (keyed by window reference) expect(controller.monitorManager._evacuatedWindows.has(win)).toBe(true); diff --git a/tests/window.test.js b/tests/window.test.js index 5a7d08f..88c52d2 100644 --- a/tests/window.test.js +++ b/tests/window.test.js @@ -48,7 +48,7 @@ describe('WindowWrapper', () => { const wrapper = new WindowWrapper(mockWindow, mockController); wrapper.bindSignals(); wrapper.bindSignals(); // should not connect again - expect(mockWindow.connect).toHaveBeenCalledTimes(6); + expect(mockWindow.connect).toHaveBeenCalledTimes(7); }); it('should bind size changed and respect _isResizing', () => { @@ -80,7 +80,7 @@ describe('WindowWrapper', () => { wrapper.bindSizeChanged(); wrapper.destroy(); - expect(mockWindow.disconnect).toHaveBeenCalledTimes(7); + expect(mockWindow.disconnect).toHaveBeenCalledTimes(8); }); it('should apply geometry skipping unmanaged', () => {