diff --git a/docs/layouts.md b/docs/layouts.md index 27819ea..a2885cb 100644 --- a/docs/layouts.md +++ b/docs/layouts.md @@ -9,11 +9,11 @@ The JSON keys represent the total number of windows on a monitor. The values are ```json { "1": [ - {"x": 0, "y": 0, "w": 100, "h": 100, "id": 1} + {"x": 0, "y": 0, "w": 100, "h": 100, "id": 0} ], "2": [ - {"x": 0, "y": 0, "w": 50, "h": 100, "id": 1}, - {"x": 50, "y": 0, "w": 50, "h": 100, "id": 2} + {"x": 0, "y": 0, "w": 50, "h": 100, "id": 0}, + {"x": 50, "y": 0, "w": 50, "h": 100, "id": 1} ] } ``` @@ -23,7 +23,7 @@ The JSON keys represent the total number of windows on a monitor. The values are - `y`: Y percentage offset (0 to 100) - `w`: Width percentage (0 to 100) - `h`: Height percentage (0 to 100) -- `id` (Required): The logical 1-indexed ID this window occupies. The IDs map to the insertion order of windows. E.g., `id: 1` is the oldest window, `id: 2` is the second oldest. It MUST be unique and cover 1 to the window count. +- `id` (Required): The logical 0-indexed ID this window occupies. The IDs map to the insertion order of windows. E.g., `id: 0` is the oldest window, `id: 1` is the second oldest. It MUST be unique and cover 0 to `count - 1`. ## Fallback If the window count exceeds the highest key defined in the JSON, extra windows will fall back to floating mode (unmanaged by the auto-tiler). diff --git a/lib/controller.js b/lib/controller.js index 47af81e..317b1d5 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -207,6 +207,11 @@ export class TilingController { if (monitorIndex < 0) monitorIndex = global.display.get_current_monitor(); if (!workspace) return null; + + // Guard against transient GNOME states during monitor unplug + if (monitorIndex >= this.monitorManager.getMonitorCount()) { + return null; + } const isRestoring = this._restoringWindows.has(window); const preferredSlot = isRestoring ? this._restoringWindows.get(window) : undefined; @@ -244,7 +249,11 @@ export class TilingController { const layout = this.workspaceManager.getLayout(workspace); const isEvacuated = this.monitorManager.isEvacuated(window); - if ((window.minimized || isEvacuated) && !isRestoring) { + if (isEvacuated && !window.minimized) { + this.monitorManager.clearEvacuation(window); + } + + if (window.minimized && !isRestoring) { layout.untrackWindow(window, monitorId); } else { layout.trackWindow(window, monitorId, preferredSlot); diff --git a/lib/editor/preview.js b/lib/editor/preview.js index cd948b7..ac180e7 100644 --- a/lib/editor/preview.js +++ b/lib/editor/preview.js @@ -146,7 +146,7 @@ function getDefaultSplit(layoutBase, targetCount) { }); newLayoutEstates.push(e2); newLayoutEstates.forEach((item, i) => { - item.id = i + 1; + item.id = i; }); return newLayoutEstates; @@ -197,7 +197,7 @@ class LayoutPreviewPage extends Adw.PreferencesPage { y: Math.round(e.y), w: Math.round(e.w), h: Math.round(e.h), - id: parseInt(e.id || 1, 10) + id: parseInt(e.id || 0, 10) }))); } }); @@ -470,15 +470,15 @@ class LayoutPreviewPage extends Adw.PreferencesPage { splitVal = Math.round(pctX); if (splitVal >= estate.pct_x + 5 && splitVal <= estate.pct_x + estate.pct_w - 5) { splitOk = true; - e1 = { x: estate.pct_x, y: estate.pct_y, w: splitVal - estate.pct_x, h: estate.pct_h, id: estate.id || (index + 1) }; - e2 = { x: splitVal, y: estate.pct_y, w: estate.pct_x + estate.pct_w - splitVal, h: estate.pct_h, id: targetCount }; + e1 = { x: estate.pct_x, y: estate.pct_y, w: splitVal - estate.pct_x, h: estate.pct_h, id: estate.id !== undefined ? estate.id : index }; + e2 = { x: splitVal, y: estate.pct_y, w: estate.pct_x + estate.pct_w - splitVal, h: estate.pct_h, id: targetCount - 1 }; } } else { splitVal = Math.round(pctY); if (splitVal >= estate.pct_y + 5 && splitVal <= estate.pct_y + estate.pct_h - 5) { splitOk = true; - e1 = { x: estate.pct_x, y: estate.pct_y, w: estate.pct_w, h: splitVal - estate.pct_y, id: estate.id || (index + 1) }; - e2 = { x: estate.pct_x, y: splitVal, w: estate.pct_w, h: estate.pct_y + estate.pct_h - splitVal, id: targetCount }; + e1 = { x: estate.pct_x, y: estate.pct_y, w: estate.pct_w, h: splitVal - estate.pct_y, id: estate.id !== undefined ? estate.id : index }; + e2 = { x: estate.pct_x, y: splitVal, w: estate.pct_w, h: estate.pct_y + estate.pct_h - splitVal, id: targetCount - 1 }; } } @@ -488,12 +488,12 @@ class LayoutPreviewPage extends Adw.PreferencesPage { if (i === index) { newLayoutEstates.push(e1); } else { - newLayoutEstates.push({ x: e.pct_x, y: e.pct_y, w: e.pct_w, h: e.pct_h, id: e.id || (i + 1) }); + newLayoutEstates.push({ x: e.pct_x, y: e.pct_y, w: e.pct_w, h: e.pct_h, id: e.id !== undefined ? e.id : i }); } }); newLayoutEstates.push(e2); newLayoutEstates.forEach((item, i) => { - item.id = i + 1; + item.id = i; }); onSplitSuccess(newLayoutEstates); @@ -558,7 +558,7 @@ class LayoutPreviewPage extends Adw.PreferencesPage { margin_bottom: 24 }); addFirstBtn.connect('clicked', () => { - const initial = { "1": [{ "x": 0, "y": 0, "w": 100, "h": 100, "id": 1 }] }; + const initial = { "1": [{ "x": 0, "y": 0, "w": 100, "h": 100, "id": 0 }] }; this.settings.set_string('custom-layouts', JSON.stringify(initial, null, 2)); this._editingCount = 1; this._initializeDrafts(); @@ -595,7 +595,7 @@ class LayoutPreviewPage extends Adw.PreferencesPage { y: Math.round(e.pct_y), w: Math.round(e.pct_w), h: Math.round(e.pct_h), - id: e.id || (idx + 1) + id: e.id !== undefined ? e.id : idx })); } } @@ -1002,7 +1002,7 @@ class LayoutPreviewPage extends Adw.PreferencesPage { const c = colors[k % colors.length]; const label = new Gtk.Label({ - label: ` Slot ${estate.id || (k + 1)}:`, + label: ` Slot ${(estate.id !== undefined ? estate.id : k) + 1}:`, use_markup: true, width_request: 80, halign: Gtk.Align.START diff --git a/lib/layout.js b/lib/layout.js index ec09b51..ca34873 100644 --- a/lib/layout.js +++ b/lib/layout.js @@ -1,11 +1,13 @@ import { getEdgingSlotForEstates } from './utils/geometry.js'; +import { Logger } from './logger.js'; /** * ScreenEstate: Immutable data object holding percentages (0-100). */ export class ScreenEstate { - constructor(pct_x, pct_y, pct_w, pct_h) { + constructor(id, pct_x, pct_y, pct_w, pct_h) { + this.id = id; this.EPSILON = 0.01 if (pct_x < 0 || pct_y < 0 || (pct_x + pct_w) > (100 + this.EPSILON) || (pct_y + pct_h) > (100 + this.EPSILON)) { throw new Error(`ScreenEstate out of bounds: x=${pct_x}, y=${pct_y}, w=${pct_w}, h=${pct_h}`); @@ -74,8 +76,14 @@ export class Layout { ); } - getEstate(index) { - return this.estates[index] || null; + // Invariant: estates[i].id === i for all i. This holds because the parser + // sorts estates by id before constructing the Layout. + getEstate(id) { + const estate = this.estates[id] || null; + if (estate && estate.id !== id) { + Logger.error(`Layout invariant violated: estates[${id}].id is ${estate.id}`); + } + return estate; } get size() { @@ -154,34 +162,30 @@ export class LayoutParser { throw new Error(`Layout for count ${count} contains ${arr.length} estates`); } - const parsedArray = arr.map(e => { + const parsedEstates = arr.map(e => { if (e.id === undefined) { throw new Error(`Layout for count ${count} has estate without an id`); } const idVal = parseInt(e.id, 10); - if (isNaN(idVal) || idVal < 1) { + if (isNaN(idVal) || idVal < 0) { throw new Error(`Layout for count ${count} has invalid id ${e.id}`); } - return { - estate: new ScreenEstate(e.x, e.y, e.w, e.h), - slot: idVal - 1 - }; + return new ScreenEstate(idVal, e.x, e.y, e.w, e.h); }); - // Validate uniqueness and completeness of ids - const idSet = new Set(parsedArray.map(item => item.slot + 1)); + // Validate uniqueness and completeness of ids (0 to count-1) + const idSet = new Set(parsedEstates.map(e => e.id)); if (idSet.size !== count) { - throw new Error(`Layout for count ${count} must have unique ids from 1 to ${count}`); + throw new Error(`Layout for count ${count} must have unique ids from 0 to ${count - 1}`); } - for (let i = 1; i <= count; i++) { + for (let i = 0; i < count; i++) { if (!idSet.has(i)) { throw new Error(`Layout for count ${count} is missing id ${i}`); } } - parsedArray.sort((a, b) => a.slot - b.slot); - const estates = parsedArray.map(item => item.estate); - const layout = new Layout(estates); + parsedEstates.sort((a, b) => a.id - b.id); + const layout = new Layout(parsedEstates); LayoutValidator.validateCoverage(layout); diff --git a/lib/monitor.js b/lib/monitor.js index 14b42fb..7d08d47 100644 --- a/lib/monitor.js +++ b/lib/monitor.js @@ -33,6 +33,14 @@ export class MonitorManager { } } + getMonitorCount() { + try { + return global.backend.get_monitor_manager().get_logical_monitors().length; + } catch (e) { + return global.display.get_n_monitors(); + } + } + getMonitorId(monitorIndex) { try { const manager = global.backend.get_monitor_manager(); @@ -85,6 +93,10 @@ export class MonitorManager { this._evacuatedWindows.clear(); } + clearEvacuation(window) { + this._evacuatedWindows.delete(window); + } + checkEvacuation(window, wrapper, newMonitorId, newWorkspace) { const oldMonitorId = wrapper.monitorId; const oldWorkspace = wrapper.workspace; @@ -117,7 +129,9 @@ export class MonitorManager { const oldGrid = this.controller.workspaceManager.getLayout(oldWorkspace); slot = oldGrid._getTracker(oldMonitorId).getSlot(window); oldGrid.untrackWindow(window, oldMonitorId); - } catch (e) {} + } catch (e) { + Logger.warn(`Evacuation untrack failed for "${wrapper.title}"`, e); + } // Track by window reference with original monitor info and slot. this.recordEvacuation(window, oldMonitorId, oldWorkspace, slot); diff --git a/lib/state.js b/lib/state.js index a2bed55..3be4718 100644 --- a/lib/state.js +++ b/lib/state.js @@ -1,55 +1,79 @@ import { Logger } from './logger.js'; /** - * StateTracker class. Maps windows to slots. + * StateTracker: Ordered window list where array position equals slot id. + * Invariant: this._windows contains no duplicates. Position = slot. */ export class StateTracker { constructor() { - this._windowToSlot = new Map(); + this._windows = []; + } + /** + * Registers a window at a specific position (insert) or appends to end. + * Rejects already-tracked windows (insert-only semantics). + */ + startTracking(window, index) { + if (this._windows.includes(window)) return; + if (index !== undefined && index >= 0 && index <= this._windows.length) { + this._windows.splice(index, 0, window); + } else { + this._windows.push(window); + } + const slot = this._windows.indexOf(window); + Logger.debug(`StateTracker: startTracking window ID ${window.get_id ? window.get_id() : 'unknown'} ("${window.get_title ? window.get_title() : 'unknown'}") at slot ${slot}`); } - track(window, index) { - Logger.debug(`StateTracker: Tracked new window ID ${window.get_id ? window.get_id() : 'unknown'} ("${window.get_title ? window.get_title() : 'unknown'}") to slot ${index}`); - this._windowToSlot.set(window, index); + /** + * Unregisters a window. Remaining windows shift down naturally. + */ + stopTracking(window) { + const idx = this._windows.indexOf(window); + if (idx !== -1) { + this._windows.splice(idx, 1); + Logger.debug(`StateTracker: stopTracking window ID ${window.get_id ? window.get_id() : 'unknown'} ("${window.get_title ? window.get_title() : 'unknown'}") from slot ${idx}`); + } } - untrack(window) { - const slot = this._windowToSlot.get(window); - this._windowToSlot.delete(window); - Logger.debug(`StateTracker: Untracked window ID ${window.get_id ? window.get_id() : 'unknown'} ("${window.get_title ? window.get_title() : 'unknown'}") from slot ${slot}`); + /** + * Atomically replaces one window with another in the same slot. + * Avoids transient state from sequential stopTracking+startTracking. + */ + replace(oldWindow, newWindow) { + const idx = this._windows.indexOf(oldWindow); + if (idx === -1) return; + this._windows[idx] = newWindow; + Logger.debug(`StateTracker: replace window ID ${oldWindow.get_id ? oldWindow.get_id() : 'unknown'} with ${newWindow.get_id ? newWindow.get_id() : 'unknown'} at slot ${idx}`); } swapWindows(win1, win2) { - if (!this._windowToSlot.has(win1) || !this._windowToSlot.has(win2)) return; - const slot1 = this._windowToSlot.get(win1); - const slot2 = this._windowToSlot.get(win2); - this._windowToSlot.set(win1, slot2); - this._windowToSlot.set(win2, slot1); - Logger.debug(`StateTracker: Swapped windows ID ${win1.get_id ? win1.get_id() : 'unknown'} (slot ${slot1} -> ${slot2}) and ID ${win2.get_id ? win2.get_id() : 'unknown'} (slot ${slot2} -> ${slot1})`); + const i = this._windows.indexOf(win1); + const j = this._windows.indexOf(win2); + if (i === -1 || j === -1) return; + [this._windows[i], this._windows[j]] = [this._windows[j], this._windows[i]]; + Logger.debug(`StateTracker: Swapped windows ID ${win1.get_id ? win1.get_id() : 'unknown'} (slot ${i} -> ${j}) and ID ${win2.get_id ? win2.get_id() : 'unknown'} (slot ${j} -> ${i})`); } getSlot(window) { - return this._windowToSlot.get(window); + const idx = this._windows.indexOf(window); + return idx === -1 ? undefined : idx; } get windows() { - return [...this._windowToSlot.entries()] - .sort((a, b) => a[1] - b[1]) - .map(entry => entry[0]); + return [...this._windows]; // defensive copy, already ordered } get size() { - return this._windowToSlot.size; + return this._windows.length; } clear() { - this._windowToSlot.clear(); + this._windows = []; } swapWith(otherTracker) { - const tempWindowToSlot = this._windowToSlot; - this._windowToSlot = otherTracker._windowToSlot; - otherTracker._windowToSlot = tempWindowToSlot; + const temp = this._windows; + this._windows = otherTracker._windows; + otherTracker._windows = temp; } } diff --git a/lib/workspace.js b/lib/workspace.js index b9d0d6c..f1a6884 100644 --- a/lib/workspace.js +++ b/lib/workspace.js @@ -19,17 +19,7 @@ export class WorkspaceLayout { trackWindow(window, monitorId, preferredSlot) { const tracker = this._getTracker(monitorId); if (tracker.getSlot(window) === undefined) { - if (preferredSlot !== undefined) { - tracker.windows.forEach(w => { - const s = tracker.getSlot(w); - if (s >= preferredSlot) { - tracker.track(w, s + 1); - } - }); - tracker.track(window, preferredSlot); - } else { - tracker.track(window, tracker.size); - } + tracker.startTracking(window, preferredSlot); } } @@ -38,7 +28,7 @@ export class WorkspaceLayout { */ untrackWindow(window, monitorId) { const tracker = this._getTracker(monitorId); - tracker.untrack(window); + tracker.stopTracking(window); } /** @@ -46,11 +36,7 @@ export class WorkspaceLayout { */ 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.replace(oldWindow, newWindow); } /** @@ -81,8 +67,6 @@ export class WorkspaceLayout { if (!layout) return []; return tracker.windows.map((win, index) => { - // Re-normalize slots - tracker.track(win, index); const estate = layout.getEstate(index); if (!estate) return null; return { @@ -169,11 +153,11 @@ export class WorkspaceLayout { if (targetEdgingSlot !== -1) { const targetWindow = targetTracker.windows.find(w => targetTracker.getSlot(w) === targetEdgingSlot); if (targetWindow) { - sourceTracker.untrack(window); - targetTracker.untrack(targetWindow); + sourceTracker.stopTracking(window); + targetTracker.stopTracking(targetWindow); - targetTracker.track(window, targetEdgingSlot); - sourceTracker.track(targetWindow, sourceSlot); + targetTracker.startTracking(window, targetEdgingSlot); + sourceTracker.startTracking(targetWindow, sourceSlot); return { swappedWindow: targetWindow, behavior: 'swap' }; } @@ -181,7 +165,7 @@ export class WorkspaceLayout { } } - sourceTracker.untrack(window); + sourceTracker.stopTracking(window); const targetLayout = this.escalator.getLayoutForCount(targetTracker.size + 1); let preferredSlot = targetTracker.size; diff --git a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml index 01828d3..6612dcc 100644 --- a/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml +++ b/schemas/org.gnome.shell.extensions.workflow-tiling.gschema.xml @@ -173,7 +173,7 @@ - + Custom Layouts JSON JSON string defining custom window layouts. diff --git a/tests/adversarial.test.js b/tests/adversarial.test.js index dd2a541..0a821c2 100644 --- a/tests/adversarial.test.js +++ b/tests/adversarial.test.js @@ -3,7 +3,7 @@ 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}]}'; +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":0}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":100,"id":1}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":50,"id":1},{"x":50,"y":50,"w":50,"h":50,"id":2}]}'; describe('Adversarial Tests', () => { describe('enteringEdge calculation in tilingRequest', () => { diff --git a/tests/controller.test.js b/tests/controller.test.js index a2e5152..2a1284b 100644 --- a/tests/controller.test.js +++ b/tests/controller.test.js @@ -3,7 +3,7 @@ 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}]}'; +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":0}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":100,"id":1}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":50,"id":1},{"x":50,"y":50,"w":50,"h":50,"id":2}]}'; describe('TilingController', () => { let controller; @@ -288,6 +288,41 @@ describe('TilingController', () => { expect(layout.monitors.get('monitor-1').windows).toContain(win); }); + it('should tile evacuated window when manually unminimized on remaining monitor', () => { + const ws = { + id: 'ws1', + get_work_area_for_monitor: vi.fn(() => ({ x: 0, y: 0, width: 1000, height: 1000 })), + list_windows: () => [win] + }; + + const win = createMockWindow(1, ws, 1); + controller.tilingRequest(win); + expect(controller._windowWrappers.get(win).monitorId).toBe('monitor-1'); + + // Remove monitor-1 -> triggers evacuation + 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' }] } + ]); + vi.mocked(win.get_monitor).mockReturnValue(0); + controller.tilingRequest(win); + + expect(win.minimize).toHaveBeenCalled(); + expect(win.minimized).toBe(true); + expect(controller.monitorManager.isEvacuated(win)).toBe(true); + + controller.monitorManager.handleMonitorsChanged(); + + // User manually unminimizes window on remaining monitor (monitor-0) + win.minimized = false; + controller.tilingRequest(win); + + // Evacuation flag should be cleared and window tracked on monitor-0 + expect(controller.monitorManager.isEvacuated(win)).toBe(false); + const layout = controller.workspaceManager.getLayout(ws); + expect(layout.monitors.get('monitor-0').windows).toContain(win); + }); + it('should handle monitor index shifting via hydration sweep', () => { const ws = { id: 'ws1', diff --git a/tests/drag.test.js b/tests/drag.test.js index a6c8fd3..a202172 100644 --- a/tests/drag.test.js +++ b/tests/drag.test.js @@ -3,7 +3,7 @@ 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}]}'; +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":0}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":100,"id":1}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":50,"id":1},{"x":50,"y":50,"w":50,"h":50,"id":2}]}'; describe('DragManager Cross-Monitor', () => { let controller; diff --git a/tests/layout.test.js b/tests/layout.test.js index 3372c38..1504908 100644 --- a/tests/layout.test.js +++ b/tests/layout.test.js @@ -4,7 +4,7 @@ import { ScreenEstate, Layout, LayoutParser, LayoutValidator } from '../lib/layo describe('ScreenEstate', () => { it('should calculate absolute coordinates with gaps', () => { // TilingConfig.GAPS.OUTER = 4, INNER = 6 - const estate = new ScreenEstate(0, 0, 100, 100); + const estate = new ScreenEstate(0, 0, 0, 100, 100); const monitor = { x: 0, y: 0, width: 1000, height: 1000 }; const absolute = estate.toAbsolute(monitor); @@ -17,14 +17,14 @@ describe('ScreenEstate', () => { }); it('should calculate inner gaps correctly in a split', () => { - const leftEstate = new ScreenEstate(0, 0, 50, 100); + const leftEstate = new ScreenEstate(0, 0, 0, 50, 100); const monitor = { x: 0, y: 0, width: 1000, height: 1000 }; const leftAbs = leftEstate.toAbsolute(monitor); expect(leftAbs.x).toBe(4); expect(leftAbs.width).toBe(493); - const rightEstate = new ScreenEstate(50, 0, 50, 100); + const rightEstate = new ScreenEstate(1, 50, 0, 50, 100); const rightAbs = rightEstate.toAbsolute(monitor); expect(rightAbs.x).toBe(503); @@ -34,30 +34,30 @@ describe('ScreenEstate', () => { }); it('should throw on out of bounds', () => { - expect(() => new ScreenEstate(-1, 0, 100, 100)).toThrow(); + expect(() => new ScreenEstate(0, -1, 0, 100, 100)).toThrow(); }); }); describe('Validation & Immutability', () => { it('should allow correct layouts', () => { expect(() => new Layout([ - new ScreenEstate(0, 0, 50, 100), - new ScreenEstate(50, 0, 50, 100) + new ScreenEstate(0, 0, 0, 50, 100), + new ScreenEstate(1, 50, 0, 50, 100) ])).not.toThrow(); }); it('should throw on out of bounds ScreenEstate', () => { - expect(() => new ScreenEstate(0, 0, 110, 100)).toThrow('out of bounds'); + expect(() => new ScreenEstate(0, 0, 0, 110, 100)).toThrow('out of bounds'); }); it('should throw on overlapping estates in Layout', () => { - const e1 = new ScreenEstate(0, 0, 60, 100); - const e2 = new ScreenEstate(40, 0, 60, 100); + const e1 = new ScreenEstate(0, 0, 0, 60, 100); + const e2 = new ScreenEstate(1, 40, 0, 60, 100); expect(() => new Layout([e1, e2])).toThrow('Overlap detected'); }); it('should be immutable', () => { - const estate = new ScreenEstate(0, 0, 100, 100); + const estate = new ScreenEstate(0, 0, 0, 100, 100); expect(() => { estate.pct_x = 10; }).toThrow(); const layout = new Layout([estate]); @@ -66,7 +66,7 @@ describe('Validation & Immutability', () => { }); it('should validate the default escalator layouts', () => { - 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 defaultJson = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":0}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":100,"id":1}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":50,"id":1},{"x":50,"y":50,"w":50,"h":50,"id":2}]}'; const escalator = LayoutParser.parse(defaultJson); for (let i = 1; i <= 3; i++) { const layout = escalator.getLayoutForCount(i); @@ -80,15 +80,15 @@ describe('LayoutValidator', () => { describe('validateCoverage', () => { it('should pass for exact 10000 area', () => { const layout = new Layout([ - new ScreenEstate(0, 0, 100, 100) + new ScreenEstate(0, 0, 0, 100, 100) ]); expect(() => LayoutValidator.validateCoverage(layout)).not.toThrow(); }); it('should throw if area is less than 10000', () => { const layout = new Layout([ - new ScreenEstate(0, 0, 50, 100), - new ScreenEstate(50, 0, 40, 100) + new ScreenEstate(0, 0, 0, 50, 100), + new ScreenEstate(1, 50, 0, 40, 100) ]); expect(() => LayoutValidator.validateCoverage(layout)).toThrow(/expected 10000/); }); @@ -97,9 +97,9 @@ describe('LayoutValidator', () => { describe('Arbitrary Transitions', () => { it('should allow arbitrary transitions like 50/50 split to 33/33/33 split', () => { const json = JSON.stringify({ - "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": 33.33, "h": 100, "id": 1 }, { "x": 33.33, "y": 0, "w": 33.33, "h": 100, "id": 2 }, { "x": 66.66, "y": 0, "w": 33.34, "h": 100, "id": 3 } ] + "1": [ { "x": 0, "y": 0, "w": 100, "h": 100, "id": 0 } ], + "2": [ { "x": 0, "y": 0, "w": 50, "h": 100, "id": 0 }, { "x": 50, "y": 0, "w": 50, "h": 100, "id": 1 } ], + "3": [ { "x": 0, "y": 0, "w": 33.33, "h": 100, "id": 0 }, { "x": 33.33, "y": 0, "w": 33.33, "h": 100, "id": 1 }, { "x": 66.66, "y": 0, "w": 33.34, "h": 100, "id": 2 } ] }); const escalator = LayoutParser.parse(json); expect(escalator).not.toBeNull(); @@ -126,8 +126,8 @@ describe('LayoutParser', () => { it('should parse valid json and return escalator', () => { const json = JSON.stringify({ - "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 } ] + "1": [ { "x": 0, "y": 0, "w": 100, "h": 100, "id": 0 } ], + "2": [ { "x": 0, "y": 0, "w": 50, "h": 100, "id": 0 }, { "x": 50, "y": 0, "w": 50, "h": 100, "id": 1 } ] }); const escalator = LayoutParser.parse(json); expect(escalator).not.toBeNull(); @@ -141,9 +141,9 @@ describe('LayoutParser', () => { it('should sort estates by id property', () => { const json = JSON.stringify({ "3": [ - { "x": 33.33, "y": 0, "w": 33.33, "h": 100, "id": 3 }, - { "x": 66.66, "y": 0, "w": 33.34, "h": 100, "id": 2 }, - { "x": 0, "y": 0, "w": 33.33, "h": 100, "id": 1 } + { "x": 33.33, "y": 0, "w": 33.33, "h": 100, "id": 2 }, + { "x": 66.66, "y": 0, "w": 33.34, "h": 100, "id": 1 }, + { "x": 0, "y": 0, "w": 33.33, "h": 100, "id": 0 } ] }); const escalator = LayoutParser.parse(json); @@ -156,7 +156,7 @@ describe('LayoutParser', () => { it('should throw if any estate is missing an id', () => { const json = JSON.stringify({ "2": [ - { "x": 0, "y": 0, "w": 50, "h": 100, "id": 1 }, + { "x": 0, "y": 0, "w": 50, "h": 100, "id": 0 }, { "x": 50, "y": 0, "w": 50, "h": 100 } ] }); @@ -166,8 +166,8 @@ describe('LayoutParser', () => { it('should throw if duplicate ids are present', () => { const json = JSON.stringify({ "2": [ - { "x": 0, "y": 0, "w": 50, "h": 100, "id": 1 }, - { "x": 50, "y": 0, "w": 50, "h": 100, "id": 1 } + { "x": 0, "y": 0, "w": 50, "h": 100, "id": 0 }, + { "x": 50, "y": 0, "w": 50, "h": 100, "id": 0 } ] }); expect(() => LayoutParser.parse(json)).toThrow(/must have unique ids/); @@ -176,10 +176,10 @@ describe('LayoutParser', () => { it('should throw if id is out of bounds', () => { const json = JSON.stringify({ "2": [ - { "x": 0, "y": 0, "w": 50, "h": 100, "id": 1 }, - { "x": 50, "y": 0, "w": 50, "h": 100, "id": 3 } + { "x": 0, "y": 0, "w": 50, "h": 100, "id": 0 }, + { "x": 50, "y": 0, "w": 50, "h": 100, "id": 2 } ] }); - expect(() => LayoutParser.parse(json)).toThrow(/is missing id 2/); + expect(() => LayoutParser.parse(json)).toThrow(/is missing id 1/); }); }); diff --git a/tests/monitor-transition.test.js b/tests/monitor-transition.test.js index 5d67cad..992c1f8 100644 --- a/tests/monitor-transition.test.js +++ b/tests/monitor-transition.test.js @@ -5,11 +5,11 @@ 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}]}'; +const DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":0}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":100,"id":1}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":50,"id":1},{"x":50,"y":50,"w":50,"h":50,"id":2}]}'; describe('Edging Tile Identification', () => { it('should identify edging tile for layout of size 1', () => { - const estates = [new ScreenEstate(0, 0, 100, 100)]; + const estates = [new ScreenEstate(0, 0, 0, 100, 100)]; const layout = new Layout(estates); expect(layout.getEdgingSlot('left')).toBe(0); expect(layout.getEdgingSlot('right')).toBe(0); @@ -20,8 +20,8 @@ describe('Edging Tile Identification', () => { 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) + new ScreenEstate(0, 0, 0, 50, 100), + new ScreenEstate(1, 50, 0, 50, 100) ]; const layout = new Layout(estates); expect(layout.getEdgingSlot('left')).toBe(0); @@ -34,9 +34,9 @@ describe('Edging Tile Identification', () => { 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) + new ScreenEstate(0, 0, 0, 50, 100), + new ScreenEstate(1, 50, 0, 50, 50), + new ScreenEstate(2, 50, 50, 50, 50) ]; const layout = new Layout(estates); expect(layout.getEdgingSlot('left')).toBe(0); @@ -51,9 +51,9 @@ describe('Edging Tile Identification', () => { 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) + new ScreenEstate(0, 0, 0, 30, 100), + new ScreenEstate(1, 30, 0, 70, 40), + new ScreenEstate(2, 30, 40, 70, 60) ]; const layout = new Layout(estates); expect(layout.getEdgingSlot('left')).toBe(0); diff --git a/tests/regressions.test.js b/tests/regressions.test.js index 411e990..0965f69 100644 --- a/tests/regressions.test.js +++ b/tests/regressions.test.js @@ -4,7 +4,7 @@ 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 DEFAULT_JSON = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":0}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":100,"id":1}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":50,"id":1},{"x":50,"y":50,"w":50,"h":50,"id":2}]}'; const escalator = LayoutParser.parse(DEFAULT_JSON); describe('Regressions', () => { diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 6e61209..cb5eda0 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -3,7 +3,7 @@ import { WorkspaceLayout, WorkspaceManager } from '../lib/workspace.js'; 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 defaultJson = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":0}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":100,"id":1}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":50,"id":1},{"x":50,"y":50,"w":50,"h":50,"id":2}]}'; const escalator = LayoutParser.parse(defaultJson); const controller = { escalator }; const monitorRect = { x: 0, y: 0, width: 1000, height: 1000 }; @@ -232,7 +232,7 @@ describe('WorkspaceManager', () => { 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}]}') + escalator: LayoutParser.parse('{"1":[{"x":0,"y":0,"w":100,"h":100,"id":0}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":100,"id":1}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":50,"id":1},{"x":50,"y":50,"w":50,"h":50,"id":2}]}') }; manager = new WorkspaceManager(controller); }); @@ -331,7 +331,7 @@ 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 defaultJson = '{"1":[{"x":0,"y":0,"w":100,"h":100,"id":0}],"2":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":100,"id":1}],"3":[{"x":0,"y":0,"w":50,"h":100,"id":0},{"x":50,"y":0,"w":50,"h":50,"id":1},{"x":50,"y":50,"w":50,"h":50,"id":2}]}'; const escalator = LayoutParser.parse(defaultJson); const controller = { escalator };