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 new file mode 100644 index 00000000..15a688a8 --- /dev/null +++ b/doc-onevcat/plans/2026-06-24-canvas-tile-layout-plan.md @@ -0,0 +1,168 @@ +# Canvas Tile Layout(平铺占满视口) + +## Context + +Prowl 的画布模式(Canvas)当前提供两种卡片排序,入口在 `CanvasView.canvasToolbar` +(`supacode/Features/Canvas/Views/CanvasView.swift`)与命令面板: + +| 模式 | 快捷键 | 卡片尺寸 | 算法 | 入口函数 | +|------|--------|---------|------|---------| +| **Organize** | ⌘⌥G | 统一默认尺寸(`adaptiveDefaultCardSize`),**不随卡片当前大小变化** | √N 平衡网格(`gridColumns`/`gridPosition`) | `organizeCards()` | +| **Arrange** | ⌘⌥R | **保留每张卡片当前尺寸** | MaxRects 风格 bin-packing(`CanvasCardPacker`,waterfall vs row-break 竞争) | `arrangeCards()` | + +两者都把卡片放进**无限画布坐标系**,再由 `fitToView(canvasSize:)` 计算缩放/平移把整组卡片 +塞进视口(缩放上限 1.0,四周留 30pt padding,底部预留 `bottomToolbarReserve = 50`)。 + +卡片数据是 `CanvasCardLayout { position(center), size }`,存活在 `CanvasLayoutStore` +(`@Observable`,落 UserDefaults `canvasCardLayouts`),`zOrder` 决定渲染层级。 + +排序的触发是**三通路**复用同一套基建: + +1. 工具栏按钮 → `arrangeCardsWithFit()` / `organizeCardsWithFit()` +2. 键盘快捷键 → `body` 的 `.onKeyPress` +3. 命令面板 → `AppFeature+CommandPalette` 发 `.repositories(.requestCanvasCommand(.arrange/.organize))` + → `CanvasCommandRequest.Command` → `CanvasView+Focus.fulfillCommandRequest()` + +## Goal + +新增**第三种**布局 **Tile**(⌘⌥T,图标 `rectangle.split.2x1`),定位为 +**自动平铺窗口管理器**式排序:把所有打开的卡片**重新调整尺寸**,按规整网格铺满整个可视 +画布,让用户用尽可能大的面积组织卡片。 + +与现有两种的本质区别:Organize 用固定默认尺寸、Arrange 保留卡片原尺寸,而 **Tile 由视口 +反推卡片尺寸**——这是它"占满"的关键。 + +### 行为规格(与 onevcat 对齐确认) + +**平衡网格 + 宽高比自适应**: + +- 短边(视觉上较短的轴)放 `s = max(1, floor(√N))` 条"线",N 张卡片在这 `s` 条线上 + 尽量均分,多出来的卡片放到**靠后的线**(靠下的行 / 靠右的列)。 +- **宽窗口(W ≥ H)→ 线即"行",左右铺开**;**高窗口(W < H)→ 线即"列",上下堆叠**。 + 这是纯粹的横/纵方向翻转(短边永远放 `floor(√N)` 条线)。 +- 每条线**独立铺满整条**:2 卡的行每张占 ½ 宽,3 卡的行每张占 ⅓ 宽(所以不同线上的 + 卡片尺寸可以不同——这才是"尽可能大")。同方向的所有线等分另一轴。 + +**线分配 `lineCounts(for: N)`**:`base = N / s`,`rem = N % s`;前 `s - rem` 条线各 +`base` 张,后 `rem` 条线各 `base + 1` 张。 + +| N | s = floor(√N) | 分配 | 宽窗口(行) | 高窗口(列) | +|---|---|---|---|---| +| 1 | 1 | [1] | 整屏 1 张 | 整屏 1 张 | +| 2 | 1 | [2] | 左右各半 | 上下各半 | +| 3 | 1 | [3] | 横排 3 | 竖排 3 | +| 4 | 2 | [2,2] | 2×2 | 2×2 | +| 5 | 2 | [2,3] | 上 2 下 3 | 左 2 右 3 | +| 6 | 2 | [3,3] | 2 行 ×3 | 2 列 ×3 | +| 7 | 2 | [3,4] | 上 3 下 4 | 左 3 右 4 | +| 8 | 2 | [4,4] | 2 行 ×4 | 2 列 ×4 | +| 9 | 3 | [3,3,3] | 3×3 | 3×3 | +| 10 | 3 | [3,3,4] | 3 行(3,3,4) | 3 列(3,3,4) | + +> 取舍说明:方向自适应是**二元翻转**(看 `W ≥ H`),不做极端宽高比的列数微调(例如 +> 32:9 超宽屏 4 张仍是 2×2,而非 1×4)。这保持了与上面确定性例子完全一致的"平衡网格" +> 观感。若日后想要极端比例下进一步铺开,可在 `lineCounts` 上叠加一层 aspect-aware 的 +> 候选评分(按最大化最小卡片面积选 `s`),属于后续增强、不在本次范围。 + +### 缩放策略(已确认) + +**复用现有 `fitToView`**:Tile 在画布坐标系按视口比例摆好卡片后,调用 `fitToView` 居中并 +缩放。因为布局 bounding box 的宽高比 ≈ 视口宽高比,`fitToView` 的 `min(W/bboxW, H/bboxH)` +会让两个方向同时贴合(仅受 30pt padding + 底部 reserve 影响留出少量边距),行为与 +Arrange/Organize 完全一致,改动最小。 + +## 算法细节 + +新增可单测的纯逻辑类型 `CanvasTileLayout`,与 `CanvasCardPacker` 并列放在 +`CanvasCardLayout.swift`: + +``` +struct CanvasTileLayout { + var spacing: CGFloat + var titleBarHeight: CGFloat + // clamp 边界(minCard*/maxCard*)由调用方传入或用默认 + + static func lineCounts(for count: Int) -> [Int] // 上面的分配规则 + func layout(keys: [String], viewport: CGSize) -> [String: CanvasCardLayout] +} +``` + +`layout` 几何(以**宽窗口=行**为例,高窗口为对称转置): + +- `rows = lineCounts(for: keys.count)`,`rowVisualHeight = (H - (rows+1)·spacing) / rows`, + `terminalHeight = rowVisualHeight - titleBarHeight`。 +- 第 `r` 行有 `k` 张:`cardWidth = (W - (k+1)·spacing) / k`。 +- 卡片中心:`y = spacing + r·(rowVisualHeight + spacing) + rowVisualHeight/2`; + `x = spacing + i·(cardWidth + spacing) + cardWidth/2`。 +- `CanvasCardLayout(position: center, size: CGSize(cardWidth, terminalHeight))`。 + **不做 min/max 夹紧**:tile 的卡片尺寸就是视口除以网格的结果,夹紧只会在窗口过小时 + 把卡片撑大到超出格子、造成重叠。min/maxCard 约束属于"手动拖拽 resize"与"新卡默认 + 尺寸"的范畴,与 tile 的"按视口铺满"无关。窗口很小时卡片会变小(低于默认尺寸),由 + `fitToView` 负责后续视觉缩放——与 Organize 的降级思路一致,但保证恒不重叠、恰好铺满。 + +高窗口对称:线=列,`colVisualWidth = (W-(cols+1)·spacing)/cols`,每列 `k` 张时 +`cardVisualHeight = (H-(k+1)·spacing)/k`、`terminalHeight = cardVisualHeight - titleBarHeight`。 + +边界:`count == 0` 或 `viewport` 任一维 ≤ 0 时返回空 dict(调用方 no-op,与 `arrangeCards` +的 guard 一致)。 + +## 改动清单(按文件) + +### 1. 核心算法 — `supacode/Features/Canvas/Models/CanvasCardLayout.swift` +新增 `CanvasTileLayout`(`lineCounts(for:)` + `layout(keys:viewport:)`)。纯函数、无副作用、 +`@MainActor` 无关,便于单测。 + +### 2. 触发逻辑 — `supacode/Features/Canvas/Views/CanvasView.swift` +- `func tileCards()`:取 `collectCardKeys` → `CanvasTileLayout(...).layout(keys:viewport:)` + → `layoutStore.setCardLayouts(result, zOrder: keys)`(仿 `organizeCards()`)。guard 视口有效。 +- `func tileCardsWithFit()`:`withAnimation(.easeInOut(0.2))` 内 `cancelExpandForRelayout()` + + `tileCards()` + `fitToView(canvasSize: viewportSize)`(仿 `*WithFit`)。 +- `body` 顶部新增 `tileCanvasShortcut = AppShortcuts.resolvedShortcut(for: .tileCanvasCards, ...)`。 +- 新增一条 `.onKeyPress(tileCanvasShortcut?.keyEquivalent ?? AppShortcuts.tileCanvasCards.keyEquivalent, phases: .down)`, + 模式与 arrange/organize 完全一致(解析为 nil 时 `.ignored`,校验 modifiers)。 +- `canvasToolbar` 第三个按钮:`Image(systemName: "rectangle.split.2x1")`, + `help(AppShortcuts.helpText(title: "Tile cards to fill the canvas", commandID: .tileCanvasCards, ...))`。 + +### 3. 命令通路(接入 arrange/organize 的全套基建) +- `CanvasFocusRequest.swift`:`CanvasCommandRequest.Command` 加 `case tile`。 +- `CanvasView+Focus.swift`:`fulfillCommandRequest` 的 switch 加 `case .tile: tileCardsWithFit()`。 +- `AppShortcuts.swift`: + - `CommandID.tileCanvasCards = "tile_canvas_cards"`(≈ line 143 区) + - `static let tileCanvasCards = AppShortcut(key: "t", modifiers: [.command, .option])`(≈ line 303) + —— ⌘⌥T 当前空闲(已核对 ⌘⌥ 已用:p/u/return/[/]/方向键/a/r/g/e) + - 注册进命令表(≈ line 778-787 区,title `"Tile Canvas Cards"`) +- `AppFeature+CommandPalette.swift`:`case .tileCanvasCards: return .send(.repositories(.requestCanvasCommand(.tile)))`。 +- 命令面板枚举/映射四处:`CommandPaletteItem.swift`、`CommandPaletteFeature.swift` + (`kind` + 注册项 ≈ line 613-620 区)、`CommandPaletteSupport.swift` + (`globalTileCanvasCards` 常量 + 各 mapping ≈ line 19/192/267/319)、 + `CommandPaletteOverlayView.swift`(各 switch/list ≈ line 525/590/640/775)。 +- `ShortcutsSettingsView.swift`:canvas 快捷键列表加 `.tileCanvasCards`(≈ line 942)。 + +### 4. 测试 — 新建 `supacodeTests/CanvasTileLayoutTests.swift` +- `lineCounts(for:)`:断言 N=1…10 全部命中上表(重点覆盖 5→[2,3]、7→[3,4]、9→[3,3,3])。 +- `layout`: + - **宽窗口**(如 1600×900)N=2 → 两张左右、各约半宽、等高、无重叠。 + - **高窗口**(如 900×1600)N=2 → 两张上下(方向翻转生效)。 + - N=5 宽窗口 → 上排 2 下排 3,下排卡片更窄。 + - 通用:任意两卡矩形不相交;每行/列铺满对应轴;clamp 在极小视口下生效。 +- 复用 `CanvasCardPackerTests` 的无重叠/间距断言风格。 + +### 5. 文档(同 PR) +- `docs/components/canvas.md`:≈ line 57-59 追加 `⌘⌥T Tile Cards` 段落;line 6 keywords 加 `tile`。 +- `docs/reference/keyboard-shortcuts.md`:≈ line 67-68 加一行 + `| Tile Canvas Cards (fill viewport) | ⌘⌥T | \`tile_canvas_cards\` | yes (local) |`, + 必要时更新 line 132 的 local-action 说明。 + +### 6. 收尾 +- 新分支 `feature/canvas-tile-layout`(从最新 `origin/main`)。 +- `make build-app`、`make test`(含新测试)、`make check` 全绿。 +- 仅提交本次改动文件(不 `git add .`),开 PR 到 `onevcat/Prowl`。 + +## 验收标准 + +1. 画布有 2/3/4/5 张卡片时点 Tile,宽窗口下分别得到 左右 / 横排3 / 2×2 / 上2下3。 +2. 把窗口拉成竖屏后点 Tile,2 张变上下、3 张变竖排、5 张变左2右3。 +3. 卡片铺满可视区域(仅四周少量边距),无重叠、间距一致。 +4. 三通路(按钮 / ⌘⌥T / 命令面板 "Tile Canvas Cards")行为一致。 +5. 设置里能看到并改键,禁用后 ⌘⌥T 不触发。 +6. `lineCounts` 单测与布局单测通过。 diff --git a/docs/components/canvas.md b/docs/components/canvas.md index 70175c0d..e99229fe 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -3,7 +3,7 @@ > A zoomable board where every agent is a live, interactive terminal card — > watch them all at once, and broadcast one command to every selected agent. -**Keywords:** canvas, board, cards, grid, zoom, pan, broadcast, multi-select, select all, arrange, organize, expand card, bird's-eye, overview of agents +**Keywords:** canvas, board, cards, grid, zoom, pan, broadcast, multi-select, select all, arrange, organize, tile, fill, expand card, bird's-eye, overview of agents **Related:** [view-modes](view-modes.md) · [shelf](shelf.md) · [terminal](terminal.md) · [active-agents](active-agents.md) · [keyboard-shortcuts](../reference/keyboard-shortcuts.md) @@ -58,6 +58,11 @@ Selection controls: aspect ratio, preserving each card's size, then fit-to-view. - **`⌘⌥G` Organize Cards** — reset every card to a uniform grid (≈√N columns) at a default size and center them. +- **`⌘⌥T` Tile Cards** — resize every card to tile and fill the viewport, like an + 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. These also appear as toolbar buttons. There's a `?` help popover (bottom-left) explaining pan/zoom/expand. diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 185bd266..d2bb33e5 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -66,6 +66,7 @@ Symbols: **⌘** Command · **⇧** Shift · **⌥** Option · **⌃** Control | Select All Canvas Cards | ⌘⌥A | `select_all_canvas_cards` | yes (local) | | Arrange Canvas Cards (pack to fit) | ⌘⌥R | `arrange_canvas_cards` | yes (local) | | Organize Canvas Cards (uniform grid) | ⌘⌥G | `organize_canvas_cards` | yes (local) | +| Tile Canvas Cards (fill viewport) | ⌘⌥T | `tile_canvas_cards` | yes (local) | | Expand / Restore Canvas Card | ⌘⌥E | `expand_canvas_card` | yes (local) | | Clear selection | Esc | — | — | | Zoom | ⌘ + scroll, or pinch | — | — | diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index 2d799b1a..e29996a7 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -142,6 +142,7 @@ enum AppShortcuts { static let selectAllCanvasCards = "select_all_canvas_cards" static let arrangeCanvasCards = "arrange_canvas_cards" static let organizeCanvasCards = "organize_canvas_cards" + static let tileCanvasCards = "tile_canvas_cards" static let expandCanvasCard = "expand_canvas_card" static let selectPreviousTerminalTab = "select_previous_terminal_tab" static let selectNextTerminalTab = "select_next_terminal_tab" @@ -301,6 +302,7 @@ enum AppShortcuts { static let selectAllCanvasCards = AppShortcut(key: "a", modifiers: [.command, .option]) static let arrangeCanvasCards = AppShortcut(key: "r", modifiers: [.command, .option]) static let organizeCanvasCards = AppShortcut(key: "g", modifiers: [.command, .option]) + static let tileCanvasCards = AppShortcut(key: "t", modifiers: [.command, .option]) static let expandCanvasCard = AppShortcut(key: "e", modifiers: [.command, .option]) static let worktreeSelection: [AppShortcut] = [ selectWorktree1, @@ -786,6 +788,12 @@ enum AppShortcuts { scope: .localInteraction, shortcut: organizeCanvasCards ), + .init( + id: CommandID.tileCanvasCards, + title: "Tile Canvas Cards", + scope: .localInteraction, + shortcut: tileCanvasCards + ), .init( id: CommandID.expandCanvasCard, title: "Expand / Restore Canvas Card", diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index fcfa7114..e1907b33 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -140,6 +140,9 @@ extension AppFeature { case .organizeCanvasCards: return .send(.repositories(.requestCanvasCommand(.organize))) + case .tileCanvasCards: + return .send(.repositories(.requestCanvasCommand(.tile))) + case .selectAllCanvasCards: return .send(.repositories(.requestCanvasCommand(.selectAll))) diff --git a/supacode/Features/Canvas/Models/CanvasCardLayout.swift b/supacode/Features/Canvas/Models/CanvasCardLayout.swift index dfcac4e6..4f1b3e2d 100644 --- a/supacode/Features/Canvas/Models/CanvasCardLayout.swift +++ b/supacode/Features/Canvas/Models/CanvasCardLayout.swift @@ -255,6 +255,82 @@ struct CanvasCardPacker { } } +// MARK: - Tile Layout + +/// Resizes cards to tile and fill the entire viewport, like an automatic window +/// manager. Cards are laid into a balanced grid whose orientation follows the +/// viewport: wide viewports spread cards into rows (left-to-right), tall +/// viewports stack them into columns (top-to-bottom). Each line independently +/// fills its full extent, so a 2-card row gets ½-width cards while a 3-card row +/// gets ⅓-width cards — maximizing the area every card uses. +struct CanvasTileLayout { + var spacing: CGFloat + var titleBarHeight: CGFloat + + /// Distribute `count` cards across `floor(√count)` lines, packing the extra + /// cards into the later lines. e.g. 5 → `[2, 3]`, 7 → `[3, 4]`, 9 → `[3, 3, 3]`. + static func lineCounts(for count: Int) -> [Int] { + guard count > 0 else { return [] } + let lines = max(1, Int(Double(count).squareRoot().rounded(.down))) + let base = count / lines + let remainder = count % lines + // Earlier lines get `base`; the last `remainder` lines get one extra. + return (0.. [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 + 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) + var originY = spacing + for cardsInRow in counts { + let cardWidth = (viewport.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) + guard !layouts.isEmpty else { return } + layoutStore.setCardLayouts(layouts, zOrder: keys) + } + /// Arrange cards (preserving sizes) and refit the viewport, animated. /// Shared by the toolbar button and the keyboard shortcut. func arrangeCardsWithFit() { @@ -642,6 +667,16 @@ struct CanvasView: View { } } + /// Tile cards to fill the viewport and refit, animated. Shared by the toolbar + /// button and the keyboard shortcut. + func tileCardsWithFit() { + withAnimation(.easeInOut(duration: 0.2)) { + cancelExpandForRelayout() + tileCards() + fitToView(canvasSize: viewportSize) + } + } + /// Adjust scale and offset so all cards fit within the viewport. func fitToView(canvasSize: CGSize) { guard canvasSize.width > 0, canvasSize.height > 0 else { return } @@ -812,6 +847,21 @@ struct CanvasView: View { commandID: AppShortcuts.CommandID.organizeCanvasCards, in: resolvedKeybindings )) + + Button { + tileCardsWithFit() + } label: { + Image(systemName: "rectangle.split.2x1") + .font(.body) + .accessibilityLabel("Tile") + } + .buttonStyle(.bordered) + .help( + AppShortcuts.helpText( + title: "Tile cards to fill the canvas", + commandID: AppShortcuts.CommandID.tileCanvasCards, + in: resolvedKeybindings + )) } .padding() } diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index 07bb8397..bcccf4f4 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -68,6 +68,7 @@ struct CommandPaletteItem: Identifiable, Equatable { case expandCanvasCard case arrangeCanvasCards case organizeCanvasCards + case tileCanvasCards case selectAllCanvasCards case toggleShelf case showDiff @@ -133,6 +134,8 @@ struct CommandPaletteItem: Identifiable, Equatable { return AppShortcuts.CommandID.arrangeCanvasCards case .organizeCanvasCards: return AppShortcuts.CommandID.organizeCanvasCards + case .tileCanvasCards: + return AppShortcuts.CommandID.tileCanvasCards case .selectAllCanvasCards: return AppShortcuts.CommandID.selectAllCanvasCards case .toggleShelf: diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index c81153ef..3b4fa274 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -58,6 +58,7 @@ struct CommandPaletteFeature { case expandCanvasCard case arrangeCanvasCards case organizeCanvasCards + case tileCanvasCards case selectAllCanvasCards case toggleShelf case showDiff @@ -620,6 +621,13 @@ private func canvasCommandItems() -> [CommandPaletteItem] { kind: .organizeCanvasCards, keywords: ["canvas", "organize", "grid", "tidy", "uniform"] ), + .appShortcut( + id: CommandPaletteItemID.globalTileCanvasCards, + title: "Tile Canvas Cards", + category: .view, + kind: .tileCanvasCards, + keywords: ["canvas", "tile", "fill", "layout", "window", "split"] + ), .appShortcut( id: CommandPaletteItemID.globalSelectAllCanvasCards, title: "Select All Canvas Cards", diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift index b32dd849..5e7b502e 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift @@ -18,6 +18,7 @@ enum CommandPaletteItemID { static let globalExpandCanvasCard = "global.expand-canvas-card" static let globalArrangeCanvasCards = "global.arrange-canvas-cards" static let globalOrganizeCanvasCards = "global.organize-canvas-cards" + static let globalTileCanvasCards = "global.tile-canvas-cards" static let globalSelectAllCanvasCards = "global.select-all-canvas-cards" static let globalToggleShelf = "global.toggle-shelf" static let globalShowDiff = "global.show-diff" @@ -191,6 +192,7 @@ func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature. .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, + .tileCanvasCards, .selectAllCanvasCards, .toggleShelf, .showDiff, @@ -268,6 +270,8 @@ func viewDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeat return .arrangeCanvasCards case .organizeCanvasCards: return .organizeCanvasCards + case .tileCanvasCards: + return .tileCanvasCards case .selectAllCanvasCards: return .selectAllCanvasCards case .toggleShelf: @@ -318,6 +322,7 @@ func pullRequestDelegateAction( .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, + .tileCanvasCards, .selectAllCanvasCards, .toggleShelf, .showDiff, diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 44ec7db9..acc83ef1 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -522,7 +522,7 @@ private struct CommandPaletteRowView: View { .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect, .changeFocusedTabIcon, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, - .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .selectAllCanvasCards, + .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .tileCanvasCards, .selectAllCanvasCards, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, @@ -591,6 +591,8 @@ private struct CommandPaletteRowView: View { return "rectangle.3.group" case .organizeCanvasCards: return "square.grid.2x2" + case .tileCanvasCards: + return "rectangle.split.2x1" case .selectAllCanvasCards: return "checkmark.rectangle.stack" case .toggleShelf: @@ -637,7 +639,7 @@ private struct CommandPaletteRowView: View { .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .changeFocusedTabIcon, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, - .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .selectAllCanvasCards, + .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .tileCanvasCards, .selectAllCanvasCards, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch, @@ -776,6 +778,8 @@ private struct CommandPaletteRowView: View { base = "Arrange Canvas Cards" case .organizeCanvasCards: base = "Organize Canvas Cards" + case .tileCanvasCards: + base = "Tile Canvas Cards" case .selectAllCanvasCards: base = "Select All Canvas Cards" case .toggleShelf: diff --git a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift index 37e236e9..e092e4ad 100644 --- a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift +++ b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift @@ -941,6 +941,7 @@ private enum ShortcutGroup: String, CaseIterable, Identifiable { AppShortcuts.CommandID.selectAllCanvasCards, AppShortcuts.CommandID.arrangeCanvasCards, AppShortcuts.CommandID.organizeCanvasCards, + AppShortcuts.CommandID.tileCanvasCards, AppShortcuts.CommandID.archivedWorktrees: return .scripts diff --git a/supacodeTests/CanvasTileLayoutTests.swift b/supacodeTests/CanvasTileLayoutTests.swift new file mode 100644 index 00000000..d9ff5267 --- /dev/null +++ b/supacodeTests/CanvasTileLayoutTests.swift @@ -0,0 +1,149 @@ +import CoreGraphics +import Testing + +@testable import supacode + +struct CanvasTileLayoutTests { + private let titleBarHeight: CGFloat = 28 + private let spacing: CGFloat = 20 + + private var tiler: CanvasTileLayout { + CanvasTileLayout(spacing: spacing, titleBarHeight: titleBarHeight) + } + + private func keys(_ count: Int) -> [String] { + (0.. CGRect { + let width = layout.size.width + let height = layout.size.height + titleBarHeight + return CGRect( + x: layout.position.x - width / 2, + y: layout.position.y - height / 2, + width: width, + height: height + ) + } + + private func assertNoOverlap(_ rects: [CGRect], sourceLocation: SourceLocation = #_sourceLocation) { + for outer in 0.. topRow[0].position.y) + // 3-card row cards are narrower than 2-card row cards. + #expect(bottomRow[0].size.width < topRow[0].size.width) + } + + // MARK: - Fill & non-overlap + + @Test func cardsNeverOverlap() throws { + 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) + #expect(layouts.count == count) + assertNoOverlap(layouts.values.map(visualRect)) + } + } + } + + @Test func eachRowFillsViewportWidth() throws { + let width: CGFloat = 1600 + let layouts = tiler.layout(keys: keys(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()! + // Row spans from the left spacing to the right spacing of the viewport. + #expect(abs(minX - spacing) < 0.001) + #expect(abs(maxX - (width - spacing)) < 0.001) + } + + // 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)) + 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 }) + assertNoOverlap(rects) + } +} diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index d5f27288..9f734cfb 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -1722,7 +1722,7 @@ private func testCategory(for kind: CommandPaletteItem.Kind) -> CommandPaletteIt case .ghosttyCommand: return .terminal case .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, - .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .selectAllCanvasCards, + .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .tileCanvasCards, .selectAllCanvasCards, .toggleShelf, .showDiff: return .view #if DEBUG @@ -1739,7 +1739,7 @@ private func testDefaultSuggestion(for kind: CommandPaletteItem.Kind) -> Bool { .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, .rerunFailedJobs, .openFailingCheckDetails, .toggleLeftSidebar, .toggleActiveAgentsPanel, .toggleCanvas, - .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .selectAllCanvasCards, + .expandCanvasCard, .arrangeCanvasCards, .organizeCanvasCards, .tileCanvasCards, .selectAllCanvasCards, .toggleShelf, .showDiff, .revealInFinder, .copyPath, .revealInSidebar, .runScript, .stopRunScript, .togglePinWorktree, .renameBranch,