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, -- 2.51.2 From c751f8c994c2b812ed39aba83ade74bc46055ec1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 24 Jun 2026 22:23:43 +0900 Subject: [PATCH 2/3] Make Tile layout zoom adapt to card count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously Tile laid cards out in a viewport-sized frame, so fitToView always landed near scale 1.0. With many cards each surface became tiny, so the terminal showed few rows/columns and the text looked oversized — and the 20pt gap, never scaled down, looked larger than the other layouts. Tile now builds the grid in a `viewport × zoom` frame, so fitToView reproduces a scale of `1 / zoom`. `zoom` stays 1 (native scale, unchanged for a handful of cards) until the tiled cards shrink below a comfortable surface (`adaptiveDefaultCardSize × 0.6`), then grows so each card keeps a readable terminal — more content at smaller text — as the count rises. Spacing moves to a tighter `tileCardSpacing` (14) that lives in the scaled frame, so the on-screen gap (`spacing × scale`) also tightens automatically with more cards. Adds tests for native-zoom (few cards) and adaptive-zoom (many cards) and updates the no-overlap test to exercise zoom > 1. --- .../2026-06-24-canvas-tile-layout-plan.md | 18 ++++- docs/components/canvas.md | 5 +- .../Canvas/Models/CanvasCardLayout.swift | 40 +++++++++-- .../Features/Canvas/Views/CanvasView.swift | 14 +++- supacodeTests/CanvasTileLayoutTests.swift | 66 +++++++++++++++---- 5 files changed, 121 insertions(+), 22 deletions(-) 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 }) -- 2.51.2 From 12e017ae3a222cc755374cb97d781de7730ec07b Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 24 Jun 2026 22:54:51 +0900 Subject: [PATCH 3/3] Cover tile canvas shortcut behavior in tests --- supacodeTests/AppShortcutsTests.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index ee79b157..c354d1af 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -180,6 +180,10 @@ struct AppShortcutsTests { idToDisplay["organize_canvas_cards"], AppShortcuts.organizeCanvasCards.display ) + expectNoDifference( + idToDisplay["tile_canvas_cards"], + AppShortcuts.tileCanvasCards.display + ) #expect(idToScope["command_palette"] == .configurableAppAction) #expect(idToScope["toggle_active_agents_panel"] == .configurableAppAction) @@ -188,13 +192,16 @@ struct AppShortcutsTests { #expect(idToScope["select_all_canvas_cards"] == .localInteraction) #expect(idToScope["arrange_canvas_cards"] == .localInteraction) #expect(idToScope["organize_canvas_cards"] == .localInteraction) + #expect(idToScope["tile_canvas_cards"] == .localInteraction) } @Test func canvasLayoutShortcutsUseCommandOptionFamily() { expectNoDifference(AppShortcuts.arrangeCanvasCards.display, "⌘⌥R") expectNoDifference(AppShortcuts.organizeCanvasCards.display, "⌘⌥G") + expectNoDifference(AppShortcuts.tileCanvasCards.display, "⌘⌥T") #expect(AppShortcuts.arrangeCanvasCards.modifiers == [.command, .option]) #expect(AppShortcuts.organizeCanvasCards.modifiers == [.command, .option]) + #expect(AppShortcuts.tileCanvasCards.modifiers == [.command, .option]) } @Test func userOverrideConflictsDetectsReservedAppShortcuts() { @@ -453,6 +460,10 @@ struct AppShortcutsTests { binding: Keybinding(key: "g", modifiers: .init(command: true, option: true)), isEnabled: false ), + AppShortcuts.CommandID.tileCanvasCards: KeybindingUserOverride( + binding: Keybinding(key: "t", modifiers: .init(command: true, option: true)), + isEnabled: false + ), ] ) let resolved = KeybindingResolver.resolve( @@ -461,9 +472,10 @@ struct AppShortcutsTests { ) // When disabled the resolved shortcut must be nil so CanvasView's handler bails - // instead of falling back to the app-default ⌘⌥R / ⌘⌥G key. + // instead of falling back to the app-default layout keys. #expect(AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.arrangeCanvasCards, in: resolved) == nil) #expect(AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.organizeCanvasCards, in: resolved) == nil) + #expect(AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.tileCanvasCards, in: resolved) == nil) } @Test func resolvedShortcutFallsBackToDefaultWhenCommandMissingInResolvedMap() {