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', '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.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, '' + 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', 'z', Lang.bind(zoneManager, zoneManager.cycleLayout) ); } function disable() { if (zoneManager) { Main.keybindingManager.removeHotKey('cycle-layout'); zoneManager.destroy(); zoneManager = null; } }