From 095f5e8c56bb3546def6a3b74f4e1fdd67fa7427 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Wed, 17 Jun 2026 09:44:03 -0500 Subject: [PATCH 1/4] Add support for canvas layouts and blank spacer zones --- src/extension/editor.ts | 72 +++++++++++++++++++++++++--------------- src/extension/layouts.ts | 14 ++++---- 2 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/extension/editor.ts b/src/extension/editor.ts index 4df3644..d7b4df6 100644 --- a/src/extension/editor.ts +++ b/src/extension/editor.ts @@ -39,6 +39,9 @@ export class ZoneBase { } public contains(x: number, y: number, width: number = 1, height: number = 1): boolean { + if (this.layoutItem.blank) { + return false; + } return ( this.x <= x && this.y <= y && @@ -125,7 +128,14 @@ export class ZoneBase { public applyPercentages() { if (this.parent) { - if (this.parent.layoutItem.type == 0) { + if (this.parent.layoutItem.type == 2) { + let parentW = this.parent.width || 1; + let parentH = this.parent.height || 1; + this.layoutItem.x = ((this.x - this.parent.x) / parentW) * 100; + this.layoutItem.y = ((this.y - this.parent.y) / parentH) * 100; + this.layoutItem.w = (this.width / parentW) * 100; + this.layoutItem.h = (this.height / parentH) * 100; + } else if (this.parent.layoutItem.type == 0) { let factor = this.parent.width / this.width; this.layoutItem.length = 100 / factor; } else { @@ -221,6 +231,10 @@ export class Zone extends ZoneBase { } public show() { + if (this.layoutItem.blank) { + this.widget.visible = false; + return; + } this.widget.visible = true; (this.widget as any).ease({ opacity: 255, @@ -547,40 +561,41 @@ export class ZoneGroup extends ZoneBase { for (let i = 0; i < this.children.length; i++) { let child = this.children[i]; let item = child.layoutItem; - let factor = this.layoutItem.type == 0 ? this.width : this.height; - let length = (factor / 100) * item.length; - - let w = 0; - let h = 0; - if (this.layoutItem.type == 0) { - w = length; - h = this.height; + let cx: number, cy: number, cw: number, ch: number; + if (this.layoutItem.type == 2) { + cx = this.x + (this.width / 100) * (item.x ?? 0); + cy = this.y + (this.height / 100) * (item.y ?? 0); + cw = (this.width / 100) * (item.w ?? 100); + ch = (this.height / 100) * (item.h ?? 100); } else { - h = length; - w = this.width; + let factor = this.layoutItem.type == 0 ? this.width : this.height; + let length = (factor / 100) * item.length; + cx = x; + cy = y; + if (this.layoutItem.type == 0) { + cw = length; + ch = this.height; + x += length; + } else { + ch = length; + cw = this.width; + y += length; + } } if (child instanceof ZoneGroup) { child.layoutItem = item; - child.x = x; - child.y = y; - child.width = w; - child.height = h; + child.x = cx; + child.y = cy; + child.width = cw; + child.height = ch; (child).adjustLayout(root); } else { - child.x = x; - child.y = y; - child.width = w; - child.height = h; - } - - if (this.layoutItem.type == 0) { - x += length; - } else { - y += length; + child.x = cx; + child.y = cy; + child.width = cw; + child.height = ch; } } - - } public applyLayout(root: ZoneDisplay) { @@ -892,6 +907,9 @@ export class ZoneEditor extends ZoneDisplay { protected zoneGroupCreated(z: ZoneGroup) { super.zoneGroupCreated(z); + if (z.layoutItem.type === 2) { + return; + } // if (this.anchors == null) { this.anchors = []; diff --git a/src/extension/layouts.ts b/src/extension/layouts.ts index a695290..34e0b3c 100644 --- a/src/extension/layouts.ts +++ b/src/extension/layouts.ts @@ -5,7 +5,12 @@ export interface WorkspaceMonitorSettings { export interface LayoutItem { type: number, length: number, - items: LayoutItem[] + items: LayoutItem[], + x?: number, + y?: number, + w?: number, + h?: number, + blank?: boolean }; export interface Layout extends LayoutItem { @@ -20,17 +25,14 @@ export interface LayoutsSettings { export function cloneLayoutItem(layoutItem: LayoutItem) : LayoutItem { return { - type: layoutItem.type, - length: layoutItem.length, + ...layoutItem, items: layoutItem.items?.map(x => cloneLayoutItem(x)) ?? [], }; } export function cloneLayout(layout: Layout) : Layout { return { - name: layout.name, - type: layout.type, - length: layout.length, + ...layout, items: layout.items?.map(x => cloneLayoutItem(x)) ?? [], }; } \ No newline at end of file From 7011ab780807c7fe19da1f44404fb572c725cf26 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Wed, 17 Jun 2026 10:34:53 -0500 Subject: [PATCH 2/4] feat: implement canvas overlapping layouts, custom dropdown type selector, and double-click zone spawning --- README.md | 25 ++- src/extension/app.ts | 48 ++++-- src/extension/dialogs.ts | 160 +++++++++++++----- src/extension/editor.ts | 319 +++++++++++++++++++++++++++++++++++- src/extension/gnometypes.ts | 1 + 5 files changed, 481 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 7d1f81f..fa6a44a 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,24 @@ For configuration, please use the built-in preferences dialog (Gnome Tweak Tool * You can set the margins between the windows as well ## Edit Layouts: -To edit a layout follow the instructions below -* Click "Edit Layout" (will edit the currently applied layout). -* Left-Click to split the region -* Middle-Click to split with opposite orientation -* Right-Click to remove - -When done click "Stop Editing" +To edit a layout, click **"Edit Layout"** (this will edit the currently applied layout). + +### Grid Layouts +* **Left-Click** a zone to split it vertically. +* **Middle-Click** a zone to split it horizontally. +* **Right-Click** a zone to remove it. +* **Shift + Click** or **Ctrl + Click** a zone to toggle it between an active snapping zone and a blank spacer zone (invisible during normal snapping). + +### Canvas / Overlapping Layouts +* **Drag and drop** any zone to move it. +* **Drag zone edges or corners** to resize it. +* **Double-Click in empty space** to create a new zone. +* **Right-Click** a zone to remove it. +* **Shift + Click** or **Ctrl + Click** a zone to toggle it between an active snapping zone and a blank spacer zone (rendered with a dashed red outline as `[Spacer]`). +* **Left-Click** a zone to split it vertically. +* **Middle-Click** a zone to split it horizontally. + +When done, click **"Save Layout"** or **"Cancel Editing"**. Layouts are stored in the default config dir (usually `~/.config/gSnap`). This directory should be user-writable, to save the layouts changes. diff --git a/src/extension/app.ts b/src/extension/app.ts index d8bce64..8c58bd3 100644 --- a/src/extension/app.ts +++ b/src/extension/app.ts @@ -701,6 +701,7 @@ export default class App extends Extension { let dialog = new LayoutNameDialog( `Rename Layout ${currentMonitorLayout.name}`, currentMonitorLayout.name, + false, (text: string) => { currentMonitorLayout.name = text; this.saveLayouts(); @@ -713,19 +714,39 @@ export default class App extends Extension { let dialog = new LayoutNameDialog( `Create new layout`, 'New Layout', - (text: string) => { - this.layouts.definitions.push({ - name: text, - type: 0, - length: 100, - items: [ - { - type: 0, - length: 100, - items: [] - } - ] - }); + true, + (text: string, isCanvas: boolean) => { + if (isCanvas) { + this.layouts.definitions.push({ + name: text, + type: 2, + length: 100, + items: [ + { + type: 0, + length: 100, + x: 20, + y: 20, + w: 60, + h: 60, + items: [] + } + ] + }); + } else { + this.layouts.definitions.push({ + name: text, + type: 0, + length: 100, + items: [ + { + type: 0, + length: 100, + items: [] + } + ] + }); + } this.setLayout(this.layouts.definitions.length - 1); this.saveLayouts(); this.reloadMenu(); @@ -741,7 +762,6 @@ export default class App extends Extension { this.editor[m.index]?.destroy(); this.editor[m.index] = new ZoneEditor(activeMonitors()[m.index], editLayout, gridSettings[SETTINGS.WINDOW_MARGIN]); - this.editor[m.index]?.init(); this.editor[m.index]?.show(); }); diff --git a/src/extension/dialogs.ts b/src/extension/dialogs.ts index 67602fe..f38f45b 100644 --- a/src/extension/dialogs.ts +++ b/src/extension/dialogs.ts @@ -1,44 +1,118 @@ -// @ts-ignore -import * as Dialog from 'resource:///org/gnome/shell/ui/dialog.js'; -// @ts-ignore -import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js'; -// @ts-ignore -import St from 'gi://St'; -// @ts-ignore -import Clutter from 'gi://Clutter'; - - -export class LayoutNameDialog { - private _modalDialog: ModalDialog.ModalDialog; - private _okayCallback: (text: string) => void; - - constructor(title: string, layoutName: string, okayCallback: (text: string) => void) { - this._modalDialog = new ModalDialog.ModalDialog({}); - this._okayCallback = okayCallback; - - const listLayout = new Dialog.ListSection({ title }); - this._modalDialog.contentLayout.add_child(listLayout); - - const entry = new St.Entry({ text: layoutName }); - listLayout.add_child(entry); - - this._modalDialog.setButtons([ - { - label: "Ok", - key: Clutter.Return, - action: () => { - this._okayCallback(entry.text); - this._modalDialog.close(); - }, - }, - { - label: "Cancel", - key: Clutter.Escape, - action: () => this._modalDialog.close(), - }]); - } - - open() { - this._modalDialog.open(); - } +// @ts-ignore +import * as Dialog from 'resource:///org/gnome/shell/ui/dialog.js'; +// @ts-ignore +import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js'; +// @ts-ignore +import St from 'gi://St'; +// @ts-ignore +import Clutter from 'gi://Clutter'; +// @ts-ignore +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +// @ts-ignore +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + + +export class LayoutNameDialog { + private _modalDialog: ModalDialog.ModalDialog; + private _okayCallback: (text: string, isCanvas: boolean) => void; + private _menu: any = null; + private _menuManager: any = null; + + constructor(title: string, layoutName: string, showTypeSelection: boolean, okayCallback: (text: string, isCanvas: boolean) => void) { + this._modalDialog = new ModalDialog.ModalDialog({}); + this._okayCallback = okayCallback; + + const listLayout = new Dialog.ListSection({ title }); + this._modalDialog.contentLayout.add_child(listLayout); + + const entry = new St.Entry({ text: layoutName }); + listLayout.add_child(entry); + + let isCanvas = false; + if (showTypeSelection) { + const row = new St.BoxLayout({ + vertical: false, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + style: 'margin-top: 10px;' + }); + + const label = new St.Label({ + text: "Layout Type: ", + y_align: Clutter.ActorAlign.CENTER, + x_expand: true + }); + row.add_child(label); + + const dropdownButton = new St.Button({ + label: "Grid Layout ▾", + style_class: "button", + can_focus: true, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.CENTER + }); + dropdownButton.set_style('padding: 6px 12px; border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; min-width: 150px; text-align: center;'); + row.add_child(dropdownButton); + + this._menu = new PopupMenu.PopupMenu(dropdownButton, 0.5, St.Side.BOTTOM); + Main.uiGroup.add_child(this._menu.actor); + this._menu.actor.hide(); + + this._menuManager = new PopupMenu.PopupMenuManager(this); + this._menuManager.addMenu(this._menu); + + const gridItem = new PopupMenu.PopupMenuItem("Grid Layout"); + gridItem.connect('activate', () => { + isCanvas = false; + dropdownButton.label = "Grid Layout ▾"; + this._menu.close(); + }); + this._menu.addMenuItem(gridItem); + + const canvasItem = new PopupMenu.PopupMenuItem("Canvas Layout"); + canvasItem.connect('activate', () => { + isCanvas = true; + dropdownButton.label = "Canvas Layout ▾"; + this._menu.close(); + }); + this._menu.addMenuItem(canvasItem); + + dropdownButton.connect('clicked', () => { + this._menu.toggle(); + }); + + listLayout.add_child(row); + } + + this._modalDialog.setButtons([ + { + label: "Ok", + key: Clutter.Return, + action: () => { + this._okayCallback(entry.text, isCanvas); + this.destroyMenu(); + this._modalDialog.close(); + }, + }, + { + label: "Cancel", + key: Clutter.Escape, + action: () => { + this.destroyMenu(); + this._modalDialog.close(); + }, + }]); + } + + destroyMenu() { + if (this._menu) { + this._menu.destroy(); + this._menu = null; + } + this._menuManager = null; + } + + open() { + this._modalDialog.open(); + } } \ No newline at end of file diff --git a/src/extension/editor.ts b/src/extension/editor.ts index d7b4df6..ed3c5a1 100644 --- a/src/extension/editor.ts +++ b/src/extension/editor.ts @@ -448,25 +448,134 @@ function toRoundedString(val: number, places = 0) { } export class EditableZone extends Zone { + private dragMode: string | null = null; + private startMouseX: number = 0; + private startMouseY: number = 0; + private startZoneX: number = 0; + private startZoneY: number = 0; + private startZoneW: number = 0; + private startZoneH: number = 0; + private hasMovedSignificantDistance: boolean = false; + private motionId: number = 0; + private releaseId: number = 0; + constructor(layoutItem: LayoutItem, parent: ZoneGroup | null, stage: ClutterActor) { super(layoutItem, parent, stage); } + + public show() { + this.widget.visible = true; + (this.widget as any).ease({ + opacity: 255, + duration: ANIMATION_SPEED, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + this.widget.add_style_pseudo_class('activate'); + } + positionChanged() { super.positionChanged(); - this.widget.label = `${toRoundedString(this.innerWidth)}x${toRoundedString(this.innerHeight)}\n(${toRoundedString(this.layoutItem.length,2)}%)`; + this.updateLabel(); } sizeChanged() { super.sizeChanged(); - this.widget.label = `${toRoundedString(this.innerWidth)}x${toRoundedString(this.innerHeight)}\n(${toRoundedString(this.layoutItem.length,2)}%)`; + this.updateLabel(); } - // the CSS class "tile-preview" is declared by GNOME shell. It is used by - // GNOME edge-tiling system. More info here: https://github.com/GNOME/gnome-shell/blob/7c4b1d4ae62cfc6c5b0637819d465ce8968bd944/js/ui/windowManager.js#L388 - createWidget(styleClass: string = 'tile-preview grid-preview') { - this.widget = new St.Button({ style_class: styleClass }); - this.widget.connect('button-press-event', (_actor: ClutterActor, event: any) => { - var btn = event.get_button(); + private updateLabel() { + let sizeText = `${toRoundedString(this.innerWidth)}x${toRoundedString(this.innerHeight)}`; + let pctText = ''; + if (this.parent && this.parent.layoutItem.type === 2) { + let item = this.layoutItem; + pctText = `x:${toRoundedString(item.x ?? 0)}% y:${toRoundedString(item.y ?? 0)}%\nw:${toRoundedString(item.w ?? 100)}% h:${toRoundedString(item.h ?? 100)}%`; + } else { + pctText = `(${toRoundedString(this.layoutItem.length, 2)}%)`; + } + + if (this.layoutItem.blank) { + this.widget.label = `[Spacer]\n${sizeText}\n${pctText}`; + } else { + this.widget.label = `${sizeText}\n${pctText}`; + } + } + + private handleDragMotion() { + let [mouseX, mouseY] = global.get_pointer(); + let dx = mouseX - this.startMouseX; + let dy = mouseY - this.startMouseY; + + if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { + this.hasMovedSignificantDistance = true; + } + + if (!this.dragMode) return; + + if (this.dragMode === 'move') { + this.x = this.startZoneX + dx; + this.y = this.startZoneY + dy; + } else { + if (this.dragMode.indexOf('left') !== -1) { + let maxDx = this.startZoneW - this.minWidth; + if (dx > maxDx) { + this.startMouseX = mouseX - maxDx; + dx = maxDx; + } + this.x = this.startZoneX + dx; + this.width = this.startZoneW - dx; + } else if (this.dragMode.indexOf('right') !== -1) { + let minDx = this.minWidth - this.startZoneW; + if (dx < minDx) { + this.startMouseX = mouseX - minDx; + dx = minDx; + } + this.width = this.startZoneW + dx; + } + + if (this.dragMode.indexOf('top') !== -1) { + let maxDy = this.startZoneH - this.minHeight; + if (dy > maxDy) { + this.startMouseY = mouseY - maxDy; + dy = maxDy; + } + this.y = this.startZoneY + dy; + this.height = this.startZoneH - dy; + } else if (this.dragMode.indexOf('bottom') !== -1) { + let minDy = this.minHeight - this.startZoneH; + if (dy < minDy) { + this.startMouseY = mouseY - minDy; + dy = minDy; + } + this.height = this.startZoneH + dy; + } + } + + this.applyPercentages(); + if (this.parent) { + this.parent.adjustLayout(this.parent.root); + } + } + + private handleDragRelease(btn: number, isShift: boolean, isCtrl: boolean) { + if (this.motionId) { + global.stage.disconnect(this.motionId); + this.motionId = 0; + } + if (this.releaseId) { + this.widget.disconnect(this.releaseId); + this.releaseId = 0; + } + + this.dragMode = null; + this.widget.cursor_type = Clutter.CursorType.POINTER; + + if (!this.hasMovedSignificantDistance) { + if (isShift || isCtrl) { + this.layoutItem.blank = !this.layoutItem.blank; + this.parent?.root.reinit(); + return; + } + if (btn == 1) { log("Splitting"); this.parent?.split(this); @@ -479,6 +588,110 @@ export class EditableZone extends Zone { if (btn == 3) { this.parent?.remove(this); } + } else { + this.applyPercentages(); + this.parent?.root.reinit(); + } + } + + createWidget(styleClass: string = 'tile-preview grid-preview') { + this.widget = new St.Button({ style_class: styleClass }); + if (this.layoutItem.blank) { + this.widget.set_style('background-color: rgba(239, 68, 68, 0.2); border: 2px dashed rgba(239, 68, 68, 0.6);'); + } + + this.widget.cursor_type = Clutter.CursorType.POINTER; + + this.widget.connect('motion-event', (_actor: ClutterActor, event: any) => { + if (this.dragMode) return false; + + let [mouseX, mouseY] = global.get_pointer(); + let nearLeft = (mouseX - this.x) < 25; + let nearRight = ((this.x + this.width) - mouseX) < 25; + let nearTop = (mouseY - this.y) < 25; + let nearBottom = ((this.y + this.height) - mouseY) < 25; + + let cursor = Clutter.CursorType.POINTER; + if (this.parent && this.parent.layoutItem.type === 2) { + if ((nearTop && nearLeft) || (nearBottom && nearRight)) { + cursor = Clutter.CursorType.NWSE_RESIZE; + } else if ((nearTop && nearRight) || (nearBottom && nearLeft)) { + cursor = Clutter.CursorType.NESW_RESIZE; + } else if (nearTop || nearBottom) { + cursor = Clutter.CursorType.NS_RESIZE; + } else if (nearLeft || nearRight) { + cursor = Clutter.CursorType.EW_RESIZE; + } else { + cursor = Clutter.CursorType.MOVE; + } + } + this.widget.cursor_type = cursor; + return false; + }); + + this.widget.connect('button-press-event', (_actor: ClutterActor, event: any) => { + var btn = event.get_button(); + let state = event.get_state(); + let isShift = (state & 1) !== 0; + let isCtrl = (state & 4) !== 0; + + if (this.parent && this.parent.layoutItem.type === 2) { + let [mouseX, mouseY] = global.get_pointer(); + this.startMouseX = mouseX; + this.startMouseY = mouseY; + this.startZoneX = this.x; + this.startZoneY = this.y; + this.startZoneW = this.width; + this.startZoneH = this.height; + this.hasMovedSignificantDistance = false; + + let nearLeft = (mouseX - this.x) < 25; + let nearRight = ((this.x + this.width) - mouseX) < 25; + let nearTop = (mouseY - this.y) < 25; + let nearBottom = ((this.y + this.height) - mouseY) < 25; + + let mode = ''; + if (nearTop) mode += 'top'; + else if (nearBottom) mode += 'bottom'; + + if (nearLeft) mode += 'left'; + else if (nearRight) mode += 'right'; + + if (mode === '') { + mode = 'move'; + } + this.dragMode = mode; + + this.motionId = global.stage.connect('motion-event', () => { + this.handleDragMotion(); + return true; + }); + this.releaseId = this.widget.connect('button-release-event', () => { + this.handleDragRelease(btn, isShift, isCtrl); + return true; + }); + } else { + if (isShift || isCtrl) { + this.layoutItem.blank = !this.layoutItem.blank; + this.parent?.root.reinit(); + return true; + } + + if (btn == 1) { + log("Splitting"); + this.parent?.split(this); + } + + if (btn == 2) { + log("Splitting other direction"); + this.parent?.splitOtherDirection(this); + } + + if (btn == 3) { + this.parent?.remove(this); + } + } + return true; }); } } @@ -524,6 +737,29 @@ export class ZoneGroup extends ZoneBase { } public splitOtherDirection(zone: Zone) { + if (this.layoutItem.type == 2) { + let index = this.children.indexOf(zone); + let item = zone.layoutItem; + let currentW = item.w ?? 100; + let currentH = item.h ?? 100; + let currentX = item.x ?? 0; + let currentY = item.y ?? 0; + + item.h = currentH / 2; + + this.layoutItem.items.splice(index + 1, 0, { + type: 0, + length: 50, + x: currentX, + y: currentY + currentH / 2, + w: currentW, + h: currentH / 2, + items: [] + }); + this.root.reinit(); + return; + } + zone.layoutItem.items = []; const layoutType = this.layoutItem.type == 1 ? 0 : 1; @@ -538,6 +774,29 @@ export class ZoneGroup extends ZoneBase { } public split(zone: Zone) { + if (this.layoutItem.type == 2) { + let index = this.children.indexOf(zone); + let item = zone.layoutItem; + let currentW = item.w ?? 100; + let currentH = item.h ?? 100; + let currentX = item.x ?? 0; + let currentY = item.y ?? 0; + + item.w = currentW / 2; + + this.layoutItem.items.splice(index + 1, 0, { + type: 0, + length: 50, + x: currentX + currentW / 2, + y: currentY, + w: currentW / 2, + h: currentH, + items: [] + }); + this.root.reinit(); + return; + } + if(zone.layoutItem.type === 0 && Math.floor(zone.width / 2) < zone.minWidth) return; if(zone.layoutItem.type === 1 && Math.floor(zone.height / 2) < zone.minHeight) return; @@ -821,6 +1080,12 @@ export class ZoneDisplay extends ZoneGroup { this.width = this.workArea.width - (this.margin * 2); this.height = this.workArea.height - (this.margin * 2); + + this.stage.x = 0; + this.stage.y = 0; + this.stage.width = this.workArea.x + this.workArea.width; + this.stage.height = this.workArea.y + this.workArea.height; + this.applyLayout(this); this.adjustLayout(this); } @@ -861,6 +1126,8 @@ export class ZoneEditor extends ZoneDisplay { public motionConnection = null; public anchors: ZoneAnchor[]; public isMoving: boolean = false; + public stageClickId: number = 0; + public lastClickTime: number = 0; public createZone(layout: LayoutItem, parent: ZoneGroup) { @@ -889,10 +1156,46 @@ export class ZoneEditor extends ZoneDisplay { } this.apply(); }); + + if (this.layoutItem.type === 2) { + this.stage.reactive = true; + this.stageClickId = this.stage.connect('button-press-event', (_actor: any, event: any) => { + let btn = event.get_button(); + let clickTime = event.get_time(); + let diff = clickTime - this.lastClickTime; + this.lastClickTime = clickTime; + if (btn === 1 && diff < 250) { + let [mouseX, mouseY] = global.get_pointer(); + if (this.workArea) { + let w = 20; // Default size: 20% of work area + let h = 20; + let x = ((mouseX - this.margin - this.workArea.x) / this.width) * 100 - w / 2; + let y = ((mouseY - this.margin - this.workArea.y) / this.height) * 100 - h / 2; + + this.layoutItem.items.push({ + type: 0, + length: 50, + x: Math.max(0, Math.min(100 - w, x)), + y: Math.max(0, Math.min(100 - h, y)), + w: w, + h: h, + items: [] + }); + this.reinit(); + } + return true; + } + return false; + }); + } } public destroy() { global.stage.disconnect(this.motionConnection); + if (this.stageClickId) { + this.stage.disconnect(this.stageClickId); + this.stageClickId = 0; + } for (let i = 0; i < this.anchors.length; i++) { this.anchors[i].destroy(); } diff --git a/src/extension/gnometypes.ts b/src/extension/gnometypes.ts index ac586b4..09da50b 100644 --- a/src/extension/gnometypes.ts +++ b/src/extension/gnometypes.ts @@ -712,6 +712,7 @@ export interface StWidget extends ClutterActor { // set_hover(hover): someTYpe; // set_label_actor(label): someTYpe; set_style(style: string): void; + cursor_type: number; // set_style_class_name(style_class_list): someTYpe; // set_style_pseudo_class(pseudo_class_list): someTYpe; // set_track_hover(track_hover): someTYpe; From a85fdff349e8f94d8bdbb8fa2a7b148bc24d63a2 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Wed, 17 Jun 2026 10:45:22 -0500 Subject: [PATCH 3/4] chore: completely remove spacer zone feature --- README.md | 1 - src/extension/editor.ts | 21 +-------------------- src/extension/layouts.ts | 3 +-- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index fa6a44a..880014f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,6 @@ To edit a layout, click **"Edit Layout"** (this will edit the currently applied * **Drag zone edges or corners** to resize it. * **Double-Click in empty space** to create a new zone. * **Right-Click** a zone to remove it. -* **Shift + Click** or **Ctrl + Click** a zone to toggle it between an active snapping zone and a blank spacer zone (rendered with a dashed red outline as `[Spacer]`). * **Left-Click** a zone to split it vertically. * **Middle-Click** a zone to split it horizontally. diff --git a/src/extension/editor.ts b/src/extension/editor.ts index ed3c5a1..4bbd9bb 100644 --- a/src/extension/editor.ts +++ b/src/extension/editor.ts @@ -39,9 +39,6 @@ export class ZoneBase { } public contains(x: number, y: number, width: number = 1, height: number = 1): boolean { - if (this.layoutItem.blank) { - return false; - } return ( this.x <= x && this.y <= y && @@ -231,10 +228,6 @@ export class Zone extends ZoneBase { } public show() { - if (this.layoutItem.blank) { - this.widget.visible = false; - return; - } this.widget.visible = true; (this.widget as any).ease({ opacity: 255, @@ -493,11 +486,7 @@ export class EditableZone extends Zone { pctText = `(${toRoundedString(this.layoutItem.length, 2)}%)`; } - if (this.layoutItem.blank) { - this.widget.label = `[Spacer]\n${sizeText}\n${pctText}`; - } else { - this.widget.label = `${sizeText}\n${pctText}`; - } + this.widget.label = `${sizeText}\n${pctText}`; } private handleDragMotion() { @@ -571,8 +560,6 @@ export class EditableZone extends Zone { if (!this.hasMovedSignificantDistance) { if (isShift || isCtrl) { - this.layoutItem.blank = !this.layoutItem.blank; - this.parent?.root.reinit(); return; } @@ -596,10 +583,6 @@ export class EditableZone extends Zone { createWidget(styleClass: string = 'tile-preview grid-preview') { this.widget = new St.Button({ style_class: styleClass }); - if (this.layoutItem.blank) { - this.widget.set_style('background-color: rgba(239, 68, 68, 0.2); border: 2px dashed rgba(239, 68, 68, 0.6);'); - } - this.widget.cursor_type = Clutter.CursorType.POINTER; this.widget.connect('motion-event', (_actor: ClutterActor, event: any) => { @@ -672,8 +655,6 @@ export class EditableZone extends Zone { }); } else { if (isShift || isCtrl) { - this.layoutItem.blank = !this.layoutItem.blank; - this.parent?.root.reinit(); return true; } diff --git a/src/extension/layouts.ts b/src/extension/layouts.ts index 34e0b3c..bc3bae8 100644 --- a/src/extension/layouts.ts +++ b/src/extension/layouts.ts @@ -9,8 +9,7 @@ export interface LayoutItem { x?: number, y?: number, w?: number, - h?: number, - blank?: boolean + h?: number }; export interface Layout extends LayoutItem { From 61ecfe59b0a01a303b608c7587275312532e0e24 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Wed, 17 Jun 2026 10:57:47 -0500 Subject: [PATCH 4/4] feat: add overlap snapping strategy preference setting --- src/extension/editor.ts | 39 +++++++++++++++++- src/extension/prefs_builder.ts | 18 ++++++++ src/extension/settings.ts | 1 + src/extension/settings_data.ts | 6 +++ src/schemas/gschemas.compiled | Bin 3812 -> 3816 bytes ...g.gnome.shell.extensions.gsnap.gschema.xml | 5 +++ 6 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/extension/editor.ts b/src/extension/editor.ts index 4bbd9bb..e67e8c0 100644 --- a/src/extension/editor.ts +++ b/src/extension/editor.ts @@ -21,6 +21,8 @@ import { import { areEqual, getWorkAreaByMonitor, getTrackedWindowsOfMonitor, Monitor, WorkArea } from './monitors'; import { Layout, LayoutItem } from './layouts'; +import { gridSettings } from './settings'; +import * as SETTINGS from './settings_data'; const ANIMATION_SPEED = 100; // animation speed of zones' fade-in, fade-out, position and size changes @@ -1289,9 +1291,44 @@ export class ZoneManager extends ZoneDisplay { let [x, y] = global.get_pointer(); let children = this.recursiveChildren(); let smallestX = x, smallestY = y, biggestX = x, biggestY = y; + + let chosenZone: Zone | null = null; + if (this.layoutItem.type === 2) { + let matchingZones: Zone[] = []; + for (let i = 0; i < children.length; i++) { + let zone = (children[i] as Zone); + if (zone.contains(x, y)) { + matchingZones.push(zone); + } + } + if (matchingZones.length > 0) { + const strategy = gridSettings[SETTINGS.OVERLAP_STRATEGY] ?? 0; + if (strategy === 1) { // Closest to Center + let minDistance = Infinity; + for (let zone of matchingZones) { + let centerX = zone.x + zone.width / 2; + let centerY = zone.y + zone.height / 2; + let dist = Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2); + if (dist < minDistance) { + minDistance = dist; + chosenZone = zone; + } + } + } else { // First Match + chosenZone = matchingZones[0]; + } + } + } + for (let i = 0; i < children.length; i++) { let zone = (children[i] as Zone); - let contained = zone.contains(x, y); + let contained = false; + if (this.layoutItem.type === 2) { + contained = (zone === chosenZone); + } else { + contained = zone.contains(x, y); + } + zone.selected = contained || (this._select_multiple_zones && (zone as Zone).selected); if (!zone.selected) continue; diff --git a/src/extension/prefs_builder.ts b/src/extension/prefs_builder.ts index f08671e..0561a39 100644 --- a/src/extension/prefs_builder.ts +++ b/src/extension/prefs_builder.ts @@ -127,6 +127,12 @@ export default class GSnapPreferences extends ExtensionPreferences { "Window margins and invisible borders around screen.", ); + this.add_combo(group, SETTINGS.OVERLAP_STRATEGY, + "Overlap Snapping Strategy", + ["First Match", "Closest Center"], + "Decides which overlapping canvas zone a window snaps to when dropped." + ); + return group; } @@ -299,6 +305,18 @@ export default class GSnapPreferences extends ExtensionPreferences { group.add(row); } + add_combo(group: any, setting: string, title: string, options: string[], subtitle: string | null = null) { + const stringList = Gtk.StringList.new(options); + const combo = new Adw.ComboRow({ + title, + subtitle, + model: stringList, + }); + this.settings.bind(setting, combo, 'selected', Gio.SettingsBindFlags.DEFAULT); + group.add(combo); + return combo; + } + add_linkbutton(group: any, title: string, uri: string) { let button = new Gtk.LinkButton({ uri: uri, diff --git a/src/extension/settings.ts b/src/extension/settings.ts index da301cc..ed3d036 100644 --- a/src/extension/settings.ts +++ b/src/extension/settings.ts @@ -51,6 +51,7 @@ export function initSettings(settings: SettingsObject, changed_settings: () => v gridSettings[SETTINGS.SHOW_TABS] = getBoolSetting(SETTINGS.SHOW_TABS); gridSettings[SETTINGS.WINDOW_MARGIN] = getIntSetting(SETTINGS.WINDOW_MARGIN); gridSettings[SETTINGS.ANIMATIONS_ENABLED] = getBoolSetting(SETTINGS.ANIMATIONS_ENABLED); + gridSettings[SETTINGS.OVERLAP_STRATEGY] = getIntSetting(SETTINGS.OVERLAP_STRATEGY); log("Init complete"); } diff --git a/src/extension/settings_data.ts b/src/extension/settings_data.ts index 20b4c0a..ea6b791 100644 --- a/src/extension/settings_data.ts +++ b/src/extension/settings_data.ts @@ -25,6 +25,7 @@ export type NumberSettingName = ( "insets-secondary-left" | "insets-secondary-right" | "insets-secondary-top" | + "overlap-strategy" | "window-margin"); // A setting for a key binding i.e. a 'preset' in the app.ts code. @@ -86,6 +87,7 @@ export type AnySettingName = ( "move-focused-right" | "move-focused-up" | "moveresize-enabled" | + "overlap-strategy" | "preset-resize-1" | "preset-resize-10" | "preset-resize-11" | @@ -172,6 +174,9 @@ export class ParsedSettings { /** Enables shortcuts for moving and resizing the current window. */ ["moveresize-enabled"]: boolean = true; + /** Overlap Snapping Strategy */ + ["overlap-strategy"]: number = 0; + /** Preset resize 1. */ ["preset-resize-1"]: string[] = ['KP_1']; @@ -297,6 +302,7 @@ export const MOVE_FOCUSED_LEFT = "move-focused-left"; export const MOVE_FOCUSED_RIGHT = "move-focused-right"; export const MOVE_FOCUSED_UP = "move-focused-up"; export const MOVERESIZE_ENABLED = "moveresize-enabled"; +export const OVERLAP_STRATEGY = "overlap-strategy"; export const PRESET_RESIZE_1 = "preset-resize-1"; export const PRESET_RESIZE_10 = "preset-resize-10"; export const PRESET_RESIZE_11 = "preset-resize-11"; diff --git a/src/schemas/gschemas.compiled b/src/schemas/gschemas.compiled index f6ce4a2c7913e172fec906c7a878a523b2361b78..5e6a44275f1f8dad22fb8c3482f9dad1ae529bbc 100644 GIT binary patch literal 3816 zcmai1TWl0%7#%N#UMRGdOA!MV1$Lpc+itttT}1-%LX9Yu211NPrtEZglHHlf&Xl&G zhKMGZn81UHMPuv>2}U9s6I9e#6Cdh@#PDDQV@!lZo=hKk5j!;iOj!sX@lAX_|-+4*04g;ZsWSP6o$4- zyAHkqIHg=HXFEkR=H<;oA!bhartMjd?ZtAQZMXe}qqzW! z!Hqx@&v2cwJHN?;YR8dw9Y1-gLszy@F=unCC#3!?Ce&4O-l42T0g zKrhe-^aDvC1zdmkjpuYQ=cz+GtWANZft@Gz{Y9U;4gN>qPl53@Yi#<|?eJ&8SAoVO z#|G(BuYf-Xz6pH)?7ARN&3qdsWg})s< z2>d+Re2G3a=XpQ)0I>GE3&D7)>AT^3=?y!MlOWskvd!5A`bed9VvSEO~0? zkAdF;wu(Hh@&6L{vmTko(w^%D3u!G8lkUcPXRJ~f~7wk4X@2|O|T z?fdkp>Gyz>K!c2nn&TP-?*S&Rg>s1$c1JELQYUb16VPJ{))b#V<<3OwU z)byvop8!k6r=~v(=DuOsp6PqKTe6BqX;Lqkqp}@Etf*Tu@kbs1T1QO1uLqk&#PFg; z=dh`dI)}zQGplEvaeM3Vm}{1{JqC$;#fVh(%tMZyt*g7BK&3@Z>g-}47SsuQH{$rd zQ{;`oi*7}|yfdy_Xrk<}dm~Z9#z=f_cDiXBBLy>C_UpP-vU2(Ptv9HbD>x%Y0dv7& zd)ymQZ$W3vzmb0K2@13Z0}i_48zcCY{H|tVYg!B1%bFu&xuEZ8r>=j#!;O0W!g$9m z8`G`hM&+z<%$x^w_^@gFI`?+hvQZNCaFa>}!__^%WcX%oGAxArf+3B$;k@s4v7__u zExhlE@V?jBz2Wb+4(F{=f7??-FZANkRTM}_+cIyJd_tY)Ue2p7->1%V-{)1A?^ot~ z!`zO7^txTu>tubsq_SS3rd|RE0>vz(>g%PH_4;b+^+~;$YxsGNGmZmZJUHORg9Bc? za=?oR2Rs~%yqdek^IlkY{(cQ8+wTu^GC%$5@uchPrIq!PHT9C}dYSrq8D+gxO}&)V zt9yg0mkW7Ms@AiC`SxU-1Im7-EAu9~v@(~e%Vi{oby6=nS!>iXOT2x!D(`!^w9UWm z@_x#q8!lnNuY&bx;eGAp`B&EEn!i-u&qp2GFFA$s8@eKRHZ>miu=U_f$a6;d;+7W$ z9t8L;fTL4zw&%A2zXxzMz}{Luhx44@32Xtnff(>#=ka^-_YPn=@PFR~_aVdYl6AoS zzym<_cLKjPaO@7gJ-D_+fMr0<1kAseRpU>f@%neOx4hzn&s9~vPn|EXIEs9~I?u0? z%JWfGKPh?7HEg{&R`4yiVCqL4+YELRJSR1S;iZBKcLRzVOQ5rtMhp1 L)%lF%gTMa+xIIxs literal 3812 zcmaKvU2GIp9K{Ev&=v}m@+pc11-6#m((Sh*+8}}vm5)doqmf-Y?M||tS$DRykYE~( zu@5Hl;6p_XB!ol~V&FvyO2kAXF_I8W4DkttL=zLiH(o@4XXb9ZyPdkp={Y%b=FXiv zcmDJ5_I;_mlX44|`K^HaLLIx$c(dTI_B$KII@W0Iutwp`9LuU&YFYpCyRiVn+G&Yz z0WUw87<9cMJK<;SY&Kz!7HrpdJl9VQ`fe&mIM%6C_~|-D%tluMhPLK{YA_GX2fD;U zu&4y}=*0#Nu&@L$Eo&KA4pxFy;6bn&G=en-YhmFL@F-XZ)`JZo2AaTQpcy1U3)lo& zLHi8oz|XAyrAgyyjq0p})+js%KKtf_mGY&R;!nUQK-KuZzvN3V!aoat0WR(tP0E*E zjDHor3Fer*wDNc11{Q3t@ulU*;H{wA_|o$GU?0>NUt0b+d=fl+`P-@{4$&%^>&oxp$P8*7#4u+rWI2mk#C$`=G8k zPveinC&2>aODlgK{t0Y1zO?*5;M*W~;fky4N-x2$uHiVi^~rm$$d_)wkHMS3wazp+hgM_S`_!!LoyjV~>KKRg1KuYLcF#+TMQ9EB&q6DBXM z{HO5OV6*Y1wfOF8J|W-r>-pc-kuzyrGigxx7sv&R^%S9dF%a&9Y)CziG%jXvYV< z1H-=EAJ033nc@d*^WNc{o!_!2u|slGcA_tjy>@iROC=YSV1f7JQ< z9!H$tY0gi%Jfng>;qm{UM_2SXUD5a7O*-4;nesi6`8Iv6`Gxz&Y&YL#8Tz&1 z3Z7aK^P9dOv-Uu7XOWpYJ~cq;j^%j8=9yg2-fSVrpQ+_d{IyFun3Y?4AM?VBZu*eBX%mE(6r&VvnAF^|sjc@$s!(lMJN)0U~mv?XXvY4H5=iw7I7V%#1xZdo0o zz_}*(43c`OM>v@E(y1&JPKC@5*0b!a@Clgnc51(c=Y>6QGjmpds@_|Du6kHi4E4Dy zfF6c=VD+==Vebd3ShP1mAFRH5HYn|>X(@u9noovBX{O$X{qjt`??#R%K(Y5#U#%Wo zz4Ze?z4b$&*juZ@(gp;5cN`8YBE45xEl-9oX82xK9BQ3>j(aoCfO&HoBHoB(Se>Vy z`(!@J5>0=DlEt|cpXX%0Ejk}o=ZNv!qw~Sb!97nKzr*DF?ey> z^K&UTJ~W&yIJvAHKkT`-c}1sv2a=`rcXFJr==p}#H)6is(fP3YM&x^@@&|3V5Z6C> zImZodRky4AE!+3QLE$y)^m2|JqsPSuJwD|1I|GiL4^Gp06-~lq&5o+arKN=98sk6P C(nogy diff --git a/src/schemas/org.gnome.shell.extensions.gsnap.gschema.xml b/src/schemas/org.gnome.shell.extensions.gsnap.gschema.xml index 689a65b..95efaf7 100644 --- a/src/schemas/org.gnome.shell.extensions.gsnap.gschema.xml +++ b/src/schemas/org.gnome.shell.extensions.gsnap.gschema.xml @@ -205,6 +205,11 @@ 0 Bottom gap around border of screen for secondary monitor + + 0 + Overlap Snapping Strategy + 0 for First Match, 1 for Closest to Center + false Put debug lines into global.log. To see, run journalctl /usr/bin/gnome-shell -f in terminal