Files
GridSpaces/extension.js
hzhang 57df2bcfbd GridSpaces: per-workspace n×m tiling for GNOME Shell 48
Mark a workspace as a grid; windows auto-fill cells, Alt+drag dividers to
resize rows/columns, drag-to-swap, overflow pops to a normal workspace.
Normal workspaces untouched. Tiling Assistant coexistence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:47:23 +01:00

1007 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// GridSpaces — per-workspace flexible tiling for GNOME Shell 48 (Wayland).
//
// A "grid workspace" is a stack of rows; each row has its own column count.
// Row heights (h%) and per-row column widths (w%) are percentages of the whole
// work area (dividers live on those percentage boundaries — the 100 already
// accounts for them). Windows entering the workspace auto-fill the first free
// cell and fully occupy it. Drag a window onto another cell to swap. Drag the
// always-visible divider lines to resize adjacent rows/columns (preview line
// while dragging, applied on release). Normal workspaces get zero hooks.
// Overflow windows pop to the nearest normal workspace.
//
// SAFETY: every signal / timeout / event callback is funnelled through a guard
// that (a) no-ops after teardown and (b) swallows+logs exceptions. On Wayland
// an unhandled throw inside such a callback is uncatchable by GNOME and takes
// the whole shell down, so this is load-bearing, not defensive paranoia.
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Clutter from 'gi://Clutter';
import St from 'gi://St';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
const MAX_ROWS = 8;
const MAX_COLS = 8;
const DIVIDER = 8; // px: thickness of the draggable seam
const MIN_CELL = 80; // px: smallest a row/column may be dragged to
const TA_SCHEMA = 'org.gnome.shell.extensions.tiling-assistant';
function logErr(where, e) {
try {
logError(e, `GridSpaces:${where}`);
} catch (_e) {}
}
function isManaged(win) {
return !!win &&
win.get_window_type() === Meta.WindowType.NORMAL &&
!win.is_skip_taskbar() &&
!win.is_on_all_workspaces();
}
// Owns every connection / timeout for the whole extension instance. A single
// place to wrap callbacks and to tear everything down deterministically.
class Lifetime {
constructor() {
this.dead = false;
this._signals = []; // [obj, id]
this._timeouts = new Set(); // GLib source ids
}
// Wrap any callback: no-op once dead, never let an exception escape.
guard(where, fn) {
return (...args) => {
if (this.dead)
return undefined;
try {
return fn(...args);
} catch (e) {
logErr(where, e);
return undefined;
}
};
}
connect(obj, signal, where, fn) {
const id = obj.connect(signal, this.guard(where, fn));
this._signals.push([obj, id]);
return id;
}
// Track an arbitrary external connection so destroy() reaps it.
track(obj, id) {
this._signals.push([obj, id]);
return id;
}
timeout(where, ms, fn) {
let id = 0;
const run = () => {
this._timeouts.delete(id);
if (this.dead)
return GLib.SOURCE_REMOVE;
try {
return fn();
} catch (e) {
logErr(where, e);
return GLib.SOURCE_REMOVE;
}
};
id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ms, run);
this._timeouts.add(id);
return id;
}
destroy() {
this.dead = true;
for (const id of this._timeouts) {
try { GLib.source_remove(id); } catch (_e) {}
}
this._timeouts.clear();
for (const [obj, id] of this._signals) {
try { obj.disconnect(id); } catch (_e) {}
}
this._signals = [];
}
}
// ---- Tiling Assistant coexistence (scoped, fully reversible) ---------------
class TilingAssistantGuard {
constructor() {
this._settings = null;
this._origPopup = null;
this._suppressed = false;
try {
const src = Gio.SettingsSchemaSource.get_default();
if (src && src.lookup(TA_SCHEMA, true))
this._settings = new Gio.Settings({schema_id: TA_SCHEMA});
} catch (_e) {
this._settings = null;
}
}
sync(gridActive) {
if (!this._settings || this._suppressed === gridActive)
return;
try {
if (gridActive) {
this._origPopup =
this._settings.get_boolean('enable-tiling-popup');
this._settings.set_boolean('enable-tiling-popup', false);
this._suppressed = true;
} else {
this.restore();
}
} catch (e) {
logErr('ta.sync', e);
}
}
restore() {
try {
if (this._settings && this._suppressed && this._origPopup !== null)
this._settings.set_boolean(
'enable-tiling-popup', this._origPopup);
} catch (e) {
logErr('ta.restore', e);
}
this._suppressed = false;
this._origPopup = null;
}
}
// ---- One grid bound to one Meta.Workspace ---------------------------------
class WorkspaceGrid {
constructor(manager, workspace) {
this._mgr = manager;
this._life = manager._life;
this.ws = workspace;
this._destroyed = false;
// Default: 2 rows, 2 columns each.
this.rows = [
{h: 50, cols: [{w: 50}, {w: 50}]},
{h: 50, cols: [{w: 50}, {w: 50}]},
];
this.slots = new Array(this.capacity).fill(null);
this._addedId = this._life.track(workspace,
workspace.connect('window-added',
this._life.guard('grid.added',
(_ws, win) => this._onWindowAdded(win))));
this._removedId = this._life.track(workspace,
workspace.connect('window-removed',
this._life.guard('grid.removed',
(_ws, win) => this._onWindowRemoved(win))));
for (const win of workspace.list_windows())
this._onWindowAdded(win);
}
get capacity() {
return this.rows.reduce((n, r) => n + r.cols.length, 0);
}
_coord(index) {
let i = index;
for (let r = 0; r < this.rows.length; r++) {
const n = this.rows[r].cols.length;
if (i < n)
return {row: r, col: i};
i -= n;
}
return null;
}
_workArea() {
return this.ws.get_work_area_for_monitor(
Main.layoutManager.primaryIndex);
}
// Pixel rect of a cell. Outer edges sit flush with the work area; internal
// seams reserve DIVIDER px (carved evenly from both neighbours).
_cellRect(row, col) {
const a = this._workArea();
const R = this.rows.length;
const r = this.rows[row];
const C = r.cols.length;
let topPct = 0;
for (let i = 0; i < row; i++)
topPct += this.rows[i].h;
const y0 = a.y + Math.round((a.height * topPct) / 100);
const y1 = a.y + Math.round((a.height * (topPct + r.h)) / 100);
let leftPct = 0;
for (let i = 0; i < col; i++)
leftPct += r.cols[i].w;
const x0 = a.x + Math.round((a.width * leftPct) / 100);
const x1 =
a.x + Math.round((a.width * (leftPct + r.cols[col].w)) / 100);
const top = y0 + (row > 0 ? DIVIDER / 2 : 0);
const bot = y1 - (row < R - 1 ? DIVIDER / 2 : 0);
const left = x0 + (col > 0 ? DIVIDER / 2 : 0);
const right = x1 - (col < C - 1 ? DIVIDER / 2 : 0);
return {
x: Math.round(left),
y: Math.round(top),
w: Math.max(1, Math.round(right - left)),
h: Math.max(1, Math.round(bot - top)),
};
}
cellRectForSlot(index) {
const c = this._coord(index);
return c ? this._cellRect(c.row, c.col) : null;
}
slotAtPoint(px, py) {
for (let i = 0; i < this.capacity; i++) {
const rc = this.cellRectForSlot(i);
if (rc && px >= rc.x && px < rc.x + rc.w &&
py >= rc.y && py < rc.y + rc.h)
return i;
}
let best = -1, bd = Infinity;
for (let i = 0; i < this.capacity; i++) {
const rc = this.cellRectForSlot(i);
if (!rc)
continue;
const dx = px - (rc.x + rc.w / 2);
const dy = py - (rc.y + rc.h / 2);
const d = dx * dx + dy * dy;
if (d < bd) {
bd = d;
best = i;
}
}
return best;
}
slotOf(win) {
return this.slots.indexOf(win);
}
_place(win, index) {
const rect = this.cellRectForSlot(index);
if (!rect)
return;
const apply = () => {
if (this._destroyed || !win || !win.get_compositor_private())
return GLib.SOURCE_REMOVE;
if (win.get_maximized())
win.unmaximize(Meta.MaximizeFlags.BOTH);
win.move_resize_frame(true, rect.x, rect.y, rect.w, rect.h);
return GLib.SOURCE_REMOVE;
};
apply();
this._life.timeout('grid.place', 60, apply);
}
relayout() {
if (this._destroyed)
return;
for (let i = 0; i < this.slots.length; i++) {
if (this.slots[i])
this._place(this.slots[i], i);
}
this._mgr.refreshOverlay();
}
_onWindowAdded(win) {
if (this._destroyed || !isManaged(win) ||
this._mgr.isRelocating(win))
return;
if (this.slotOf(win) !== -1)
return;
const free = this.slots.indexOf(null);
if (free === -1) {
this._mgr.bounce(win, this.ws);
return;
}
this.slots[free] = win;
this._place(win, free);
this._mgr.refreshOverlay();
}
_onWindowRemoved(win) {
if (this._destroyed)
return;
const i = this.slotOf(win);
if (i !== -1) {
this.slots[i] = null;
this._mgr.refreshOverlay();
}
}
handleDragEnd(win) {
if (this._destroyed)
return;
const from = this.slotOf(win);
if (from === -1)
return;
const [px, py] = global.get_pointer();
const to = this.slotAtPoint(px, py);
if (to !== -1 && to !== from) {
const other = this.slots[to];
this.slots[from] = other; // swap (other may be null)
this.slots[to] = win;
}
this.relayout();
for (const delay of [50, 180]) {
this._life.timeout('grid.dragEnd', delay, () => {
if (!this._destroyed)
this.relayout();
return GLib.SOURCE_REMOVE;
});
}
}
_reseat() {
const live = this.slots.filter(w => w !== null);
const cap = this.capacity;
this.slots = new Array(cap).fill(null);
live.slice(0, cap).forEach((w, i) => (this.slots[i] = w));
for (const w of live.slice(cap))
this._mgr.popToNormal(w, this.ws);
this.relayout();
}
addRow() {
if (this._destroyed || this.rows.length >= MAX_ROWS)
return;
this.rows.push({h: 0, cols: [{w: 100}]});
const eq = 100 / this.rows.length;
this.rows.forEach(r => (r.h = eq));
this._reseat();
}
removeRow(idx) {
if (this._destroyed || this.rows.length <= 1 ||
idx < 0 || idx >= this.rows.length)
return;
this.rows.splice(idx, 1);
const eq = 100 / this.rows.length;
this.rows.forEach(r => (r.h = eq));
this._reseat();
}
setRowColumns(idx, n) {
if (this._destroyed)
return;
const r = this.rows[idx];
if (!r)
return;
n = Math.max(1, Math.min(MAX_COLS, n));
if (n === r.cols.length)
return;
r.cols = Array.from({length: n}, () => ({w: 100 / n}));
this._reseat();
}
setRowHeights(a, b, hA, hB) {
if (this._destroyed)
return;
this.rows[a].h = hA;
this.rows[b].h = hB;
this.relayout();
}
setColWidths(row, a, b, wA, wB) {
if (this._destroyed)
return;
this.rows[row].cols[a].w = wA;
this.rows[row].cols[b].w = wB;
this.relayout();
}
destroy() {
this._destroyed = true;
try {
this.ws.disconnect(this._addedId);
this.ws.disconnect(this._removedId);
} catch (_e) {}
this.slots = [];
}
}
// ---- Always-visible draggable divider overlay -----------------------------
class DividerOverlay {
constructor(manager) {
this._mgr = manager;
this._life = manager._life;
this._strips = [];
this._preview = null;
this._dragCtx = null;
this._grab = null;
this._grabber = null;
this._watchdog = 0;
}
_clearStrips() {
for (const s of this._strips) {
try {
Main.layoutManager.removeChrome(s);
s.destroy();
} catch (_e) {}
}
this._strips = [];
}
_addStrip(rect, vertical, onPress) {
const strip = new St.Widget({
reactive: true,
track_hover: true,
style_class: vertical
? 'gridspaces-divider-v' : 'gridspaces-divider-h',
});
strip.set_position(rect.x, rect.y);
strip.set_size(Math.max(1, rect.w), Math.max(1, rect.h));
// Only Alt+click starts a resize drag; a plain click on the seam is
// ignored (the 8px strip sits in the empty gutter between windows, so
// letting it through changes nothing) — this prevents accidental drags.
strip.connect('button-press-event',
this._life.guard('ovl.press', (_a, ev) => {
if (!(ev.get_state() & Clutter.ModifierType.MOD1_MASK))
return Clutter.EVENT_PROPAGATE;
onPress(ev);
return Clutter.EVENT_STOP;
}));
Main.layoutManager.addChrome(strip, {affectsInputRegion: true});
this._strips.push(strip);
}
rebuild() {
this._clearStrips();
if (this._life.dead || this._dragCtx)
return;
const grid = this._mgr.gridForActiveIfGrid();
if (!grid || Main.overview.visible)
return;
const a = grid._workArea();
let topPct = 0;
for (let r = 0; r < grid.rows.length; r++) {
const row = grid.rows[r];
const rowTop = a.y + Math.round((a.height * topPct) / 100);
const rowBot =
a.y + Math.round((a.height * (topPct + row.h)) / 100);
let leftPct = 0;
for (let c = 0; c < row.cols.length - 1; c++) {
leftPct += row.cols[c].w;
const x = a.x + Math.round((a.width * leftPct) / 100);
const cc = c, rr = r;
this._addStrip(
{x: x - DIVIDER / 2, y: rowTop + DIVIDER / 2,
w: DIVIDER, h: rowBot - rowTop - DIVIDER},
true,
ev => this._beginDrag(ev, {
kind: 'v', grid, row: rr, seam: cc,
area: a, rowTop, rowBot}));
}
if (r < grid.rows.length - 1) {
const rr = r;
this._addStrip(
{x: a.x, y: rowBot - DIVIDER / 2, w: a.width, h: DIVIDER},
false,
ev => this._beginDrag(ev, {
kind: 'h', grid, seam: rr, area: a}));
}
topPct += row.h;
}
}
_beginDrag(ev, ctx) {
if (this._dragCtx || this._life.dead)
return;
try {
const a = ctx.area;
// Full-work-area transparent input catcher. We take an explicit
// Clutter grab on it so motion/release/Esc reach us reliably even
// when the pointer leaves the thin seam (the old global
// captured-event approach lost events after ~10px on Wayland).
this._grabber = new St.Widget({reactive: true});
this._grabber.set_position(a.x, a.y);
this._grabber.set_size(a.width, a.height);
Main.layoutManager.addChrome(this._grabber);
this._preview = new St.Widget({
style_class: 'gridspaces-preview',
reactive: false,
});
if (ctx.kind === 'h') {
const [, py] = ev.get_coords();
this._preview.set_position(a.x, Math.round(py - DIVIDER / 2));
this._preview.set_size(a.width, DIVIDER);
} else {
const [px] = ev.get_coords();
this._preview.set_position(
Math.round(px - DIVIDER / 2), ctx.rowTop);
this._preview.set_size(DIVIDER, ctx.rowBot - ctx.rowTop);
}
Main.layoutManager.addChrome(this._preview);
this._dragCtx = ctx;
this._grabber.connect('motion-event',
this._life.guard('ovl.motion',
(_a, e) => this._onMotion(e)));
this._grabber.connect('button-release-event',
this._life.guard('ovl.release', (_a, e) => {
this._finishDrag(e);
return Clutter.EVENT_STOP;
}));
this._grabber.connect('key-press-event',
this._life.guard('ovl.key', (_a, e) => {
if (e.get_key_symbol() === Clutter.KEY_Escape)
this._cancelDrag();
return Clutter.EVENT_STOP;
}));
this._grab = global.stage.grab(this._grabber);
// Watchdog: never let a drag outlive a sane interaction window.
this._watchdog = this._life.timeout('ovl.watchdog', 8000, () => {
this._cancelDrag();
return GLib.SOURCE_REMOVE;
});
} catch (e) {
logErr('ovl.beginDrag', e);
this._cancelDrag();
}
}
_clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
_onMotion(e) {
const ctx = this._dragCtx;
if (!ctx || !this._preview)
return Clutter.EVENT_PROPAGATE;
const [px, py] = e.get_coords();
const a = ctx.area;
if (ctx.kind === 'h') {
const y = this._clamp(py,
a.y + MIN_CELL, a.y + a.height - MIN_CELL);
this._preview.set_position(a.x, Math.round(y - DIVIDER / 2));
} else {
const row = ctx.grid.rows[ctx.row];
let leftPx = a.x;
for (let i = 0; i < ctx.seam; i++)
leftPx += (a.width * row.cols[i].w) / 100;
const rightPx = leftPx +
(a.width * (row.cols[ctx.seam].w +
row.cols[ctx.seam + 1].w)) / 100;
const x = this._clamp(px,
leftPx + MIN_CELL, rightPx - MIN_CELL);
this._preview.set_position(
Math.round(x - DIVIDER / 2), ctx.rowTop);
}
return Clutter.EVENT_STOP;
}
_finishDrag(e) {
try {
this._applyDrag(e);
} catch (err) {
logErr('ovl.finishDrag', err);
} finally {
this._endDrag();
}
}
_applyDrag(e) {
const ctx = this._dragCtx;
if (!ctx)
return;
const a = ctx.area;
const [px, py] = e.get_coords();
if (ctx.kind === 'h') {
const g = ctx.grid;
const A = ctx.seam, B = ctx.seam + 1;
let topPct = 0;
for (let i = 0; i < A; i++)
topPct += g.rows[i].h;
const sum = g.rows[A].h + g.rows[B].h;
const minPct = (MIN_CELL / a.height) * 100;
let hA = ((this._clamp(py, a.y + MIN_CELL,
a.y + a.height - MIN_CELL) - a.y) / a.height) * 100 - topPct;
hA = this._clamp(hA, minPct, sum - minPct);
g.setRowHeights(A, B, hA, sum - hA);
} else {
const g = ctx.grid;
const row = g.rows[ctx.row];
const A = ctx.seam, B = ctx.seam + 1;
let leftPct = 0;
for (let i = 0; i < A; i++)
leftPct += row.cols[i].w;
const sum = row.cols[A].w + row.cols[B].w;
const minPct = (MIN_CELL / a.width) * 100;
const leftPx = a.x + (a.width * leftPct) / 100;
const rightPx = leftPx + (a.width * sum) / 100;
let wA = ((this._clamp(px, leftPx + MIN_CELL,
rightPx - MIN_CELL) - a.x) / a.width) * 100 - leftPct;
wA = this._clamp(wA, minPct, sum - minPct);
g.setColWidths(ctx.row, A, B, wA, sum - wA);
}
}
_cancelDrag() {
this._endDrag();
}
// Idempotent: safe to call multiple times / from teardown.
_endDrag() {
if (this._watchdog) {
try { GLib.source_remove(this._watchdog); } catch (_e) {}
this._watchdog = 0;
}
if (this._grab) {
try { this._grab.dismiss(); } catch (_e) {}
this._grab = null;
}
if (this._grabber) {
try {
Main.layoutManager.removeChrome(this._grabber);
this._grabber.destroy();
} catch (_e) {}
this._grabber = null;
}
if (this._preview) {
try {
Main.layoutManager.removeChrome(this._preview);
this._preview.destroy();
} catch (_e) {}
this._preview = null;
}
const wasDragging = this._dragCtx !== null;
this._dragCtx = null;
if (wasDragging && !this._life.dead)
this.rebuild();
}
destroy() {
this._endDrag();
this._clearStrips();
}
}
// ---- Manager ---------------------------------------------------------------
class GridManager {
constructor(life) {
this._life = life;
this._grids = new Map();
this._relocating = new Set();
this._taGuard = new TilingAssistantGuard();
this._overlay = new DividerOverlay(this);
this._onChange = null;
this._prevWorkspace = new Map(); // win -> last ws index
this._winSignals = new Map(); // win -> [ids]
const wm = global.workspace_manager;
this._life.connect(wm, 'workspace-removed', 'wm.removed',
() => this._reconcile());
this._life.connect(wm, 'active-workspace-changed', 'wm.active',
() => this._notify());
this._life.connect(global.display, 'grab-op-end', 'disp.grabEnd',
(_d, win) => this._onGrabEnd(win));
this._life.connect(global.display, 'window-created', 'disp.winNew',
(_d, win) => this._trackWindow(win));
this._life.connect(Main.overview, 'showing', 'ovr.show',
() => this.refreshOverlay());
this._life.connect(Main.overview, 'hidden', 'ovr.hide',
() => this.refreshOverlay());
this._life.connect(Main.layoutManager, 'monitors-changed', 'mon.chg',
() => this.refreshOverlay());
// Seed already-existing windows so bounce() knows their origin.
for (const a of global.get_window_actors()) {
const w = a.meta_window;
if (w)
this._trackWindow(w);
}
}
_trackWindow(win) {
if (!win || this._winSignals.has(win))
return;
const ws = win.get_workspace();
if (ws)
this._prevWorkspace.set(win, ws.index());
const wcId = win.connect('workspace-changed',
this._life.guard('win.wsChg', () => {
const cur = win.get_workspace();
win._gridPrevWs = this._prevWorkspace.get(win);
if (cur)
this._prevWorkspace.set(win, cur.index());
}));
const unId = win.connect('unmanaged',
this._life.guard('win.unmanaged', () => this._untrackWindow(win)));
this._winSignals.set(win, [wcId, unId]);
}
_untrackWindow(win) {
const ids = this._winSignals.get(win);
if (ids) {
for (const id of ids) {
try { win.disconnect(id); } catch (_e) {}
}
this._winSignals.delete(win);
}
this._prevWorkspace.delete(win);
}
isRelocating(win) {
return this._relocating.has(win);
}
activeWorkspace() {
return global.workspace_manager.get_active_workspace();
}
gridFor(ws) {
return this._grids.get(ws) ?? null;
}
isGrid(ws) {
return this._grids.has(ws);
}
gridForActiveIfGrid() {
return this._grids.get(this.activeWorkspace()) ?? null;
}
refreshOverlay() {
if (!this._life.dead)
this._overlay.rebuild();
}
_notify() {
this._taGuard.sync(this.isGrid(this.activeWorkspace()));
this.refreshOverlay();
if (this._onChange) {
try { this._onChange(); } catch (e) { logErr('onChange', e); }
}
}
makeGrid(ws) {
if (!this._grids.has(ws))
this._grids.set(ws, new WorkspaceGrid(this, ws));
this._notify();
}
unmakeGrid(ws) {
const g = this._grids.get(ws);
if (!g)
return;
g.destroy();
this._grids.delete(ws);
this._notify();
}
_onGrabEnd(win) {
if (!win)
return;
const ws = win.get_workspace();
const g = ws ? this._grids.get(ws) : null;
if (g)
g.handleDragEnd(win);
}
_nearestNormal(fromWs) {
const wm = global.workspace_manager;
const n = wm.get_n_workspaces();
const base = fromWs ? fromWs.index() : 0;
for (let d = 1; d <= n; d++) {
for (const idx of [base + d, base - d]) {
if (idx < 0 || idx >= n)
continue;
const ws = wm.get_workspace_by_index(idx);
if (ws && !this._grids.has(ws))
return ws;
}
}
wm.append_new_workspace(false, global.get_current_time());
return wm.get_workspace_by_index(wm.get_n_workspaces() - 1);
}
_move(win, ws) {
if (!ws)
return;
this._relocating.add(win);
win.change_workspace(ws);
this._life.timeout('mgr.move', 0, () => {
this._relocating.delete(win);
return GLib.SOURCE_REMOVE;
});
}
bounce(win, fullWs) {
const back = win._gridPrevWs;
if (back != null) {
const ws = global.workspace_manager.get_workspace_by_index(back);
if (ws && ws !== fullWs && !this._grids.has(ws)) {
this._move(win, ws);
return;
}
}
this._move(win, this._nearestNormal(fullWs));
}
popToNormal(win, fromWs) {
this._move(win, this._nearestNormal(fromWs));
}
_reconcile() {
const wm = global.workspace_manager;
const live = new Set();
for (let i = 0; i < wm.get_n_workspaces(); i++)
live.add(wm.get_workspace_by_index(i));
for (const [ws, g] of [...this._grids]) {
if (!live.has(ws)) {
g.destroy();
this._grids.delete(ws);
}
}
this._notify();
}
destroy() {
this._onChange = null;
for (const [, g] of this._grids) {
try { g.destroy(); } catch (_e) {}
}
this._grids.clear();
for (const win of [...this._winSignals.keys()])
this._untrackWindow(win);
try { this._overlay.destroy(); } catch (_e) {}
this._relocating.clear();
this._taGuard.restore();
}
}
// ---- Panel button: structure editor ---------------------------------------
const GridIndicator = GObject.registerClass(
class GridIndicator extends PanelMenu.Button {
_init(manager) {
super._init(0.0, 'GridSpaces');
this._mgr = manager;
this.add_child(new St.Icon({
icon_name: 'view-grid-symbolic',
style_class: 'system-status-icon',
}));
// Keep the menu eagerly current. Rebuilding during the open
// transition makes GNOME 48 collapse the popup, so we never do that —
// we rebuild on state changes (toggle, workspace switch, in-menu
// edits) while the menu is closed or already fully open, both safe.
manager._onChange = () => {
try {
this._rebuildMenu();
} catch (e) {
logErr('menu.rebuild', e);
}
};
this._rebuildMenu();
}
_rebuildMenu() {
this.menu.removeAll();
const ws = this._mgr.activeWorkspace();
const on = this._mgr.isGrid(ws);
const toggle = new PopupMenu.PopupSwitchMenuItem(
'Grid workspace', on);
toggle.connect('toggled', (_i, state) => {
try {
if (state)
this._mgr.makeGrid(ws);
else
this._mgr.unmakeGrid(ws);
} catch (e) {
logErr('menu.toggle', e);
}
});
this.menu.addMenuItem(toggle);
if (!on)
return;
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
const grid = this._mgr.gridFor(ws);
grid.rows.forEach((row, r) => {
const item = new PopupMenu.PopupBaseMenuItem({reactive: false});
item.add_child(new St.Label({
text: `Row ${r + 1}`,
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
}));
const minus = this._btn('', () => {
this._mgr.gridFor(ws)?.setRowColumns(r, row.cols.length - 1);
this._rebuildMenu();
});
const val = new St.Label({
text: String(row.cols.length),
y_align: Clutter.ActorAlign.CENTER,
style: 'min-width: 22px; text-align: center;',
});
const plus = this._btn('+', () => {
this._mgr.gridFor(ws)?.setRowColumns(r, row.cols.length + 1);
this._rebuildMenu();
});
const del = this._btn('✕', () => {
this._mgr.gridFor(ws)?.removeRow(r);
this._rebuildMenu();
});
del.set_style('margin-left: 10px;');
item.add_child(minus);
item.add_child(val);
item.add_child(plus);
item.add_child(del);
this.menu.addMenuItem(item);
});
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
const add = new PopupMenu.PopupMenuItem(' Add row');
add.connect('activate', () => {
try {
this._mgr.gridFor(ws)?.addRow();
this._rebuildMenu();
} catch (e) {
logErr('menu.addRow', e);
}
});
this.menu.addMenuItem(add);
}
_btn(label, onClick) {
const b = new St.Button({
label,
style_class: 'button',
x_align: Clutter.ActorAlign.CENTER,
style: 'min-width: 28px; margin: 0 2px;',
});
b.connect('clicked', () => {
try { onClick(); } catch (e) { logErr('menu.btn', e); }
});
return b;
}
});
export default class GridSpacesExtension extends Extension {
enable() {
this._life = new Lifetime();
try {
this._mgr = new GridManager(this._life);
this._indicator = new GridIndicator(this._mgr);
Main.panel.addToStatusArea('gridspaces', this._indicator);
} catch (e) {
// Never leave half-initialised callbacks wired into the shell.
logErr('enable', e);
this.disable();
throw e;
}
}
disable() {
try { this._indicator?.destroy(); } catch (_e) {}
this._indicator = null;
try { this._mgr?.destroy(); } catch (_e) {}
this._mgr = null;
try { this._life?.destroy(); } catch (_e) {}
this._life = null;
}
}