// 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; } }