Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ Open Cinnamon Extensions, click on the Fancy Tiles extension and click the '+' b

## Quick start

After enabling the extension, press `<SUPER>+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 `<SHIFT>` or `<CTRL>` while hovering over the region to split the region horizontally or vertically. Use the `right mouse button` to remove dividers. Use `<Page Up>` and `<Page Down>` to increase or decrease the spacing between the regions.
After enabling the extension, press `<SUPER>+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 `<SHIFT>` or `<CTRL>` while hovering over the region to split the region horizontally or vertically. Use the `right mouse button` to remove dividers. Use `<Page Up>` and `<Page Down>` to increase or decrease the spacing between the regions.

After you have crafted your desired layout, exit the editor using `<SUPER>+G` or `<ESC>`.

![Layout editor](docs/layout-editor.png)

Now, start dragging a window and simultaneously hold the `<CTRL>` 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 `<CTRL>` 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.

### Multi-zone spanning

Windows can now span across multiple adjacent regions. When dragging a window near the edge between two adjacent regions, both regions will be highlighted, and the window will snap to cover the combined area of both regions.

![Layout editor](docs/window-snapping.png)

Expand Down
82 changes: 80 additions & 2 deletions drawing.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function addMargins(rect, margin) {
}
}

function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius = 10) {
function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius = 10, zoneCounter = { count: 0 }) {
if (!node) return;

// Draw current node
Expand All @@ -97,10 +97,88 @@ function drawLayout(cr, node, displayRect, colors = DefaultColors, cornerRadius

let regionRect = addMargins({ x, y, width, height }, node.margin);
drawRoundedRect(cr, regionRect, cornerRadius, c, colors.border);

// Increment and draw zone number with size
zoneCounter.count++;
const zoneNumber = zoneCounter.count;

// Draw zone number and size (width x height in pixels) - use regionRect for live updates
const sizeText = `${Math.round(regionRect.width)}x${Math.round(regionRect.height)}`;
const gapText = `Gap: ${node.margin}px`;

// Standard uniform font sizes - scale down only if zone is too small
const baseNumberSize = 72; // doubled for 4K visibility
const baseSizeTextSize = 36; // doubled for 4K visibility
const minDimension = Math.min(regionRect.width, regionRect.height);
const scaleFactor = Math.min(1, minDimension / 200); // scale down if smaller than 200px

const numberFontSize = baseNumberSize * scaleFactor;
const sizeFontSize = baseSizeTextSize * scaleFactor;

cr.setSourceRGBA(colors.border.r, colors.border.g, colors.border.b, 1);
cr.selectFontFace('Sans', 0, 1); // Cairo.FontSlant.NORMAL, Cairo.FontWeight.BOLD

// Measure text to center it
cr.setFontSize(numberFontSize);
const numberExtents = cr.textExtents(zoneNumber.toString());

cr.setFontSize(sizeFontSize);
const sizeExtents = cr.textExtents(sizeText);
const gapExtents = cr.textExtents(gapText);

// Calculate center position
const centerX = regionRect.x + regionRect.width / 2;
const centerY = regionRect.y + regionRect.height / 2;
const totalHeight = numberExtents.height + sizeExtents.height + gapExtents.height + 25; // spacing for 3 lines

// Draw zone number with shadow for readability (centered, larger)
cr.setFontSize(numberFontSize);
const numberX = centerX - numberExtents.width / 2;
const numberY = centerY - totalHeight / 2 + numberExtents.height;

// Shadow/outline for zone number
cr.setSourceRGBA(0, 0, 0, 0.8); // dark shadow
cr.moveTo(numberX + 2, numberY + 2);
cr.showText(zoneNumber.toString());

// Main zone number text
cr.setSourceRGBA(colors.border.r, colors.border.g, colors.border.b, 1);
cr.moveTo(numberX, numberY);
cr.showText(zoneNumber.toString());

// Draw size with shadow for readability (centered, smaller, below number)
cr.setFontSize(sizeFontSize);
const sizeX = centerX - sizeExtents.width / 2;
const sizeY = centerY + totalHeight / 2;

// Shadow/outline for size text
cr.setSourceRGBA(0, 0, 0, 0.8); // dark shadow
cr.moveTo(sizeX + 2, sizeY + 2);
cr.showText(sizeText);

// Main size text
cr.setSourceRGBA(colors.border.r, colors.border.g, colors.border.b, 1);
cr.moveTo(sizeX, sizeY);
cr.showText(sizeText);

// Draw gap text with shadow for readability (centered, smaller, below size)
cr.setFontSize(sizeFontSize);
const gapX = centerX - gapExtents.width / 2;
const gapY = sizeY + gapExtents.height + 10; // 10px below size text

// Shadow/outline for gap text
cr.setSourceRGBA(0, 0, 0, 0.8); // dark shadow
cr.moveTo(gapX + 2, gapY + 2);
cr.showText(gapText);

// Main gap text
cr.setSourceRGBA(colors.border.r, colors.border.g, colors.border.b, 1);
cr.moveTo(gapX, gapY);
cr.showText(gapText);
}

for (let child of node.children) {
drawLayout(cr, child, displayRect, colors, cornerRadius);
drawLayout(cr, child, displayRect, colors, cornerRadius, zoneCounter);
}
}

