Files
GridSnap/extension.js
Keith Smith 0d2db793c6 Implement split-based zone editor and multi-monitor support
Major changes:
- Replace free-form drawing with split-based tiling interface
- Add per-monitor zone configurations and storage
- Implement monitor selector UI for multi-monitor setups
- Add H/V keyboard shortcuts for horizontal/vertical splits
- Add divider dragging for adjusting zone positions
- Display real-time dimensions in center of each zone
- Migrate storage format to v2 with per-monitor layouts
- Update settings panel for per-monitor layout management

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 19:13:52 -07:00

1681 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const St = imports.gi.St;
const Main = imports.ui.main;
const Meta = imports.gi.Meta;
const Clutter = imports.gi.Clutter;
const Lang = imports.lang;
const Mainloop = imports.mainloop;
const Settings = imports.ui.settings;
const GLib = imports.gi.GLib;
const Gio = imports.gi.Gio;
let zoneManager;
// Storage path for custom layouts
const STORAGE_DIR = GLib.get_user_data_dir() + '/gridsnap';
const LAYOUTS_FILE = STORAGE_DIR + '/layouts.json';
// Default zone layouts
const DEFAULT_LAYOUTS = {
'grid-2x2': {
name: '2x2 Grid',
zones: [
{ x: 0, y: 0, width: 0.5, height: 0.5 }, // Top-left
{ x: 0.5, y: 0, width: 0.5, height: 0.5 }, // Top-right
{ x: 0, y: 0.5, width: 0.5, height: 0.5 }, // Bottom-left
{ x: 0.5, y: 0.5, width: 0.5, height: 0.5 } // Bottom-right
]
},
'columns-3': {
name: '3 Columns',
zones: [
{ x: 0, y: 0, width: 0.33, height: 1 },
{ x: 0.33, y: 0, width: 0.34, height: 1 },
{ x: 0.67, y: 0, width: 0.33, height: 1 }
]
},
'focus-left': {
name: 'Focus Left',
zones: [
{ x: 0, y: 0, width: 0.7, height: 1 }, // Main left
{ x: 0.7, y: 0, width: 0.3, height: 0.5 }, // Top-right
{ x: 0.7, y: 0.5, width: 0.3, height: 0.5 } // Bottom-right
]
}
};
function ZoneEditor() {
this._init();
}
ZoneEditor.prototype = {
_init: function() {
this.editorOverlay = null;
this.isEditing = false;
this.zones = [];
this.zoneActors = []; // Track zone actors for moving
this.currentZone = null;
this.dimensionLabel = null;
this.startX = 0;
this.startY = 0;
this.isDragging = false;
this.isMovingZone = false;
this.movingZoneIndex = -1;
this.moveOffsetX = 0;
this.moveOffsetY = 0;
this.isResizingZone = false;
this.resizingZoneIndex = -1;
this.resizeEdge = null; // 'n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'
this.originalZoneBounds = null;
this.editingLayoutId = null; // Track if editing existing layout
this.editingLayoutName = null;
this.selectorOverlay = null; // Layout selector UI
this.monitorSelectorOverlay = null; // Monitor selector UI
this.targetMonitor = null; // Which monitor we're editing on
this.targetMonitorIndex = 0; // Index of target monitor
// Split-based editor state
this.selectedZoneIndex = -1; // Currently selected zone (-1 = none)
this.isDraggingDivider = false; // Dragging a divider
this.dividerDragInfo = null; // {type: 'vertical'|'horizontal', zoneIndices: [i, j]}
},
showMonitorSelector: function() {
if (this.isEditing || this.monitorSelectorOverlay) return;
let monitors = Main.layoutManager.monitors;
// If only one monitor, skip selector and go straight to layout selector
if (monitors.length === 1) {
this.targetMonitor = monitors[0];
this.targetMonitorIndex = 0;
this.showLayoutSelector(0);
return;
}
let primaryMonitor = Main.layoutManager.primaryMonitor;
// Create monitor selector overlay
this.monitorSelectorOverlay = new St.Widget({
style_class: 'gridsnap-monitor-selector-overlay',
reactive: true,
x: primaryMonitor.x,
y: primaryMonitor.y,
width: primaryMonitor.width,
height: primaryMonitor.height
});
this.monitorSelectorOverlay.set_style(
'background-color: rgba(0, 0, 0, 0.7);'
);
// Create container for buttons
let buttonContainer = new St.BoxLayout({
vertical: true,
x: primaryMonitor.width / 2 - 200,
y: primaryMonitor.height / 2 - 150,
width: 400
});
buttonContainer.set_style(
'background-color: rgba(30, 30, 30, 0.95);' +
'padding: 20px;' +
'border-radius: 10px;' +
'spacing: 10px;'
);
// Add title
let title = new St.Label({
text: 'Select Monitor to Edit'
});
title.set_style(
'color: white;' +
'font-size: 24px;' +
'font-weight: bold;' +
'margin-bottom: 10px;'
);
buttonContainer.add_child(title);
// Add subtitle
let subtitle = new St.Label({
text: 'Choose which monitor to configure zones for:'
});
subtitle.set_style(
'color: rgba(255, 255, 255, 0.8);' +
'font-size: 14px;' +
'margin-bottom: 20px;'
);
buttonContainer.add_child(subtitle);
// Add button for each monitor
for (let i = 0; i < monitors.length; i++) {
let monitor = monitors[i];
let isPrimary = (i === Main.layoutManager.primaryIndex);
let label = isPrimary ? 'Primary' : 'Secondary';
let buttonText = 'Monitor ' + i + ': ' + label + ' - ' + monitor.width + 'x' + monitor.height;
let button = this._createMonitorSelectorButton(buttonText, i, monitor);
buttonContainer.add_child(button);
}
// Add cancel button
let cancelButton = new St.Button({
label: 'Cancel'
});
cancelButton.set_style(
'color: white;' +
'background-color: rgba(100, 100, 100, 0.5);' +
'padding: 10px 20px;' +
'border-radius: 5px;' +
'margin-top: 20px;'
);
cancelButton.connect('clicked', Lang.bind(this, function() {
this._closeMonitorSelector();
}));
buttonContainer.add_child(cancelButton);
this.monitorSelectorOverlay.add_child(buttonContainer);
// Add key press handler for ESC
this.monitorSelectorKeyPressId = this.monitorSelectorOverlay.connect('key-press-event',
Lang.bind(this, function(actor, event) {
let symbol = event.get_key_symbol();
if (symbol === Clutter.KEY_Escape) {
this._closeMonitorSelector();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
})
);
Main.layoutManager.addChrome(this.monitorSelectorOverlay);
Main.pushModal(this.monitorSelectorOverlay);
},
_createMonitorSelectorButton: function(label, monitorIndex, monitor) {
let button = new St.Button({
label: label
});
button.set_style(
'color: white;' +
'background-color: rgba(100, 149, 237, 0.5);' +
'padding: 15px 20px;' +
'border-radius: 5px;' +
'text-align: left;'
);
button.connect('clicked', Lang.bind(this, function() {
this.targetMonitor = monitor;
this.targetMonitorIndex = monitorIndex;
this._closeMonitorSelector();
this.showLayoutSelector(monitorIndex);
}));
return button;
},
_closeMonitorSelector: function() {
if (!this.monitorSelectorOverlay) return;
if (this.monitorSelectorKeyPressId) {
this.monitorSelectorOverlay.disconnect(this.monitorSelectorKeyPressId);
}
Main.popModal(this.monitorSelectorOverlay);
Main.layoutManager.removeChrome(this.monitorSelectorOverlay);
this.monitorSelectorOverlay.destroy();
this.monitorSelectorOverlay = null;
},
showLayoutSelector: function(monitorIndex) {
if (this.isEditing || this.selectorOverlay) return;
// Use target monitor if set, otherwise use primary
let monitor = this.targetMonitor || Main.layoutManager.primaryMonitor;
// Create selector overlay
this.selectorOverlay = new St.Widget({
style_class: 'gridsnap-selector-overlay',
reactive: true,
x: monitor.x,
y: monitor.y,
width: monitor.width,
height: monitor.height
});
this.selectorOverlay.set_style(
'background-color: rgba(0, 0, 0, 0.7);'
);
// Create container for buttons
let buttonContainer = new St.BoxLayout({
vertical: true,
x: monitor.width / 2 - 200,
y: monitor.height / 2 - 200,
width: 400
});
buttonContainer.set_style(
'background-color: rgba(30, 30, 30, 0.95);' +
'padding: 20px;' +
'border-radius: 10px;' +
'spacing: 10px;'
);
// Add title
let title = new St.Label({
text: 'GridSnap Zone Editor'
});
title.set_style(
'color: white;' +
'font-size: 24px;' +
'font-weight: bold;' +
'margin-bottom: 10px;'
);
buttonContainer.add_child(title);
// Add subtitle
let subtitle = new St.Label({
text: 'Choose layout to edit or create new:'
});
subtitle.set_style(
'color: rgba(255, 255, 255, 0.8);' +
'font-size: 14px;' +
'margin-bottom: 20px;'
);
buttonContainer.add_child(subtitle);
// Add "Create New Layout" button
let newButton = this._createSelectorButton('+ Create New Layout', null, null);
buttonContainer.add_child(newButton);
// Add separator
let separator = new St.Widget({
height: 2
});
separator.set_style(
'background-color: rgba(255, 255, 255, 0.2);' +
'margin-top: 10px;' +
'margin-bottom: 10px;'
);
buttonContainer.add_child(separator);
// Add buttons for existing custom layouts for this monitor
if (zoneManager && zoneManager.layoutsPerMonitor && zoneManager.layoutsPerMonitor[monitorIndex]) {
let monitorLayouts = zoneManager.layoutsPerMonitor[monitorIndex];
let hasCustomLayouts = false;
for (let layoutId in monitorLayouts) {
if (layoutId.startsWith('custom-')) {
hasCustomLayouts = true;
let layout = monitorLayouts[layoutId];
let button = this._createSelectorButton(
layout.name + ' (' + layout.zones.length + ' zones)',
layoutId,
layout
);
buttonContainer.add_child(button);
}
}
if (!hasCustomLayouts) {
let noLayoutsLabel = new St.Label({
text: 'No custom layouts yet'
});
noLayoutsLabel.set_style(
'color: rgba(255, 255, 255, 0.5);' +
'font-size: 12px;' +
'font-style: italic;' +
'margin: 10px 0px;'
);
buttonContainer.add_child(noLayoutsLabel);
}
}
// Add cancel button
let cancelButton = new St.Button({
label: 'Cancel'
});
cancelButton.set_style(
'color: white;' +
'background-color: rgba(100, 100, 100, 0.5);' +
'padding: 10px 20px;' +
'border-radius: 5px;' +
'margin-top: 20px;'
);
cancelButton.connect('clicked', Lang.bind(this, function() {
this._closeLayoutSelector();
}));
buttonContainer.add_child(cancelButton);
this.selectorOverlay.add_child(buttonContainer);
// Add key press handler for ESC
this.selectorKeyPressId = this.selectorOverlay.connect('key-press-event',
Lang.bind(this, function(actor, event) {
let symbol = event.get_key_symbol();
if (symbol === Clutter.KEY_Escape) {
this._closeLayoutSelector();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
})
);
Main.layoutManager.addChrome(this.selectorOverlay);
Main.pushModal(this.selectorOverlay);
},
_createSelectorButton: function(label, layoutId, layoutData) {
let button = new St.Button({
label: label
});
button.set_style(
'color: white;' +
'background-color: rgba(100, 149, 237, 0.5);' +
'padding: 15px 20px;' +
'border-radius: 5px;' +
'text-align: left;'
);
button.connect('clicked', Lang.bind(this, function() {
this._closeLayoutSelector();
this.startEditor(layoutId, layoutData);
}));
return button;
},
_closeLayoutSelector: function() {
if (!this.selectorOverlay) return;
if (this.selectorKeyPressId) {
this.selectorOverlay.disconnect(this.selectorKeyPressId);
}
Main.popModal(this.selectorOverlay);
Main.layoutManager.removeChrome(this.selectorOverlay);
this.selectorOverlay.destroy();
this.selectorOverlay = null;
},
startEditor: function(layoutId, layoutData) {
if (this.isEditing) return;
this.isEditing = true;
// If editing existing layout, load it
if (layoutId && layoutData) {
this.editingLayoutId = layoutId;
this.editingLayoutName = layoutData.name;
this.zones = JSON.parse(JSON.stringify(layoutData.zones)); // Deep copy
this.selectedZoneIndex = 0; // Select first zone
} else {
this.editingLayoutId = null;
this.editingLayoutName = null;
// Start with one full-screen zone
this.zones = [{x: 0, y: 0, width: 1, height: 1}];
this.selectedZoneIndex = 0; // Pre-select it
}
// Use target monitor (set by monitor selector)
let monitor = this.targetMonitor || Main.layoutManager.primaryMonitor;
// Create editor overlay
this.editorOverlay = new St.Widget({
style_class: 'gridsnap-editor-overlay',
reactive: true,
x: monitor.x,
y: monitor.y,
width: monitor.width,
height: monitor.height
});
this.editorOverlay.set_style(
'background-color: rgba(0, 0, 0, 0.5);'
);
// Add instruction label
this.instructionLabel = new St.Label({
text: 'GridSnap Split Editor\n\nClick zone to select | H: split horizontal | V: split vertical | Drag dividers to adjust | Delete: remove zone | Ctrl+S: save | Esc: cancel',
style_class: 'gridsnap-editor-instructions',
x: 20,
y: 20
});
this.instructionLabel.set_style(
'color: white;' +
'font-size: 18px;' +
'background-color: rgba(0, 0, 0, 0.8);' +
'padding: 15px;' +
'border-radius: 8px;'
);
this.editorOverlay.add_child(this.instructionLabel);
// Connect mouse events
this.buttonPressId = this.editorOverlay.connect('button-press-event',
Lang.bind(this, this._onButtonPress));
this.buttonReleaseId = this.editorOverlay.connect('button-release-event',
Lang.bind(this, this._onButtonRelease));
this.motionId = this.editorOverlay.connect('motion-event',
Lang.bind(this, this._onMotion));
Main.layoutManager.addChrome(this.editorOverlay);
Main.pushModal(this.editorOverlay);
// Create zone actors for all zones (whether new or editing)
if (this.zones.length > 0) {
this._createZoneActorsFromData(monitor);
}
// Setup key listener for save/cancel
this._setupEditorKeys();
},
_createZoneActorsFromData: function(monitor) {
// Create zone actors from existing zone data
this.zoneActors = [];
this.zones.forEach((zone, index) => {
let zoneWidth = zone.width * monitor.width;
let zoneHeight = zone.height * monitor.height;
let zoneActor = new St.Widget({
style_class: 'gridsnap-editor-zone',
x: zone.x * monitor.width,
y: zone.y * monitor.height,
width: zoneWidth,
height: zoneHeight
});
zoneActor.set_style(
'border: 3px solid rgba(100, 255, 100, 0.9);' +
'background-color: rgba(100, 255, 100, 0.3);' +
'border-radius: 4px;'
);
// Create dimension label in center
let widthPx = Math.round(zoneWidth);
let heightPx = Math.round(zoneHeight);
let widthPct = Math.round(zone.width * 100);
let heightPct = Math.round(zone.height * 100);
let label = new St.Label({
text: widthPx + 'x' + heightPx + '\n(' + widthPct + '% × ' + heightPct + '%)'
});
label.set_style(
'color: white;' +
'font-size: 16px;' +
'font-weight: bold;' +
'text-align: center;' +
'text-shadow: 2px 2px 4px rgba(0,0,0,0.8);'
);
// Center the label
label.set_position(
(zoneWidth - label.width) / 2,
(zoneHeight - label.height) / 2
);
zoneActor.add_child(label);
this.editorOverlay.add_child(zoneActor);
this.zoneActors.push(zoneActor);
});
},
_setupEditorKeys: function() {
this.keyPressId = this.editorOverlay.connect('key-press-event',
Lang.bind(this, function(actor, event) {
let symbol = event.get_key_symbol();
let state = event.get_state();
// Ctrl+S to save
if (symbol === Clutter.KEY_s && (state & Clutter.ModifierType.CONTROL_MASK)) {
this._saveLayout();
return Clutter.EVENT_STOP;
}
// Ctrl+C to cancel
if (symbol === Clutter.KEY_c && (state & Clutter.ModifierType.CONTROL_MASK)) {
this._cancelEditor();
return Clutter.EVENT_STOP;
}
// H key to split horizontally
if (symbol === Clutter.KEY_h || symbol === Clutter.KEY_H) {
if (this.selectedZoneIndex >= 0) {
this._splitZoneHorizontally(this.selectedZoneIndex);
}
return Clutter.EVENT_STOP;
}
// V key to split vertically
if (symbol === Clutter.KEY_v || symbol === Clutter.KEY_V) {
if (this.selectedZoneIndex >= 0) {
this._splitZoneVertically(this.selectedZoneIndex);
}
return Clutter.EVENT_STOP;
}
// Delete to remove selected zone
if (symbol === Clutter.KEY_Delete || symbol === Clutter.KEY_BackSpace) {
this._removeSelectedZone();
return Clutter.EVENT_STOP;
}
// Escape to cancel
if (symbol === Clutter.KEY_Escape) {
this._cancelEditor();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
})
);
},
_onButtonPress: function(actor, event) {
let [x, y] = event.get_coords();
let monitor = this.targetMonitor;
let relX = x - monitor.x;
let relY = y - monitor.y;
// Check if clicking on divider first (highest priority)
let divider = this._findDividerAtPosition(relX, relY);
if (divider) {
this.isDraggingDivider = true;
this.dividerDragInfo = divider;
return Clutter.EVENT_STOP;
}
// Find zone at this position
let clickedZoneIndex = this._findZoneAtPosition(relX, relY);
if (clickedZoneIndex >= 0) {
// Select this zone
this.selectedZoneIndex = clickedZoneIndex;
this._updateZoneActorHighlight(clickedZoneIndex);
}
return Clutter.EVENT_STOP;
},
_findZoneAtPosition: function(x, y) {
// Check if click is inside any existing zone
for (let i = 0; i < this.zoneActors.length; i++) {
let actor = this.zoneActors[i];
if (x >= actor.x && x <= actor.x + actor.width &&
y >= actor.y && y <= actor.y + actor.height) {
return i;
}
}
return -1;
},
_findDividerAtPosition: function(x, y) {
// Find dividers (shared edges) between zones
const THRESHOLD = 10; // pixels
let monitor = this.targetMonitor;
if (!monitor) return null;
// Check for vertical dividers (shared vertical edge)
for (let i = 0; i < this.zones.length; i++) {
for (let j = i + 1; j < this.zones.length; j++) {
let zone1 = this.zones[i];
let zone2 = this.zones[j];
// Check if they share a vertical edge
let edge1Right = zone1.x + zone1.width;
let edge2Left = zone2.x;
if (Math.abs(edge1Right - edge2Left) < 0.01) {
// They share an edge, check if cursor is near it
let edgeX = (edge1Right + edge2Left) / 2;
let edgeXPixels = edgeX * monitor.width;
if (Math.abs(x - edgeXPixels) < THRESHOLD) {
// Check if Y overlaps
let overlapTop = Math.max(zone1.y, zone2.y);
let overlapBottom = Math.min(zone1.y + zone1.height, zone2.y + zone2.height);
if (y >= overlapTop * monitor.height && y <= overlapBottom * monitor.height) {
return {
type: 'vertical',
zoneIndices: [i, j],
position: edgeX
};
}
}
}
}
}
// Check for horizontal dividers (shared horizontal edge)
for (let i = 0; i < this.zones.length; i++) {
for (let j = i + 1; j < this.zones.length; j++) {
let zone1 = this.zones[i];
let zone2 = this.zones[j];
// Check if they share a horizontal edge
let edge1Bottom = zone1.y + zone1.height;
let edge2Top = zone2.y;
if (Math.abs(edge1Bottom - edge2Top) < 0.01) {
// They share an edge, check if cursor is near it
let edgeY = (edge1Bottom + edge2Top) / 2;
let edgeYPixels = edgeY * monitor.height;
if (Math.abs(y - edgeYPixels) < THRESHOLD) {
// Check if X overlaps
let overlapLeft = Math.max(zone1.x, zone2.x);
let overlapRight = Math.min(zone1.x + zone1.width, zone2.x + zone2.width);
if (x >= overlapLeft * monitor.width && x <= overlapRight * monitor.width) {
return {
type: 'horizontal',
zoneIndices: [i, j],
position: edgeY
};
}
}
}
}
}
return null;
},
_findResizeEdge: function(x, y) {
// Detect if mouse is near edge/corner of any zone
// Returns {zoneIndex, edge} or null
const EDGE_THRESHOLD = 10; // pixels from edge to trigger resize
for (let i = 0; i < this.zoneActors.length; i++) {
let actor = this.zoneActors[i];
let left = actor.x;
let right = actor.x + actor.width;
let top = actor.y;
let bottom = actor.y + actor.height;
// Check if inside zone bounds (with some margin)
if (x >= left - EDGE_THRESHOLD && x <= right + EDGE_THRESHOLD &&
y >= top - EDGE_THRESHOLD && y <= bottom + EDGE_THRESHOLD) {
let nearLeft = Math.abs(x - left) <= EDGE_THRESHOLD;
let nearRight = Math.abs(x - right) <= EDGE_THRESHOLD;
let nearTop = Math.abs(y - top) <= EDGE_THRESHOLD;
let nearBottom = Math.abs(y - bottom) <= EDGE_THRESHOLD;
// Corners have priority
if (nearTop && nearLeft) return {zoneIndex: i, edge: 'nw'};
if (nearTop && nearRight) return {zoneIndex: i, edge: 'ne'};
if (nearBottom && nearLeft) return {zoneIndex: i, edge: 'sw'};
if (nearBottom && nearRight) return {zoneIndex: i, edge: 'se'};
// Edges
if (nearTop) return {zoneIndex: i, edge: 'n'};
if (nearBottom) return {zoneIndex: i, edge: 's'};
if (nearLeft) return {zoneIndex: i, edge: 'w'};
if (nearRight) return {zoneIndex: i, edge: 'e'};
}
}
return null;
},
_onMotion: function(actor, event) {
let [x, y] = event.get_coords();
let monitor = this.targetMonitor;
let relX = (x - monitor.x) / monitor.width;
let relY = (y - monitor.y) / monitor.height;
if (this.isDraggingDivider) {
let info = this.dividerDragInfo;
const MIN_SIZE = 50 / monitor.width; // 50px minimum in relative coords
if (info.type === 'vertical') {
// Dragging vertical divider (left/right)
let leftZoneIndex = info.zoneIndices[0];
let rightZoneIndex = info.zoneIndices[1];
let leftZone = this.zones[leftZoneIndex];
let rightZone = this.zones[rightZoneIndex];
// Calculate new widths
let leftNewWidth = relX - leftZone.x;
let rightNewX = relX;
let rightNewWidth = (rightZone.x + rightZone.width) - relX;
// Apply constraints
if (leftNewWidth >= MIN_SIZE && rightNewWidth >= MIN_SIZE) {
leftZone.width = leftNewWidth;
rightZone.x = rightNewX;
rightZone.width = rightNewWidth;
// Update visual actors
this._updateZoneActor(leftZoneIndex);
this._updateZoneActor(rightZoneIndex);
}
} else {
// Dragging horizontal divider (top/bottom)
let topZoneIndex = info.zoneIndices[0];
let bottomZoneIndex = info.zoneIndices[1];
let topZone = this.zones[topZoneIndex];
let bottomZone = this.zones[bottomZoneIndex];
// Calculate new heights
let topNewHeight = relY - topZone.y;
let bottomNewY = relY;
let bottomNewHeight = (bottomZone.y + bottomZone.height) - relY;
// Apply constraints (minimum 50 pixels)
let minHeight = 50 / monitor.height;
if (topNewHeight >= minHeight && bottomNewHeight >= minHeight) {
topZone.height = topNewHeight;
bottomZone.y = bottomNewY;
bottomZone.height = bottomNewHeight;
// Update visual actors
this._updateZoneActor(topZoneIndex);
this._updateZoneActor(bottomZoneIndex);
}
}
return Clutter.EVENT_STOP;
}
// Check if hovering over divider to show cursor feedback
let divider = this._findDividerAtPosition(x - monitor.x, y - monitor.y);
if (divider) {
// TODO: Change cursor to resize cursor
// Note: Cinnamon cursor changing is complex, skipping for now
}
return Clutter.EVENT_PROPAGATE;
},
_onButtonRelease: function(actor, event) {
if (this.isDraggingDivider) {
// Finish dragging divider
this.isDraggingDivider = false;
this.dividerDragInfo = null;
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
},
_removeLastZone: function() {
if (this.zones.length > 0) {
// Remove the last zone from data
this.zones.pop();
// Remove and destroy the last zone actor
if (this.zoneActors.length > 0) {
let removedActor = this.zoneActors.pop();
this.editorOverlay.remove_child(removedActor);
removedActor.destroy();
}
// Update zone numbers on remaining zones
// Since we removed the last zone, we only need to update if there are still zones
// The numbering is 1-indexed, so zone 0 displays "1", zone 1 displays "2", etc.
// No need to update numbers since we removed the last one - other numbers stay the same
}
},
// Split-based editor methods
_splitZoneHorizontally: function(zoneIndex) {
if (zoneIndex < 0 || zoneIndex >= this.zones.length) return;
let zone = this.zones[zoneIndex];
let topZone = {
x: zone.x,
y: zone.y,
width: zone.width,
height: zone.height / 2
};
let bottomZone = {
x: zone.x,
y: zone.y + zone.height / 2,
width: zone.width,
height: zone.height / 2
};
// Replace original zone with split zones
this.zones.splice(zoneIndex, 1, topZone, bottomZone);
// Rebuild UI
this._rebuildZoneActors();
// Select first new zone
this.selectedZoneIndex = zoneIndex;
this._updateZoneActorHighlight(zoneIndex);
},
_splitZoneVertically: function(zoneIndex) {
if (zoneIndex < 0 || zoneIndex >= this.zones.length) return;
let zone = this.zones[zoneIndex];
let leftZone = {
x: zone.x,
y: zone.y,
width: zone.width / 2,
height: zone.height
};
let rightZone = {
x: zone.x + zone.width / 2,
y: zone.y,
width: zone.width / 2,
height: zone.height
};
// Replace original zone with split zones
this.zones.splice(zoneIndex, 1, leftZone, rightZone);
// Rebuild UI
this._rebuildZoneActors();
// Select first new zone
this.selectedZoneIndex = zoneIndex;
this._updateZoneActorHighlight(zoneIndex);
},
_removeSelectedZone: function() {
if (this.selectedZoneIndex >= 0 && this.zones.length > 1) {
this.zones.splice(this.selectedZoneIndex, 1);
this.selectedZoneIndex = -1;
this._rebuildZoneActors();
}
},
_rebuildZoneActors: function() {
// Destroy all existing zone actors
this.zoneActors.forEach(actor => {
this.editorOverlay.remove_child(actor);
actor.destroy();
});
this.zoneActors = [];
// Recreate from zones array
if (this.targetMonitor) {
this._createZoneActorsFromData(this.targetMonitor);
}
},
_updateZoneActor: function(zoneIndex) {
if (zoneIndex < 0 || zoneIndex >= this.zoneActors.length) return;
let zone = this.zones[zoneIndex];
let actor = this.zoneActors[zoneIndex];
let monitor = this.targetMonitor;
if (actor && monitor) {
let zoneWidth = zone.width * monitor.width;
let zoneHeight = zone.height * monitor.height;
actor.set_position(
zone.x * monitor.width,
zone.y * monitor.height
);
actor.set_size(zoneWidth, zoneHeight);
// Update dimension label
let children = actor.get_children();
if (children.length > 0) {
let label = children[0];
let widthPx = Math.round(zoneWidth);
let heightPx = Math.round(zoneHeight);
let widthPct = Math.round(zone.width * 100);
let heightPct = Math.round(zone.height * 100);
label.set_text(widthPx + 'x' + heightPx + '\n(' + widthPct + '% × ' + heightPct + '%)');
// Re-center the label
label.set_position(
(zoneWidth - label.width) / 2,
(zoneHeight - label.height) / 2
);
}
}
},
_updateZoneActorHighlight: function(selectedIndex) {
// Update all zone actors - yellow for selected, green for others
this.zoneActors.forEach((actor, index) => {
if (index === selectedIndex) {
actor.set_style(
'border: 3px solid rgba(255, 255, 100, 0.9);' +
'background-color: rgba(255, 255, 100, 0.3);' +
'border-radius: 4px;'
);
} else {
actor.set_style(
'border: 3px solid rgba(100, 255, 100, 0.9);' +
'background-color: rgba(100, 255, 100, 0.3);' +
'border-radius: 4px;'
);
}
});
},
_saveLayout: function() {
if (this.zones.length === 0) {
Main.notify('GridSnap', 'No zones to save!');
this._cancelEditor();
return;
}
let layoutId, layoutName;
// Check if editing existing layout or creating new
if (this.editingLayoutId) {
// Update existing layout
layoutId = this.editingLayoutId;
layoutName = this.editingLayoutName + ' (updated)';
} else {
// Create new layout
layoutId = 'custom-' + Date.now();
layoutName = 'Custom Layout (' + this.zones.length + ' zones)';
}
// Save to zoneManager's layouts for the target monitor
if (zoneManager) {
// Ensure monitor layouts exist
if (!zoneManager.layoutsPerMonitor[this.targetMonitorIndex]) {
zoneManager.layoutsPerMonitor[this.targetMonitorIndex] = {};
}
zoneManager.layoutsPerMonitor[this.targetMonitorIndex][layoutId] = {
name: layoutName,
zones: this.zones
};
zoneManager.currentLayoutPerMonitor[this.targetMonitorIndex] = layoutId;
// Persist to file for permanent storage
zoneManager._saveLayouts();
if (this.editingLayoutId) {
Main.notify('GridSnap', 'Layout updated: ' + layoutName);
} else {
Main.notify('GridSnap', 'Layout saved: ' + layoutName);
}
}
this._cancelEditor();
},
_cancelEditor: function() {
if (!this.isEditing) return;
// Clean up dimension label if it exists
if (this.dimensionLabel) {
if (this.editorOverlay) {
this.editorOverlay.remove_child(this.dimensionLabel);
}
this.dimensionLabel.destroy();
this.dimensionLabel = null;
}
if (this.editorOverlay) {
if (this.buttonPressId) this.editorOverlay.disconnect(this.buttonPressId);
if (this.buttonReleaseId) this.editorOverlay.disconnect(this.buttonReleaseId);
if (this.motionId) this.editorOverlay.disconnect(this.motionId);
if (this.keyPressId) this.editorOverlay.disconnect(this.keyPressId);
Main.popModal(this.editorOverlay);
Main.layoutManager.removeChrome(this.editorOverlay);
this.editorOverlay.destroy();
this.editorOverlay = null;
}
this.isEditing = false;
this.zones = [];
this.zoneActors = [];
this.currentZone = null;
this.isMovingZone = false;
this.movingZoneIndex = -1;
this.isResizingZone = false;
this.resizingZoneIndex = -1;
this.resizeEdge = null;
this.originalZoneBounds = null;
}
};
function ZoneManager() {
this._init();
}
ZoneManager.prototype = {
_init: function() {
this.overlay = null;
this.currentLayoutPerMonitor = {}; // Map: monitorIndex -> layoutId
this.layoutsPerMonitor = {}; // Map: monitorIndex -> {layoutId -> layoutData}
this.isShowing = false;
this.dragInProgress = false;
this.draggedWindow = null;
this.draggedWindowMonitor = null; // Track which monitor is being used
this.editor = new ZoneEditor();
// Initialize settings
this.settings = new Settings.ExtensionSettings(this, 'gridsnap@cinnamon-extension');
// Load layouts (default + saved custom layouts)
this._loadLayouts();
// Connect to window grab events
this._connectSignals();
this._setupKeybindings();
},
_connectSignals: function() {
// Monitor for window grab begin/end
this.grabOpBeginId = global.display.connect('grab-op-begin',
Lang.bind(this, this._onGrabBegin));
this.grabOpEndId = global.display.connect('grab-op-end',
Lang.bind(this, this._onGrabEnd));
// Monitor for modifier key changes
this.modifierPollTimeoutId = null;
},
_setupKeybindings: function() {
// Add keybinding to show zones overlay (Super+Z)
Main.keybindingManager.addHotKey(
'show-zones',
'<Super>z',
Lang.bind(this, this._toggleZonesOverlay)
);
// Add keybinding to open zone editor (Super+Shift+E)
Main.keybindingManager.addHotKey(
'open-zone-editor',
'<Super><Shift>e',
Lang.bind(this, function() { this.editor.showMonitorSelector(); })
);
// Add keybindings for quick snap to zones (Super+Ctrl+1-9)
for (let i = 1; i <= 9; i++) {
const zoneIndex = i - 1; // Capture the value for this iteration
Main.keybindingManager.addHotKey(
'snap-to-zone-' + i,
'<Super><Ctrl>' + i,
Lang.bind(this, function() {
this._snapToZone(zoneIndex);
})
);
}
},
_loadLayouts: function() {
let monitors = Main.layoutManager.monitors;
let needsMigration = false;
let loadedData = null;
// Try to load custom layouts from file
try {
let file = Gio.File.new_for_path(LAYOUTS_FILE);
if (file.query_exists(null)) {
let [success, contents] = file.load_contents(null);
if (success) {
loadedData = JSON.parse(contents.toString());
// Check if this is v1 format (no version field)
if (!loadedData.version) {
global.log('GridSnap: Migrating layouts from v1 to v2 format...');
loadedData = this._migrateLayoutsV1toV2(loadedData);
needsMigration = true;
}
}
}
} catch(e) {
global.logError('GridSnap: Error loading custom layouts - ' + e);
}
// Initialize all monitors with default layouts
for (let i = 0; i < monitors.length; i++) {
// Start with default layouts for each monitor
this.layoutsPerMonitor[i] = {};
for (let layoutId in DEFAULT_LAYOUTS) {
this.layoutsPerMonitor[i][layoutId] = DEFAULT_LAYOUTS[layoutId];
}
// Set default current layout
this.currentLayoutPerMonitor[i] = 'grid-2x2';
}
// Apply loaded data if available
if (loadedData && loadedData.perMonitorLayouts) {
for (let monitorIndex in loadedData.perMonitorLayouts) {
let monitorData = loadedData.perMonitorLayouts[monitorIndex];
let idx = parseInt(monitorIndex);
// Only apply if this monitor exists
if (idx < monitors.length) {
// Merge custom layouts
for (let layoutId in monitorData.customLayouts) {
this.layoutsPerMonitor[idx][layoutId] = monitorData.customLayouts[layoutId];
}
// Set current layout
if (monitorData.currentLayout) {
this.currentLayoutPerMonitor[idx] = monitorData.currentLayout;
}
}
}
global.log('GridSnap: Loaded per-monitor layouts for ' + monitors.length + ' monitor(s)');
}
// Save migrated data
if (needsMigration) {
this._saveLayouts();
global.log('GridSnap: Migration complete, saved v2 format');
}
},
_migrateLayoutsV1toV2: function(oldData) {
let monitors = Main.layoutManager.monitors;
let newData = {
version: 2,
perMonitorLayouts: {}
};
// Initialize all monitors
for (let i = 0; i < monitors.length; i++) {
newData.perMonitorLayouts[i] = {
currentLayout: 'grid-2x2', // New monitors default to grid-2x2
customLayouts: {}
};
}
// Migrate old custom layouts to primary monitor only
if (monitors.length > 0) {
newData.perMonitorLayouts[0].customLayouts = oldData;
}
return newData;
},
_saveLayouts: function() {
try {
// Create directory if it doesn't exist
let dir = Gio.File.new_for_path(STORAGE_DIR);
if (!dir.query_exists(null)) {
dir.make_directory_with_parents(null);
}
// Build v2 format data structure
let saveData = {
version: 2,
perMonitorLayouts: {}
};
// Extract custom layouts per monitor
for (let monitorIndex in this.layoutsPerMonitor) {
let customLayouts = {};
for (let layoutId in this.layoutsPerMonitor[monitorIndex]) {
if (layoutId.startsWith('custom-')) {
customLayouts[layoutId] = this.layoutsPerMonitor[monitorIndex][layoutId];
}
}
saveData.perMonitorLayouts[monitorIndex] = {
currentLayout: this.currentLayoutPerMonitor[monitorIndex] || 'grid-2x2',
customLayouts: customLayouts
};
}
// Save to file
let file = Gio.File.new_for_path(LAYOUTS_FILE);
let contents = JSON.stringify(saveData, null, 2);
file.replace_contents(contents, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
global.log('GridSnap: Saved per-monitor layouts to ' + LAYOUTS_FILE);
} catch(e) {
global.logError('GridSnap: Error saving custom layouts - ' + e);
}
},
_getMonitorForWindow: function(window) {
if (!window) {
return Main.layoutManager.primaryMonitor;
}
try {
let monitorIndex = window.get_monitor();
let monitors = Main.layoutManager.monitors;
if (monitorIndex >= 0 && monitorIndex < monitors.length) {
return monitors[monitorIndex];
}
} catch(e) {
global.logError('GridSnap: Error getting window monitor - ' + e);
}
return Main.layoutManager.primaryMonitor;
},
_getAllMonitors: function() {
return Main.layoutManager.monitors;
},
_getMonitorInfo: function(monitor) {
let isPrimary = (monitor.index === Main.layoutManager.primaryIndex);
let label = isPrimary ? 'Primary' : 'Secondary';
return 'Monitor ' + monitor.index + ': ' + label + ' - ' + monitor.width + 'x' + monitor.height;
},
_checkModifiersDuringDrag: function() {
if (!this.dragInProgress) {
return false; // Stop polling
}
// Check if shift-drag is enabled
if (!this.settings.getValue('enable-shift-drag')) {
return true; // Continue polling but don't show overlay
}
let pointerInfo = global.get_pointer();
let mods = pointerInfo[2];
let shiftPressed = !!(mods & Clutter.ModifierType.SHIFT_MASK);
if (shiftPressed && !this.isShowing) {
this._showZonesOverlay(this.draggedWindowMonitor);
} else if (!shiftPressed && this.isShowing) {
this._hideZonesOverlay();
}
return true; // Continue polling
},
_onGrabBegin: function(display, window) {
// Window drag has begun
this.dragInProgress = true;
// Get the actual window being dragged - use focus_window as it's the dragged window
this.draggedWindow = global.display.focus_window;
// Detect which monitor the window is on
this.draggedWindowMonitor = this._getMonitorForWindow(this.draggedWindow);
// Start polling for modifier key changes during drag
if (this.modifierPollTimeoutId) {
Mainloop.source_remove(this.modifierPollTimeoutId);
}
this.modifierPollTimeoutId = Mainloop.timeout_add(50, Lang.bind(this, this._checkModifiersDuringDrag));
},
_onGrabEnd: function(display, window) {
if (this.dragInProgress) {
this.dragInProgress = false;
// Stop polling for modifiers
if (this.modifierPollTimeoutId) {
Mainloop.source_remove(this.modifierPollTimeoutId);
this.modifierPollTimeoutId = null;
}
if (this.isShowing) {
// Snap to zone if cursor is over one
this._snapWindowToZone(this.draggedWindow);
this._hideZonesOverlay();
}
this.draggedWindow = null;
}
},
_toggleZonesOverlay: function() {
if (this.isShowing) {
this._hideZonesOverlay();
} else {
this._showZonesOverlay();
}
},
_showZonesOverlay: function(monitor) {
if (this.isShowing) return;
// Use provided monitor or fall back to primary
if (!monitor) {
monitor = Main.layoutManager.primaryMonitor;
}
// Get monitor index
let monitorIndex = monitor.index;
// Create overlay container
this.overlay = new St.Widget({
style_class: 'gridsnap-overlay',
reactive: false,
x: monitor.x,
y: monitor.y,
width: monitor.width,
height: monitor.height
});
// Add semi-transparent background
this.overlay.set_style(
'background-color: rgba(0, 0, 0, 0.3);'
);
// Get layout for this monitor
let currentLayoutId = this.currentLayoutPerMonitor[monitorIndex] || 'grid-2x2';
let monitorLayouts = this.layoutsPerMonitor[monitorIndex] || this.layoutsPerMonitor[0];
let layout = monitorLayouts[currentLayoutId];
// Draw zones if layout exists
if (layout && layout.zones) {
layout.zones.forEach((zone, index) => {
let zoneActor = this._createZoneActor(zone, monitor, index);
this.overlay.add_child(zoneActor);
});
}
Main.layoutManager.addChrome(this.overlay);
this.isShowing = true;
// Fade in animation
this.overlay.opacity = 0;
this.overlay.ease({
opacity: 255,
duration: 200,
mode: Clutter.AnimationMode.EASE_OUT_QUAD
});
},
_createZoneActor: function(zone, monitor, index) {
let x = monitor.width * zone.x;
let y = monitor.height * zone.y;
let width = monitor.width * zone.width;
let height = monitor.height * zone.height;
// Get settings
let borderWidth = this.settings.getValue('zone-border-width');
let borderColor = this.settings.getValue('zone-border-color');
let fillColor = this.settings.getValue('zone-fill-color');
let opacity = this.settings.getValue('zone-opacity');
let showNumbers = this.settings.getValue('show-zone-numbers');
// Adjust fill color opacity
let fillColorWithOpacity = fillColor.replace(/[\d.]+\)$/g, (opacity / 100) + ')');
let actor = new St.Widget({
style_class: 'gridsnap-zone',
x: x,
y: y,
width: width,
height: height
});
actor.set_style(
'border: ' + borderWidth + 'px solid ' + borderColor + ';' +
'background-color: ' + fillColorWithOpacity + ';' +
'border-radius: 4px;'
);
// Add zone number label if enabled
if (showNumbers) {
let label = new St.Label({
text: String(index + 1),
style_class: 'gridsnap-zone-label',
x: 10,
y: 10
});
label.set_style(
'color: white;' +
'font-size: 24px;' +
'font-weight: bold;' +
'text-shadow: 2px 2px 4px rgba(0,0,0,0.8);'
);
actor.add_child(label);
}
return actor;
},
_hideZonesOverlay: function() {
if (!this.isShowing) return;
this.isShowing = false;
if (this.overlay) {
// Fade out animation
this.overlay.ease({
opacity: 0,
duration: 150,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
if (this.overlay) {
Main.layoutManager.removeChrome(this.overlay);
this.overlay.destroy();
this.overlay = null;
}
}
});
}
},
_snapWindowToZone: function(window) {
if (!window) return;
// Get the monitor the window is on
let monitor = this._getMonitorForWindow(window);
let monitorIndex = monitor.index;
let [x, y] = global.get_pointer();
// Convert to relative coordinates
let relX = (x - monitor.x) / monitor.width;
let relY = (y - monitor.y) / monitor.height;
// Get layout for this monitor
let currentLayoutId = this.currentLayoutPerMonitor[monitorIndex] || 'grid-2x2';
let monitorLayouts = this.layoutsPerMonitor[monitorIndex] || this.layoutsPerMonitor[0];
let layout = monitorLayouts[currentLayoutId];
if (!layout || !layout.zones) return;
// Find which zone the cursor is in
let targetZone = null;
for (let zone of layout.zones) {
if (relX >= zone.x && relX < zone.x + zone.width &&
relY >= zone.y && relY < zone.y + zone.height) {
targetZone = zone;
break;
}
}
if (targetZone) {
this._moveWindowToZone(window, targetZone, monitor);
this._showSnapFeedback(targetZone, monitor);
// Show notification if enabled
if (this.settings.getValue('notification-on-snap')) {
Main.notify('GridSnap', 'Window snapped to zone');
}
}
},
_snapToZone: function(zoneIndex) {
// Check if keyboard snap is enabled
if (!this.settings.getValue('enable-keyboard-snap')) {
return;
}
// Snap the focused window to a specific zone
let window = global.display.focus_window;
if (!window) return;
// Get the monitor the window is on
let monitor = this._getMonitorForWindow(window);
let monitorIndex = monitor.index;
// Get layout for this monitor
let currentLayoutId = this.currentLayoutPerMonitor[monitorIndex] || 'grid-2x2';
let monitorLayouts = this.layoutsPerMonitor[monitorIndex] || this.layoutsPerMonitor[0];
let layout = monitorLayouts[currentLayoutId];
if (!layout || !layout.zones || zoneIndex >= layout.zones.length) return;
let targetZone = layout.zones[zoneIndex];
this._moveWindowToZone(window, targetZone, monitor);
this._showSnapFeedback(targetZone, monitor);
// Show notification if enabled
if (this.settings.getValue('notification-on-snap')) {
Main.notify('GridSnap', 'Window snapped to zone ' + (zoneIndex + 1));
}
},
_showSnapFeedback: function(zone, monitor) {
// Create a flash effect on the zone to show snap feedback
let x = monitor.x + Math.round(monitor.width * zone.x);
let y = monitor.y + Math.round(monitor.height * zone.y);
let width = Math.round(monitor.width * zone.width);
let height = Math.round(monitor.height * zone.height);
let flashActor = new St.Widget({
style_class: 'gridsnap-flash',
x: x,
y: y,
width: width,
height: height
});
flashActor.set_style(
'border: 4px solid rgba(100, 255, 100, 1.0);' +
'background-color: rgba(100, 255, 100, 0.3);' +
'border-radius: 4px;'
);
Main.layoutManager.addChrome(flashActor);
// Animate flash effect
flashActor.opacity = 255;
flashActor.ease({
opacity: 0,
duration: 400,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
Main.layoutManager.removeChrome(flashActor);
flashActor.destroy();
}
});
},
_moveWindowToZone: function(window, zone, monitor) {
if (!window || !zone || !monitor) return;
// Unmaximize if maximized
if (window.maximized_horizontally || window.maximized_vertically) {
window.unmaximize(Meta.MaximizeFlags.BOTH);
}
// Calculate absolute coordinates
let x = monitor.x + Math.round(monitor.width * zone.x);
let y = monitor.y + Math.round(monitor.height * zone.y);
let width = Math.round(monitor.width * zone.width);
let height = Math.round(monitor.height * zone.height);
// Use Mainloop.idle_add to defer the operation
// This ensures the unmaximize completes before move/resize
Mainloop.idle_add(Lang.bind(this, function() {
// Get window actor for animation
let actor = window.get_compositor_private();
if (actor) {
// Animate window movement
actor.ease({
x: x,
y: y,
width: width,
height: height,
duration: 250,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
// Set final position precisely after animation
window.move_resize_frame(false, x, y, width, height);
}
});
} else {
// Fallback to instant move if no actor
window.move_resize_frame(false, x, y, width, height);
}
return false; // Don't repeat
}));
},
cycleLayout: function() {
let layouts = Object.keys(this.layouts);
let currentIndex = layouts.indexOf(this.currentLayout);
let nextIndex = (currentIndex + 1) % layouts.length;
this.currentLayout = layouts[nextIndex];
Main.notify('GridSnap', 'Layout: ' + this.layouts[this.currentLayout].name);
// Refresh overlay if showing
if (this.isShowing) {
this._hideZonesOverlay();
this._showZonesOverlay();
}
},
destroy: function() {
// Clean up overlay FIRST to prevent corrupt display
this._hideZonesOverlay();
// Stop modifier polling if active
if (this.modifierPollTimeoutId) {
Mainloop.source_remove(this.modifierPollTimeoutId);
this.modifierPollTimeoutId = null;
}
// Clean up editor
if (this.editor) {
this.editor._cancelEditor();
}
// Clean up settings
if (this.settings) {
this.settings.finalize();
this.settings = null;
}
// Disconnect signals
if (this.grabOpBeginId) {
global.display.disconnect(this.grabOpBeginId);
this.grabOpBeginId = null;
}
if (this.grabOpEndId) {
global.display.disconnect(this.grabOpEndId);
this.grabOpEndId = null;
}
// Remove keybindings
try {
Main.keybindingManager.removeHotKey('show-zones');
Main.keybindingManager.removeHotKey('open-zone-editor');
for (let i = 1; i <= 9; i++) {
Main.keybindingManager.removeHotKey('snap-to-zone-' + i);
}
} catch(e) {
global.logError('GridSnap: Error removing keybindings - ' + e);
}
}
};
function init(metadata) {
// Extension initialization
}
function enable() {
zoneManager = new ZoneManager();
// Add keybinding to cycle layouts
Main.keybindingManager.addHotKey(
'cycle-layout',
'<Super><Shift>z',
Lang.bind(zoneManager, zoneManager.cycleLayout)
);
}
function disable() {
if (zoneManager) {
Main.keybindingManager.removeHotKey('cycle-layout');
zoneManager.destroy();
zoneManager = null;
}
}