Files
GridSnap/extension.js
ksmith 1681580bea Add graphical zone editor and update documentation
- Implemented visual zone editor (Super+Shift+E)
- Draw zones with mouse click-and-drag
- Ctrl+S to save, Ctrl+C to cancel, Delete to remove last zone
- Updated README with zone editor documentation
- Added CSS styles for editor interface
- Custom layouts are now created without code editing
2026-01-16 01:49:09 +00:00

608 lines
19 KiB
JavaScript

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;
let zoneManager;
// 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.currentZone = null;
this.startX = 0;
this.startY = 0;
this.isDragging = false;
},
startEditor: function() {
if (this.isEditing) return;
this.isEditing = true;
this.zones = [];
let monitor = Main.layoutManager.primaryMonitor;
// Create editor overlay
this.editorOverlay = new St.Widget({
style_class: 'gridsnap-editor-overlay',
reactive: true,
x: monitor.x,
y: monitor.y,
width: monitor.width,
height: monitor.height
});
this.editorOverlay.set_style(
'background-color: rgba(0, 0, 0, 0.5);'
);
// Add instruction label
this.instructionLabel = new St.Label({
text: 'GridSnap Zone Editor\n\nDrag to draw zones | Ctrl+S to save | Ctrl+C to cancel | Delete to clear last zone',
style_class: 'gridsnap-editor-instructions',
x: 20,
y: 20
});
this.instructionLabel.set_style(
'color: white;' +
'font-size: 18px;' +
'background-color: rgba(0, 0, 0, 0.8);' +
'padding: 15px;' +
'border-radius: 8px;'
);
this.editorOverlay.add_child(this.instructionLabel);
// Connect mouse events
this.buttonPressId = this.editorOverlay.connect('button-press-event',
Lang.bind(this, this._onButtonPress));
this.buttonReleaseId = this.editorOverlay.connect('button-release-event',
Lang.bind(this, this._onButtonRelease));
this.motionId = this.editorOverlay.connect('motion-event',
Lang.bind(this, this._onMotion));
Main.layoutManager.addChrome(this.editorOverlay);
Main.pushModal(this.editorOverlay);
// Setup key listener for save/cancel
this._setupEditorKeys();
},
_setupEditorKeys: function() {
this.keyPressId = this.editorOverlay.connect('key-press-event',
Lang.bind(this, function(actor, event) {
let symbol = event.get_key_symbol();
let state = event.get_state();
// Ctrl+S to save
if (symbol === Clutter.KEY_s && (state & Clutter.ModifierType.CONTROL_MASK)) {
this._saveLayout();
return Clutter.EVENT_STOP;
}
// Ctrl+C to cancel
if (symbol === Clutter.KEY_c && (state & Clutter.ModifierType.CONTROL_MASK)) {
this._cancelEditor();
return Clutter.EVENT_STOP;
}
// Delete to remove last zone
if (symbol === Clutter.KEY_Delete || symbol === Clutter.KEY_BackSpace) {
this._removeLastZone();
return Clutter.EVENT_STOP;
}
// Escape to cancel
if (symbol === Clutter.KEY_Escape) {
this._cancelEditor();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
})
);
},
_onButtonPress: function(actor, event) {
let [x, y] = event.get_coords();
let monitor = Main.layoutManager.primaryMonitor;
this.startX = x - monitor.x;
this.startY = y - monitor.y;
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);
return Clutter.EVENT_STOP;
},
_onMotion: function(actor, event) {
if (!this.isDragging || !this.currentZone) return Clutter.EVENT_PROPAGATE;
let [x, y] = event.get_coords();
let monitor = Main.layoutManager.primaryMonitor;
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);
return Clutter.EVENT_STOP;
},
_onButtonRelease: function(actor, event) {
if (!this.isDragging) return Clutter.EVENT_PROPAGATE;
this.isDragging = false;
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);
// Add zone number label
let label = new St.Label({
text: String(this.zones.length),
style_class: 'gridsnap-editor-zone-label',
x: 10,
y: 10
});
label.set_style(
'color: white;' +
'font-size: 24px;' +
'font-weight: bold;' +
'text-shadow: 2px 2px 4px rgba(0,0,0,0.8);'
);
this.currentZone.add_child(label);
} else {
// Remove if too small
this.currentZone.destroy();
}
this.currentZone = null;
}
return Clutter.EVENT_STOP;
},
_removeLastZone: function() {
if (this.zones.length > 0) {
this.zones.pop();
// Redraw editor
this._cancelEditor();
this.startEditor();
// Re-add existing zones
let monitor = Main.layoutManager.primaryMonitor;
this.zones.forEach((zone, index) => {
let zoneActor = new St.Widget({
style_class: 'gridsnap-editor-zone',
x: zone.x * monitor.width,
y: zone.y * monitor.height,
width: zone.width * monitor.width,
height: zone.height * monitor.height
});
zoneActor.set_style(
'border: 3px solid rgba(100, 255, 100, 0.9);' +
'background-color: rgba(100, 255, 100, 0.3);' +
'border-radius: 4px;'
);
let label = new St.Label({
text: String(index + 1),
x: 10,
y: 10
});
label.set_style(
'color: white;' +
'font-size: 24px;' +
'font-weight: bold;' +
'text-shadow: 2px 2px 4px rgba(0,0,0,0.8);'
);
zoneActor.add_child(label);
this.editorOverlay.add_child(zoneActor);
});
}
},
_saveLayout: function() {
if (this.zones.length === 0) {
Main.notify('GridSnap', 'No zones to save!');
this._cancelEditor();
return;
}
// Generate layout name
let layoutId = 'custom-' + Date.now();
let layoutName = 'Custom Layout (' + this.zones.length + ' zones)';
// Add to zoneManager's layouts
if (zoneManager) {
zoneManager.layouts[layoutId] = {
name: layoutName,
zones: this.zones
};
zoneManager.currentLayout = layoutId;
Main.notify('GridSnap', 'Layout saved: ' + layoutName);
// TODO: Persist to file for permanent storage
global.log('GridSnap: Layout saved - ' + JSON.stringify(zoneManager.layouts[layoutId]));
}
this._cancelEditor();
},
_cancelEditor: function() {
if (!this.isEditing) return;
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.currentZone = null;
}
};
function ZoneManager() {
this._init();
}
ZoneManager.prototype = {
_init: function() {
this.overlay = null;
this.currentLayout = 'grid-2x2';
this.layouts = DEFAULT_LAYOUTS;
this.isShowing = false;
this.dragInProgress = false;
this.editor = new ZoneEditor();
// 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));
},
_setupKeybindings: function() {
// Add keybinding to show zones overlay (Super+Z)
Main.keybindingManager.addHotKey(
'show-zones',
'<Super>z',
Lang.bind(this, this._toggleZonesOverlay)
);
// Add keybinding to open zone editor (Super+Shift+E)
Main.keybindingManager.addHotKey(
'open-zone-editor',
'<Super><Shift>e',
Lang.bind(this, function() { this.editor.startEditor(); })
);
// Add keybindings for quick snap to zones (Super+Numpad)
for (let i = 1; i <= 9; i++) {
Main.keybindingManager.addHotKey(
'snap-to-zone-' + i,
'<Super>KP_' + i,
Lang.bind(this, function() { this._snapToZone(i - 1); })
);
}
},
_onGrabBegin: function(display, window, op) {
// Check if this is a window move operation
if (op === Meta.GrabOp.MOVING) {
this.dragInProgress = true;
// Check if Shift is held - show overlay
let mods = global.get_pointer()[2];
if (mods & Clutter.ModifierType.SHIFT_MASK) {
this._showZonesOverlay();
}
}
},
_onGrabEnd: function(display, window, op) {
if (op === Meta.GrabOp.MOVING && this.dragInProgress) {
this.dragInProgress = false;
if (this.isShowing) {
// Snap to zone if cursor is over one
this._snapWindowToZone(window);
this._hideZonesOverlay();
}
}
},
_toggleZonesOverlay: function() {
if (this.isShowing) {
this._hideZonesOverlay();
} else {
this._showZonesOverlay();
}
},
_showZonesOverlay: function() {
if (this.isShowing) return;
let monitor = Main.layoutManager.primaryMonitor;
// Create overlay container
this.overlay = new St.Widget({
style_class: 'gridsnap-overlay',
reactive: false,
x: monitor.x,
y: monitor.y,
width: monitor.width,
height: monitor.height
});
// Add semi-transparent background
this.overlay.set_style(
'background-color: rgba(0, 0, 0, 0.3);'
);
// Draw zones
let layout = this.layouts[this.currentLayout];
layout.zones.forEach((zone, index) => {
let zoneActor = this._createZoneActor(zone, monitor, index);
this.overlay.add_child(zoneActor);
});
Main.layoutManager.addChrome(this.overlay);
this.isShowing = true;
},
_createZoneActor: function(zone, monitor, index) {
let x = monitor.width * zone.x;
let y = monitor.height * zone.y;
let width = monitor.width * zone.width;
let height = monitor.height * zone.height;
let actor = new St.Widget({
style_class: 'gridsnap-zone',
x: x,
y: y,
width: width,
height: height
});
actor.set_style(
'border: 2px solid rgba(100, 149, 237, 0.8);' +
'background-color: rgba(100, 149, 237, 0.2);' +
'border-radius: 4px;'
);
// Add zone number label
let label = new St.Label({
text: String(index + 1),
style_class: 'gridsnap-zone-label',
x: 10,
y: 10
});
label.set_style(
'color: white;' +
'font-size: 24px;' +
'font-weight: bold;' +
'text-shadow: 2px 2px 4px rgba(0,0,0,0.8);'
);
actor.add_child(label);
return actor;
},
_hideZonesOverlay: function() {
if (!this.isShowing) return;
if (this.overlay) {
Main.layoutManager.removeChrome(this.overlay);
this.overlay.destroy();
this.overlay = null;
}
this.isShowing = false;
},
_snapWindowToZone: function(window) {
if (!window) return;
let [x, y] = global.get_pointer();
let monitor = Main.layoutManager.primaryMonitor;
// Convert to relative coordinates
let relX = (x - monitor.x) / monitor.width;
let relY = (y - monitor.y) / monitor.height;
// Find which zone the cursor is in
let layout = this.layouts[this.currentLayout];
let targetZone = null;
for (let zone of layout.zones) {
if (relX >= zone.x && relX < zone.x + zone.width &&
relY >= zone.y && relY < zone.y + zone.height) {
targetZone = zone;
break;
}
}
if (targetZone) {
this._moveWindowToZone(window, targetZone, monitor);
}
},
_snapToZone: function(zoneIndex) {
// Snap the focused window to a specific zone
let window = global.display.focus_window;
if (!window) return;
let layout = this.layouts[this.currentLayout];
if (zoneIndex >= layout.zones.length) return;
let monitor = Main.layoutManager.primaryMonitor;
this._moveWindowToZone(window, layout.zones[zoneIndex], monitor);
},
_moveWindowToZone: function(window, zone, monitor) {
// Unmaximize if maximized
if (window.get_maximized()) {
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);
// Move and resize window
window.move_resize_frame(true, x, y, width, height);
},
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() {
// Disconnect signals
if (this.grabOpBeginId) {
global.display.disconnect(this.grabOpBeginId);
}
if (this.grabOpEndId) {
global.display.disconnect(this.grabOpEndId);
}
// Remove keybindings
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);
}
// Clean up overlay
this._hideZonesOverlay();
// Clean up editor
if (this.editor) {
this.editor._cancelEditor();
}
}
};
function init(metadata) {
// Extension initialization
}
function enable() {
zoneManager = new ZoneManager();
// Add keybinding to cycle layouts
Main.keybindingManager.addHotKey(
'cycle-layout',
'<Super><Shift>z',
Lang.bind(zoneManager, zoneManager.cycleLayout)
);
}
function disable() {
if (zoneManager) {
Main.keybindingManager.removeHotKey('cycle-layout');
zoneManager.destroy();
zoneManager = null;
}
}