Initial commit — CTile Cinnamon extension

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 <noreply@anthropic.com>
This commit is contained in:
prairienerd18
2026-04-06 20:22:24 -05:00
commit f4433450ff
5 changed files with 645 additions and 0 deletions

462
extension.js Normal file
View File

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