diff --git a/supacode/Features/Canvas/Models/CanvasCardLayout.swift b/supacode/Features/Canvas/Models/CanvasCardLayout.swift index 51b7d00d..dfcac4e6 100644 --- a/supacode/Features/Canvas/Models/CanvasCardLayout.swift +++ b/supacode/Features/Canvas/Models/CanvasCardLayout.swift @@ -23,7 +23,34 @@ struct CanvasCardLayout: Codable, Equatable, Hashable, Sendable { } } - static let defaultSize = CGSize(width: 800, height: 550) + /// Card size used on small screens (≈14" MacBook Pro). On a small viewport + /// the canvas must zoom out to fit a multi-card grid, which shrinks rendered + /// text; smaller cards keep the fit-to-view scale — and thus text — larger. + static let minDefaultSize = CGSize(width: 800, height: 550) + /// Card size used on large screens (≈27" display and up), where fit-to-view + /// caps at 1.0 so a larger card simply shows more content at native size. + static let maxDefaultSize = CGSize(width: 1000, height: 680) + /// Reference screen widths (logical points, default scaling) mapped to the + /// interpolation endpoints above. + static let minDefaultScreenWidth: CGFloat = 1512 // 14" MacBook Pro + static let maxDefaultScreenWidth: CGFloat = 2560 // 27" / Studio Display + + /// Size new cards adopt when no explicit size is given. Equal to + /// `maxDefaultSize`, used only for transient fallbacks; creation paths pass + /// an explicit `adaptiveDefaultSize(forScreenWidth:)` instead. + static let defaultSize = maxDefaultSize + + /// Linearly interpolate the default card size between `minDefaultSize` + /// (14"-class screens) and `maxDefaultSize` (27"-class and larger) by screen + /// width, clamped at both ends. + static func adaptiveDefaultSize(forScreenWidth screenWidth: CGFloat) -> CGSize { + let span = maxDefaultScreenWidth - minDefaultScreenWidth + let fraction = span > 0 ? min(max((screenWidth - minDefaultScreenWidth) / span, 0), 1) : 1 + return CGSize( + width: minDefaultSize.width + (maxDefaultSize.width - minDefaultSize.width) * fraction, + height: minDefaultSize.height + (maxDefaultSize.height - minDefaultSize.height) * fraction + ) + } init(position: CGPoint, size: CGSize = Self.defaultSize) { self.positionX = position.x diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index ce48af08..4c4c2210 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -44,6 +44,20 @@ struct CanvasView: View { /// Cards end up shifted upward by half of this amount. private let bottomToolbarReserve: CGFloat = 50 + /// Width of the screen hosting the canvas window, used to scale the default + /// card size. Falls back to the large-screen reference when unknown. + private var hostScreenWidth: CGFloat { + (NSApp.keyWindow?.screen ?? NSScreen.main)?.frame.width + ?? CanvasCardLayout.maxDefaultScreenWidth + } + + /// Default size for newly created and uniformly arranged cards, scaled to the + /// host screen so small screens (14") don't zoom out into tiny text while + /// large screens still get the roomier card. + private var adaptiveDefaultCardSize: CGSize { + CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: hostScreenWidth) + } + var body: some View { let selectAllCanvasShortcut = AppShortcuts.resolvedShortcut( for: AppShortcuts.CommandID.selectAllCanvasCards, @@ -353,10 +367,12 @@ struct CanvasView: View { : gridColumns(for: cardKeys.count) // Build locally, assign once to trigger a single save. + let cardSize = adaptiveDefaultCardSize var layouts = layoutStore.cardLayouts for (offset, key) in unpositioned.enumerated() { layouts[key] = CanvasCardLayout( - position: gridPosition(index: positionedCount + offset, columns: columns) + position: gridPosition(index: positionedCount + offset, columns: columns, cardSize: cardSize), + size: cardSize ) } layoutStore.setCardLayouts(layouts) @@ -368,9 +384,9 @@ struct CanvasView: View { max(1, Int(ceil(sqrt(Double(count))))) } - private func gridPosition(index: Int, columns: Int) -> CGPoint { - let cardW = CanvasCardLayout.defaultSize.width - let cardH = CanvasCardLayout.defaultSize.height + titleBarHeight + private func gridPosition(index: Int, columns: Int, cardSize: CGSize) -> CGPoint { + let cardW = cardSize.width + let cardH = cardSize.height + titleBarHeight let row = index / columns let col = index % columns return CGPoint( @@ -454,10 +470,12 @@ struct CanvasView: View { private func organizeCards() { let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) let columns = gridColumns(for: keys.count) + let cardSize = adaptiveDefaultCardSize var layouts = layoutStore.cardLayouts for (index, key) in keys.enumerated() { layouts[key] = CanvasCardLayout( - position: gridPosition(index: index, columns: columns) + position: gridPosition(index: index, columns: columns, cardSize: cardSize), + size: cardSize ) } layoutStore.setCardLayouts(layouts, zOrder: keys) @@ -471,7 +489,7 @@ struct CanvasView: View { guard !keys.isEmpty, viewportSize.width > 0, viewportSize.height > 0 else { return } let cards: [CanvasCardPacker.CardInfo] = keys.map { key in - let size = layoutStore.cardLayouts[key]?.size ?? CanvasCardLayout.defaultSize + let size = layoutStore.cardLayouts[key]?.size ?? adaptiveDefaultCardSize return CanvasCardPacker.CardInfo(key: key, size: size) } diff --git a/supacodeTests/CanvasCardSizingTests.swift b/supacodeTests/CanvasCardSizingTests.swift new file mode 100644 index 00000000..fa965d8a --- /dev/null +++ b/supacodeTests/CanvasCardSizingTests.swift @@ -0,0 +1,53 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import supacode + +struct CanvasCardSizingTests { + @Test func smallScreenClampsToMinSize() { + let size = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 1280) + #expect(size == CanvasCardLayout.minDefaultSize) + } + + @Test func atMinReferenceWidthReturnsMinSize() { + let size = CanvasCardLayout.adaptiveDefaultSize( + forScreenWidth: CanvasCardLayout.minDefaultScreenWidth + ) + #expect(size == CanvasCardLayout.minDefaultSize) + } + + @Test func largeScreenClampsToMaxSize() { + let size = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 3840) + #expect(size == CanvasCardLayout.maxDefaultSize) + } + + @Test func atMaxReferenceWidthReturnsMaxSize() { + let size = CanvasCardLayout.adaptiveDefaultSize( + forScreenWidth: CanvasCardLayout.maxDefaultScreenWidth + ) + #expect(size == CanvasCardLayout.maxDefaultSize) + } + + @Test func midpointInterpolatesLinearly() { + let midWidth = + (CanvasCardLayout.minDefaultScreenWidth + CanvasCardLayout.maxDefaultScreenWidth) / 2 + let size = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: midWidth) + let expectedWidth = + (CanvasCardLayout.minDefaultSize.width + CanvasCardLayout.maxDefaultSize.width) / 2 + let expectedHeight = + (CanvasCardLayout.minDefaultSize.height + CanvasCardLayout.maxDefaultSize.height) / 2 + #expect(abs(size.width - expectedWidth) < 0.001) + #expect(abs(size.height - expectedHeight) < 0.001) + } + + @Test func sizeGrowsMonotonicallyWithScreenWidth() { + let small = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 1600) + let medium = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 2000) + let large = CanvasCardLayout.adaptiveDefaultSize(forScreenWidth: 2400) + #expect(small.width < medium.width) + #expect(medium.width < large.width) + #expect(small.height < medium.height) + #expect(medium.height < large.height) + } +}