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 <noreply@anthropic.com>
1681 lines
57 KiB
JavaScript
1681 lines
57 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;
|
||
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',
|
||
'<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.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,
|
||
'<Super><Ctrl>' + 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',
|
||
'<Super><Shift>z',
|
||
Lang.bind(zoneManager, zoneManager.cycleLayout)
|
||
);
|
||
}
|
||
|
||
function disable() {
|
||
if (zoneManager) {
|
||
Main.keybindingManager.removeHotKey('cycle-layout');
|
||
zoneManager.destroy();
|
||
zoneManager = null;
|
||
}
|
||
}
|