Files
GridSnap/extension.js
Keith Smith 7816d41571 Add ability to edit existing custom layouts
Implemented layout editing functionality with selection UI:

Features:
- New layout selector dialog when pressing Super+Shift+E
- Lists all existing custom layouts with zone counts
- "Create New Layout" option for new layouts
- Click any layout to edit it in the zone editor
- Existing zones pre-populated when editing
- Updates existing layout when saving (shows "updated" message)
- ESC key or Cancel button to close selector

Implementation details:
- Added editingLayoutId and editingLayoutName properties to track edit mode
- Modified startEditor() to accept optional layoutId and layoutData parameters
- Created showLayoutSelector() method to display layout selection UI
- Created _createZoneActorsFromData() to pre-populate zones when editing
- Updated _saveLayout() to update existing layout vs create new
- Changed keybinding to call showLayoutSelector() instead of startEditor()
- Added _closeLayoutSelector() for cleanup
- Deep copy of zones array to avoid reference issues

User experience:
- Press Super+Shift+E to open layout selector
- Choose existing layout to edit or create new
- Edit zones (move, resize, add, delete)
- Save updates existing layout or creates new
- Clear visual feedback for edit vs create mode

Fixes TODO item #5.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 21:40:57 -07:00

1296 lines
44 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
},
showLayoutSelector: function() {
if (this.isEditing || this.selectorOverlay) return;
let monitor = 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
if (zoneManager && zoneManager.layouts) {
let hasCustomLayouts = false;
for (let layoutId in zoneManager.layouts) {
if (layoutId.startsWith('custom-')) {
hasCustomLayouts = true;
let layout = zoneManager.layouts[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
} else {
this.editingLayoutId = null;
this.editingLayoutName = null;
this.zones = [];
}
let monitor = 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 Zone Editor\n\nDrag to draw zones | Click zone to move | Drag edges to resize | Ctrl+S to save | Ctrl+C to cancel | Delete to clear last zone',
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);
// If editing existing layout, create zone actors for existing zones
if (this.editingLayoutId && 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 zoneActor = new St.Widget({
style_class: 'gridsnap-editor-zone',
x: zone.x * monitor.width,
y: zone.y * monitor.height,
width: zone.width * monitor.width,
height: zone.height * monitor.height
});
zoneActor.set_style(
'border: 3px solid rgba(100, 255, 100, 0.9);' +
'background-color: rgba(100, 255, 100, 0.3);' +
'border-radius: 4px;'
);
let label = new St.Label({
text: String(index + 1),
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);'
);
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;
}
// Delete to remove last zone
if (symbol === Clutter.KEY_Delete || symbol === Clutter.KEY_BackSpace) {
this._removeLastZone();
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 = Main.layoutManager.primaryMonitor;
this.startX = x - monitor.x;
this.startY = y - monitor.y;
// Check if clicking on a resize edge first (higher priority)
let resizeInfo = this._findResizeEdge(this.startX, this.startY);
if (resizeInfo) {
// Start resizing zone
this.isResizingZone = true;
this.resizingZoneIndex = resizeInfo.zoneIndex;
this.resizeEdge = resizeInfo.edge;
let zoneActor = this.zoneActors[resizeInfo.zoneIndex];
// Store original bounds for resizing
this.originalZoneBounds = {
x: zoneActor.x,
y: zoneActor.y,
width: zoneActor.width,
height: zoneActor.height
};
// Highlight zone being resized (cyan color)
zoneActor.set_style(
'border: 3px solid rgba(100, 255, 255, 0.9);' +
'background-color: rgba(100, 255, 255, 0.4);' +
'border-radius: 4px;'
);
// Create dimension label for resize feedback
this.dimensionLabel = new St.Label({
text: '',
style_class: 'gridsnap-dimension-label'
});
this.dimensionLabel.set_style(
'color: white;' +
'font-size: 14px;' +
'background-color: rgba(0, 0, 0, 0.8);' +
'padding: 5px 10px;' +
'border-radius: 4px;'
);
this.editorOverlay.add_child(this.dimensionLabel);
} else {
// Check if clicking on an existing zone to move it
let clickedZoneIndex = this._findZoneAtPosition(this.startX, this.startY);
if (clickedZoneIndex >= 0) {
// Start moving existing zone
this.isMovingZone = true;
this.movingZoneIndex = clickedZoneIndex;
let zoneActor = this.zoneActors[clickedZoneIndex];
this.moveOffsetX = this.startX - zoneActor.x;
this.moveOffsetY = this.startY - zoneActor.y;
// Highlight zone being moved
zoneActor.set_style(
'border: 3px solid rgba(255, 255, 100, 0.9);' +
'background-color: rgba(255, 255, 100, 0.4);' +
'border-radius: 4px;'
);
} else {
// Start creating new zone
this.isDragging = true;
// Create zone preview
this.currentZone = new St.Widget({
style_class: 'gridsnap-editor-zone',
x: this.startX,
y: this.startY,
width: 1,
height: 1
});
this.currentZone.set_style(
'border: 3px solid rgba(100, 255, 100, 0.9);' +
'background-color: rgba(100, 255, 100, 0.3);' +
'border-radius: 4px;'
);
this.editorOverlay.add_child(this.currentZone);
// Create dimension label
this.dimensionLabel = new St.Label({
text: '',
style_class: 'gridsnap-dimension-label'
});
this.dimensionLabel.set_style(
'color: white;' +
'font-size: 14px;' +
'background-color: rgba(0, 0, 0, 0.8);' +
'padding: 5px 10px;' +
'border-radius: 4px;'
);
this.editorOverlay.add_child(this.dimensionLabel);
}
}
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;
},
_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 = Main.layoutManager.primaryMonitor;
if (this.isResizingZone) {
// Resizing existing zone
let currentX = x - monitor.x;
let currentY = y - monitor.y;
let deltaX = currentX - this.startX;
let deltaY = currentY - this.startY;
let zoneActor = this.zoneActors[this.resizingZoneIndex];
let newX = this.originalZoneBounds.x;
let newY = this.originalZoneBounds.y;
let newWidth = this.originalZoneBounds.width;
let newHeight = this.originalZoneBounds.height;
// Apply resize based on which edge/corner is being dragged
const MIN_SIZE = 20;
if (this.resizeEdge.includes('n')) {
newY = this.originalZoneBounds.y + deltaY;
newHeight = this.originalZoneBounds.height - deltaY;
if (newHeight < MIN_SIZE) {
newHeight = MIN_SIZE;
newY = this.originalZoneBounds.y + this.originalZoneBounds.height - MIN_SIZE;
}
}
if (this.resizeEdge.includes('s')) {
newHeight = this.originalZoneBounds.height + deltaY;
newHeight = Math.max(MIN_SIZE, newHeight);
}
if (this.resizeEdge.includes('w')) {
newX = this.originalZoneBounds.x + deltaX;
newWidth = this.originalZoneBounds.width - deltaX;
if (newWidth < MIN_SIZE) {
newWidth = MIN_SIZE;
newX = this.originalZoneBounds.x + this.originalZoneBounds.width - MIN_SIZE;
}
}
if (this.resizeEdge.includes('e')) {
newWidth = this.originalZoneBounds.width + deltaX;
newWidth = Math.max(MIN_SIZE, newWidth);
}
// Constrain to monitor bounds
newX = Math.max(0, newX);
newY = Math.max(0, newY);
newWidth = Math.min(newWidth, monitor.width - newX);
newHeight = Math.min(newHeight, monitor.height - newY);
// Update actor
zoneActor.set_position(newX, newY);
zoneActor.set_size(newWidth, newHeight);
// Update zone data
this.zones[this.resizingZoneIndex].x = newX / monitor.width;
this.zones[this.resizingZoneIndex].y = newY / monitor.height;
this.zones[this.resizingZoneIndex].width = newWidth / monitor.width;
this.zones[this.resizingZoneIndex].height = newHeight / monitor.height;
// Update dimension label
if (this.dimensionLabel) {
let widthPx = Math.round(newWidth);
let heightPx = Math.round(newHeight);
let widthPct = Math.round((newWidth / monitor.width) * 100);
let heightPct = Math.round((newHeight / monitor.height) * 100);
this.dimensionLabel.set_text(
widthPx + 'x' + heightPx + 'px (' + widthPct + '% × ' + heightPct + '%)\n' +
'Position: ' + Math.round(newX) + ', ' + Math.round(newY)
);
this.dimensionLabel.set_position(
Math.min(currentX + 15, monitor.width - 200),
Math.min(currentY + 15, monitor.height - 60)
);
}
return Clutter.EVENT_STOP;
}
if (this.isMovingZone) {
// Moving existing zone
let newX = x - monitor.x - this.moveOffsetX;
let newY = y - monitor.y - this.moveOffsetY;
let zoneActor = this.zoneActors[this.movingZoneIndex];
// Constrain to monitor bounds
newX = Math.max(0, Math.min(newX, monitor.width - zoneActor.width));
newY = Math.max(0, Math.min(newY, monitor.height - zoneActor.height));
zoneActor.set_position(newX, newY);
// Update zone data
this.zones[this.movingZoneIndex].x = newX / monitor.width;
this.zones[this.movingZoneIndex].y = newY / monitor.height;
return Clutter.EVENT_STOP;
}
if (this.isDragging && this.currentZone) {
// Creating new zone
let endX = x - monitor.x;
let endY = y - monitor.y;
let rectX = Math.min(this.startX, endX);
let rectY = Math.min(this.startY, endY);
let rectWidth = Math.abs(endX - this.startX);
let rectHeight = Math.abs(endY - this.startY);
this.currentZone.set_position(rectX, rectY);
this.currentZone.set_size(rectWidth, rectHeight);
// Update dimension label
if (this.dimensionLabel) {
let widthPx = Math.round(rectWidth);
let heightPx = Math.round(rectHeight);
let widthPct = Math.round((rectWidth / monitor.width) * 100);
let heightPct = Math.round((rectHeight / monitor.height) * 100);
this.dimensionLabel.set_text(
widthPx + 'x' + heightPx + 'px (' + widthPct + '% × ' + heightPct + '%)\n' +
'Position: ' + Math.round(rectX) + ', ' + Math.round(rectY)
);
// Position label near cursor
this.dimensionLabel.set_position(
Math.min(endX + 15, monitor.width - 200),
Math.min(endY + 15, monitor.height - 60)
);
}
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
},
_onButtonRelease: function(actor, event) {
if (this.isResizingZone) {
// Finish resizing zone
let zoneActor = this.zoneActors[this.resizingZoneIndex];
// Reset highlight
zoneActor.set_style(
'border: 3px solid rgba(100, 255, 100, 0.9);' +
'background-color: rgba(100, 255, 100, 0.3);' +
'border-radius: 4px;'
);
// Clean up dimension label
if (this.dimensionLabel) {
this.editorOverlay.remove_child(this.dimensionLabel);
this.dimensionLabel.destroy();
this.dimensionLabel = null;
}
this.isResizingZone = false;
this.resizingZoneIndex = -1;
this.resizeEdge = null;
this.originalZoneBounds = null;
return Clutter.EVENT_STOP;
}
if (this.isMovingZone) {
// Finish moving zone
let zoneActor = this.zoneActors[this.movingZoneIndex];
// Reset highlight
zoneActor.set_style(
'border: 3px solid rgba(100, 255, 100, 0.9);' +
'background-color: rgba(100, 255, 100, 0.3);' +
'border-radius: 4px;'
);
this.isMovingZone = false;
this.movingZoneIndex = -1;
return Clutter.EVENT_STOP;
}
if (!this.isDragging) return Clutter.EVENT_PROPAGATE;
this.isDragging = false;
// Clean up dimension label
if (this.dimensionLabel) {
this.editorOverlay.remove_child(this.dimensionLabel);
this.dimensionLabel.destroy();
this.dimensionLabel = null;
}
if (this.currentZone) {
let monitor = Main.layoutManager.primaryMonitor;
let width = this.currentZone.width;
let height = this.currentZone.height;
// Only save zones with reasonable size
if (width > 20 && height > 20) {
// Convert to relative coordinates
let zone = {
x: this.currentZone.x / monitor.width,
y: this.currentZone.y / monitor.height,
width: width / monitor.width,
height: height / monitor.height
};
this.zones.push(zone);
// Track the zone actor for moving later
this.zoneActors.push(this.currentZone);
// Add zone number label
let label = new St.Label({
text: String(this.zones.length),
style_class: 'gridsnap-editor-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);'
);
this.currentZone.add_child(label);
} else {
// Remove if too small
this.currentZone.destroy();
}
this.currentZone = null;
}
return Clutter.EVENT_STOP;
},
_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
}
},
_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
if (zoneManager) {
zoneManager.layouts[layoutId] = {
name: layoutName,
zones: this.zones
};
zoneManager.currentLayout = 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.currentLayout = 'grid-2x2';
this.layouts = {};
this.isShowing = false;
this.dragInProgress = false;
this.draggedWindow = null;
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.showLayoutSelector(); })
);
// 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() {
// Start with default layouts
for (let layoutId in DEFAULT_LAYOUTS) {
this.layouts[layoutId] = DEFAULT_LAYOUTS[layoutId];
}
// 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) {
let customLayouts = JSON.parse(contents.toString());
// Merge custom layouts with defaults
for (let layoutId in customLayouts) {
this.layouts[layoutId] = customLayouts[layoutId];
}
global.log('GridSnap: Loaded ' + Object.keys(customLayouts).length + ' custom layout(s)');
}
}
} catch(e) {
global.logError('GridSnap: Error loading custom layouts - ' + e);
}
},
_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);
}
// Extract only custom layouts (those starting with 'custom-')
let customLayouts = {};
for (let layoutId in this.layouts) {
if (layoutId.startsWith('custom-')) {
customLayouts[layoutId] = this.layouts[layoutId];
}
}
// Save to file
let file = Gio.File.new_for_path(LAYOUTS_FILE);
let contents = JSON.stringify(customLayouts, null, 2);
file.replace_contents(contents, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
global.log('GridSnap: Saved ' + Object.keys(customLayouts).length + ' custom layout(s) to ' + LAYOUTS_FILE);
} catch(e) {
global.logError('GridSnap: Error saving custom layouts - ' + e);
}
},
_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();
} 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;
// 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() {
if (this.isShowing) return;
let monitor = Main.layoutManager.primaryMonitor;
// 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);'
);
// Draw zones
let layout = this.layouts[this.currentLayout];
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;
},
_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;
if (this.overlay) {
Main.layoutManager.removeChrome(this.overlay);
this.overlay.destroy();
this.overlay = null;
}
this.isShowing = false;
},
_snapWindowToZone: function(window) {
if (!window) return;
let [x, y] = global.get_pointer();
let monitor = Main.layoutManager.primaryMonitor;
// Convert to relative coordinates
let relX = (x - monitor.x) / monitor.width;
let relY = (y - monitor.y) / monitor.height;
// Find which zone the cursor is in
let layout = this.layouts[this.currentLayout];
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);
// 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;
let layout = this.layouts[this.currentLayout];
if (zoneIndex >= layout.zones.length) return;
let monitor = Main.layoutManager.primaryMonitor;
this._moveWindowToZone(window, layout.zones[zoneIndex], monitor);
// Show notification if enabled
if (this.settings.getValue('notification-on-snap')) {
Main.notify('GridSnap', 'Window snapped to zone ' + (zoneIndex + 1));
}
},
_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() {
// Move and resize window (false = programmatic operation, not user-initiated)
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;
}
}