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>
1007 lines
32 KiB
JavaScript
1007 lines
32 KiB
JavaScript
// 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;
|
||
}
|
||
}
|