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