commit 57df2bcfbdae1ec1b37695a219e65c7217362099 Author: hzhang Date: Tue May 19 12:47:23 2026 +0100 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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2fbe225 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 hzhang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..24cfc97 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# GridSpaces + +A GNOME Shell extension that turns a chosen workspace into a flexible tiling +grid. Normal workspaces are left completely untouched. + +> **Status: experimental / alpha.** GNOME Shell 48, Wayland & X11. +> Developed and tested inside a throwaway VM — a buggy shell extension can +> crash your session on Wayland. Try it the same way (see *Safety* below). + +## What it does + +- Mark any workspace as a **grid workspace** from the panel button (⊞). +- A grid workspace is a stack of **rows**; each row has its own column count. + Every cell holds at most one window, which is resized to fill the cell. +- Any window entering a grid workspace auto-fills the first free cell. + When the grid is full, the window bounces back to where it came from. +- **Drag a window onto another cell to swap** the two windows. +- **Alt+drag the divider lines** to resize adjacent rows / columns. Sizes are + percentages of the work area and always sum to 100; a preview line follows + the pointer and the change is applied on release. +- Removing a row / shrinking the grid pops the overflow windows to the + nearest normal workspace. +- Coexists with Ubuntu's **Tiling Assistant**: its drop popup is suppressed + only while a grid workspace is active, and restored otherwise. + +## Install + +```sh +git clone https://git.hangman-lab.top/hzhang/GridSpaces.git \ + ~/.local/share/gnome-shell/extensions/gridspaces@hzhang.local +``` + +Then log out and back in (Wayland cannot hot-load a new extension), and: + +```sh +gnome-extensions enable gridspaces@hzhang.local +``` + +## Usage + +1. Switch to the workspace you want to tile. +2. Click the ⊞ panel button → toggle **Grid workspace** on. +3. Use **Add row** and the per-row column steppers to shape the layout. +4. **Alt+drag** the divider lines to adjust row heights / column widths. +5. Open or drag windows in; drag a window onto another cell to swap. + +## Safety + +This is an in-development GNOME Shell extension. On a Wayland session an +unhandled error in a shell extension can crash the whole session. **Do not +add it to your autostart while iterating.** Test in a VM with a clean +snapshot, or at minimum enable it manually (never via the autostart list) so +a crash recovers to a clean desktop instead of a login loop. + +## License + +MIT diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..640bf21 --- /dev/null +++ b/extension.js @@ -0,0 +1,1006 @@ +// 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; + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..a08c0c0 --- /dev/null +++ b/metadata.json @@ -0,0 +1,7 @@ +{ + "uuid": "gridspaces@hzhang.local", + "name": "GridSpaces", + "description": "Mark a workspace as an n×m tiling grid. Windows entering it auto-fill the first free cell and fully occupy it; drag a window onto another cell to swap. Normal workspaces are untouched. Overflow windows pop to the nearest normal workspace.", + "shell-version": ["48"], + "version": 1 +} diff --git a/stylesheet.css b/stylesheet.css new file mode 100644 index 0000000..1741e6c --- /dev/null +++ b/stylesheet.css @@ -0,0 +1,16 @@ +.gridspaces-divider-h, +.gridspaces-divider-v { + background-color: rgba(255, 255, 255, 0.10); + border-radius: 3px; + transition-duration: 120ms; +} + +.gridspaces-divider-h:hover, +.gridspaces-divider-v:hover { + background-color: rgba(120, 170, 255, 0.55); +} + +.gridspaces-preview { + background-color: rgba(120, 170, 255, 0.85); + border-radius: 3px; +}