From 4ae432be6cb1e2b887fdf61558abc83753ef5a75 Mon Sep 17 00:00:00 2001 From: Rezaul Arif Date: Wed, 30 Sep 2026 17:03:48 +1000 Subject: [PATCH] Align and Distribute layers, from the Move tool's bar and the Layer menu Left, center or right edges, top, middle or bottom edges, and even centers or spacing across three or more layers, as in Photoshop. Layers line up to the selection when there is one, to the canvas when one layer is selected, and otherwise to each other; a selected folder moves as one. Moves are whole pixels, masks go with their layers, and each command is one undo step. Layers are placed by their transform boxes, as snapping places them. --- Compositor/CompositorApp.swift | 12 ++ Compositor/Document/LayerAlign.swift | 166 +++++++++++++++++++++++++ Compositor/UI/TransformInspector.swift | 15 +++ CompositorTests/LayerAlignTests.swift | 94 ++++++++++++++ README.md | 1 + 5 files changed, 288 insertions(+) create mode 100644 Compositor/Document/LayerAlign.swift create mode 100644 CompositorTests/LayerAlignTests.swift diff --git a/Compositor/CompositorApp.swift b/Compositor/CompositorApp.swift index 0c171105a..2a4c372ac 100644 --- a/Compositor/CompositorApp.swift +++ b/Compositor/CompositorApp.swift @@ -333,6 +333,18 @@ struct CompositorApp: App { .disabled(!session.canTransform) Button("Flip Layer Vertical") { session.flipLayers(horizontally: false) } .disabled(!session.canTransform) + Menu("Align") { + ForEach(LayerAlignment.allCases, id: \.self) { alignment in + Button(alignment.rawValue) { session.alignLayers(alignment) } + } + } + .disabled(!session.canAlignLayers) + Menu("Distribute") { + ForEach(LayerDistribution.allCases, id: \.self) { distribution in + Button(distribution.rawValue) { session.distributeLayers(distribution) } + } + } + .disabled(!session.canDistributeLayers) } Divider() Button(session.selectedEffect != nil ? "Delete " + session.selectedEffect!.kind.rawValue : session.isMaskSelected && session.activeLayer?.mask != nil ? "Delete Layer Mask" : session.selectedLayerIDs.count > 1 ? "Delete Layers" : "Delete Layer") { diff --git a/Compositor/Document/LayerAlign.swift b/Compositor/Document/LayerAlign.swift new file mode 100644 index 000000000..3cc06a8bc --- /dev/null +++ b/Compositor/Document/LayerAlign.swift @@ -0,0 +1,166 @@ +import CoreGraphics +import Foundation + +/// The Move tool's Align and Distribute commands, as in Photoshop. +nonisolated enum LayerAlignment: String, CaseIterable, Sendable { + case left = "Left Edges", horizontalCenter = "Horizontal Centers", right = "Right Edges" + case top = "Top Edges", verticalCenter = "Vertical Centers", bottom = "Bottom Edges" + + var symbol: String { + switch self { + case .left: return "align.horizontal.left" + case .horizontalCenter: return "align.horizontal.center" + case .right: return "align.horizontal.right" + case .top: return "align.vertical.top" + case .verticalCenter: return "align.vertical.center" + case .bottom: return "align.vertical.bottom" + } + } + + /// How far `box` moves to line up with `target`. + func offset(of box: CGRect, to target: CGRect) -> CGSize { + switch self { + case .left: return CGSize(width: target.minX - box.minX, height: 0) + case .horizontalCenter: return CGSize(width: target.midX - box.midX, height: 0) + case .right: return CGSize(width: target.maxX - box.maxX, height: 0) + case .top: return CGSize(width: 0, height: target.minY - box.minY) + case .verticalCenter: return CGSize(width: 0, height: target.midY - box.midY) + case .bottom: return CGSize(width: 0, height: target.maxY - box.maxY) + } + } +} + +nonisolated enum LayerDistribution: String, CaseIterable, Sendable { + case horizontalCenters = "Horizontal Centers", verticalCenters = "Vertical Centers" + case horizontalSpacing = "Horizontal Spacing", verticalSpacing = "Vertical Spacing" + + var symbol: String { + switch self { + case .horizontalCenters: return "distribute.horizontal.center" + case .verticalCenters: return "distribute.vertical.center" + case .horizontalSpacing: return "distribute.horizontal.fill" + case .verticalSpacing: return "distribute.vertical.fill" + } + } + var isHorizontal: Bool { self == .horizontalCenters || self == .horizontalSpacing } + + /// How far each of `boxes` moves, in their order. The outermost two stay put and the rest are spread evenly + /// between them: their middles, or the gaps between them. + func offsets(of boxes: [CGRect]) -> [CGSize] { + guard boxes.count > 2 else { return boxes.map { _ in .zero } } + let start = { (box: CGRect) in isHorizontal ? box.minX : box.minY } + let length = { (box: CGRect) in isHorizontal ? box.width : box.height } + let order = boxes.indices.sorted { start(boxes[$0]) + length(boxes[$0]) / 2 < start(boxes[$1]) + length(boxes[$1]) / 2 } + let first = boxes[order.first!], last = boxes[order.last!] + var offsets = boxes.map { _ in CGSize.zero } + let steps = CGFloat(order.count - 1) + switch self { + case .horizontalCenters, .verticalCenters: + let from = start(first) + length(first) / 2, to = start(last) + length(last) / 2 + for (rank, index) in order.enumerated() { + let middle = from + (to - from) * CGFloat(rank) / steps + let delta = middle - (start(boxes[index]) + length(boxes[index]) / 2) + offsets[index] = isHorizontal ? CGSize(width: delta, height: 0) : CGSize(width: 0, height: delta) + } + case .horizontalSpacing, .verticalSpacing: + let span = start(last) + length(last) - start(first) + let gap = (span - order.reduce(0) { $0 + length(boxes[$1]) }) / steps + var position = start(first) + for index in order { + let delta = position - start(boxes[index]) + offsets[index] = isHorizontal ? CGSize(width: delta, height: 0) : CGSize(width: 0, height: delta) + position += length(boxes[index]) + gap + } + } + return offsets + } +} + +extension EditorSession { + /// What Align and Distribute move: each selected layer on its own, and each selected folder's contents as one, + /// with the upright box around them. A layer inside a selected folder moves with the folder. + var alignmentItems: [(members: [ImageLayer], box: CGRect)] { + guard let document, canEditLayers else { return [] } + let visible = document.effectiveVisibleIDs + let parents = Dictionary(uniqueKeysWithValues: document.layers.map { ($0.id, $0.parentID) }) + func selectedAncestor(of id: UUID) -> UUID? { + var current = parents[id] ?? nil + for _ in 0..<64 { + guard let parent = current else { return nil } + if selectedLayerIDs.contains(parent) { return parent } + current = parents[parent] ?? nil + } + return nil + } + // Each moving layer, under the outermost selected folder above it, or itself. + var groups: [UUID: [ImageLayer]] = [:] + var order: [UUID] = [] + for layer in document.layers where layer.asset != nil && !layer.isGroup && visible.contains(layer.id) { + var owner = selectedLayerIDs.contains(layer.id) ? layer.id : nil + var ancestor = selectedAncestor(of: layer.id) + while let folder = ancestor { owner = folder; ancestor = selectedAncestor(of: folder) } + guard let owner else { continue } + if groups[owner] == nil { order.append(owner) } + groups[owner, default: []].append(layer) + } + return order.compactMap { owner in + guard let members = groups[owner] else { return nil } + let box = members.map { layer in + let points = DistortWarp.corners(of: layer.transform) + let xs = points.map(\.x), ys = points.map(\.y) + return CGRect(x: xs.min()!, y: ys.min()!, width: xs.max()! - xs.min()!, height: ys.max()! - ys.min()!) + }.reduce(CGRect.null) { $0.union($1) } + return (members, box) + } + } + + /// Align lines things up with the selection when there is one, with the canvas when one thing is selected, and + /// otherwise with the box around them all. + /// Cheap enough for menus and buttons to ask on every redraw; the commands themselves find what moves. + var canAlignLayers: Bool { canEditLayers && !selectedLayerIDs.isEmpty } + var canDistributeLayers: Bool { canEditLayers && selectedLayerIDs.count > 2 } + + func alignLayers(_ alignment: LayerAlignment) { + commitTransform() + let items = alignmentItems + guard let document, !items.isEmpty else { return } + let target: CGRect + if let selection, !selection.isEmpty { target = selection.path.boundingBoxOfPath } + else if items.count == 1 { target = CGRect(origin: .zero, size: document.size) } + else { target = items.map(\.box).reduce(CGRect.null) { $0.union($1) } } + move(items, by: items.map { alignment.offset(of: $0.box, to: target) }, name: "Align " + alignment.rawValue) + } + + func distributeLayers(_ distribution: LayerDistribution) { + commitTransform() + let items = alignmentItems + guard items.count > 2 else { return } + move(items, by: distribution.offsets(of: items.map(\.box)), name: "Distribute " + distribution.rawValue) + } + + /// Moves each item's layers by its offset, rounded to whole pixels so nothing lands between them and has to be + /// resampled, as one undo step. Masks follow their link, as they do in a move. + private func move(_ items: [(members: [ImageLayer], box: CGRect)], by offsets: [CGSize], name: String) { + var moves: [UUID: CGSize] = [:] + for (item, offset) in zip(items, offsets) { + let whole = CGSize(width: offset.width.rounded(), height: offset.height.rounded()) + guard whole != .zero else { continue } + for layer in item.members { moves[layer.id] = whole } + } + guard !moves.isEmpty, let document else { return } + finishOpacityEdit() + beginEdit(name) + for index in document.layers.indices { + let layer = document.layers[index] + guard let offset = moves[layer.id] else { continue } + var moved = layer.transform + moved.origin.x += offset.width + moved.origin.y += offset.height + if let mask = layer.mask { + self.document?.layers[index].mask?.placement = mask.placement(movingLayer: layer.transform, to: moved) + } + self.document?.layers[index].transform = moved + } + endEdit() + } +} \ No newline at end of file diff --git a/Compositor/UI/TransformInspector.swift b/Compositor/UI/TransformInspector.swift index 79a4e7e99..9655dbb3f 100644 --- a/Compositor/UI/TransformInspector.swift +++ b/Compositor/UI/TransformInspector.swift @@ -41,6 +41,21 @@ struct TransformInspector: View { }.frame(width: 170) Button("Flip H") { change { $0.flipX.toggle() } } Button("Flip V") { change { $0.flipY.toggle() } } + Divider().frame(height: 16) + // Align to the selection, the canvas with one thing selected, or else to each other. + HStack(spacing: 2) { + ForEach(LayerAlignment.allCases, id: \.self) { alignment in + Button { session.alignLayers(alignment) } label: { Image(systemName: alignment.symbol) } + .help("Align " + alignment.rawValue).accessibilityLabel("Align " + alignment.rawValue) + } + }.buttonStyle(.borderless).disabled(!session.canAlignLayers) + HStack(spacing: 2) { + ForEach(LayerDistribution.allCases, id: \.self) { distribution in + Button { session.distributeLayers(distribution) } label: { Image(systemName: distribution.symbol) } + .help("Distribute " + distribution.rawValue + " (three or more layers)") + .accessibilityLabel("Distribute " + distribution.rawValue) + } + }.buttonStyle(.borderless).disabled(!session.canDistributeLayers) // Numbers describe an ordinary transform; while distorted, the handles are the controls. }.disabled((!session.canTransform && session.transformEdit == nil) || session.transformEdit?.corners != nil) diff --git a/CompositorTests/LayerAlignTests.swift b/CompositorTests/LayerAlignTests.swift new file mode 100644 index 000000000..64f98eb6f --- /dev/null +++ b/CompositorTests/LayerAlignTests.swift @@ -0,0 +1,94 @@ +import AppKit +import Testing +@testable import Compositor + +@MainActor +struct LayerAlignTests { + /// A 400 × 300 canvas with a solid layer for each of `rects`, bottom to top. + private func session(_ rects: [CGRect]) throws -> (EditorSession, [UUID]) { + let session = EditorSession() + session.createDocument(width: 400, height: 300) + var ids: [UUID] = [] + for rect in rects { + let context = try BrushRaster.context(width: Int(rect.width), height: Int(rect.height), mask: false) + context.setFillColor(CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1)) + context.fill(CGRect(origin: .zero, size: rect.size)) + let image = try #require(context.makeImage()) + session.insert(ImportedImage(image: image, thumbnail: image, name: "Box"), centeredAt: CGPoint(x: rect.midX, y: rect.midY)) + ids.append(try #require(session.activeLayerID)) + } + return (session, ids) + } + + private func origins(_ session: EditorSession, _ ids: [UUID]) -> [CGPoint] { + ids.compactMap { id in session.document?.layers.first { $0.id == id }?.transform.origin } + } + + @Test func severalLayersAlignToEachOther() throws { + let (session, ids) = try session([CGRect(x: 10, y: 20, width: 40, height: 30), + CGRect(x: 100, y: 60, width: 60, height: 20), + CGRect(x: 200, y: 10, width: 20, height: 50)]) + session.selectedLayerIDs = Set(ids) + session.alignLayers(.left) + #expect(origins(session, ids).map(\.x) == [10, 10, 10]) + session.alignLayers(.bottom) + #expect(origins(session, ids).map(\.y) == [50, 60, 30]) + session.undo() + #expect(origins(session, ids).map(\.y) == [20, 60, 10]) + } + + @Test func oneLayerAlignsToTheCanvasAndTheSelectionComesFirst() throws { + let (session, ids) = try session([CGRect(x: 10, y: 10, width: 101, height: 60)]) + session.selectedLayerIDs = Set(ids) + session.alignLayers(.horizontalCenter) + // 400 − 101 leaves an odd 299 to share: the layer lands on a whole pixel, not between two. + #expect(origins(session, ids).first?.x == 150) + session.alignLayers(.verticalCenter) + #expect(origins(session, ids).first?.y == 120) + session.document?.selection = DocumentSelection(path: CGPath(rect: CGRect(x: 300, y: 0, width: 50, height: 50), transform: nil)) + session.alignLayers(.right) + #expect(origins(session, ids).first?.x == 249) + } + + @Test func aSelectedFolderMovesAsOne() throws { + let (session, ids) = try session([CGRect(x: 50, y: 10, width: 20, height: 20), + CGRect(x: 90, y: 40, width: 20, height: 20), + CGRect(x: 200, y: 100, width: 20, height: 20)]) + session.selectedLayerIDs = [ids[0], ids[1]] + session.groupSelectedLayers() + let folder = try #require(session.activeLayerID) + session.selectedLayerIDs = [folder, ids[2]] + session.alignLayers(.left) + // The folder's box already starts furthest left; its layers keep their spacing, and the third joins them. + #expect(origins(session, ids).map(\.x) == [50, 90, 50]) + } + + @Test func distributeSpreadsMiddlesOrGapsEvenly() { + let boxes = [CGRect(x: 0, y: 0, width: 10, height: 10), CGRect(x: 60, y: 0, width: 30, height: 10), + CGRect(x: 100, y: 0, width: 10, height: 10)] + // Middles at 5, 75 and 105: the middle one moves to 55. + #expect(LayerDistribution.horizontalCenters.offsets(of: boxes).map(\.width) == [0, -20, 0]) + // 110 across, 50 of it boxes: gaps of 30 put the middle one at 40. + #expect(LayerDistribution.horizontalSpacing.offsets(of: boxes).map(\.width) == [0, -20, 0]) + let uneven = [CGRect(x: 0, y: 0, width: 10, height: 10), CGRect(x: 0, y: 70, width: 10, height: 10), + CGRect(x: 0, y: 20, width: 10, height: 40), CGRect(x: 0, y: 100, width: 10, height: 10)] + // Order follows position, not the list: gaps of (110 − 70) / 3. + let gap: CGFloat = 40 / 3 + let spaced = LayerDistribution.verticalSpacing.offsets(of: uneven).map(\.height) + #expect(abs(spaced[2] - (10 + gap - 20)) < 0.001) + #expect(abs(spaced[1] - (10 + gap + 40 + gap - 70)) < 0.001) + #expect(spaced[0] == 0 && spaced[3] == 0) + } + + @Test func distributeNeedsThreeAndMovesWholePixels() throws { + let (session, ids) = try session([CGRect(x: 0, y: 0, width: 10, height: 10), + CGRect(x: 50, y: 0, width: 10, height: 10), + CGRect(x: 101, y: 0, width: 10, height: 10)]) + session.selectedLayerIDs = [ids[0], ids[1]] + session.distributeLayers(.horizontalCenters) + #expect(origins(session, ids).map(\.x) == [0, 50, 101]) + session.selectedLayerIDs = Set(ids) + session.distributeLayers(.horizontalCenters) + #expect(origins(session, ids).map(\.x) == [0, 51, 101]) + } +} \ No newline at end of file diff --git a/README.md b/README.md index 9540063cf..7057d9171 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ brew install --cask robbietilton-compositor - Snapping to canvas and layer edges and centers, with guides - Exact values for position, size, scale and angle, stepped with the arrow keys - Flip Layer and Flip Canvas, horizontal and vertical +- Align and Distribute layers to each other, the selection or the canvas, from the Move tool's bar or the Layer menu ### Selections - Rectangle and Ellipse Marquee, Freehand and Polygonal Lasso, and the Magic tool — Wand selects by color, Object traces whatever you click (Tab switches)