diff --git a/README.md b/README.md index 76d833b..88fb671 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,15 @@ Open Cinnamon Extensions, click on the Fancy Tiles extension and click the '+' b ## Quick start -After enabling the extension, press `+G` to open the layout editor. It will start by a 2x2 grid layout. Click and drag the dividers (the lines between regions) to resize the regions. If you want to split a region, press `` or `` while hovering over the region to split the region horizontally or vertically. Use the `right mouse button` to remove dividers. Use `` and `` to increase or decrease the spacing between the regions. +After enabling the extension, press `+G` to open the layout editor. It will start with a 2x2 grid layout. Click and drag the dividers (the lines between regions) to resize the regions. If you want to split a region, press `` or `` while hovering over the region to split the region horizontally or vertically. Use the `right mouse button` to remove dividers. Use `` and `` to increase or decrease the spacing between the regions. After you have crafted your desired layout, exit the editor using `+G` or ``. ![Layout editor](docs/layout-editor.png) -Now, start dragging a window and simultaneously hold the `` key. The layout will become visible. Hover your mose over the region you want the window to snap to and release the mouse button. The window will now be snapped into place. +Now, start dragging a window and simultaneously hold the `` key. The layout will become visible. Hover your mouse over the region you want the window to snap to and release the mouse button. The window will now be snapped into place. When the mouse hovers over the border between two regions, these regions are merged into a single region into which the window will snap. + +If you want to make the snapping region even larger you can hold the `` key and hover over adjacent regions to merge them all into a single large snapping region. ![Layout editor](docs/window-snapping.png) diff --git a/application.js b/application.js index 1b85f77..52e8756 100644 --- a/application.js +++ b/application.js @@ -75,12 +75,12 @@ function mapModifierSettingToModifierType(modifierSetting) { return [Clutter.ModifierType.SUPER_MASK, Clutter.ModifierType.MOD4_MASK]; case 'SHIFT': return [Clutter.ModifierType.SHIFT_MASK]; - default: + default: return []; } } -// The application class is only constructed once and is the main entry +// The application class is only constructed once and is the main entry // of the extension. class Application { // the active grid editor @@ -132,7 +132,7 @@ class Application { } #loadThemeColors() { - // hidden element to fetch the styling + // hidden element to fetch the styling let stylingActor = new St.DrawingArea({ style_class: 'tile-preview tile-hud', visible: false @@ -182,7 +182,7 @@ class Application { } #enableHotkey() { - this.#disableHotkey(); + this.#disableHotkey(); Main.keybindingManager.addHotKey('fancytiles', this.#settings.settingsData.hotkey.value, this.#toggleEditor.bind(this)); } @@ -269,16 +269,19 @@ class Application { #connectWindowGrabs() { // start snapping when the user starts moving a window this.#signals.connect(global.display, 'grab-op-begin', (display, screen, window, op) => { - if (op === Meta.GrabOp.MOVING && window.window_type === Meta.WindowType.NORMAL) { + if (op === Meta.GrabOp.MOVING && window.window_type === Meta.WindowType.NORMAL) { // reload styling this.#loadThemeColors(); const enableSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableSnappingModifiers.value); - + const enableMultiSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableMultiSnappingModifiers.value); + const enableMergeAdjacentOnHover = this.#settings.settingsData.mergeAdjacentOnHover.value; + const mergingRadius = this.#settings.settingsData.mergingRadius.value; + // Create WindowSnapper for each monitor const nMonitors = global.display.get_n_monitors(); for (let i = 0; i < nMonitors; i++) { const layout = this.#readOrCreateLayoutForDisplay(i, LayoutOf2x2); - const snapper = new WindowSnapper(i, layout, window, enableSnappingModifiers); + const snapper = new WindowSnapper(i, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableMergeAdjacentOnHover, mergingRadius); this.#windowSnappers.push(snapper); } } @@ -300,4 +303,4 @@ class Application { } } -module.exports = { Application, LayoutOf2x2 }; \ No newline at end of file +module.exports = { Application, LayoutOf2x2 }; diff --git a/drawing.js b/drawing.js index 18cc04e..3b856da 100644 --- a/drawing.js +++ b/drawing.js @@ -1,8 +1,6 @@ // this module contains functionality to draw the layout of a node tree // on a given Cairo context -const TAU = Math.PI * 2; - // blueish default / fallback colors const DefaultColors = { background: { @@ -25,41 +23,89 @@ const DefaultColors = { } } -function drawRoundedRect(cr, rect, radius, fillColor, strokeColor) { - let { x, y, width, height } = rect; - - let drawPath = function () { - // Start a new path for the rounded rectangle - cr.newPath(); - - // Move to starting point - cr.moveTo(x + radius, y); +const { buildDifferencePath, polygonArea } = require('./shapes'); - // Top edge and top-right corner - cr.lineTo(x + width - radius, y); - cr.arc(x + width - radius, y + radius, radius, -TAU / 4, 0); +function drawRoundedRect(cr, rect, radius, fillColor, strokeColor, excludedRect) { + const pathPoints = buildDifferencePath(rect, excludedRect); + if (!pathPoints || pathPoints.length < 3) { + return; + } - // Right edge and bottom-right corner - cr.lineTo(x + width, y + height - radius); - cr.arc(x + width - radius, y + height - radius, radius, 0, TAU / 4); + const drawPath = function () { + cr.newPath(); - // Bottom edge and bottom-left corner - cr.lineTo(x + radius, y + height); - cr.arc(x + radius, y + height - radius, radius, TAU / 4, TAU / 2); + let polygon = pathPoints.slice(); + if (polygonArea(polygon) < 0) { + polygon = polygon.reverse(); + } - // Left edge and top-left corner - cr.lineTo(x, y + radius); - cr.arc(x + radius, y + radius, radius, TAU / 2, TAU * 3 / 4); + const pointCount = polygon.length; + const cornerRadiusInput = Math.max(0, radius); + + for (let i = 0; i < pointCount; i++) { + const prev = polygon[(i - 1 + pointCount) % pointCount]; + const current = polygon[i]; + const next = polygon[(i + 1) % pointCount]; + + let dirIn = { x: current.x - prev.x, y: current.y - prev.y }; + let dirOut = { x: next.x - current.x, y: next.y - current.y }; + + const lenIn = Math.hypot(dirIn.x, dirIn.y); + const lenOut = Math.hypot(dirOut.x, dirOut.y); + + if (lenIn === 0 || lenOut === 0) { + continue; + } + + dirIn = { x: dirIn.x / lenIn, y: dirIn.y / lenIn }; + dirOut = { x: dirOut.x / lenOut, y: dirOut.y / lenOut }; + + const cornerRadius = Math.min(cornerRadiusInput, lenIn / 2, lenOut / 2); + // trim the straight segments so the arc touches correct tangents + const startPoint = { + x: current.x - dirIn.x * cornerRadius, + y: current.y - dirIn.y * cornerRadius + }; + const endPoint = { + x: current.x + dirOut.x * cornerRadius, + y: current.y + dirOut.y * cornerRadius + }; + + if (i === 0) { + cr.moveTo(startPoint.x, startPoint.y); + } else { + cr.lineTo(startPoint.x, startPoint.y); + } + + const turn = dirIn.x * dirOut.y - dirIn.y * dirOut.x; + + if (cornerRadius > 1e-6 && Math.abs(turn) > 1e-6) { + // center sits in the quadrant spanned by dirIn/dirOut; sign of turn decides arc direction + const center = { + x: current.x - dirIn.x * cornerRadius + dirOut.x * cornerRadius, + y: current.y - dirIn.y * cornerRadius + dirOut.y * cornerRadius + }; + const startAngle = Math.atan2(startPoint.y - center.y, startPoint.x - center.x); + const endAngle = Math.atan2(endPoint.y - center.y, endPoint.x - center.x); + + if (turn > 0) { + cr.arc(center.x, center.y, cornerRadius, startAngle, endAngle); + } else { + cr.arcNegative(center.x, center.y, cornerRadius, startAngle, endAngle); + } + } else { + // no curved corner here; continue with straight run + cr.lineTo(endPoint.x, endPoint.y); + } + } cr.closePath(); - } + }; - // fill the region cr.setSourceRGBA(fillColor.r, fillColor.g, fillColor.b, fillColor.a); drawPath(); cr.fill(); - // draw the border cr.setSourceRGBA(strokeColor.r, strokeColor.g, strokeColor.b, strokeColor.a); drawPath(); cr.stroke(); @@ -74,11 +120,11 @@ function addMargins(rect, margin) { } } -function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius = 10) { +function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius = 10, cutoutRect = null) { if (!node) return; // Draw current node - let rect = node.rect; + let rect = node.rect; // Offset by monitor displayRect let x = rect.x - displayRect.x; @@ -86,6 +132,16 @@ function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius let width = rect.width; let height = rect.height; + // do we have a cutout for all children? + if(node.insetNode && !cutoutRect){ + cutoutRect = addMargins({ + x: node.insetNode.rect.x - displayRect.x, + y: node.insetNode.rect.y - displayRect.y, + width: node.insetNode.rect.width, + height: node.insetNode.rect.height + }, -node.margin*2); + } + // draw the region of a leaf node if (node.isLeaf()) { let c = colors.background; @@ -95,16 +151,21 @@ function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius } cr.setSourceRGBA(c.r, c.g, c.b, c.a); - let regionRect = addMargins({ x, y, width, height }, node.margin); - drawRoundedRect(cr, regionRect, cornerRadius, c, colors.border); + let regionRect = addMargins({ x, y, width, height }, node.margin); + drawRoundedRect(cr, regionRect, cornerRadius, c, colors.border, cutoutRect); } for (let child of node.children) { - drawLayout(cr, child, displayRect, colors, cornerRadius); + drawLayout(cr, child, displayRect, colors, cornerRadius, cutoutRect); + } + + // draw the cutout once here + if (node.insetNode) { + drawLayout(cr, node.insetNode, displayRect, colors, cornerRadius, null); } } module.exports = { drawLayout, DefaultColors -}; \ No newline at end of file +}; diff --git a/node_tree.js b/node_tree.js index d4409f0..4920ed4 100644 --- a/node_tree.js +++ b/node_tree.js @@ -1,33 +1,33 @@ // A layout is a tree data structure that represents the layout of snapping regions // on a display. Each node in the tree represents a region. -// The internal nodes represent a partitioning (either row or column oriented) and +// The internal nodes represent a partitioning (either row or column oriented) and // the leaf nodes represent the region for a single window to snap into. // // The internal nodes, that partition the cell into multiple rows or columns, // can contain two or more child nodes. These child nodes can either be // another internal node or a leaf node. -// -// Each node holds a percentage value that represents the bottom edge (y-value) or +// +// Each node holds a percentage value that represents the bottom edge (y-value) or // right edge (x-value) of the cell as a fraction of the display width (dw) or display height (dh) -// The percentages are NOT a fraction of the width or height of the parent node. Percentages are -// used instead of absolute pixel values to allow the same layout to be used on displays with +// The percentages are NOT a fraction of the width or height of the parent node. Percentages are +// used instead of absolute pixel values to allow the same layout to be used on displays with // different resolutions. // -// NEGATIVE percentages are defined as going along the y-axis (row partitioning) and +// NEGATIVE percentages are defined as going along the y-axis (row partitioning) and // POSITIVE percentages are defined as going along the x-axis (column partitioning). -// Another perspective on this is that the x axis is positive to the right and the -// y axis is negative downwards. By having this convention, we can don´t have to store +// Another perspective on this is that the x axis is positive to the right and the +// y axis is negative downwards. By having this convention, we can don´t have to store // the partition type in the node. // -// For example, for a cell that has row partitioning, a value of -0.25 means that the +// For example, for a cell that has row partitioning, a value of -0.25 means that the // y-coordinate, the bottom edge of the cell, is 25% of the display height (0.25dh). For a cell that -// has column partitioning, a value of 0.45 means that the x-coordinate, the right edge +// has column partitioning, a value of 0.45 means that the x-coordinate, the right edge // of the cell, is 45% of the screen width (0.45dw). Percentages are floating point numbers. // // The LAST child of an internal node does not have a percentage value as the right edge or // bottom edge is defined by the parent node. I.e. it fills the remaining space. // -// The calculateRects method calculates the rectangles for all the nodes in the tree in +// The calculateRects method calculates the rectangles for all the nodes in the tree in // absolute screen coordinates. // @@ -47,7 +47,7 @@ const LastNodeYPercentageJson = -99999; // A node in the tree layout structure class LayoutNode { - // percentage of screen width (positive) or height (negative). + // percentage of screen width (positive) or height (negative). // Always INFINITY or NEGATIVE INFINITY for the last child. percentage; @@ -60,10 +60,15 @@ class LayoutNode { // the on-screen rectangle covering the region of the node rect = { x: 0, y: 0, width: 0, height: 0 }; + // the inset node is a fixed node that is used as a cutout for this node + // and its descendants when drawing. in the resulting cutout, the node is drawn. + // this functionality is used to draw the cutout for multi-snapping. + insetNode = null; + // isResizing indicates if the divider belonging to this node is being moved by the user isResizing = false; - // isPreview indicates that this node is part of a preview split + // isPreview indicates that this node is part of a preview split isPreview = false; // isHighlighted indicates that this node is visually highlighted @@ -104,7 +109,7 @@ class LayoutNode { return clone; } - // revert the node to the state of the snapshotRootNode, often used + // revert the node to the state of the snapshotRootNode, often used // on the root node to revert the whole layout to a previous state revert(snapshotRootNode) { this.percentage = snapshotRootNode.percentage; @@ -245,8 +250,8 @@ class LayoutNode { // validate the calculated rectangles for the node and its descendants validateRects() { - // we constrain the mnimum size to a reasonable 100 pixels - // as smaller is likely not what the user wants + // we constrain the mnimum size to a reasonable 100 pixels + // as smaller is likely not what the user wants if (this.rect.width <= 100 || this.rect.height <= 100) { return false; } @@ -278,8 +283,8 @@ class LayoutNode { child.parent = this; - // insert the child at the correct position/index - // to maintain the sorted invariant + // insert the child at the correct position/index + // to maintain the sorted invariant this.children.splice( this.children.findIndex(c => Math.abs(c.percentage) > Math.abs(child.percentage)), 0, @@ -294,6 +299,19 @@ class LayoutNode { return this.children.reduce((found, child) => found || child.findNode(predicate), null); } + // find all node in the tree that matches the given predicate and return them in flat list + findAllNodes(predicate) { + let result = []; + + this.forSelfAndDescendants((n) => { + if(predicate(n)) { + result.push(n); + } + }) + + return result; + } + // delete the given node in the tree if found delete(node) { let index = this.children.indexOf(node); @@ -327,6 +345,39 @@ class LayoutNode { return this.children.reduce((found, child) => found || child.findNodeAtPosition(x, y), null); } + // find all leaf nodes near the given position (for multi-zone spanning) + findNodesNearPosition(x, y, threshold) { + let nearbyNodes = []; + let insideNodes = []; + + this.forSelfAndDescendants(node => { + if (!node.isLeaf()) return; + + // Check if inside the node (highest priority) + let insideX = x >= node.rect.x && x <= node.rect.x + node.rect.width; + let insideY = y >= node.rect.y && y <= node.rect.y + node.rect.height; + + if (insideX && insideY) { + insideNodes.push(node); + return; + } + + // Check if position is within threshold of any edge + let nearLeft = Math.abs(x - node.rect.x) <= threshold; + let nearRight = Math.abs(x - (node.rect.x + node.rect.width)) <= threshold; + let nearTop = Math.abs(y - node.rect.y) <= threshold; + let nearBottom = Math.abs(y - (node.rect.y + node.rect.height)) <= threshold; + + // Include node if position is near edges (but prioritize inside nodes) + if ((insideX || nearLeft || nearRight) && (insideY || nearTop || nearBottom)) { + nearbyNodes.push(node); + } + }); + + // Return inside nodes with highest priority, then nearby nodes + return insideNodes.length > 0 ? [...insideNodes, ...nearbyNodes] : nearbyNodes; + } + // get the rectangle of the divider for this node, useful for grabbing and moving the divider getDividerRect(dividerWidth) { dividerWidth = Math.max(dividerWidth, 2 * this.margin); @@ -630,7 +681,7 @@ class PreviewSplitOperation extends LayoutOperation { } _handlePreview(x, y, state) { - // Check for preview partition creation + // Check for preview partition creation let ctrlPressed = (state & Clutter.ModifierType.CONTROL_MASK) !== 0; let shiftPressed = (state & Clutter.ModifierType.SHIFT_MASK) !== 0; let previewModeEnabled = ctrlPressed || shiftPressed; @@ -653,9 +704,9 @@ class PreviewSplitOperation extends LayoutOperation { result = OperationResult.handledAndRedraw(); } - // start a new preview if - // 1) there is no preview yet and - // 2) we are moving over a cell and + // start a new preview if + // 1) there is no preview yet and + // 2) we are moving over a cell and // 3) ctrl or shift is pressed (preview mode) if (!previewNode && node && node.isLeaf() @@ -678,7 +729,7 @@ class PreviewSplitOperation extends LayoutOperation { // move around the divider on a resizing (preview)node if (previewNode) { - // calculate the percentages + // calculate the percentages let percentage = previewNode.isColumn() ? ((x - this.tree.rect.x) / this.tree.rect.width) : -((y - this.tree.rect.y) / this.tree.rect.height); let oldPercentage = previewNode.percentage; @@ -700,7 +751,7 @@ class PreviewSplitOperation extends LayoutOperation { } _startPreview(splittingNode, percentage) { - // Split a leaf node into two nodes, with the given percentage as starting point + // Split a leaf node into two nodes, with the given percentage as starting point let previewNode = new LayoutNode(percentage); previewNode.isPreview = true; previewNode.isHighlighted = true; @@ -738,47 +789,119 @@ class PreviewSplitOperation extends LayoutOperation { class SnappingOperation extends LayoutOperation { showRegions = false; #enableSnappingModifiers; + #enableMultiSnappingModifiers; + #enableAdjacentMerging; + #mergingRadius; - constructor(tree, enableSnappingModifiers) { + constructor(tree, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius) { super(tree); this.#enableSnappingModifiers = enableSnappingModifiers; + this.#enableMultiSnappingModifiers = enableMultiSnappingModifiers; + this.#enableAdjacentMerging = enableAdjacentMerging; + this.#mergingRadius = mergingRadius; } onMotion(x, y, state) { var snappingEnabled = this.#enableSnappingModifiers.length == 0 || this.#enableSnappingModifiers.some((e) => (state & e)); - if (!snappingEnabled) { return this.cancel(); } - // Find node at mouse position let node = this.tree.findNodeAtPosition(x, y); - if (!node) { - return this.cancel(); + if(!node){ + return OperationResult.notHandled(); } - // activate the region to snap into - this.showRegions = true; + // first check for multi-region snapping using the key modifier + const multiSnapEnabled = this.#enableMultiSnappingModifiers.some((e) => (state & e)); - this.tree.forSelfAndDescendants(n => { - n.isSnappingDestination = false; - n.isHighlighted = false; + // if multi-region snapping is enabled the regions that are snapping destinations are retained + // this allows the user to expand the snapping region by moving around. + // if multi-region snapping is not enabled we first clear all snapping destinations + if(!multiSnapEnabled) { + this.tree.forSelfAndDescendants(n => { + n.isSnappingDestination = false; + n.isHighlighted = false; + }); + this.tree.insetNode = null; + } + + // the regions in a radius around the mouse position will become snapping destinations + const regionSelectionRadius = this.#enableAdjacentMerging ? this.#mergingRadius : 0; + + // find the regions with the radius and set them as snapping destinations + let nearbyNodes = this.tree.findNodesNearPosition(x, y, regionSelectionRadius); + nearbyNodes.forEach(node => { + node.isSnappingDestination = true; + node.isHighlighted = true; }); - node.isSnappingDestination = true; - node.isHighlighted = true; + // show multi-region insetnode if there are multiple snapping destinations + const snappingDestinations = this.tree.findAllNodes(n => n.isSnappingDestination); + if(snappingDestinations.length > 1) { + const multisnapRect = this.multisnapRect(); + if(!this.tree.insetNode){ + this.tree.insetNode = new LayoutNode(0); + this.tree.insetNode.parent = this.tree; + this.tree.insetNode.margin = node.margin; + this.tree.insetNode.isSnappingDestination = true; + this.tree.insetNode.isHighlighted = true; + } + this.tree.insetNode.rect = multisnapRect; + } else { + this.tree.insetNode = null; + } + + this.showRegions = true; return OperationResult.handledAndRedraw(); } + multisnapRect() { + let snapToNodes = this.tree.findAllNodes(n => n.isSnappingDestination); + + if (snapToNodes.length == 0) { + return null; + } + + return snapToNodes + .map((n) => n.rect) + .reduce((rect_a, rect_b) => { + let min_x = Math.min(rect_a.x, rect_b.x); + let min_y = Math.min(rect_a.y, rect_b.y); + let max_x = Math.max( + rect_a.x + rect_a.width, + rect_b.x + rect_b.width, + ); + let max_y = Math.max( + rect_a.y + rect_a.height, + rect_b.y + rect_b.height, + ); + + return { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + }; + }); + } + currentSnapToRect() { - var snapToNode = this.tree.findNode(n => n.isSnappingDestination); + if(this.tree.insetNode){ + return this.tree.insetNode.snapRect(); + } + + const snapToNode = this.tree.findNode(n => n.isSnappingDestination); if (!snapToNode) { return null; } + return snapToNode.snapRect(); } cancel() { + this.tree.insetNode = null; + if (this.showRegions) { this.showRegions = false; this.tree.forSelfAndDescendants(n => { @@ -867,4 +990,4 @@ module.exports = { SnappingOperation, MarginsOperation, PresetShortcutOperation -}; +}; diff --git a/settings-schema.json b/settings-schema.json index 1964a39..f13f715 100644 --- a/settings-schema.json +++ b/settings-schema.json @@ -6,7 +6,7 @@ }, "enableSnappingModifiers": { "type": "combobox", - "description": "Key modifier required to activate snapping", + "description": "Key modifier to activate snapping", "default": "CTRL", "options": { "(none)": "", @@ -15,5 +15,32 @@ "SUPER": "SUPER", "SHIFT": "SHIFT" } + }, + "enableMultiSnappingModifiers": { + "type": "combobox", + "description": "Key modifier to enable merging additional regions", + "tooltip": "When this key modifier is held down in snapping mode, all selected regions will be merged into a single region into which the window will snap", + "default": "ALT", + "options": { + "(disabled)": "", + "CTRL": "CTRL", + "ALT": "ALT", + "SUPER": "SUPER", + "SHIFT": "SHIFT" + } + }, + "mergeAdjacentOnHover": { + "type": "switch", + "description": "Merge regions within the mouse radius", + "tooltip": "When enabled, all regions with a radius of the mouse cursor are merged into a single region into which the window will snap", + "default": true + }, + "mergingRadius": { + "type": "scale", + "description": "Radius around the mouse cursor used for merging", + "default": 15, + "min": 5, + "max": 50, + "step": 5 } -} \ No newline at end of file +} diff --git a/shapes.js b/shapes.js new file mode 100644 index 0000000..6d6adc1 --- /dev/null +++ b/shapes.js @@ -0,0 +1,234 @@ +// helper utilities to build polygon paths used for drawing + +// quantize floating point coordinates so we can safely use them as map keys +const COORD_PRECISION = 1e6; + +function toKeyCoord(value) { + return Math.round(value * COORD_PRECISION); +} + +// collect unique finite values while preserving numeric ordering +function uniqueSorted(values) { + const seen = new Set(); + const unique = []; + for (const value of values) { + if (!Number.isFinite(value)) continue; + const key = toKeyCoord(value); + if (seen.has(key)) continue; + seen.add(key); + unique.push(value); + } + unique.sort((a, b) => a - b); + return unique; +} + +function pointKey(point) { + return `${toKeyCoord(point.x)}:${toKeyCoord(point.y)}`; +} + +function edgeKey(x1, y1, x2, y2) { + const ax = toKeyCoord(x1); + const ay = toKeyCoord(y1); + const bx = toKeyCoord(x2); + const by = toKeyCoord(y2); + const minX = Math.min(ax, bx); + const minY = Math.min(ay, by); + const maxX = Math.max(ax, bx); + const maxY = Math.max(ay, by); + return `${minX}:${minY}:${maxX}:${maxY}`; +} + +function orientedEdgeKey(edge) { + return `${pointKey(edge.start)}->${pointKey(edge.end)}`; +} + +function polygonArea(points) { + if (!points || points.length < 3) return 0; + let area = 0; + for (let i = 0; i < points.length; i++) { + const { x: x1, y: y1 } = points[i]; + const { x: x2, y: y2 } = points[(i + 1) % points.length]; + area += x1 * y2 - x2 * y1; + } + return area * 0.5; +} + +// return the axis-aligned portion shared by both rectangles (if any) +function computeIntersection(rect, excludedRect) { + if (!rect || !excludedRect) return null; + + const left = Math.max(rect.x, excludedRect.x); + const right = Math.min(rect.x + rect.width, excludedRect.x + excludedRect.width); + const top = Math.max(rect.y, excludedRect.y); + const bottom = Math.min(rect.y + rect.height, excludedRect.y + excludedRect.height); + + if (right <= left || bottom <= top) return null; + + return { + x: left, + y: top, + width: right - left, + height: bottom - top + }; +} + +// create a clockwise polygon for rect minus the overlapping part of excludedRect +function buildDifferencePath(rect, excludedRect) { + if (!rect) return []; + + const rectLeft = rect.x; + const rectRight = rect.x + rect.width; + const rectTop = rect.y; + const rectBottom = rect.y + rect.height; + + if (!(rectRight > rectLeft) || !(rectBottom > rectTop)) { + return []; + } + + const intersection = computeIntersection(rect, excludedRect); + + if (!intersection) { + return [ + { x: rectLeft, y: rectTop }, + { x: rectRight, y: rectTop }, + { x: rectRight, y: rectBottom }, + { x: rectLeft, y: rectBottom } + ]; + } + + // slice the area into a minimal grid defined by unique X/Y breakpoints + const xs = uniqueSorted([rectLeft, rectRight, intersection.x, intersection.x + intersection.width]); + const ys = uniqueSorted([rectTop, rectBottom, intersection.y, intersection.y + intersection.height]); + + const cells = []; + + for (let xi = 0; xi < xs.length - 1; xi++) { + const x0 = xs[xi]; + const x1 = xs[xi + 1]; + if (x1 <= x0) continue; + const cx = (x0 + x1) / 2; + + for (let yi = 0; yi < ys.length - 1; yi++) { + const y0 = ys[yi]; + const y1 = ys[yi + 1]; + if (y1 <= y0) continue; + const cy = (y0 + y1) / 2; + + const insideRect = cx >= rectLeft && cx <= rectRight && cy >= rectTop && cy <= rectBottom; + const insideIntersection = cx >= intersection.x && cx <= intersection.x + intersection.width && + cy >= intersection.y && cy <= intersection.y + intersection.height; + + // retain cells that belong to rect but not the overlapped region + if (insideRect && !insideIntersection) { + cells.push({ x0, x1, y0, y1 }); + } + } + } + + if (cells.length === 0) { + return []; + } + + const edgeMap = new Map(); + + // add rectangle edges, removing pairs that are shared between adjacent cells + function addEdge(x1, y1, x2, y2) { + if (Math.abs(x1 - x2) < 1e-7 && Math.abs(y1 - y2) < 1e-7) { + return; + } + const key = edgeKey(x1, y1, x2, y2); + if (edgeMap.has(key)) { + edgeMap.delete(key); + } else { + edgeMap.set(key, { + start: { x: x1, y: y1 }, + end: { x: x2, y: y2 } + }); + } + } + + for (const cell of cells) { + addEdge(cell.x0, cell.y0, cell.x1, cell.y0); + addEdge(cell.x1, cell.y0, cell.x1, cell.y1); + addEdge(cell.x1, cell.y1, cell.x0, cell.y1); + addEdge(cell.x0, cell.y1, cell.x0, cell.y0); + } + + const edges = Array.from(edgeMap.values()); + if (edges.length === 0) { + return []; + } + + const edgesByStart = new Map(); + // bucket edges by start vertex so we can follow connected boundary segments + for (const edge of edges) { + const startKey = pointKey(edge.start); + if (!edgesByStart.has(startKey)) { + edgesByStart.set(startKey, []); + } + edgesByStart.get(startKey).push(edge); + } + + const visited = new Set(); + let bestLoop = null; + let bestArea = -Infinity; + + // walk every possible loop and keep the one with the largest area (outer boundary) + for (const edge of edges) { + const firstKey = orientedEdgeKey(edge); + if (visited.has(firstKey)) continue; + + const loop = []; + let currentEdge = edge; + const loopStartKey = pointKey(edge.start); + + while (true) { + const currentKey = orientedEdgeKey(currentEdge); + if (visited.has(currentKey)) break; + visited.add(currentKey); + loop.push({ x: currentEdge.start.x, y: currentEdge.start.y }); + + const nextKey = pointKey(currentEdge.end); + if (nextKey === loopStartKey) { + break; + } + + const candidates = edgesByStart.get(nextKey); + if (!candidates || candidates.length === 0) { + loop.length = 0; + break; + } + + let nextEdge = null; + for (const candidate of candidates) { + const candidateKey = orientedEdgeKey(candidate); + if (!visited.has(candidateKey)) { + nextEdge = candidate; + break; + } + } + + if (!nextEdge) { + loop.length = 0; + break; + } + + currentEdge = nextEdge; + } + + if (loop.length > 0) { + const area = Math.abs(polygonArea(loop)); + if (area > bestArea) { + bestArea = area; + bestLoop = loop; + } + } + } + + return bestLoop || []; +} + +module.exports = { + buildDifferencePath, + polygonArea +}; diff --git a/window-snapper.js b/window-snapper.js index 84eeff9..f17497c 100644 --- a/window-snapper.js +++ b/window-snapper.js @@ -30,9 +30,18 @@ class WindowSnapper { // the modifier key to enable snapping #enableSnappingModifiers; + // the modifier key to enable snapping to multiple areas + #enableMultiSnappingModifiers; + + // whether to merge adjacent regions when hovering over the shared border + #enableAdjacentMerging; + + // the radius around the mouse position used for merging + #mergingRadius; + #signals = new SignalManager.SignalManager(null); - constructor(displayIdx, layout, window, enableSnappingModifiers) { + constructor(displayIdx, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius) { // the layout to use for the snapping operation this.#layout = layout; @@ -42,6 +51,14 @@ class WindowSnapper { // the modifier key to enable snapping this.#enableSnappingModifiers = enableSnappingModifiers; + // the modifier key to enable snapping to multiple areas + this.#enableMultiSnappingModifiers = enableMultiSnappingModifiers; + + // whether to merge adjacent regions when hovering over the shared border + this.#enableAdjacentMerging = enableAdjacentMerging; + + this.#mergingRadius = mergingRadius; + // get the size of the display let workArea = getUsableScreenArea(displayIdx); @@ -65,7 +82,7 @@ class WindowSnapper { // ensure the layout is correct for the snap area this.#layout.calculateRects(workArea.x, workArea.y, workArea.width, workArea.height); - this.#snappingOperation = new SnappingOperation(this.#layout, this.#enableSnappingModifiers); + this.#snappingOperation = new SnappingOperation(this.#layout, this.#enableSnappingModifiers, this.#enableMultiSnappingModifiers, this.#enableAdjacentMerging, this.#mergingRadius); this.#signals.connect(this.#window, 'position-changed', this.#onWindowMoved.bind(this)); } @@ -134,4 +151,4 @@ class WindowSnapper { } } -module.exports = { WindowSnapper }; \ No newline at end of file +module.exports = { WindowSnapper };