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 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 19:13:52 -07:00
parent eb7a57617e
commit 0d2db793c6
3 changed files with 796 additions and 397 deletions

12
TODO.md
View File

@@ -21,13 +21,13 @@
## Functionality Improvements ## Functionality Improvements
### 3. Multi-Monitor Support ### 3. Multi-Monitor Support ✅ COMPLETED
**Priority: High** **Priority: High**
- [ ] Currently only works on primary monitor (extension.js:61) - [x] Currently only works on primary monitor (extension.js:61)
- [ ] Need per-monitor zone configurations - [x] Need per-monitor zone configurations
- [ ] Detect which monitor window is on when snapping - [x] Detect which monitor window is on when snapping
- [ ] Allow zone editor to work on any monitor - [x] Allow zone editor to work on any monitor
- [ ] Store zone layouts per monitor - [x] Store zone layouts per monitor
### 4. High-DPI Display Support ### 4. High-DPI Display Support
**Priority: Medium** **Priority: Medium**

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,21 @@ function init() {
// Nothing to do here // 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() { function buildPrefsWidget() {
let frame = new Gtk.Box({ let frame = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL, orientation: Gtk.Orientation.VERTICAL,
@@ -36,9 +51,34 @@ function buildPrefsWidget() {
}); });
frame.pack_start(layoutsLabel, false, false, 5); frame.pack_start(layoutsLabel, false, false, 5);
// List of custom layouts // Monitor selector
let layoutsList = createLayoutsList(); let monitorBox = new Gtk.Box({
frame.pack_start(layoutsList, true, true, 0); 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 // Help text
let helpLabel = new Gtk.Label({ let helpLabel = new Gtk.Label({
@@ -62,7 +102,7 @@ function buildPrefsWidget() {
return frame; return frame;
} }
function createLayoutsList() { function createLayoutsList(monitorIndex, monitorCombo, layoutsListContainer) {
let scrolled = new Gtk.ScrolledWindow({ let scrolled = new Gtk.ScrolledWindow({
shadow_type: Gtk.ShadowType.IN, shadow_type: Gtk.ShadowType.IN,
min_content_height: 200 min_content_height: 200
@@ -73,8 +113,8 @@ function createLayoutsList() {
}); });
scrolled.add(listBox); scrolled.add(listBox);
// Load custom layouts // Load custom layouts for this monitor
let layouts = loadCustomLayouts(); let layouts = loadCustomLayoutsForMonitor(monitorIndex);
if (Object.keys(layouts).length === 0) { if (Object.keys(layouts).length === 0) {
let emptyRow = new Gtk.ListBoxRow({ let emptyRow = new Gtk.ListBoxRow({
@@ -82,7 +122,7 @@ function createLayoutsList() {
activatable: false activatable: false
}); });
let emptyLabel = new Gtk.Label({ let emptyLabel = new Gtk.Label({
label: '<i>No custom layouts yet</i>', label: '<i>No custom layouts yet for this monitor</i>',
use_markup: true, use_markup: true,
margin: 20 margin: 20
}); });
@@ -90,15 +130,31 @@ function createLayoutsList() {
listBox.add(emptyRow); listBox.add(emptyRow);
} else { } else {
for (let layoutId in layouts) { for (let layoutId in layouts) {
let row = createLayoutRow(layoutId, layouts[layoutId], listBox); let row = createLayoutRow(layoutId, layouts[layoutId], listBox, monitorIndex);
listBox.add(row); 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; return scrolled;
} }
function createLayoutRow(layoutId, layout, listBox) { function createLayoutRow(layoutId, layout, listBox, monitorIndex) {
let row = new Gtk.ListBoxRow(); let row = new Gtk.ListBoxRow();
let hbox = new Gtk.Box({ let hbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL, orientation: Gtk.Orientation.HORIZONTAL,
@@ -135,7 +191,7 @@ function createLayoutRow(layoutId, layout, listBox) {
}); });
deleteButton.get_style_context().add_class('destructive-action'); deleteButton.get_style_context().add_class('destructive-action');
deleteButton.connect('clicked', function() { deleteButton.connect('clicked', function() {
deleteLayout(layoutId, row, listBox); deleteLayout(layoutId, row, listBox, monitorIndex);
}); });
hbox.pack_end(deleteButton, false, false, 0); hbox.pack_end(deleteButton, false, false, 0);
@@ -159,16 +215,43 @@ function loadCustomLayouts() {
if (file.query_exists(null)) { if (file.query_exists(null)) {
let [success, contents] = file.load_contents(null); let [success, contents] = file.load_contents(null);
if (success) { 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) { } catch(e) {
log('Error loading custom layouts: ' + 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 {}; return {};
} }
function saveCustomLayouts(layouts) { function saveCustomLayouts(allData) {
try { try {
// Create directory if it doesn't exist // Create directory if it doesn't exist
let dir = Gio.File.new_for_path(STORAGE_DIR); 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 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); file.replace_contents(contents, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
return true; return true;
} catch(e) { } 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({ let dialog = new Gtk.MessageDialog({
message_type: Gtk.MessageType.QUESTION, message_type: Gtk.MessageType.QUESTION,
buttons: Gtk.ButtonsType.YES_NO, buttons: Gtk.ButtonsType.YES_NO,
@@ -196,10 +279,16 @@ function deleteLayout(layoutId, row, listBox) {
dialog.connect('response', function(dialog, response) { dialog.connect('response', function(dialog, response) {
if (response === Gtk.ResponseType.YES) { if (response === Gtk.ResponseType.YES) {
let layouts = loadCustomLayouts(); let allData = loadCustomLayouts();
delete layouts[layoutId];
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); listBox.remove(row);
// If no more layouts, show empty message // If no more layouts, show empty message
@@ -210,7 +299,7 @@ function deleteLayout(layoutId, row, listBox) {
activatable: false activatable: false
}); });
let emptyLabel = new Gtk.Label({ let emptyLabel = new Gtk.Label({
label: '<i>No custom layouts yet</i>', label: '<i>No custom layouts yet for this monitor</i>',
use_markup: true, use_markup: true,
margin: 20 margin: 20
}); });