diff --git a/doc-onevcat/plans/2026-06-24-canvas-tile-layout-plan.md b/doc-onevcat/plans/2026-06-24-canvas-tile-layout-plan.md index 15a688a8..6ca83ac0 100644 --- a/doc-onevcat/plans/2026-06-24-canvas-tile-layout-plan.md +++ b/doc-onevcat/plans/2026-06-24-canvas-tile-layout-plan.md @@ -64,12 +64,24 @@ Prowl 的画布模式(Canvas)当前提供两种卡片排序,入口在 `Can > 观感。若日后想要极端比例下进一步铺开,可在 `lineCounts` 上叠加一层 aspect-aware 的 > 候选评分(按最大化最小卡片面积选 `s`),属于后续增强、不在本次范围。 -### 缩放策略(已确认) +### 缩放策略(已确认 + 自适应增强) **复用现有 `fitToView`**:Tile 在画布坐标系按视口比例摆好卡片后,调用 `fitToView` 居中并 缩放。因为布局 bounding box 的宽高比 ≈ 视口宽高比,`fitToView` 的 `min(W/bboxW, H/bboxH)` -会让两个方向同时贴合(仅受 30pt padding + 底部 reserve 影响留出少量边距),行为与 -Arrange/Organize 完全一致,改动最小。 +会让两个方向同时贴合。 + +**自适应 zoom(v2 增强,回应"字太大、间距偏大"反馈)**:固定 scale=1 时,卡片多→单卡 +surface 小→终端行列少→字相对显得大、内容少。改进做法:`layout` 在一个 +`viewport × zoom` 的放大画框里铺卡,`fitToView` 自然得到 `scale ≈ 1/zoom`。 + +- `zoom = max(1, comfortableSize / 单卡 surface)`:卡片本就够大时 `zoom=1`(scale≈1, + 与单窗口体验一致);卡片缩小到 `comfortableSize` 以下时 `zoom>1`,surface 维持舒适 + 尺寸(更多行列、字更小、内容更多)。`comfortableSize = adaptiveDefaultCardSize × 0.6`, + 让少量卡片保持原生 scale,再平滑过渡。 +- **间距**:Tile 用更小的 `tileCardSpacing = 14`(其余模式 20);它活在放大画框里,屏幕 + 间距 = `14 × scale`,会随卡片增多自动收紧——同时解决"间距偏大"与"不随尺寸适配"。 +- `fitToView` 的 scale 夹在 `[0.25, 1.0]`:`zoom>1 → scale≤1`;极端卡片数 zoom 很大时 + scale 触底 0.25、卡片轻微溢出,属可接受降级。 ## 算法细节 diff --git a/docs/components/canvas.md b/docs/components/canvas.md index e99229fe..877e4296 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -62,7 +62,10 @@ Selection controls: automatic window manager. Cards form a balanced grid whose orientation follows the window: a wide window spreads them into rows (2 cards → left/right, 5 → top 2 / bottom 3), a tall window stacks them into columns. Each line fills its - full extent, so the cards use as much area as possible. + full extent, so the cards use as much area as possible. The zoom adapts to card + count: a few cards stay at native scale (large, detailed), while many cards zoom + out so each keeps a readable terminal surface (more rows/columns at smaller text + — enough to follow what each agent is doing). These also appear as toolbar buttons. There's a `?` help popover (bottom-left) explaining pan/zoom/expand. diff --git a/supacode/Features/Canvas/Models/CanvasCardLayout.swift b/supacode/Features/Canvas/Models/CanvasCardLayout.swift index 4f1b3e2d..131e50cd 100644 --- a/supacode/Features/Canvas/Models/CanvasCardLayout.swift +++ b/supacode/Features/Canvas/Models/CanvasCardLayout.swift @@ -281,21 +281,51 @@ struct CanvasTileLayout { /// Resize and position `keys` to tile and fill `viewport`. Returns an empty /// dictionary when there is nothing to lay out (the caller treats this as a /// no-op, matching `CanvasCardPacker.pack`). - func layout(keys: [String], viewport: CGSize) -> [String: CanvasCardLayout] { + /// + /// The layout is built in a `viewport × zoom` frame and laid out edge-to-edge, + /// so `fitToView` reproduces a scale of `1 / zoom`. `zoom` stays `1` (native + /// scale, identical to single-card framing) while the tiled cards are already + /// at least `comfortableSize`; once the grid shrinks cards below that, `zoom` + /// grows so each card keeps a comfortable terminal surface (more rows/columns + /// at smaller on-screen text) instead of a few oversized glyphs. The constant + /// `spacing` lives in this scaled frame, so the on-screen gap (`spacing × scale`) + /// tightens automatically as more cards are tiled. + func layout(keys: [String], viewport: CGSize, comfortableSize: CGSize) -> [String: CanvasCardLayout] { guard !keys.isEmpty, viewport.width > 0, viewport.height > 0 else { return [:] } let counts = Self.lineCounts(for: keys.count) let landscape = viewport.width >= viewport.height + let lineCount = CGFloat(counts.count) + let maxPerLine = CGFloat(counts.max() ?? 1) + + // Enlarge the frame so the smallest card surface reaches `comfortableSize`; + // `fitToView` later scales the whole frame back down to the viewport. + let zoom: CGFloat + if landscape { + // Lines are rows: a card's width ≈ frameWidth / maxPerLine, height ≈ frameHeight / lineCount. + zoom = max( + 1, + max(comfortableSize.width * maxPerLine / viewport.width, comfortableSize.height * lineCount / viewport.height) + ) + } else { + // Lines are columns: width ≈ frameWidth / lineCount, height ≈ frameHeight / maxPerLine. + zoom = max( + 1, + max(comfortableSize.width * lineCount / viewport.width, comfortableSize.height * maxPerLine / viewport.height) + ) + } + let frame = CGSize(width: viewport.width * zoom, height: viewport.height * zoom) + var layouts: [String: CanvasCardLayout] = [:] var cursor = 0 if landscape { // Lines are rows: split the height evenly, fill each row's width. let rows = counts.count - let rowVisualHeight = (viewport.height - CGFloat(rows + 1) * spacing) / CGFloat(rows) + let rowVisualHeight = (frame.height - CGFloat(rows + 1) * spacing) / CGFloat(rows) var originY = spacing for cardsInRow in counts { - let cardWidth = (viewport.width - CGFloat(cardsInRow + 1) * spacing) / CGFloat(cardsInRow) + let cardWidth = (frame.width - CGFloat(cardsInRow + 1) * spacing) / CGFloat(cardsInRow) var originX = spacing for _ in 0.. 0, viewportSize.height > 0 else { return } - let tiler = CanvasTileLayout(spacing: cardSpacing, titleBarHeight: titleBarHeight) - let layouts = tiler.layout(keys: keys, viewport: viewportSize) + // Below this card surface, scale the layout up (and the viewport back down) + // so cards keep enough rows/columns to read at a glance. 0.6 keeps a handful + // of cards at native scale before the gentle zoom-out begins. + let comfortableSize = CGSize( + width: adaptiveDefaultCardSize.width * 0.6, + height: adaptiveDefaultCardSize.height * 0.6 + ) + let tiler = CanvasTileLayout(spacing: tileCardSpacing, titleBarHeight: titleBarHeight) + let layouts = tiler.layout(keys: keys, viewport: viewportSize, comfortableSize: comfortableSize) guard !layouts.isEmpty else { return } layoutStore.setCardLayouts(layouts, zOrder: keys) } diff --git a/supacodeTests/CanvasTileLayoutTests.swift b/supacodeTests/CanvasTileLayoutTests.swift index d9ff5267..a9d1ed10 100644 --- a/supacodeTests/CanvasTileLayoutTests.swift +++ b/supacodeTests/CanvasTileLayoutTests.swift @@ -15,6 +15,17 @@ struct CanvasTileLayoutTests { (0.. [String: CanvasCardLayout] { + tiler.layout(keys: keys(count), viewport: viewport, comfortableSize: comfortable) + } + /// Visual rect (terminal + title bar) of a laid-out card, used for overlap checks. private func visualRect(_ layout: CanvasCardLayout) -> CGRect { let width = layout.size.width @@ -63,17 +74,17 @@ struct CanvasTileLayoutTests { // MARK: - Empty / guard @Test func emptyKeysProduceNoLayouts() { - #expect(tiler.layout(keys: [], viewport: CGSize(width: 1600, height: 900)).isEmpty) + #expect(layout(0, viewport: CGSize(width: 1600, height: 900)).isEmpty) } @Test func zeroViewportProducesNoLayouts() { - #expect(tiler.layout(keys: keys(3), viewport: .zero).isEmpty) + #expect(layout(3, viewport: .zero).isEmpty) } // MARK: - Orientation @Test func wideViewportPlacesTwoCardsSideBySide() throws { - let layouts = tiler.layout(keys: keys(2), viewport: CGSize(width: 1600, height: 900)) + let layouts = layout(2, viewport: CGSize(width: 1600, height: 900)) let left = try #require(layouts["card0"]) let right = try #require(layouts["card1"]) @@ -86,7 +97,7 @@ struct CanvasTileLayoutTests { } @Test func tallViewportStacksTwoCardsVertically() throws { - let layouts = tiler.layout(keys: keys(2), viewport: CGSize(width: 900, height: 1600)) + let layouts = layout(2, viewport: CGSize(width: 900, height: 1600)) let top = try #require(layouts["card0"]) let bottom = try #require(layouts["card1"]) @@ -97,7 +108,7 @@ struct CanvasTileLayoutTests { } @Test func wideFiveCardsFormTopTwoBottomThree() throws { - let layouts = tiler.layout(keys: keys(5), viewport: CGSize(width: 1600, height: 900)) + let layouts = layout(5, viewport: CGSize(width: 1600, height: 900)) let topRow = try [layouts["card0"], layouts["card1"]].map { try #require($0) } let bottomRow = try [layouts["card2"], layouts["card3"], layouts["card4"]].map { try #require($0) } @@ -113,10 +124,13 @@ struct CanvasTileLayoutTests { // MARK: - Fill & non-overlap @Test func cardsNeverOverlap() throws { + // A realistic comfortable size triggers zoom > 1 for the denser counts; + // overlap-freedom must hold at every zoom (a uniform frame scale). + let comfortable = CGSize(width: 500, height: 340) let viewports = [CGSize(width: 1600, height: 900), CGSize(width: 900, height: 1600)] for viewport in viewports { for count in 1...12 { - let layouts = tiler.layout(keys: keys(count), viewport: viewport) + let layouts = layout(count, viewport: viewport, comfortable: comfortable) #expect(layouts.count == count) assertNoOverlap(layouts.values.map(visualRect)) } @@ -125,7 +139,7 @@ struct CanvasTileLayoutTests { @Test func eachRowFillsViewportWidth() throws { let width: CGFloat = 1600 - let layouts = tiler.layout(keys: keys(3), viewport: CGSize(width: width, height: 900)) + let layouts = layout(3, viewport: CGSize(width: width, height: 900)) let rects = (0..<3).compactMap { layouts["card\($0)"] }.map(visualRect) let minX = rects.map(\.minX).min()! let maxX = rects.map(\.maxX).max()! @@ -134,13 +148,43 @@ struct CanvasTileLayoutTests { #expect(abs(maxX - (width - spacing)) < 0.001) } + // MARK: - Adaptive zoom + + @Test func fewComfortableCardsStayAtNativeZoom() throws { + // Two cards on a wide viewport are already larger than `comfortable`, so the + // frame equals the viewport (zoom == 1 → fitToView scale ≈ 1, native text). + let viewport = CGSize(width: 1600, height: 900) + let layouts = layout(2, viewport: viewport, comfortable: CGSize(width: 500, height: 340)) + let maxX = layouts.values.map { visualRect($0).maxX }.max()! + #expect(abs(maxX - (viewport.width - spacing)) < 0.001) + } + + @Test func manySmallCardsEnlargeFrameForAdaptiveZoom() throws { + // Twelve cards shrink each cell below `comfortable`, so the frame grows past + // the viewport; fitToView then zooms out (smaller text, more terminal content). + let viewport = CGSize(width: 1600, height: 900) + let comfortable = CGSize(width: 500, height: 340) + let adaptive = layout(12, viewport: viewport, comfortable: comfortable) + let native = layout(12, viewport: viewport) // comfortable 1×1 → zoom 1 + + let adaptiveFrameWidth = adaptive.values.map { visualRect($0).maxX }.max()! + spacing + let nativeFrameWidth = native.values.map { visualRect($0).maxX }.max()! + spacing + + // Adaptive frame is larger than the viewport, and larger than the un-zoomed + // layout — i.e. every card surface gains resolution. + #expect(adaptiveFrameWidth > viewport.width) + #expect(adaptiveFrameWidth > nativeFrameWidth) + let adaptiveMinWidth = adaptive.values.map(\.size.width).min()! + let nativeMinWidth = native.values.map(\.size.width).min()! + #expect(adaptiveMinWidth > nativeMinWidth) + } + // MARK: - Small viewports @Test func smallViewportTilesExactlyWithoutClamping() throws { - // Tile sizes cards by dividing the viewport, so a small viewport yields - // small cards (below default sizes) rather than overlapping ones — fitToView - // handles the visual scaling afterwards. - let layouts = tiler.layout(keys: keys(4), viewport: CGSize(width: 400, height: 300)) + // With no comfortable floor (1×1), tile sizes cards by dividing the viewport, + // so a small viewport yields small cards rather than overlapping ones. + let layouts = layout(4, viewport: CGSize(width: 400, height: 300)) let rects = (0..<4).compactMap { layouts["card\($0)"] }.map(visualRect) // 2×2 grid: each cell is well under the 300pt default minimum width. #expect(rects.allSatisfy { $0.width < 300 })