From f4433450ff719e89303c1e9c0df01eed342e0b92 Mon Sep 17 00:00:00 2001 From: prairienerd18 Date: Mon, 6 Apr 2026 20:22:24 -0500 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20CTile=20Cinnamon?= =?UTF-8?q?=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snap layout overlay for Cinnamon 6.6. Drag a window to the top edge to reveal a picker with 14 tiling presets (halves, quarters, thirds). Layout previews use the system theme's accent color and update live when the theme changes. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 63 +++++++ README.md | 81 +++++++++ extension.js | 462 +++++++++++++++++++++++++++++++++++++++++++++++++ metadata.json | 9 + stylesheet.css | 30 ++++ 5 files changed, 645 insertions(+) create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 extension.js create mode 100644 metadata.json create mode 100644 stylesheet.css diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4f62978 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# Agent guidance for CTile + +## What this project is + +A Cinnamon desktop extension (JavaScript, GJS runtime). There is no build step — files are loaded directly by Cinnamon's JS interpreter. + +## Repository layout + +``` +ctile/ + extension.js ← all logic; entry points are init(), enable(), disable() + metadata.json ← UUID, display name, supported Cinnamon versions + stylesheet.css ← St/CSS styles for the overlay and buttons + README.md + AGENTS.md +``` + +Install by copying the whole directory to `~/.local/share/cinnamon/extensions/ctile@ctile/`. + +## Runtime environment + +- **Engine:** GJS (GNOME JavaScript, SpiderMonkey-based) +- **Cinnamon version:** 6.6.7 · **Muffin version:** 6.6.3 +- **Imports:** `imports.gi.*` for GObject introspection bindings; `imports.ui.*` for Cinnamon shell modules; `imports.mainloop` for timers +- **No npm, no bundler, no transpiler** — plain ES6 classes work; `import`/`export` do not + +## How to test changes + +1. Copy changed files into the installed location: + ```bash + cp ctile@ctile/* ~/.local/share/cinnamon/extensions/ctile@ctile/ + ``` +2. Reload the extension without restarting Cinnamon: + ```bash + dbus-send --session --dest=org.Cinnamon /org/Cinnamon org.Cinnamon.Eval \ + string:'const es = imports.ui.extensionSystem; es.disableExtension("ctile@ctile"); es.enableExtension("ctile@ctile");' + ``` +3. Check logs: `grep '\[ctile\]' ~/.xsession-errors` + +Syntax-check JS before deploying: +```bash +node --check ctile@ctile/extension.js +``` + +## Key implementation details + +### Signal arity (important gotcha) +Muffin 6.6 fires `grab-op-begin` with **four** arguments: `(display, screen, window, op)`. +Older Muffin versions used three. Getting this wrong silently breaks everything — the op check never matches and no drag is detected. + +### Overlay z-ordering +The overlay actor is added to `global.stage` (not `Main.uiGroup`) and raised to the top sibling on each show, so it appears above panels and all windows. + +### Accent color +Read at build time via `Gtk.Button().get_style_context().lookup_color('accent_color')` and refreshed on both `St.ThemeContext 'changed'` and `Gtk.Settings 'notify::gtk-theme-name'` signals. + +### Pointer tracking during drag +Muffin consumes pointer events during a window drag, so overlay buttons are `reactive: false`. Hover state is computed manually in a `Mainloop.timeout_add` poll loop (every 40 ms) by comparing raw pointer coordinates against each button's transformed bounding box. + +### Overlay visibility logic +- Show when pointer Y ≤ `TRIGGER_Y` px below the monitor's top edge +- Stay visible while pointer is within 20 px of the overlay's bounding box +- Hide otherwise diff --git a/README.md b/README.md new file mode 100644 index 0000000..563445b --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# CTile + +A Cinnamon desktop extension that brings snap layout overlays to window management — drag any window toward the top of your screen and a layout picker appears, letting you tile it into a preset zone without touching the keyboard. + +## How it works + +1. Start dragging any window by its titlebar +2. Move the cursor within ~50px of the top screen edge — the layout picker appears +3. Hover over the desired zone (the overlay stays visible as long as your cursor is within 20px of it) +4. Release — the window snaps to that zone + +The overlay displays 14 layout presets across three rows: + +| Row | Layouts | +|-----|---------| +| Basic | Full, Left ½, Right ½, Top ½, Bottom ½ | +| Quarters | Top-left, Top-right, Bottom-left, Bottom-right | +| Thirds | Left ⅓, Center ⅓, Right ⅓, Left ⅔, Right ⅔ | + +Layout previews are drawn in your system theme's accent color and update automatically when you change themes. + +## Installation + +Copy (or symlink) the repo directory to Cinnamon's extensions folder under the extension's UUID, then enable it: + +```bash +cp -r /path/to/ctile ~/.local/share/cinnamon/extensions/ctile@ctile +``` + +Then open **System Settings → Extensions**, find **CTile**, and enable it. Or via the command line: + +```bash +gsettings set org.cinnamon enabled-extensions "['ctile@ctile']" +``` + +Restart Cinnamon (`Alt+F2` → `r` → Enter) if the extension doesn't appear immediately. + +## Development + +After editing, sync and reload without restarting Cinnamon: + +```bash +cp extension.js stylesheet.css metadata.json ~/.local/share/cinnamon/extensions/ctile@ctile/ +``` + +Then reload via Looking Glass (`Alt+F2` → `lg`) or with: + +```bash +dbus-send --session --dest=org.Cinnamon /org/Cinnamon org.Cinnamon.Eval \ + string:'const es = imports.ui.extensionSystem; es.disableExtension("ctile@ctile"); es.enableExtension("ctile@ctile");' +``` + +Debug output goes to `~/.xsession-errors` — search for `[ctile]`. + +## Tunables + +At the top of [extension.js](extension.js): + +| Constant | Default | Description | +|----------|---------|-------------| +| `TRIGGER_Y` | `50` | Pixels from top edge that triggers the overlay | +| `POLL_INTERVAL` | `40` | Milliseconds between pointer polls during a drag | +| `THUMB_W` / `THUMB_H` | `66` / `44` | Layout preview thumbnail dimensions | +| `SHOW_ANIM_MS` / `HIDE_ANIM_MS` | `120` / `100` | Fade in/out duration | + +## Compatibility + +Tested on **Cinnamon 6.6.7** / **Muffin 6.6.3**. + +> **Note for porters:** Muffin 6.6 passes four arguments to `grab-op-begin` — `(display, screen, window, op)` — unlike older versions which passed three. If you see the overlay never appearing, verify the signal arity with Looking Glass. + +## File structure + +``` +ctile/ +├── extension.js — all extension logic (~460 lines) +├── metadata.json — UUID, name, supported Cinnamon versions +├── stylesheet.css — overlay and button styles +├── README.md +└── AGENTS.md +``` diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..e9b1d54 --- /dev/null +++ b/extension.js @@ -0,0 +1,462 @@ +// CTile - Snap Layouts for Cinnamon +// Drag a window near the top of the screen to reveal tiling layout picker. +// +// uuid: ctile@ctile + +const { Clutter, St, Meta, GLib, Gtk } = imports.gi; +const Main = imports.ui.main; +const Mainloop = imports.mainloop; + +// ─── tunables ──────────────────────────────────────────────────────────────── +const TRIGGER_Y = 50; // px from screen top that activates the overlay +const POLL_INTERVAL = 40; // ms between pointer polls during a drag +const THUMB_W = 66; // layout-button preview width (px) +const THUMB_H = 44; // layout-button preview height (px) +const SHOW_ANIM_MS = 120; +const HIDE_ANIM_MS = 100; + +// ─── layout definitions ────────────────────────────────────────────────────── +const ROWS = [ + // row 0 — five basic slots + [ + { id:'maximize', fracs:[0, 0, 1, 1 ], label:'Full' }, + { id:'left-half', fracs:[0, 0, 0.5, 1 ], label:'Left ½' }, + { id:'right-half', fracs:[0.5, 0, 0.5, 1 ], label:'Right ½'}, + { id:'top-half', fracs:[0, 0, 1, 0.5 ], label:'Top ½' }, + { id:'bot-half', fracs:[0, 0.5, 1, 0.5 ], label:'Bot ½' }, + ], + // row 1 — quarters + [ + { id:'top-left', fracs:[0, 0, 0.5, 0.5 ], label:'↖' }, + { id:'top-right', fracs:[0.5, 0, 0.5, 0.5 ], label:'↗' }, + { id:'bot-left', fracs:[0, 0.5, 0.5, 0.5 ], label:'↙' }, + { id:'bot-right', fracs:[0.5, 0.5, 0.5, 0.5 ], label:'↘' }, + ], + // row 2 — thirds / two-thirds + [ + { id:'left-third', fracs:[0, 0, 1/3, 1 ], label:'⅓ L' }, + { id:'mid-third', fracs:[1/3, 0, 1/3, 1 ], label:'⅓ C' }, + { id:'right-third', fracs:[2/3, 0, 1/3, 1 ], label:'⅓ R' }, + { id:'left-2third', fracs:[0, 0, 2/3, 1 ], label:'⅔ L' }, + { id:'right-2third', fracs:[1/3, 0, 2/3, 1 ], label:'⅔ R' }, + ], +]; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function log(msg) { + global.log(`[ctile] ${msg}`); +} + +// Returns [r, g, b] normalized (0-1) for the current theme's accent color. +// Falls back to a neutral blue if the theme doesn't define accent_color. +function getAccentColor() { + try { + const dummy = new Gtk.Button(); + const [found, color] = dummy.get_style_context().lookup_color('accent_color'); + if (found) return [color.red, color.green, color.blue]; + } catch(e) { /* ignore */ } + return [0.20, 0.55, 0.85]; +} + +function getMonitorForPointer(px, py) { + const n = global.display.get_n_monitors(); + for (let i = 0; i < n; i++) { + const r = global.display.get_monitor_geometry(i); + if (px >= r.x && px < r.x + r.width && + py >= r.y && py < r.y + r.height) + return i; + } + return global.display.get_primary_monitor(); +} + +function applyLayout(win, layout) { + const ws = win.get_workspace(); + const monIdx = win.get_monitor(); + const area = ws.get_work_area_for_monitor(monIdx); + + if (win.maximized_horizontally || win.maximized_vertically) + win.unmaximize(Meta.MaximizeFlags.BOTH); + + if (layout.id === 'maximize') { + win.maximize(Meta.MaximizeFlags.BOTH); + return; + } + + const [xF, yF, wF, hF] = layout.fracs; + const x = area.x + Math.round(xF * area.width); + const y = area.y + Math.round(yF * area.height); + const w = Math.round(wF * area.width); + const h = Math.round(hF * area.height); + log(`applyLayout ${layout.id}: move_resize_frame to ${x},${y} ${w}x${h}`); + win.move_resize_frame(false, x, y, w, h); +} + +// simple rounded rectangle helper for Cairo +function roundRect(cr, x, y, w, h, r) { + if (w <= 0 || h <= 0) return; + r = Math.min(r, w / 2, h / 2); + cr.newPath(); + cr.arc(x + r, y + r, r, Math.PI, 1.5 * Math.PI); + cr.arc(x + w - r, y + r, r, 1.5 * Math.PI, 2 * Math.PI); + cr.arc(x + w - r, y + h - r, r, 0, 0.5 * Math.PI); + cr.arc(x + r, y + h - r, r, 0.5 * Math.PI, Math.PI); + cr.closePath(); +} + +// ─── LayoutButton ───────────────────────────────────────────────────────────── + +class LayoutButton { + constructor(layout, accentColor) { + this.layout = layout; + this.hovered = false; + this._accent = accentColor; // [r, g, b] normalized + + this.actor = new St.Bin({ + style_class : 'ctile-btn', + reactive : false, + }); + + this._canvas = new St.DrawingArea({ + width : THUMB_W, + height : THUMB_H, + style_class : 'ctile-canvas', + }); + this._canvas.connect('repaint', this._draw.bind(this)); + + this.actor.set_child(this._canvas); + } + + setHover(on) { + if (this.hovered === on) return; + this.hovered = on; + if (on) + this.actor.add_style_pseudo_class('hover'); + else + this.actor.remove_style_pseudo_class('hover'); + this._canvas.queue_repaint(); + } + + _draw(area) { + let cr; + try { + cr = area.get_context(); + } catch(e) { + log(`draw error: ${e}`); + return; + } + + const [W, H] = area.get_surface_size(); + const PAD = 2; + const iw = W - 2 * PAD; + const ih = H - 2 * PAD; + const [ar, ag, ab] = this._accent; + + // screen background + cr.setSourceRGBA(0.12, 0.12, 0.14, 1); + roundRect(cr, PAD, PAD, iw, ih, 3); + cr.fill(); + + // window zone fill — accent color, dimmed when not hovered + const alpha = this.hovered ? 1.0 : 0.45; + cr.setSourceRGBA(ar, ag, ab, alpha); + const [xF, yF, wF, hF] = this.layout.fracs; + roundRect(cr, PAD + xF * iw, PAD + yF * ih, wF * iw, hF * ih, 2); + cr.fill(); + + cr.$dispose(); + } +} + +// ─── TileOverlay ───────────────────────────────────────────────────────────── + +class TileOverlay { + constructor() { + this._actor = null; + this._buttons = []; + this._visible = false; + this._currentMonIdx = -1; + } + + _build() { + if (this._actor) return; + + this._actor = new St.BoxLayout({ + style_class : 'ctile-overlay', + vertical : true, + reactive : false, + visible : false, + }); + this._actor.set_opacity(0); + + this._buttons = []; + const accent = getAccentColor(); + + for (const row of ROWS) { + const rowBox = new St.BoxLayout({ + style_class : 'ctile-row', + vertical : false, + reactive : false, + }); + for (const layout of row) { + const lb = new LayoutButton(layout, accent); + rowBox.add_child(lb.actor); + this._buttons.push(lb); + } + this._actor.add_child(rowBox); + } + + // Add to the top-level stage so it is always above panels/windows + global.stage.add_child(this._actor); + log('overlay built and added to stage'); + } + + show(monitorIndex) { + this._build(); + + // Re-show on new monitor; guard same-monitor repeated calls + if (this._visible && this._currentMonIdx === monitorIndex) return; + this._currentMonIdx = monitorIndex; + this._visible = true; + + const mon = global.display.get_monitor_geometry(monitorIndex); + + this._actor.set_opacity(0); + this._actor.show(); + // raise to top of stage z-order + this._actor.get_parent().set_child_above_sibling(this._actor, null); + + // Position: defer one frame so Clutter allocates the actor's size + Mainloop.idle_add(() => { + if (!this._actor) return GLib.SOURCE_REMOVE; + const aw = this._actor.width; + const ah = this._actor.height; // eslint-disable-line no-unused-vars + const ax = mon.x + Math.round((mon.width - aw) / 2); + const ay = mon.y + 10; + this._actor.set_position(ax, ay); + log(`overlay shown at ${ax},${ay} size=${aw}x${ah} monitor=${monitorIndex}`); + this._actor.ease({ + opacity : 255, + duration : SHOW_ANIM_MS, + mode : Clutter.AnimationMode.EASE_OUT_QUAD, + }); + return GLib.SOURCE_REMOVE; + }); + } + + hide() { + if (!this._actor || !this._visible) return; + this._clearHover(); + this._visible = false; + this._currentMonIdx = -1; + log('overlay hiding'); + + this._actor.ease({ + opacity : 0, + duration : HIDE_ANIM_MS, + mode : Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + if (this._actor) this._actor.hide(); + }, + }); + } + + // Returns the hovered layout (or null) based on raw pointer coords. + updatePointer(px, py) { + if (!this._actor || !this._visible) return null; + + let found = null; + for (const lb of this._buttons) { + const [bx, by] = lb.actor.get_transformed_position(); + const bw = lb.actor.width; + const bh = lb.actor.height; + const over = (px >= bx && px <= bx + bw && py >= by && py <= by + bh); + lb.setHover(over); + if (over) found = lb; + } + return found ? found.layout : null; + } + + _clearHover() { + for (const lb of this._buttons) lb.setHover(false); + } + + // Returns true if (px,py) is within `margin` pixels of the overlay's bounds. + isNearPointer(px, py, margin) { + if (!this._actor || !this._visible) return false; + const [ax, ay] = this._actor.get_transformed_position(); + const aw = this._actor.width; + const ah = this._actor.height; + return px >= ax - margin && px <= ax + aw + margin && + py >= ay - margin && py <= ay + ah + margin; + } + + // Re-read the accent color and repaint all buttons. + refreshAccent() { + if (!this._buttons.length) return; + const accent = getAccentColor(); + for (const lb of this._buttons) { + lb._accent = accent; + lb._canvas.queue_repaint(); + } + } + + get visible() { return this._visible; } + + destroy() { + if (this._actor) { + this._actor.destroy(); + this._actor = null; + } + this._buttons = []; + } +} + +// ─── CTileExtension ────────────────────────────────────────────────────────── + +class CTileExtension { + constructor() { + this._overlay = null; + this._dragWindow = null; + this._pollId = null; + this._grabbedLayout = null; + this._grabBeginId = null; + this._grabEndId = null; + this._stThemeChangedId = null; + this._gtkThemeChangedId = null; + } + + enable() { + this._overlay = new TileOverlay(); + + this._grabBeginId = global.display.connect( + 'grab-op-begin', this._onGrabBegin.bind(this)); + this._grabEndId = global.display.connect( + 'grab-op-end', this._onGrabEnd.bind(this)); + + // Refresh accent color when the Cinnamon (St) theme changes + const themeCtx = St.ThemeContext.get_for_stage(global.stage); + this._stThemeChangedId = themeCtx.connect( + 'changed', () => this._overlay.refreshAccent()); + + // Also refresh when the GTK theme name changes + const gtkSettings = Gtk.Settings.get_default(); + if (gtkSettings) { + this._gtkThemeChangedId = gtkSettings.connect( + 'notify::gtk-theme-name', () => this._overlay.refreshAccent()); + } + + log(`enabled — grab-op-begin id=${this._grabBeginId}`); + } + + disable() { + this._stopPoll(); + if (this._grabBeginId) { + global.display.disconnect(this._grabBeginId); + this._grabBeginId = null; + } + if (this._grabEndId) { + global.display.disconnect(this._grabEndId); + this._grabEndId = null; + } + if (this._stThemeChangedId) { + St.ThemeContext.get_for_stage(global.stage).disconnect(this._stThemeChangedId); + this._stThemeChangedId = null; + } + if (this._gtkThemeChangedId) { + const gtkSettings = Gtk.Settings.get_default(); + if (gtkSettings) gtkSettings.disconnect(this._gtkThemeChangedId); + this._gtkThemeChangedId = null; + } + if (this._overlay) { + this._overlay.destroy(); + this._overlay = null; + } + this._dragWindow = null; + log('disabled'); + } + + _onGrabBegin(_display, _screen, win, op) { + log(`grab-op-begin op=${op} MOVING=${Meta.GrabOp.MOVING} win=${win}`); + if (op !== Meta.GrabOp.MOVING && op !== Meta.GrabOp.KEYBOARD_MOVING) return; + if (!win) return; + this._dragWindow = win; + this._grabbedLayout = null; + this._startPoll(); + log('drag started, poll started'); + } + + _onGrabEnd(_display, _screen, _win, _op) { + log(`grab-op-end, hoveredLayout=${this._grabbedLayout ? this._grabbedLayout.id : 'none'}`); + this._stopPoll(); + const layout = this._grabbedLayout; + const dragWin = this._dragWindow; + + this._overlay.hide(); + this._dragWindow = null; + this._grabbedLayout = null; + + if (layout && dragWin) { + Mainloop.timeout_add(20, () => { + try { applyLayout(dragWin, layout); } catch(e) { log(`applyLayout error: ${e}`); } + return GLib.SOURCE_REMOVE; + }); + } + } + + _startPoll() { + if (this._pollId) return; + this._pollId = Mainloop.timeout_add(POLL_INTERVAL, this._poll.bind(this)); + } + + _stopPoll() { + if (this._pollId) { + Mainloop.source_remove(this._pollId); + this._pollId = null; + } + } + + _poll() { + if (!this._dragWindow) { + this._stopPoll(); + return GLib.SOURCE_REMOVE; + } + + const [px, py] = global.get_pointer(); + const monIdx = getMonitorForPointer(px, py); + const monGeo = global.display.get_monitor_geometry(monIdx); + const relY = py - monGeo.y; + + if (relY <= TRIGGER_Y) { + this._overlay.show(monIdx); + this._grabbedLayout = this._overlay.updatePointer(px, py); + } else if (this._overlay.visible) { + if (this._overlay.isNearPointer(px, py, 20)) { + this._grabbedLayout = this._overlay.updatePointer(px, py); + } else { + this._overlay.hide(); + this._grabbedLayout = null; + } + } + + return GLib.SOURCE_CONTINUE; + } +} + +// ─── entry points ──────────────────────────────────────────────────────────── + +let extension = null; + +function init() {} + +function enable() { + extension = new CTileExtension(); + extension.enable(); +} + +function disable() { + if (extension) { + extension.disable(); + extension = null; + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..10c65e5 --- /dev/null +++ b/metadata.json @@ -0,0 +1,9 @@ +{ + "uuid": "ctile@ctile", + "name": "CTile", + "description": "Drag windows to the top edge to select tiling layouts — like Windows 11 Snap Layouts.", + "version": "1.0.0", + "cinnamon-version": ["6.0", "6.2", "6.4", "6.6"], + "max-instances": "1", + "url": "" +} diff --git a/stylesheet.css b/stylesheet.css new file mode 100644 index 0000000..5aa012d --- /dev/null +++ b/stylesheet.css @@ -0,0 +1,30 @@ +/* CTile — stylesheet.css */ + +.ctile-overlay { + background-color: rgba(20, 22, 30, 0.92); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.10); + padding: 10px 12px; + spacing: 6px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6); +} + +.ctile-row { + spacing: 6px; +} + +.ctile-btn { + padding: 5px; + border-radius: 7px; + border: 1px solid transparent; + transition-duration: 80ms; +} + +.ctile-btn:hover { + background-color: rgba(255, 255, 255, 0.07); + border-color: rgba(255, 255, 255, 0.18); +} + +.ctile-canvas { + border-radius: 3px; +}