native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #8 from onevcat/feature/maxrects-arrange

feat(canvas): Waterfall + row-break hybrid packing for Arrange

authored by

Wei Wang and committed by
GitHub
fd1db6cf 95ffe378

+384 -186
+171 -18
supacode/Features/Canvas/Models/CanvasCardLayout.swift
··· 33 33 } 34 34 } 35 35 36 - struct CanvasWaterfallPacker { 36 + // MARK: - Card Packing 37 + 38 + struct CanvasCardPacker { 37 39 var spacing: CGFloat 38 40 var titleBarHeight: CGFloat 39 41 ··· 42 44 var size: CGSize 43 45 } 44 46 45 - struct Result { 47 + struct PackResult { 46 48 var layouts: [String: CanvasCardLayout] 47 - var totalHeight: CGFloat 49 + var boundingSize: CGSize 50 + } 51 + 52 + /// The maximum card count for exhaustive row-break enumeration. 53 + private static let exhaustiveLimit = 20 54 + 55 + /// Pack cards to maximize the fitToView scale — cards appear as large as 56 + /// possible on screen. 57 + /// 58 + /// Two strategies compete: **waterfall** (equal-width columns, cards drop 59 + /// into the shortest column — great for varying heights) and **row-break** 60 + /// (cards flow left-to-right with centered rows — great for varying widths). 61 + /// The configuration with the highest `min(vW/bW, vH/bH)` wins. 62 + func pack(cards: [CardInfo], targetRatio: CGFloat) -> PackResult { 63 + guard !cards.isEmpty, targetRatio > 0 else { 64 + return PackResult(layouts: [:], boundingSize: .zero) 65 + } 66 + 67 + let columnWidth = cards.map(\.size.width).max()! 68 + var bestScale: CGFloat = -1 69 + var bestArea = CGFloat.infinity 70 + // Positive = waterfall column count, negative = row-break mask (offset by -1). 71 + var bestTag = 1 72 + 73 + // Strategy 1: Waterfall — try all column counts. 74 + for cols in 1...cards.count { 75 + let (boxW, boxH) = waterfallBoundingSize(cards: cards, columns: cols, columnWidth: columnWidth) 76 + let scale = min(targetRatio / boxW, 1.0 / boxH) 77 + let area = boxW * boxH 78 + if scale > bestScale || (scale == bestScale && area < bestArea) { 79 + bestScale = scale 80 + bestArea = area 81 + bestTag = cols 82 + } 83 + } 84 + 85 + // Strategy 2: Row-break — try all row configurations (exhaustive for small N). 86 + if cards.count <= Self.exhaustiveLimit { 87 + for mask in 0..<(1 << (cards.count - 1)) { 88 + let (boxW, boxH) = rowBreakBoundingSize(cards: cards, breakMask: mask) 89 + let scale = min(targetRatio / boxW, 1.0 / boxH) 90 + let area = boxW * boxH 91 + if scale > bestScale || (scale == bestScale && area < bestArea) { 92 + bestScale = scale 93 + bestArea = area 94 + bestTag = -(mask + 1) 95 + } 96 + } 97 + } 98 + 99 + if bestTag > 0 { 100 + return waterfallPack(cards: cards, columns: bestTag, columnWidth: columnWidth) 101 + } else { 102 + return rowBreakLayout(cards: cards, breakMask: -(bestTag + 1)) 103 + } 104 + } 105 + 106 + // MARK: - Waterfall layout 107 + 108 + /// Compute bounding size for a waterfall layout without building layouts. 109 + private func waterfallBoundingSize( 110 + cards: [CardInfo], 111 + columns: Int, 112 + columnWidth: CGFloat 113 + ) -> (CGFloat, CGFloat) { 114 + var colHeights = Array(repeating: spacing, count: columns) 115 + for card in cards { 116 + let col = colHeights.enumerated().min(by: { $0.element < $1.element })!.offset 117 + colHeights[col] += card.size.height + titleBarHeight + spacing 118 + } 119 + let totalWidth = spacing + CGFloat(columns) * (columnWidth + spacing) 120 + let totalHeight = colHeights.max() ?? spacing 121 + return (totalWidth, totalHeight) 48 122 } 49 123 50 - /// Pack cards into a fixed number of equal-width columns using the waterfall 51 - /// rule: each card drops into whichever column is currently shortest. 52 - func pack( 124 + /// Place cards into equal-width columns, each card going to the shortest 125 + /// column. Cards are horizontally centered within their column. 126 + private func waterfallPack( 53 127 cards: [CardInfo], 54 128 columns: Int, 55 129 columnWidth: CGFloat 56 - ) -> Result { 57 - var columnHeights = Array(repeating: spacing, count: columns) 130 + ) -> PackResult { 131 + var colHeights = Array(repeating: spacing, count: columns) 58 132 var layouts: [String: CanvasCardLayout] = [:] 59 133 60 134 for card in cards { 61 - let col = columnHeights.enumerated().min(by: { $0.element < $1.element })!.offset 62 - let totalCardHeight = card.size.height + titleBarHeight 63 - 64 - let slotX = spacing + CGFloat(col) * (columnWidth + spacing) 65 - let centerX = slotX + columnWidth / 2 66 - let centerY = columnHeights[col] + totalCardHeight / 2 135 + let col = colHeights.enumerated().min(by: { $0.element < $1.element })!.offset 136 + let cardHeight = card.size.height + titleBarHeight 137 + let colLeft = spacing + CGFloat(col) * (columnWidth + spacing) 67 138 68 139 layouts[card.key] = CanvasCardLayout( 69 - position: CGPoint(x: centerX, y: centerY), 140 + position: CGPoint( 141 + x: colLeft + columnWidth / 2, 142 + y: colHeights[col] + cardHeight / 2 143 + ), 70 144 size: card.size 71 145 ) 72 146 73 - columnHeights[col] += totalCardHeight + spacing 147 + colHeights[col] += cardHeight + spacing 148 + } 149 + 150 + let totalWidth = spacing + CGFloat(columns) * (columnWidth + spacing) 151 + let totalHeight = colHeights.max() ?? spacing 152 + 153 + return PackResult( 154 + layouts: layouts, 155 + boundingSize: CGSize(width: totalWidth, height: totalHeight) 156 + ) 157 + } 158 + 159 + // MARK: - Row-break layout 160 + 161 + /// Compute bounding size for a row-break configuration without allocating. 162 + private func rowBreakBoundingSize(cards: [CardInfo], breakMask: Int) -> (CGFloat, CGFloat) { 163 + var maxWidth = spacing 164 + var totalHeight = spacing 165 + var rowWidth = spacing 166 + var rowHeight: CGFloat = 0 167 + 168 + for idx in 0..<cards.count { 169 + if idx > 0 && (breakMask & (1 << (idx - 1))) != 0 { 170 + maxWidth = max(maxWidth, rowWidth) 171 + totalHeight += rowHeight + spacing 172 + rowWidth = spacing 173 + rowHeight = 0 174 + } 175 + rowWidth += cards[idx].size.width + spacing 176 + rowHeight = max(rowHeight, cards[idx].size.height + titleBarHeight) 74 177 } 75 178 76 - let totalHeight = columnHeights.max() ?? spacing 77 - return Result(layouts: layouts, totalHeight: totalHeight) 179 + maxWidth = max(maxWidth, rowWidth) 180 + totalHeight += rowHeight + spacing 181 + return (maxWidth, totalHeight) 182 + } 183 + 184 + /// Build card layouts from a row-break mask. Rows are centered horizontally. 185 + private func rowBreakLayout(cards: [CardInfo], breakMask: Int) -> PackResult { 186 + var rows: [[Int]] = [[0]] 187 + for idx in 1..<cards.count { 188 + if breakMask & (1 << (idx - 1)) != 0 { 189 + rows.append([idx]) 190 + } else { 191 + rows[rows.count - 1].append(idx) 192 + } 193 + } 194 + 195 + let rowWidths = rows.map { row -> CGFloat in 196 + row.reduce(spacing) { $0 + cards[$1].size.width + spacing } 197 + } 198 + let maxRowWidth = rowWidths.max() ?? 0 199 + 200 + var layouts: [String: CanvasCardLayout] = [:] 201 + var posY = spacing 202 + 203 + for (rowIndex, row) in rows.enumerated() { 204 + let rowHeight = row.map { cards[$0].size.height + titleBarHeight }.max() ?? 0 205 + let xOffset = (maxRowWidth - rowWidths[rowIndex]) / 2 206 + var posX = spacing + xOffset 207 + 208 + for idx in row { 209 + let card = cards[idx] 210 + let cardHeight = card.size.height + titleBarHeight 211 + layouts[card.key] = CanvasCardLayout( 212 + position: CGPoint( 213 + x: posX + card.size.width / 2, 214 + y: posY + cardHeight / 2 215 + ), 216 + size: card.size 217 + ) 218 + posX += card.size.width + spacing 219 + } 220 + 221 + posY += rowHeight + spacing 222 + } 223 + 224 + return PackResult( 225 + layouts: layouts, 226 + boundingSize: CGSize(width: maxRowWidth, height: posY) 227 + ) 78 228 } 79 229 } 80 230 ··· 82 232 @Observable 83 233 final class CanvasLayoutStore { 84 234 private static let storageKey = "canvasCardLayouts" 235 + 236 + /// Whether auto-arrange has run in this app session. Resets on app launch. 237 + static var hasAutoArrangedInSession = false 85 238 86 239 var cardLayouts: [String: CanvasCardLayout] { 87 240 didSet { save() }
+19 -34
supacode/Features/Canvas/Views/CanvasView.swift
··· 30 30 // Background layer: handles canvas pan and tap-to-unfocus. 31 31 Color.clear 32 32 .onAppear { ensureLayouts(for: allCardKeys) } 33 - .onChange(of: allCardKeys) { _, newKeys in ensureLayouts(for: newKeys) } 33 + .onChange(of: allCardKeys) { _, newKeys in 34 + if newKeys.isEmpty { 35 + CanvasLayoutStore.hasAutoArrangedInSession = false 36 + } 37 + ensureLayouts(for: newKeys) 38 + } 34 39 .contentShape(.rect) 35 40 .accessibilityAddTraits(.isButton) 36 41 .onTapGesture { unfocusAll() } ··· 95 100 viewportSize = newSize 96 101 if !hasPerformedInitialFit { 97 102 hasPerformedInitialFit = true 103 + if !CanvasLayoutStore.hasAutoArrangedInSession { 104 + CanvasLayoutStore.hasAutoArrangedInSession = true 105 + arrangeCards() 106 + } 98 107 fitToView(canvasSize: newSize) 99 108 } 100 109 } ··· 256 265 layoutStore.cardLayouts = layouts 257 266 } 258 267 259 - /// Arrange cards in a waterfall (masonry) layout that preserves each card's 260 - /// current size. Tries every possible column count, picks the one whose 261 - /// bounding rectangle best matches the viewport aspect ratio. 268 + /// Arrange cards using MaxRects-BSSF bin packing. Preserves each card's 269 + /// current size and finds a compact layout whose aspect ratio matches 270 + /// the viewport. 262 271 private func arrangeCards() { 263 272 let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) 264 273 guard !keys.isEmpty, viewportSize.width > 0, viewportSize.height > 0 else { return } 265 274 266 - let cards: [CanvasWaterfallPacker.CardInfo] = keys.map { key in 275 + let cards: [CanvasCardPacker.CardInfo] = keys.map { key in 267 276 let size = layoutStore.cardLayouts[key]?.size ?? CanvasCardLayout.defaultSize 268 - return CanvasWaterfallPacker.CardInfo(key: key, size: size) 277 + return CanvasCardPacker.CardInfo(key: key, size: size) 269 278 } 270 279 271 - let packer = waterfallPacker 280 + let packer = CanvasCardPacker(spacing: cardSpacing, titleBarHeight: titleBarHeight) 272 281 let targetRatio = viewportSize.width / viewportSize.height 273 - let columnWidth = cards.map(\.size.width).max() ?? CanvasCardLayout.defaultSize.width 274 - 275 - var bestResult: [String: CanvasCardLayout]? 276 - var bestRatioDiff = CGFloat.infinity 277 - 278 - for cols in 1...keys.count { 279 - let result = packer.pack(cards: cards, columns: cols, columnWidth: columnWidth) 280 - 281 - let totalWidth = CGFloat(cols) * (columnWidth + cardSpacing) + cardSpacing 282 - let ratio = totalWidth / result.totalHeight 283 - let diff = abs(ratio - targetRatio) 282 + let result = packer.pack(cards: cards, targetRatio: targetRatio) 284 283 285 - if diff < bestRatioDiff { 286 - bestRatioDiff = diff 287 - bestResult = result.layouts 288 - } 289 - 290 - // Once we've overshot the target ratio, further columns only make it worse. 291 - if ratio > targetRatio { break } 292 - } 293 - 294 - if let bestResult { 295 - layoutStore.cardLayouts = bestResult 296 - } 297 - } 298 - 299 - private var waterfallPacker: CanvasWaterfallPacker { 300 - CanvasWaterfallPacker(spacing: cardSpacing, titleBarHeight: titleBarHeight) 284 + guard !result.layouts.isEmpty else { return } 285 + layoutStore.cardLayouts = result.layouts 301 286 } 302 287 303 288 /// Adjust scale and offset so all cards fit within the viewport.
+194
supacodeTests/CanvasCardPackerTests.swift
··· 1 + import CoreGraphics 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct CanvasCardPackerTests { 7 + private let packer = CanvasCardPacker(spacing: 20, titleBarHeight: 28) 8 + 9 + private func card(_ key: String, width: CGFloat = 800, height: CGFloat = 550) -> CanvasCardPacker.CardInfo { 10 + CanvasCardPacker.CardInfo(key: key, size: CGSize(width: width, height: height)) 11 + } 12 + 13 + // MARK: - Basic packing 14 + 15 + @Test func singleCardPacks() throws { 16 + let result = packer.pack(cards: [card("a")], targetRatio: 16.0 / 9.0) 17 + 18 + let layout = try #require(result.layouts["a"]) 19 + #expect(layout.size == CGSize(width: 800, height: 550)) 20 + #expect(result.boundingSize.width > 0) 21 + #expect(result.boundingSize.height > 0) 22 + } 23 + 24 + @Test func preservesOriginalCardSizes() { 25 + let cards = [ 26 + card("a", width: 600, height: 400), 27 + card("b", width: 800, height: 300), 28 + ] 29 + let result = packer.pack(cards: cards, targetRatio: 1.5) 30 + 31 + #expect(result.layouts["a"]?.size == CGSize(width: 600, height: 400)) 32 + #expect(result.layouts["b"]?.size == CGSize(width: 800, height: 300)) 33 + } 34 + 35 + @Test func allCardsArePlaced() { 36 + let cards = (0..<5).map { card("card\($0)") } 37 + let result = packer.pack(cards: cards, targetRatio: 16.0 / 9.0) 38 + #expect(result.layouts.count == 5) 39 + } 40 + 41 + // MARK: - Scale maximization 42 + 43 + @Test func threeEqualCardsUseTwoColumns() throws { 44 + // 3 equal default-size cards on 16:9 viewport. 45 + // 2 columns gives the best scale; waterfall distributes them 2+1. 46 + let cards = (0..<3).map { card("card\($0)") } 47 + let result = packer.pack(cards: cards, targetRatio: 16.0 / 9.0) 48 + 49 + let card0 = try #require(result.layouts["card0"]) 50 + let card1 = try #require(result.layouts["card1"]) 51 + let card2 = try #require(result.layouts["card2"]) 52 + 53 + // Waterfall: card0 → col0, card1 → col1, card2 → col0 (shortest). 54 + #expect(card0.position.y == card1.position.y) 55 + #expect(card0.position.x != card1.position.x) 56 + #expect(card2.position.y > card0.position.y) 57 + } 58 + 59 + @Test func fourUniformCardsFormTwoByTwo() throws { 60 + // 4 equal cards, square target → 2 columns, 2 per column. 61 + let cards = (0..<4).map { card("card\($0)", width: 400, height: 400) } 62 + let result = packer.pack(cards: cards, targetRatio: 1.0) 63 + 64 + let card0 = try #require(result.layouts["card0"]) 65 + let card1 = try #require(result.layouts["card1"]) 66 + let card2 = try #require(result.layouts["card2"]) 67 + let card3 = try #require(result.layouts["card3"]) 68 + 69 + // Waterfall: card0→col0, card1→col1, card2→col0, card3→col1 70 + #expect(card0.position.x == card2.position.x) 71 + #expect(card1.position.x == card3.position.x) 72 + #expect(card0.position.y < card2.position.y) 73 + #expect(card1.position.y < card3.position.y) 74 + } 75 + 76 + // MARK: - Waterfall gap filling 77 + 78 + @Test func shortCardFillsGapBesideTallCard() throws { 79 + // One tall card + two short cards. Waterfall should place short cards 80 + // beside the tall card instead of waiting for the "row" to finish. 81 + let cards = [ 82 + card("tall", width: 800, height: 800), 83 + card("short1", width: 800, height: 300), 84 + card("short2", width: 800, height: 300), 85 + ] 86 + let result = packer.pack(cards: cards, targetRatio: 16.0 / 9.0) 87 + 88 + let tall = try #require(result.layouts["tall"]) 89 + let short2 = try #require(result.layouts["short2"]) 90 + 91 + // With 2 columns: tall→col0, short1→col1, short2→col1 (stacks in col1). 92 + // short2 should end within the tall card's height range, not below it. 93 + let tallBottom = tall.position.y + (tall.size.height + 28) / 2 94 + let short2Bottom = short2.position.y + (short2.size.height + 28) / 2 95 + #expect(short2Bottom <= tallBottom + 1, "short2 should fill gap beside tall card") 96 + } 97 + 98 + // MARK: - Mixed widths (row-break strategy) 99 + 100 + @Test func wideAndNarrowCardsUseRowBreak() throws { 101 + // 1 wide + 2 narrow cards with enough total width that single-row is 102 + // too wide. Row-break [wide][n1+n2] gives best scale by using the 103 + // narrow cards' actual width instead of max-width columns. 104 + let cards = [ 105 + card("wide", width: 1000, height: 550), 106 + card("narrow1", width: 600, height: 550), 107 + card("narrow2", width: 600, height: 550), 108 + ] 109 + let result = packer.pack(cards: cards, targetRatio: 16.0 / 9.0) 110 + 111 + let narrow1 = try #require(result.layouts["narrow1"]) 112 + let narrow2 = try #require(result.layouts["narrow2"]) 113 + 114 + // Row-break should place narrow cards side by side on their own row. 115 + #expect(narrow1.position.y == narrow2.position.y) 116 + #expect(narrow1.position.x != narrow2.position.x) 117 + // Bounding width should use actual card widths, not max-width columns. 118 + #expect(result.boundingSize.width < 1500) 119 + } 120 + 121 + // MARK: - No overlap 122 + 123 + @Test func cardsDoNotOverlap() { 124 + let cards = [ 125 + card("a", width: 600, height: 400), 126 + card("b", width: 800, height: 300), 127 + card("c", width: 500, height: 500), 128 + card("d", width: 700, height: 350), 129 + ] 130 + let result = packer.pack(cards: cards, targetRatio: 1.5) 131 + 132 + let rects = result.layouts.map { (_, layout) -> CGRect in 133 + CGRect( 134 + x: layout.position.x - layout.size.width / 2, 135 + y: layout.position.y - (layout.size.height + 28) / 2, 136 + width: layout.size.width, 137 + height: layout.size.height + 28 138 + ) 139 + } 140 + 141 + for outer in 0..<rects.count { 142 + for inner in (outer + 1)..<rects.count { 143 + let insetA = rects[outer].insetBy(dx: 1, dy: 1) 144 + let insetB = rects[inner].insetBy(dx: 1, dy: 1) 145 + #expect(!insetA.intersects(insetB), "Cards \(outer) and \(inner) overlap") 146 + } 147 + } 148 + } 149 + 150 + // MARK: - Edge cases 151 + 152 + @Test func emptyCardsReturnsEmptyResult() { 153 + let result = packer.pack(cards: [], targetRatio: 1.5) 154 + #expect(result.layouts.isEmpty) 155 + #expect(result.boundingSize == .zero) 156 + } 157 + 158 + // MARK: - Spacing 159 + 160 + @Test func columnsHaveMinimumSpacing() throws { 161 + let cards = [ 162 + card("a", width: 600, height: 400), 163 + card("b", width: 600, height: 400), 164 + ] 165 + // Wide target → 2 columns. 166 + let result = packer.pack(cards: cards, targetRatio: 3.0) 167 + 168 + let layoutA = try #require(result.layouts["a"]) 169 + let layoutB = try #require(result.layouts["b"]) 170 + 171 + // Cards in different columns should have spacing between column edges. 172 + #expect(layoutA.position.x != layoutB.position.x) 173 + let colWidth: CGFloat = 600 174 + let aRight = layoutA.position.x + colWidth / 2 175 + let bLeft = layoutB.position.x - colWidth / 2 176 + #expect(bLeft - aRight >= 20 - 1, "Column gap too small: \(bLeft - aRight)") 177 + } 178 + 179 + @Test func cardsInSameColumnHaveMinimumSpacing() throws { 180 + let cards = [ 181 + card("a", width: 800, height: 400), 182 + card("b", width: 800, height: 400), 183 + ] 184 + // Narrow target → 1 column, stacked. 185 + let result = packer.pack(cards: cards, targetRatio: 0.5) 186 + 187 + let layoutA = try #require(result.layouts["a"]) 188 + let layoutB = try #require(result.layouts["b"]) 189 + 190 + let aBottom = layoutA.position.y + (layoutA.size.height + 28) / 2 191 + let bTop = layoutB.position.y - (layoutB.size.height + 28) / 2 192 + #expect(bTop - aBottom >= 20 - 1, "Vertical gap too small: \(bTop - aBottom)") 193 + } 194 + }
-134
supacodeTests/CanvasWaterfallPackerTests.swift
··· 1 - import CoreGraphics 2 - import Testing 3 - 4 - @testable import supacode 5 - 6 - struct CanvasWaterfallPackerTests { 7 - private let packer = CanvasWaterfallPacker(spacing: 20, titleBarHeight: 28) 8 - 9 - private func card(_ key: String, width: CGFloat = 800, height: CGFloat = 550) -> CanvasWaterfallPacker.CardInfo { 10 - CanvasWaterfallPacker.CardInfo(key: key, size: CGSize(width: width, height: height)) 11 - } 12 - 13 - // MARK: - Single column 14 - 15 - @Test func singleCardSingleColumn() throws { 16 - let result = packer.pack(cards: [card("a")], columns: 1, columnWidth: 800) 17 - 18 - let layout = try #require(result.layouts["a"]) 19 - // centerX = spacing + columnWidth / 2 = 20 + 400 = 420 20 - #expect(layout.position.x == 420) 21 - // centerY = spacing + (height + titleBar) / 2 = 20 + (550 + 28) / 2 = 20 + 289 = 309 22 - #expect(layout.position.y == 309) 23 - #expect(layout.size == CGSize(width: 800, height: 550)) 24 - // totalHeight = spacing + (height + titleBar) + spacing = 20 + 578 + 20 = 618 25 - #expect(result.totalHeight == 618) 26 - } 27 - 28 - @Test func multipleCardsSingleColumnStackVertically() throws { 29 - let cards = [card("a", height: 400), card("b", height: 300)] 30 - let result = packer.pack(cards: cards, columns: 1, columnWidth: 800) 31 - 32 - let layoutA = try #require(result.layouts["a"]) 33 - let layoutB = try #require(result.layouts["b"]) 34 - 35 - // Card "a" starts at spacing (20) 36 - let aCardHeight: CGFloat = 400 + 28 37 - #expect(layoutA.position.y == 20 + aCardHeight / 2) 38 - 39 - // Card "b" starts after "a" + spacing 40 - let bCardHeight: CGFloat = 300 + 28 41 - let bStartY = 20 + aCardHeight + 20 42 - #expect(layoutB.position.y == bStartY + bCardHeight / 2) 43 - 44 - // Both in same column → same centerX 45 - #expect(layoutA.position.x == layoutB.position.x) 46 - 47 - // totalHeight = spacing + aCardH + spacing + bCardH + spacing 48 - #expect(result.totalHeight == 20 + aCardHeight + 20 + bCardHeight + 20) 49 - } 50 - 51 - // MARK: - Multiple columns 52 - 53 - @Test func twoCardsTwoColumnsPlaceSideBySide() throws { 54 - let cards = [card("a"), card("b")] 55 - let result = packer.pack(cards: cards, columns: 2, columnWidth: 800) 56 - 57 - let layoutA = try #require(result.layouts["a"]) 58 - let layoutB = try #require(result.layouts["b"]) 59 - 60 - // Same Y (both start at top) 61 - #expect(layoutA.position.y == layoutB.position.y) 62 - // Different X (different columns) 63 - #expect(layoutA.position.x != layoutB.position.x) 64 - // Column 0 centerX = 20 + 800/2 = 420 65 - #expect(layoutA.position.x == 420) 66 - // Column 1 centerX = 20 + (800 + 20) + 800/2 = 20 + 820 + 400 = 1240 67 - #expect(layoutB.position.x == 1240) 68 - } 69 - 70 - // MARK: - Waterfall distribution 71 - 72 - @Test func thirdCardGoesToShorterColumn() throws { 73 - let cards = [ 74 - card("a", height: 600), 75 - card("b", height: 300), 76 - card("c", height: 200), 77 - ] 78 - let result = packer.pack(cards: cards, columns: 2, columnWidth: 800) 79 - 80 - let layoutA = try #require(result.layouts["a"]) 81 - let layoutB = try #require(result.layouts["b"]) 82 - let layoutC = try #require(result.layouts["c"]) 83 - 84 - // "a" → col 0, "b" → col 1 (both start at same height) 85 - // After placing: col0 = 20+628+20 = 668, col1 = 20+328+20 = 368 86 - // "c" → col 1 (shorter) 87 - #expect(layoutA.position.x == layoutC.position.x || layoutB.position.x == layoutC.position.x) 88 - // "c" should be in col 1 (same x as "b") 89 - #expect(layoutC.position.x == layoutB.position.x) 90 - } 91 - 92 - // MARK: - Size preservation 93 - 94 - @Test func preservesOriginalCardSizes() { 95 - let cards = [ 96 - card("a", width: 600, height: 400), 97 - card("b", width: 800, height: 300), 98 - ] 99 - let result = packer.pack(cards: cards, columns: 2, columnWidth: 800) 100 - 101 - #expect(result.layouts["a"]?.size == CGSize(width: 600, height: 400)) 102 - #expect(result.layouts["b"]?.size == CGSize(width: 800, height: 300)) 103 - } 104 - 105 - // MARK: - Edge cases 106 - 107 - @Test func emptyCardsReturnsSpacingHeight() { 108 - let result = packer.pack(cards: [], columns: 1, columnWidth: 800) 109 - #expect(result.layouts.isEmpty) 110 - #expect(result.totalHeight == 20) 111 - } 112 - 113 - @Test func moreColumnsThanCards() { 114 - let cards = [card("a")] 115 - let result = packer.pack(cards: cards, columns: 5, columnWidth: 800) 116 - 117 - #expect(result.layouts.count == 1) 118 - // Card should be in the first column 119 - #expect(result.layouts["a"]?.position.x == 420) 120 - } 121 - 122 - @Test func totalHeightIsMaxColumnHeight() { 123 - let cards = [ 124 - card("a", height: 600), 125 - card("b", height: 200), 126 - card("c", height: 400), 127 - ] 128 - // 2 cols: a→col0, b→col1, c→col1 (shorter after b) 129 - // col0 = 20 + 628 + 20 = 668 130 - // col1 = 20 + 228 + 20 + 428 + 20 = 716 131 - let result = packer.pack(cards: cards, columns: 2, columnWidth: 800) 132 - #expect(result.totalHeight == 716) 133 - } 134 - }