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', 'z', Lang.bind(this, this._toggleZonesOverlay) ); // Add keybinding to open zone editor (Super+Shift+E) Main.keybindingManager.addHotKey( 'open-zone-editor', '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, '' + 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; // 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; 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); 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; let layout = this.layouts[this.currentLayout]; if (zoneIndex >= layout.zones.length) return; let monitor = Main.layoutManager.primaryMonitor; 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', 'z', Lang.bind(zoneManager, zoneManager.cycleLayout) ); } function disable() { if (zoneManager) { Main.keybindingManager.removeHotKey('cycle-layout'); zoneManager.destroy(); zoneManager = null; } }