Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
]
}
```
Expand All @@ -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).
11 changes: 10 additions & 1 deletion lib/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 11 additions & 11 deletions lib/editor/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ function getDefaultSplit(layoutBase, targetCount) {
});
newLayoutEstates.push(e2);
newLayoutEstates.forEach((item, i) => {
item.id = i + 1;
item.id = i;
});

return newLayoutEstates;
Expand Down Expand Up @@ -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)
})));
}
});
Expand Down Expand Up @@ -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 };
}
}

Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
}));
}
}
Expand Down Expand Up @@ -1002,7 +1002,7 @@ class LayoutPreviewPage extends Adw.PreferencesPage {

const c = colors[k % colors.length];
const label = new Gtk.Label({
label: `<span foreground="${c.hex}">●</span> Slot ${estate.id || (k + 1)}:`,
label: `<span foreground="${c.hex}">●</span> Slot ${(estate.id !== undefined ? estate.id : k) + 1}:`,
use_markup: true,
width_request: 80,
halign: Gtk.Align.START
Expand Down
36 changes: 20 additions & 16 deletions lib/layout.js
Original file line number Diff line number Diff line change
@@ -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}`);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);

Expand Down
16 changes: 15 additions & 1 deletion lib/monitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
72 changes: 48 additions & 24 deletions lib/state.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading