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();
+}