From 0d2db793c6d265ea102478855f9e06a9939cb092 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Wed, 21 Jan 2026 19:13:52 -0700 Subject: [PATCH] Implement split-based zone editor and multi-monitor support Major changes: - Replace free-form drawing with split-based tiling interface - Add per-monitor zone configurations and storage - Implement monitor selector UI for multi-monitor setups - Add H/V keyboard shortcuts for horizontal/vertical splits - Add divider dragging for adjusting zone positions - Display real-time dimensions in center of each zone - Migrate storage format to v2 with per-monitor layouts - Update settings panel for per-monitor layout management Co-Authored-By: Claude Sonnet 4.5 --- TODO.md | 12 +- extension.js | 1056 ++++++++++++++++++++++++++++++++------------------ settings.js | 125 +++++- 3 files changed, 796 insertions(+), 397 deletions(-) diff --git a/TODO.md b/TODO.md index a294328..d9b5f14 100644 --- a/TODO.md +++ b/TODO.md @@ -21,13 +21,13 @@ ## Functionality Improvements -### 3. Multi-Monitor Support +### 3. Multi-Monitor Support ✅ COMPLETED **Priority: High** -- [ ] Currently only works on primary monitor (extension.js:61) -- [ ] Need per-monitor zone configurations -- [ ] Detect which monitor window is on when snapping -- [ ] Allow zone editor to work on any monitor -- [ ] Store zone layouts per monitor +- [x] Currently only works on primary monitor (extension.js:61) +- [x] Need per-monitor zone configurations +- [x] Detect which monitor window is on when snapping +- [x] Allow zone editor to work on any monitor +- [x] Store zone layouts per monitor ### 4. High-DPI Display Support **Priority: Medium** diff --git a/extension.js b/extension.js index f3e4fff..44bc5fe 100644 --- a/extension.js +++ b/extension.js @@ -69,12 +69,165 @@ ZoneEditor.prototype = { 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]} }, - showLayoutSelector: function() { + 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; - let monitor = Main.layoutManager.primaryMonitor; + // Use target monitor if set, otherwise use primary + let monitor = this.targetMonitor || Main.layoutManager.primaryMonitor; // Create selector overlay this.selectorOverlay = new St.Widget({ @@ -142,13 +295,14 @@ ZoneEditor.prototype = { ); buttonContainer.add_child(separator); - // Add buttons for existing custom layouts - if (zoneManager && zoneManager.layouts) { + // 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 zoneManager.layouts) { + for (let layoutId in monitorLayouts) { if (layoutId.startsWith('custom-')) { hasCustomLayouts = true; - let layout = zoneManager.layouts[layoutId]; + let layout = monitorLayouts[layoutId]; let button = this._createSelectorButton( layout.name + ' (' + layout.zones.length + ' zones)', layoutId, @@ -247,13 +401,17 @@ ZoneEditor.prototype = { 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; - this.zones = []; + // Start with one full-screen zone + this.zones = [{x: 0, y: 0, width: 1, height: 1}]; + this.selectedZoneIndex = 0; // Pre-select it } - let monitor = Main.layoutManager.primaryMonitor; + // Use target monitor (set by monitor selector) + let monitor = this.targetMonitor || Main.layoutManager.primaryMonitor; // Create editor overlay this.editorOverlay = new St.Widget({ @@ -271,7 +429,7 @@ ZoneEditor.prototype = { // 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', + 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 @@ -296,8 +454,8 @@ ZoneEditor.prototype = { 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) { + // Create zone actors for all zones (whether new or editing) + if (this.zones.length > 0) { this._createZoneActorsFromData(monitor); } @@ -309,12 +467,15 @@ ZoneEditor.prototype = { // 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: zone.width * monitor.width, - height: zone.height * monitor.height + width: zoneWidth, + height: zoneHeight }); zoneActor.set_style( 'border: 3px solid rgba(100, 255, 100, 0.9);' + @@ -322,17 +483,29 @@ ZoneEditor.prototype = { '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: String(index + 1), - x: 10, - y: 10 + text: widthPx + 'x' + heightPx + '\n(' + widthPct + '% × ' + heightPct + '%)' }); label.set_style( 'color: white;' + - 'font-size: 24px;' + + '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); @@ -344,31 +517,47 @@ ZoneEditor.prototype = { 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(); + + // 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; }) ); @@ -376,102 +565,26 @@ ZoneEditor.prototype = { _onButtonPress: function(actor, event) { let [x, y] = event.get_coords(); - let monitor = Main.layoutManager.primaryMonitor; + let monitor = this.targetMonitor; - this.startX = x - monitor.x; - this.startY = y - monitor.y; + let relX = x - monitor.x; + let relY = y - monitor.y; - // Check if clicking on a resize edge first (higher priority) - let resizeInfo = this._findResizeEdge(this.startX, this.startY); + // 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; + } - if (resizeInfo) { - // Start resizing zone - this.isResizingZone = true; - this.resizingZoneIndex = resizeInfo.zoneIndex; - this.resizeEdge = resizeInfo.edge; + // Find zone at this position + let clickedZoneIndex = this._findZoneAtPosition(relX, relY); - 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); - } + if (clickedZoneIndex >= 0) { + // Select this zone + this.selectedZoneIndex = clickedZoneIndex; + this._updateZoneActorHighlight(clickedZoneIndex); } return Clutter.EVENT_STOP; @@ -489,6 +602,80 @@ ZoneEditor.prototype = { 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 @@ -528,240 +715,85 @@ ZoneEditor.prototype = { _onMotion: function(actor, event) { let [x, y] = event.get_coords(); - let monitor = Main.layoutManager.primaryMonitor; + let monitor = this.targetMonitor; + let relX = (x - monitor.x) / monitor.width; + let relY = (y - monitor.y) / monitor.height; - 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; + if (this.isDraggingDivider) { + let info = this.dividerDragInfo; + const MIN_SIZE = 50 / monitor.width; // 50px minimum in relative coords - 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; + if (info.type === 'vertical') { + // Dragging vertical divider (left/right) + let leftZoneIndex = info.zoneIndices[0]; + let rightZoneIndex = info.zoneIndices[1]; - // Apply resize based on which edge/corner is being dragged - const MIN_SIZE = 20; + let leftZone = this.zones[leftZoneIndex]; + let rightZone = this.zones[rightZoneIndex]; - 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; + // 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); } - } - 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; + } 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); } } - 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; + // 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.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; + if (this.isDraggingDivider) { + // Finish dragging divider + this.isDraggingDivider = false; + this.dividerDragInfo = 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; + return Clutter.EVENT_PROPAGATE; }, _removeLastZone: function() { @@ -782,7 +814,143 @@ ZoneEditor.prototype = { // 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!'); @@ -803,13 +971,18 @@ ZoneEditor.prototype = { layoutName = 'Custom Layout (' + this.zones.length + ' zones)'; } - // Save to zoneManager's layouts + // Save to zoneManager's layouts for the target monitor if (zoneManager) { - zoneManager.layouts[layoutId] = { + // Ensure monitor layouts exist + if (!zoneManager.layoutsPerMonitor[this.targetMonitorIndex]) { + zoneManager.layoutsPerMonitor[this.targetMonitorIndex] = {}; + } + + zoneManager.layoutsPerMonitor[this.targetMonitorIndex][layoutId] = { name: layoutName, zones: this.zones }; - zoneManager.currentLayout = layoutId; + zoneManager.currentLayoutPerMonitor[this.targetMonitorIndex] = layoutId; // Persist to file for permanent storage zoneManager._saveLayouts(); @@ -868,11 +1041,12 @@ function ZoneManager() { ZoneManager.prototype = { _init: function() { this.overlay = null; - this.currentLayout = 'grid-2x2'; - this.layouts = {}; + 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 @@ -909,7 +1083,7 @@ ZoneManager.prototype = { Main.keybindingManager.addHotKey( 'open-zone-editor', 'e', - Lang.bind(this, function() { this.editor.showLayoutSelector(); }) + Lang.bind(this, function() { this.editor.showMonitorSelector(); }) ); // Add keybindings for quick snap to zones (Super+Ctrl+1-9) @@ -926,10 +1100,9 @@ ZoneManager.prototype = { }, _loadLayouts: function() { - // Start with default layouts - for (let layoutId in DEFAULT_LAYOUTS) { - this.layouts[layoutId] = DEFAULT_LAYOUTS[layoutId]; - } + let monitors = Main.layoutManager.monitors; + let needsMigration = false; + let loadedData = null; // Try to load custom layouts from file try { @@ -937,17 +1110,83 @@ ZoneManager.prototype = { 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]; + 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; } - global.log('GridSnap: Loaded ' + Object.keys(customLayouts).length + ' custom layout(s)'); } } } 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() { @@ -958,25 +1197,66 @@ ZoneManager.prototype = { 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]; + // 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(customLayouts, null, 2); + let contents = JSON.stringify(saveData, 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); + 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 @@ -992,7 +1272,7 @@ ZoneManager.prototype = { let shiftPressed = !!(mods & Clutter.ModifierType.SHIFT_MASK); if (shiftPressed && !this.isShowing) { - this._showZonesOverlay(); + this._showZonesOverlay(this.draggedWindowMonitor); } else if (!shiftPressed && this.isShowing) { this._hideZonesOverlay(); } @@ -1007,6 +1287,9 @@ ZoneManager.prototype = { // 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); @@ -1042,10 +1325,16 @@ ZoneManager.prototype = { } }, - _showZonesOverlay: function() { + _showZonesOverlay: function(monitor) { if (this.isShowing) return; - let monitor = Main.layoutManager.primaryMonitor; + // 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({ @@ -1056,18 +1345,24 @@ ZoneManager.prototype = { 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); - }); + + // 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; @@ -1156,17 +1451,25 @@ ZoneManager.prototype = { _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(); - 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; + // 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) { @@ -1196,10 +1499,17 @@ ZoneManager.prototype = { let window = global.display.focus_window; if (!window) return; - let layout = this.layouts[this.currentLayout]; - if (zoneIndex >= layout.zones.length) 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 monitor = Main.layoutManager.primaryMonitor; let targetZone = layout.zones[zoneIndex]; this._moveWindowToZone(window, targetZone, monitor); this._showSnapFeedback(targetZone, monitor); diff --git a/settings.js b/settings.js index 6f86986..290290c 100644 --- a/settings.js +++ b/settings.js @@ -10,6 +10,21 @@ function init() { // Nothing to do here } +function getMonitorCount() { + // Try to detect monitor count from display + // Fallback to 1 if we can't detect + try { + const Gdk = imports.gi.Gdk; + let display = Gdk.Display.get_default(); + if (display) { + return display.get_n_monitors(); + } + } catch(e) { + log('Could not detect monitor count, defaulting to 1: ' + e); + } + return 1; +} + function buildPrefsWidget() { let frame = new Gtk.Box({ orientation: Gtk.Orientation.VERTICAL, @@ -36,9 +51,34 @@ function buildPrefsWidget() { }); frame.pack_start(layoutsLabel, false, false, 5); - // List of custom layouts - let layoutsList = createLayoutsList(); - frame.pack_start(layoutsList, true, true, 0); + // Monitor selector + let monitorBox = new Gtk.Box({ + orientation: Gtk.Orientation.HORIZONTAL, + spacing: 10, + margin_bottom: 10 + }); + + let monitorLabel = new Gtk.Label({ + label: 'Select Monitor:' + }); + monitorBox.pack_start(monitorLabel, false, false, 0); + + let monitorCombo = new Gtk.ComboBoxText(); + let monitorCount = getMonitorCount(); + for (let i = 0; i < monitorCount; i++) { + let label = i === 0 ? 'Monitor 0 (Primary)' : 'Monitor ' + i; + monitorCombo.append_text(label); + } + monitorCombo.set_active(0); + monitorBox.pack_start(monitorCombo, false, false, 0); + + frame.pack_start(monitorBox, false, false, 0); + + // List of custom layouts (will be updated when monitor selection changes) + let layoutsListContainer = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL}); + let layoutsList = createLayoutsList(0, monitorCombo, layoutsListContainer); + layoutsListContainer.pack_start(layoutsList, true, true, 0); + frame.pack_start(layoutsListContainer, true, true, 0); // Help text let helpLabel = new Gtk.Label({ @@ -62,7 +102,7 @@ function buildPrefsWidget() { return frame; } -function createLayoutsList() { +function createLayoutsList(monitorIndex, monitorCombo, layoutsListContainer) { let scrolled = new Gtk.ScrolledWindow({ shadow_type: Gtk.ShadowType.IN, min_content_height: 200 @@ -73,8 +113,8 @@ function createLayoutsList() { }); scrolled.add(listBox); - // Load custom layouts - let layouts = loadCustomLayouts(); + // Load custom layouts for this monitor + let layouts = loadCustomLayoutsForMonitor(monitorIndex); if (Object.keys(layouts).length === 0) { let emptyRow = new Gtk.ListBoxRow({ @@ -82,7 +122,7 @@ function createLayoutsList() { activatable: false }); let emptyLabel = new Gtk.Label({ - label: 'No custom layouts yet', + label: 'No custom layouts yet for this monitor', use_markup: true, margin: 20 }); @@ -90,15 +130,31 @@ function createLayoutsList() { listBox.add(emptyRow); } else { for (let layoutId in layouts) { - let row = createLayoutRow(layoutId, layouts[layoutId], listBox); + let row = createLayoutRow(layoutId, layouts[layoutId], listBox, monitorIndex); listBox.add(row); } } + // Connect monitor combo box change event + if (monitorCombo && layoutsListContainer) { + monitorCombo.connect('changed', function() { + let newMonitorIndex = monitorCombo.get_active(); + // Remove old scrolled window + let children = layoutsListContainer.get_children(); + for (let child of children) { + layoutsListContainer.remove(child); + } + // Add new one + let newLayoutsList = createLayoutsList(newMonitorIndex, monitorCombo, layoutsListContainer); + layoutsListContainer.pack_start(newLayoutsList, true, true, 0); + layoutsListContainer.show_all(); + }); + } + return scrolled; } -function createLayoutRow(layoutId, layout, listBox) { +function createLayoutRow(layoutId, layout, listBox, monitorIndex) { let row = new Gtk.ListBoxRow(); let hbox = new Gtk.Box({ orientation: Gtk.Orientation.HORIZONTAL, @@ -135,7 +191,7 @@ function createLayoutRow(layoutId, layout, listBox) { }); deleteButton.get_style_context().add_class('destructive-action'); deleteButton.connect('clicked', function() { - deleteLayout(layoutId, row, listBox); + deleteLayout(layoutId, row, listBox, monitorIndex); }); hbox.pack_end(deleteButton, false, false, 0); @@ -159,16 +215,43 @@ function loadCustomLayouts() { if (file.query_exists(null)) { let [success, contents] = file.load_contents(null); if (success) { - return JSON.parse(contents.toString()); + let data = JSON.parse(contents.toString()); + + // Check if v2 format + if (data.version === 2 && data.perMonitorLayouts) { + return data; + } else { + // V1 format - migrate to v2 + return { + version: 2, + perMonitorLayouts: { + '0': { + currentLayout: 'grid-2x2', + customLayouts: data + } + } + }; + } } } } catch(e) { log('Error loading custom layouts: ' + e); } + return { + version: 2, + perMonitorLayouts: {} + }; +} + +function loadCustomLayoutsForMonitor(monitorIndex) { + let allData = loadCustomLayouts(); + if (allData.perMonitorLayouts && allData.perMonitorLayouts[monitorIndex]) { + return allData.perMonitorLayouts[monitorIndex].customLayouts || {}; + } return {}; } -function saveCustomLayouts(layouts) { +function saveCustomLayouts(allData) { try { // Create directory if it doesn't exist let dir = Gio.File.new_for_path(STORAGE_DIR); @@ -177,7 +260,7 @@ function saveCustomLayouts(layouts) { } let file = Gio.File.new_for_path(LAYOUTS_FILE); - let contents = JSON.stringify(layouts, null, 2); + let contents = JSON.stringify(allData, null, 2); file.replace_contents(contents, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); return true; } catch(e) { @@ -186,7 +269,7 @@ function saveCustomLayouts(layouts) { } } -function deleteLayout(layoutId, row, listBox) { +function deleteLayout(layoutId, row, listBox, monitorIndex) { let dialog = new Gtk.MessageDialog({ message_type: Gtk.MessageType.QUESTION, buttons: Gtk.ButtonsType.YES_NO, @@ -196,10 +279,16 @@ function deleteLayout(layoutId, row, listBox) { dialog.connect('response', function(dialog, response) { if (response === Gtk.ResponseType.YES) { - let layouts = loadCustomLayouts(); - delete layouts[layoutId]; + let allData = loadCustomLayouts(); - if (saveCustomLayouts(layouts)) { + // Delete from the specific monitor's layouts + if (allData.perMonitorLayouts && + allData.perMonitorLayouts[monitorIndex] && + allData.perMonitorLayouts[monitorIndex].customLayouts) { + delete allData.perMonitorLayouts[monitorIndex].customLayouts[layoutId]; + } + + if (saveCustomLayouts(allData)) { listBox.remove(row); // If no more layouts, show empty message @@ -210,7 +299,7 @@ function deleteLayout(layoutId, row, listBox) { activatable: false }); let emptyLabel = new Gtk.Label({ - label: 'No custom layouts yet', + label: 'No custom layouts yet for this monitor', use_markup: true, margin: 20 });