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:
63
AGENTS.md
Normal file
63
AGENTS.md
Normal file
@@ -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
|
||||||
81
README.md
Normal file
81
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
462
extension.js
Normal file
462
extension.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
metadata.json
Normal file
9
metadata.json
Normal file
@@ -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": ""
|
||||||
|
}
|
||||||
30
stylesheet.css
Normal file
30
stylesheet.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user