native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #7 from onevcat/feature/canvas-arrange

feat(canvas): improve card sizing and add waterfall Arrange

authored by

Wei Wang and committed by
GitHub
85d80318 ce9a1540

+251 -15
+46 -1
supacode/Features/Canvas/Models/CanvasCardLayout.swift
··· 23 23 } 24 24 } 25 25 26 - static let defaultSize = CGSize(width: 600, height: 400) 26 + static let defaultSize = CGSize(width: 800, height: 550) 27 27 28 28 init(position: CGPoint, size: CGSize = Self.defaultSize) { 29 29 self.positionX = position.x 30 30 self.positionY = position.y 31 31 self.width = size.width 32 32 self.height = size.height 33 + } 34 + } 35 + 36 + struct CanvasWaterfallPacker { 37 + var spacing: CGFloat 38 + var titleBarHeight: CGFloat 39 + 40 + struct CardInfo { 41 + var key: String 42 + var size: CGSize 43 + } 44 + 45 + struct Result { 46 + var layouts: [String: CanvasCardLayout] 47 + var totalHeight: CGFloat 48 + } 49 + 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( 53 + cards: [CardInfo], 54 + columns: Int, 55 + columnWidth: CGFloat 56 + ) -> Result { 57 + var columnHeights = Array(repeating: spacing, count: columns) 58 + var layouts: [String: CanvasCardLayout] = [:] 59 + 60 + 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 67 + 68 + layouts[card.key] = CanvasCardLayout( 69 + position: CGPoint(x: centerX, y: centerY), 70 + size: card.size 71 + ) 72 + 73 + columnHeights[col] += totalCardHeight + spacing 74 + } 75 + 76 + let totalHeight = columnHeights.max() ?? spacing 77 + return Result(layouts: layouts, totalHeight: totalHeight) 33 78 } 34 79 } 35 80
+71 -14
supacode/Features/Canvas/Views/CanvasView.swift
··· 16 16 17 17 private let minCardWidth: CGFloat = 300 18 18 private let minCardHeight: CGFloat = 200 19 - private let maxCardWidth: CGFloat = 1200 20 - private let maxCardHeight: CGFloat = 900 19 + private let maxCardWidth: CGFloat = 2400 20 + private let maxCardHeight: CGFloat = 1600 21 21 private let titleBarHeight: CGFloat = 28 22 22 private let cardSpacing: CGFloat = 20 23 23 ··· 100 100 } 101 101 } 102 102 .overlay(alignment: .bottomTrailing) { 103 - organizeButton 103 + canvasToolbar 104 104 } 105 105 .task { activateCanvas() } 106 106 .onDisappear { deactivateCanvas() } ··· 243 243 } 244 244 } 245 245 246 - /// Reset all card positions to a clean grid layout. 246 + /// Reset all card positions to a clean grid layout (uniform sizes). 247 247 private func organizeCards() { 248 248 let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) 249 249 let columns = gridColumns(for: keys.count) ··· 256 256 layoutStore.cardLayouts = layouts 257 257 } 258 258 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. 262 + private func arrangeCards() { 263 + let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) 264 + guard !keys.isEmpty, viewportSize.width > 0, viewportSize.height > 0 else { return } 265 + 266 + let cards: [CanvasWaterfallPacker.CardInfo] = keys.map { key in 267 + let size = layoutStore.cardLayouts[key]?.size ?? CanvasCardLayout.defaultSize 268 + return CanvasWaterfallPacker.CardInfo(key: key, size: size) 269 + } 270 + 271 + let packer = waterfallPacker 272 + 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) 284 + 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) 301 + } 302 + 259 303 /// Adjust scale and offset so all cards fit within the viewport. 260 304 private func fitToView(canvasSize: CGSize) { 261 305 guard canvasSize.width > 0, canvasSize.height > 0 else { return } ··· 308 352 layoutStore.cardLayouts = layouts 309 353 } 310 354 311 - private var organizeButton: some View { 312 - Button { 313 - organizeCards() 314 - fitToView(canvasSize: viewportSize) 315 - } label: { 316 - Image(systemName: "square.grid.2x2") 317 - .font(.body) 318 - .accessibilityLabel("Organize") 355 + private var canvasToolbar: some View { 356 + HStack(spacing: 8) { 357 + Button { 358 + arrangeCards() 359 + fitToView(canvasSize: viewportSize) 360 + } label: { 361 + Image(systemName: "rectangle.3.group") 362 + .font(.body) 363 + .accessibilityLabel("Arrange") 364 + } 365 + .buttonStyle(.bordered) 366 + .help("Arrange cards preserving sizes") 367 + 368 + Button { 369 + organizeCards() 370 + fitToView(canvasSize: viewportSize) 371 + } label: { 372 + Image(systemName: "square.grid.2x2") 373 + .font(.body) 374 + .accessibilityLabel("Organize") 375 + } 376 + .buttonStyle(.bordered) 377 + .help("Organize cards in a uniform grid") 319 378 } 320 - .buttonStyle(.bordered) 321 379 .padding() 322 - .help("Organize cards in a grid") 323 380 } 324 381 325 382 // MARK: - Drag
+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 + }