Expand Down
135 changes: 124 additions & 11 deletions node_tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,63 @@ 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 = 60) {
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;
}

// check if two nodes are adjacent (share an edge)
areNodesAdjacent(node1, node2) {
if (!node1 || !node2) return false;

let tolerance = 15;

// Check horizontal adjacency (left/right)
let horizontalOverlap = !(node1.rect.y + node1.rect.height <= node2.rect.y + tolerance ||
node2.rect.y + node2.rect.height <= node1.rect.y + tolerance);

let node1RightOfNode2 = Math.abs(node1.rect.x - (node2.rect.x + node2.rect.width)) <= tolerance;
let node2RightOfNode1 = Math.abs(node2.rect.x - (node1.rect.x + node1.rect.width)) <= tolerance;

// Check vertical adjacency (top/bottom)
let verticalOverlap = !(node1.rect.x + node1.rect.width <= node2.rect.x + tolerance ||
node2.rect.x + node2.rect.width <= node1.rect.x + tolerance);

let node1BelowNode2 = Math.abs(node1.rect.y - (node2.rect.y + node2.rect.height)) <= tolerance;
let node2BelowNode1 = Math.abs(node2.rect.y - (node1.rect.y + node1.rect.height)) <= tolerance;

return (horizontalOverlap && (node1RightOfNode2 || node2RightOfNode1)) ||
(verticalOverlap && (node1BelowNode2 || node2BelowNode1));
}

// 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);
Expand Down Expand Up @@ -751,31 +808,87 @@ class SnappingOperation extends LayoutOperation {
return this.cancel();
}

// Find node at mouse position
let node = this.tree.findNodeAtPosition(x, y);
if (!node) {
// Find nodes near mouse position for multi-zone spanning
let nearbyNodes = this.tree.findNodesNearPosition(x, y);
if (!nearbyNodes || nearbyNodes.length === 0) {
return this.cancel();
}

// activate the region to snap into
this.showRegions = true;

// Reset all snapping destinations and highlighting
this.tree.forSelfAndDescendants(n => {
n.isSnappingDestination = false;
n.isHighlighted = false;
});
node.isSnappingDestination = true;
node.isHighlighted = true;

// For multi-zone spanning, check if nodes are adjacent
if (nearbyNodes.length > 1) {
let adjacentNodes = [];
let mainNode = nearbyNodes[0]; // The primary node
adjacentNodes.push(mainNode);

// Find all nodes adjacent to the main node
for (let i = 1; i < nearbyNodes.length; i++) {
if (this.tree.areNodesAdjacent(mainNode, nearbyNodes[i])) {
adjacentNodes.push(nearbyNodes[i]);
}
}

// Set all adjacent nodes as snapping destinations
adjacentNodes.forEach(node => {
node.isSnappingDestination = true;
node.isHighlighted = true;
});
} else {
// Single zone
nearbyNodes[0].isSnappingDestination = true;
nearbyNodes[0].isHighlighted = true;
}

this.showRegions = true;
return OperationResult.handledAndRedraw();
}

currentSnapToRect() {
var snapToNode = this.tree.findNode(n => n.isSnappingDestination);
if (!snapToNode) {
// Find all nodes that are snapping destinations
let snapToNodes = [];
this.tree.forSelfAndDescendants(n => {
if (n.isSnappingDestination) {
snapToNodes.push(n);
}
});

if (snapToNodes.length === 0) {
return null;
}
return snapToNode.snapRect();

if (snapToNodes.length === 1) {
// Single zone
return snapToNodes[0].snapRect();
}

// Multiple zones - calculate combined rectangle
let combinedRect = null;
snapToNodes.forEach(node => {
let nodeRect = node.snapRect();
if (!combinedRect) {
combinedRect = { ...nodeRect };
} else {
// Expand combined rectangle to include this node
let left = Math.min(combinedRect.x, nodeRect.x);
let top = Math.min(combinedRect.y, nodeRect.y);
let right = Math.max(combinedRect.x + combinedRect.width, nodeRect.x + nodeRect.width);
let bottom = Math.max(combinedRect.y + combinedRect.height, nodeRect.y + nodeRect.height);

combinedRect = {
x: left,
y: top,
width: right - left,
height: bottom - top
};
}
});

return combinedRect;
}

cancel() {
Expand Down