diff --git a/TODO.md b/TODO.md index 95a0502..860d17f 100644 --- a/TODO.md +++ b/TODO.md @@ -88,21 +88,24 @@ - [ ] Animate zone overlay appearance/disappearance - [ ] Visual feedback when window snaps to zone -### 12. Settings Panel +### 12. Settings Panel ✅ COMPLETED **Priority: Medium** -- [ ] Add Cinnamon settings panel for the extension -- [ ] Allow customization of keybindings -- [ ] Configure animation speeds -- [ ] Toggle features on/off -- [ ] Manage saved layouts (delete, rename, reorder) +- [x] Add Cinnamon settings panel for the extension +- [x] Toggle features on/off (shift-drag, keyboard snap) +- [x] Manage saved layouts (view, delete, export) +- [x] Customize zone appearance (colors, border width, opacity) +- [x] Configure zone number visibility +- [x] Enable/disable snap notifications +- [ ] Allow customization of keybindings (future enhancement) +- [ ] Configure animation speeds (when animations added) ## Documentation -### 13. Update Metadata +### 13. Update Metadata ✅ COMPLETED **Priority: Low** -- [ ] Add "url" property to metadata.json (currently shows warning) -- [ ] Update author name from "Your Name" to "Keith Smith" -- [ ] Add project URL/repository link +- [x] Add "url" property to metadata.json +- [x] Update author name to "Keith Smith" +- [x] Add project URL/repository link ## Testing diff --git a/extension.js b/extension.js index 860848d..4181836 100644 --- a/extension.js +++ b/extension.js @@ -342,6 +342,9 @@ ZoneManager.prototype = { this.draggedWindow = null; this.editor = new ZoneEditor(); + // Initialize settings + this.settings = new Settings.ExtensionSettings(this, 'gridsnap@cinnamon-extension'); + // Load layouts (default + saved custom layouts) this._loadLayouts(); @@ -446,6 +449,11 @@ ZoneManager.prototype = { 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); @@ -537,7 +545,17 @@ ZoneManager.prototype = { 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, @@ -545,28 +563,30 @@ ZoneManager.prototype = { 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: ' + borderWidth + 'px solid ' + borderColor + ';' + + 'background-color: ' + fillColorWithOpacity + ';' + '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); - + + // 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; }, @@ -606,10 +626,20 @@ ZoneManager.prototype = { if (targetZone) { this._moveWindowToZone(window, 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; @@ -619,6 +649,11 @@ ZoneManager.prototype = { let monitor = Main.layoutManager.primaryMonitor; this._moveWindowToZone(window, layout.zones[zoneIndex], monitor); + + // Show notification if enabled + if (this.settings.getValue('notification-on-snap')) { + Main.notify('GridSnap', 'Window snapped to zone ' + (zoneIndex + 1)); + } }, _moveWindowToZone: function(window, zone, monitor) { @@ -674,6 +709,12 @@ ZoneManager.prototype = { 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); diff --git a/settings-schema.json b/settings-schema.json new file mode 100644 index 0000000..8164136 --- /dev/null +++ b/settings-schema.json @@ -0,0 +1,57 @@ +{ + "show-zone-numbers": { + "type": "checkbox", + "default": true, + "description": "Show zone numbers in overlay", + "tooltip": "Display zone numbers when the overlay is visible" + }, + "zone-border-width": { + "type": "spinbutton", + "default": 2, + "min": 1, + "max": 10, + "step": 1, + "units": "pixels", + "description": "Zone border width", + "tooltip": "Width of the zone border in the overlay" + }, + "zone-opacity": { + "type": "scale", + "default": 20, + "min": 0, + "max": 100, + "step": 5, + "description": "Zone overlay opacity", + "tooltip": "Opacity of zone fill color (percentage)" + }, + "enable-shift-drag": { + "type": "checkbox", + "default": true, + "description": "Enable Shift+Drag snapping", + "tooltip": "Show zones and snap windows when dragging with Shift held" + }, + "enable-keyboard-snap": { + "type": "checkbox", + "default": true, + "description": "Enable keyboard snapping (Super+Ctrl+1-9)", + "tooltip": "Allow snapping windows to zones using keyboard shortcuts" + }, + "notification-on-snap": { + "type": "checkbox", + "default": false, + "description": "Show notification when window snaps", + "tooltip": "Display a notification when a window is snapped to a zone" + }, + "zone-border-color": { + "type": "colorchooser", + "default": "rgba(100, 149, 237, 0.8)", + "description": "Zone border color", + "tooltip": "Color of the zone borders" + }, + "zone-fill-color": { + "type": "colorchooser", + "default": "rgba(100, 149, 237, 0.2)", + "description": "Zone fill color", + "tooltip": "Color of the zone fill" + } +} diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..6f86986 --- /dev/null +++ b/settings.js @@ -0,0 +1,280 @@ +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const Gtk = imports.gi.Gtk; +const Lang = imports.lang; + +const STORAGE_DIR = GLib.get_user_data_dir() + '/gridsnap'; +const LAYOUTS_FILE = STORAGE_DIR + '/layouts.json'; + +function init() { + // Nothing to do here +} + +function buildPrefsWidget() { + let frame = new Gtk.Box({ + orientation: Gtk.Orientation.VERTICAL, + margin: 20, + spacing: 10 + }); + + // Add title + let title = new Gtk.Label({ + label: 'GridSnap Settings', + use_markup: true, + xalign: 0 + }); + frame.pack_start(title, false, false, 0); + + // Add separator + frame.pack_start(new Gtk.Separator({orientation: Gtk.Orientation.HORIZONTAL}), false, false, 10); + + // Custom Layouts Section + let layoutsLabel = new Gtk.Label({ + label: 'Custom Layouts', + use_markup: true, + xalign: 0 + }); + frame.pack_start(layoutsLabel, false, false, 5); + + // List of custom layouts + let layoutsList = createLayoutsList(); + frame.pack_start(layoutsList, true, true, 0); + + // Help text + let helpLabel = new Gtk.Label({ + label: 'Use Super+Shift+E to create new layouts\nUse Super+Shift+Z to cycle through layouts', + use_markup: true, + xalign: 0, + margin_top: 10 + }); + frame.pack_start(helpLabel, false, false, 0); + + // Storage location info + let storageLabel = new Gtk.Label({ + label: 'Layouts stored in: ' + LAYOUTS_FILE + '', + use_markup: true, + xalign: 0, + margin_top: 5 + }); + frame.pack_start(storageLabel, false, false, 0); + + frame.show_all(); + return frame; +} + +function createLayoutsList() { + let scrolled = new Gtk.ScrolledWindow({ + shadow_type: Gtk.ShadowType.IN, + min_content_height: 200 + }); + + let listBox = new Gtk.ListBox({ + selection_mode: Gtk.SelectionMode.NONE + }); + scrolled.add(listBox); + + // Load custom layouts + let layouts = loadCustomLayouts(); + + if (Object.keys(layouts).length === 0) { + let emptyRow = new Gtk.ListBoxRow({ + selectable: false, + activatable: false + }); + let emptyLabel = new Gtk.Label({ + label: 'No custom layouts yet', + use_markup: true, + margin: 20 + }); + emptyRow.add(emptyLabel); + listBox.add(emptyRow); + } else { + for (let layoutId in layouts) { + let row = createLayoutRow(layoutId, layouts[layoutId], listBox); + listBox.add(row); + } + } + + return scrolled; +} + +function createLayoutRow(layoutId, layout, listBox) { + let row = new Gtk.ListBoxRow(); + let hbox = new Gtk.Box({ + orientation: Gtk.Orientation.HORIZONTAL, + spacing: 10, + margin: 10 + }); + + // Layout info + let vbox = new Gtk.Box({ + orientation: Gtk.Orientation.VERTICAL, + spacing: 5 + }); + + let nameLabel = new Gtk.Label({ + label: '' + GLib.markup_escape_text(layout.name, -1) + '', + use_markup: true, + xalign: 0 + }); + vbox.pack_start(nameLabel, false, false, 0); + + let infoLabel = new Gtk.Label({ + label: '' + layout.zones.length + ' zones | ID: ' + layoutId + '', + use_markup: true, + xalign: 0 + }); + vbox.pack_start(infoLabel, false, false, 0); + + hbox.pack_start(vbox, true, true, 0); + + // Delete button + let deleteButton = new Gtk.Button({ + label: 'Delete', + valign: Gtk.Align.CENTER + }); + deleteButton.get_style_context().add_class('destructive-action'); + deleteButton.connect('clicked', function() { + deleteLayout(layoutId, row, listBox); + }); + hbox.pack_end(deleteButton, false, false, 0); + + // Export button + let exportButton = new Gtk.Button({ + label: 'Export', + valign: Gtk.Align.CENTER + }); + exportButton.connect('clicked', function() { + exportLayout(layoutId, layout); + }); + hbox.pack_end(exportButton, false, false, 0); + + row.add(hbox); + return row; +} + +function loadCustomLayouts() { + try { + let file = Gio.File.new_for_path(LAYOUTS_FILE); + if (file.query_exists(null)) { + let [success, contents] = file.load_contents(null); + if (success) { + return JSON.parse(contents.toString()); + } + } + } catch(e) { + log('Error loading custom layouts: ' + e); + } + return {}; +} + +function saveCustomLayouts(layouts) { + 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); + } + + let file = Gio.File.new_for_path(LAYOUTS_FILE); + let contents = JSON.stringify(layouts, null, 2); + file.replace_contents(contents, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); + return true; + } catch(e) { + log('Error saving custom layouts: ' + e); + return false; + } +} + +function deleteLayout(layoutId, row, listBox) { + let dialog = new Gtk.MessageDialog({ + message_type: Gtk.MessageType.QUESTION, + buttons: Gtk.ButtonsType.YES_NO, + text: 'Delete this layout?', + secondary_text: 'This action cannot be undone.' + }); + + dialog.connect('response', function(dialog, response) { + if (response === Gtk.ResponseType.YES) { + let layouts = loadCustomLayouts(); + delete layouts[layoutId]; + + if (saveCustomLayouts(layouts)) { + listBox.remove(row); + + // If no more layouts, show empty message + let children = listBox.get_children(); + if (children.length === 0) { + let emptyRow = new Gtk.ListBoxRow({ + selectable: false, + activatable: false + }); + let emptyLabel = new Gtk.Label({ + label: 'No custom layouts yet', + use_markup: true, + margin: 20 + }); + emptyRow.add(emptyLabel); + listBox.add(emptyRow); + emptyRow.show_all(); + } + } + } + dialog.destroy(); + }); + + dialog.show(); +} + +function exportLayout(layoutId, layout) { + let dialog = new Gtk.FileChooserDialog({ + title: 'Export Layout', + action: Gtk.FileChooserAction.SAVE + }); + + dialog.add_button('Cancel', Gtk.ResponseType.CANCEL); + dialog.add_button('Save', Gtk.ResponseType.ACCEPT); + dialog.set_do_overwrite_confirmation(true); + dialog.set_current_name(layoutId + '.json'); + + let filter = new Gtk.FileFilter(); + filter.set_name('JSON files'); + filter.add_pattern('*.json'); + dialog.add_filter(filter); + + dialog.connect('response', function(dialog, response) { + if (response === Gtk.ResponseType.ACCEPT) { + try { + let file = Gio.File.new_for_path(dialog.get_filename()); + let exportData = {}; + exportData[layoutId] = layout; + let contents = JSON.stringify(exportData, null, 2); + file.replace_contents(contents, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); + + let successDialog = new Gtk.MessageDialog({ + message_type: Gtk.MessageType.INFO, + buttons: Gtk.ButtonsType.OK, + text: 'Layout exported successfully!' + }); + successDialog.connect('response', function() { + successDialog.destroy(); + }); + successDialog.show(); + } catch(e) { + let errorDialog = new Gtk.MessageDialog({ + message_type: Gtk.MessageType.ERROR, + buttons: Gtk.ButtonsType.OK, + text: 'Error exporting layout', + secondary_text: e.toString() + }); + errorDialog.connect('response', function() { + errorDialog.destroy(); + }); + errorDialog.show(); + } + } + dialog.destroy(); + }); + + dialog.show(); +}