Add zone resizing to graphical editor

Implemented comprehensive zone resizing functionality in the zone editor:

Features:
- Detects when mouse is near zone edges/corners (10px threshold)
- Supports all 8 resize handles: N, S, E, W, NE, NW, SE, SW
- Visual feedback with cyan highlight during resize
- Real-time dimension label updates while resizing
- Enforces minimum zone size (20x20 pixels)
- Constrains resizing to monitor boundaries
- Maintains zone data structure integrity during resize

Implementation details:
- Added state tracking: isResizingZone, resizingZoneIndex, resizeEdge, originalZoneBounds
- New method _findResizeEdge() for edge/corner detection
- Enhanced _onButtonPress() to prioritize resize over move
- Extended _onMotion() with resize delta calculations for all 8 directions
- Updated instruction label to include resize guidance
- Updated TODO.md to mark item #17 as completed

The zone editor now supports create, move, and resize operations with
clear visual feedback for each mode (green=normal, yellow=moving, cyan=resizing).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 21:28:25 -07:00
parent 5cef19690a
commit 83a6a1ee8e
2 changed files with 213 additions and 13 deletions

12
TODO.md
View File

@@ -60,6 +60,18 @@
- [x] Maintain zone size while moving (only change position) - [x] Maintain zone size while moving (only change position)
- [ ] Snap to grid or guides (future enhancement) - [ ] Snap to grid or guides (future enhancement)
### 17. Resize Zones in Editor ✅ COMPLETED
**Priority: High**
- [x] Detect mouse near edges and corners of zones (10px threshold)
- [x] Allow dragging edges to resize zones horizontally or vertically
- [x] Allow dragging corners to resize zones diagonally
- [x] Visual feedback when zone is being resized (cyan highlight)
- [x] Show dimension label during resize with real-time updates
- [x] Enforce minimum zone size (20x20 pixels)
- [x] Constrain resize to monitor bounds
- [x] Update zone data structure in real-time during resize
- [x] Support all 8 resize handles (N, S, E, W, NE, NW, SE, SW)
## Code Quality & Robustness ## Code Quality & Robustness
### 6. Comprehensive Error Handling ### 6. Comprehensive Error Handling

View File

@@ -62,6 +62,10 @@ ZoneEditor.prototype = {
this.movingZoneIndex = -1; this.movingZoneIndex = -1;
this.moveOffsetX = 0; this.moveOffsetX = 0;
this.moveOffsetY = 0; this.moveOffsetY = 0;
this.isResizingZone = false;
this.resizingZoneIndex = -1;
this.resizeEdge = null; // 'n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'
this.originalZoneBounds = null;
}, },
startEditor: function() { startEditor: function() {
@@ -88,7 +92,7 @@ ZoneEditor.prototype = {
// Add instruction label // Add instruction label
this.instructionLabel = new St.Label({ this.instructionLabel = new St.Label({
text: 'GridSnap Zone Editor\n\nDrag to draw zones | Click zone to move | Ctrl+S to save | Ctrl+C to cancel | Delete to clear last zone', 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', style_class: 'gridsnap-editor-instructions',
x: 20, x: 20,
y: 20 y: 20
@@ -159,25 +163,65 @@ ZoneEditor.prototype = {
this.startX = x - monitor.x; this.startX = x - monitor.x;
this.startY = y - monitor.y; this.startY = y - monitor.y;
// Check if clicking on an existing zone to move it // Check if clicking on a resize edge first (higher priority)
let clickedZoneIndex = this._findZoneAtPosition(this.startX, this.startY); let resizeInfo = this._findResizeEdge(this.startX, this.startY);
if (clickedZoneIndex >= 0) { if (resizeInfo) {
// Start moving existing zone // Start resizing zone
this.isMovingZone = true; this.isResizingZone = true;
this.movingZoneIndex = clickedZoneIndex; this.resizingZoneIndex = resizeInfo.zoneIndex;
this.resizeEdge = resizeInfo.edge;
let zoneActor = this.zoneActors[clickedZoneIndex]; let zoneActor = this.zoneActors[resizeInfo.zoneIndex];
this.moveOffsetX = this.startX - zoneActor.x;
this.moveOffsetY = this.startY - zoneActor.y;
// Highlight zone being moved // 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( zoneActor.set_style(
'border: 3px solid rgba(255, 255, 100, 0.9);' + 'border: 3px solid rgba(100, 255, 255, 0.9);' +
'background-color: rgba(255, 255, 100, 0.4);' + 'background-color: rgba(100, 255, 255, 0.4);' +
'border-radius: 4px;' '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 { } 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 // Start creating new zone
this.isDragging = true; this.isDragging = true;
@@ -225,11 +269,126 @@ ZoneEditor.prototype = {
} }
return -1; 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) { _onMotion: function(actor, event) {
let [x, y] = event.get_coords(); let [x, y] = event.get_coords();
let monitor = Main.layoutManager.primaryMonitor; 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) { if (this.isMovingZone) {
// Moving existing zone // Moving existing zone
let newX = x - monitor.x - this.moveOffsetX; let newX = x - monitor.x - this.moveOffsetX;
@@ -289,6 +448,31 @@ ZoneEditor.prototype = {
}, },
_onButtonRelease: function(actor, event) { _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) { if (this.isMovingZone) {
// Finish moving zone // Finish moving zone
let zoneActor = this.zoneActors[this.movingZoneIndex]; let zoneActor = this.zoneActors[this.movingZoneIndex];
@@ -471,6 +655,10 @@ ZoneEditor.prototype = {
this.currentZone = null; this.currentZone = null;
this.isMovingZone = false; this.isMovingZone = false;
this.movingZoneIndex = -1; this.movingZoneIndex = -1;
this.isResizingZone = false;
this.resizingZoneIndex = -1;
this.resizeEdge = null;
this.originalZoneBounds = null;
} }
}; };