diff --git a/supacode/Features/Canvas/Models/CanvasSpatialNavigation.swift b/supacode/Features/Canvas/Models/CanvasSpatialNavigation.swift new file mode 100644 index 00000000..685938fe --- /dev/null +++ b/supacode/Features/Canvas/Models/CanvasSpatialNavigation.swift @@ -0,0 +1,52 @@ +import CoreGraphics + +enum CanvasNavigationDirection { + case moveUp, moveDown, moveLeft, moveRight +} + +enum CanvasSpatialNavigation { + struct CardEntry { + var id: String + var center: CGPoint + } + + static func nearest( + from currentID: String, + direction: CanvasNavigationDirection, + cards: [CardEntry] + ) -> String? { + guard let current = cards.first(where: { $0.id == currentID }) else { + return nil + } + + let candidates = cards.filter { candidate in + guard candidate.id != currentID else { return false } + switch direction { + case .moveUp: return candidate.center.y < current.center.y + case .moveDown: return candidate.center.y > current.center.y + case .moveLeft: return candidate.center.x < current.center.x + case .moveRight: return candidate.center.x > current.center.x + } + } + + return candidates.min(by: { lhs, rhs in + score(from: current.center, to: lhs.center, direction: direction) + < score(from: current.center, to: rhs.center, direction: direction) + })?.id + } + + private static func score( + from origin: CGPoint, + to target: CGPoint, + direction: CanvasNavigationDirection + ) -> CGFloat { + let deltaX = abs(target.x - origin.x) + let deltaY = abs(target.y - origin.y) + switch direction { + case .moveUp, .moveDown: + return deltaY + deltaX * 2 + case .moveLeft, .moveRight: + return deltaX + deltaY * 2 + } + } +} diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index f3d6d112..f08145c2 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -97,6 +97,22 @@ struct CanvasView: View { for: AppShortcuts.CommandID.expandCanvasCard, in: resolvedKeybindings ) + let selectNextWorktreeShortcut = AppShortcuts.resolvedShortcut( + for: AppShortcuts.CommandID.selectNextWorktree, + in: resolvedKeybindings + ) + let selectPreviousWorktreeShortcut = AppShortcuts.resolvedShortcut( + for: AppShortcuts.CommandID.selectPreviousWorktree, + in: resolvedKeybindings + ) + let selectNextShelfBookShortcut = AppShortcuts.resolvedShortcut( + for: AppShortcuts.CommandID.selectNextShelfBook, + in: resolvedKeybindings + ) + let selectPreviousShelfBookShortcut = AppShortcuts.resolvedShortcut( + for: AppShortcuts.CommandID.selectPreviousShelfBook, + in: resolvedKeybindings + ) let _ = configReloadCounter CanvasScrollContainer( offset: $canvasOffset, @@ -240,6 +256,38 @@ struct CanvasView: View { toggleExpandFocusedCard() return .handled } + .onKeyPress( + selectPreviousWorktreeShortcut?.keyEquivalent ?? AppShortcuts.selectPreviousWorktree.keyEquivalent, + phases: .down + ) { keyPress in + guard let shortcut = selectPreviousWorktreeShortcut else { return .ignored } + guard keyPress.modifiers == shortcut.modifiers else { return .ignored } + return navigateCard(.moveUp) ? .handled : .ignored + } + .onKeyPress( + selectNextWorktreeShortcut?.keyEquivalent ?? AppShortcuts.selectNextWorktree.keyEquivalent, + phases: .down + ) { keyPress in + guard let shortcut = selectNextWorktreeShortcut else { return .ignored } + guard keyPress.modifiers == shortcut.modifiers else { return .ignored } + return navigateCard(.moveDown) ? .handled : .ignored + } + .onKeyPress( + selectPreviousShelfBookShortcut?.keyEquivalent ?? AppShortcuts.selectPreviousShelfBook.keyEquivalent, + phases: .down + ) { keyPress in + guard let shortcut = selectPreviousShelfBookShortcut else { return .ignored } + guard keyPress.modifiers == shortcut.modifiers else { return .ignored } + return navigateCard(.moveLeft) ? .handled : .ignored + } + .onKeyPress( + selectNextShelfBookShortcut?.keyEquivalent ?? AppShortcuts.selectNextShelfBook.keyEquivalent, + phases: .down + ) { keyPress in + guard let shortcut = selectNextShelfBookShortcut else { return .ignored } + guard keyPress.modifiers == shortcut.modifiers else { return .ignored } + return navigateCard(.moveRight) ? .handled : .ignored + } .onChange(of: expandedTabID) { _, newValue in onExpandedChange(newValue != nil) } @@ -886,6 +934,56 @@ struct CanvasView: View { } } + // MARK: - Spatial Navigation + + @discardableResult + func navigateCard(_ direction: CanvasNavigationDirection) -> Bool { + guard expandedTabID == nil else { return false } + let activeStates = terminalManager.activeWorktreeStates + guard let currentTabID = selectionState.primaryTabID else { + let allTabIDs = collectVisibleTabIDs(from: activeStates) + if let first = allTabIDs.first { + focusSingleCard(first, states: activeStates) + focusViewport(on: first) + } + return !allTabIDs.isEmpty + } + + let entries = cardEntries(from: activeStates) + let currentKey = currentTabID.rawValue.uuidString + guard + let targetKey = CanvasSpatialNavigation.nearest( + from: currentKey, + direction: direction, + cards: entries + ) + else { + return true + } + + let allTabIDs = collectVisibleTabIDs(from: activeStates) + guard let targetTabID = allTabIDs.first(where: { $0.rawValue.uuidString == targetKey }) else { + return true + } + + focusSingleCard(targetTabID, states: activeStates) + focusViewport(on: targetTabID) + return true + } + + private func cardEntries( + from states: [WorktreeTerminalState] + ) -> [CanvasSpatialNavigation.CardEntry] { + states.flatMap { state in + state.tabManager.tabs.compactMap { tab -> CanvasSpatialNavigation.CardEntry? in + guard state.surfaceView(for: tab.id) != nil else { return nil } + let key = tab.id.rawValue.uuidString + guard let layout = layoutStore.cardLayouts[key] else { return nil } + return CanvasSpatialNavigation.CardEntry(id: key, center: layout.position) + } + } + } + // MARK: - Expand In Place var expandMetrics: CanvasExpandGeometry.Metrics { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index a1bf0106..28cd78ca 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -621,6 +621,8 @@ extension RepositoriesFeature { } case .selectNextWorktree: + // Canvas handles directional navigation in CanvasView.onKeyPress. + guard !state.isShowingCanvas else { return .none } // In Shelf, the vertical arrow pair maps to tab navigation // within the open book — horizontal (← / →) is already book // navigation, so the two axes match the Shelf layout. @@ -633,6 +635,7 @@ extension RepositoriesFeature { return .send(.selectWorktree(id, focusTerminal: true)) case .selectPreviousWorktree: + guard !state.isShowingCanvas else { return .none } if state.isShelfActive, let worktree = state.selectedTerminalWorktree { return .run { _ in await terminalClient.send(.performBindingAction(worktree, action: "previous_tab")) diff --git a/supacodeTests/CanvasSpatialNavigationTests.swift b/supacodeTests/CanvasSpatialNavigationTests.swift new file mode 100644 index 00000000..47146632 --- /dev/null +++ b/supacodeTests/CanvasSpatialNavigationTests.swift @@ -0,0 +1,201 @@ +import CoreGraphics +import Testing + +@testable import supacode + +struct CanvasSpatialNavigationTests { + private typealias Entry = CanvasSpatialNavigation.CardEntry + + // Layout used by most tests: + // + // A(0,0) B(200,0) + // C(0,200) D(200,200) + // + private let grid = [ + Entry(id: "A", center: CGPoint(x: 0, y: 0)), + Entry(id: "B", center: CGPoint(x: 200, y: 0)), + Entry(id: "C", center: CGPoint(x: 0, y: 200)), + Entry(id: "D", center: CGPoint(x: 200, y: 200)), + ] + + // MARK: - Basic directional movement + + @Test func rightFromTopLeft() { + let result = CanvasSpatialNavigation.nearest(from: "A", direction: .moveRight, cards: grid) + #expect(result == "B") + } + + @Test func leftFromTopRight() { + let result = CanvasSpatialNavigation.nearest(from: "B", direction: .moveLeft, cards: grid) + #expect(result == "A") + } + + @Test func downFromTopLeft() { + let result = CanvasSpatialNavigation.nearest(from: "A", direction: .moveDown, cards: grid) + #expect(result == "C") + } + + @Test func upFromBottomLeft() { + let result = CanvasSpatialNavigation.nearest(from: "C", direction: .moveUp, cards: grid) + #expect(result == "A") + } + + @Test func downFromTopRight() { + let result = CanvasSpatialNavigation.nearest(from: "B", direction: .moveDown, cards: grid) + #expect(result == "D") + } + + @Test func upFromBottomRight() { + let result = CanvasSpatialNavigation.nearest(from: "D", direction: .moveUp, cards: grid) + #expect(result == "B") + } + + // MARK: - No candidate in direction + + @Test func leftFromLeftmostReturnsNil() { + let result = CanvasSpatialNavigation.nearest(from: "A", direction: .moveLeft, cards: grid) + #expect(result == nil) + } + + @Test func upFromTopmostReturnsNil() { + let result = CanvasSpatialNavigation.nearest(from: "A", direction: .moveUp, cards: grid) + #expect(result == nil) + } + + @Test func rightFromRightmostReturnsNil() { + let result = CanvasSpatialNavigation.nearest(from: "D", direction: .moveRight, cards: grid) + #expect(result == nil) + } + + @Test func downFromBottommostReturnsNil() { + let result = CanvasSpatialNavigation.nearest(from: "D", direction: .moveDown, cards: grid) + #expect(result == nil) + } + + // MARK: - Weighted distance favors primary axis + + @Test func rightPrefersAlignedOverDiagonal() { + // E is directly right, F is far right but also far down. + let cards = [ + Entry(id: "O", center: CGPoint(x: 0, y: 0)), + Entry(id: "E", center: CGPoint(x: 100, y: 10)), + Entry(id: "F", center: CGPoint(x: 110, y: 300)), + ] + let result = CanvasSpatialNavigation.nearest(from: "O", direction: .moveRight, cards: cards) + #expect(result == "E") + } + + @Test func downPrefersAlignedOverDiagonal() { + let cards = [ + Entry(id: "O", center: CGPoint(x: 0, y: 0)), + Entry(id: "E", center: CGPoint(x: 10, y: 100)), + Entry(id: "F", center: CGPoint(x: 300, y: 110)), + ] + let result = CanvasSpatialNavigation.nearest(from: "O", direction: .moveDown, cards: cards) + #expect(result == "E") + } + + // MARK: - Single card + + @Test func singleCardReturnsNilForAllDirections() { + let cards = [Entry(id: "X", center: CGPoint(x: 50, y: 50))] + for direction: CanvasNavigationDirection in [.moveUp, .moveDown, .moveLeft, .moveRight] { + let result = CanvasSpatialNavigation.nearest(from: "X", direction: direction, cards: cards) + #expect(result == nil) + } + } + + // MARK: - Unknown current ID + + @Test func unknownCurrentIDReturnsNil() { + let result = CanvasSpatialNavigation.nearest(from: "MISSING", direction: .moveRight, cards: grid) + #expect(result == nil) + } + + // MARK: - Empty cards + + @Test func emptyCardsReturnsNil() { + let result = CanvasSpatialNavigation.nearest(from: "A", direction: .moveRight, cards: []) + #expect(result == nil) + } + + // MARK: - Three-in-a-row (horizontal strip) + + @Test func horizontalStripNavigatesCorrectly() { + let cards = [ + Entry(id: "L", center: CGPoint(x: 0, y: 0)), + Entry(id: "M", center: CGPoint(x: 200, y: 0)), + Entry(id: "R", center: CGPoint(x: 400, y: 0)), + ] + #expect(CanvasSpatialNavigation.nearest(from: "L", direction: .moveRight, cards: cards) == "M") + #expect(CanvasSpatialNavigation.nearest(from: "M", direction: .moveRight, cards: cards) == "R") + #expect(CanvasSpatialNavigation.nearest(from: "R", direction: .moveLeft, cards: cards) == "M") + #expect(CanvasSpatialNavigation.nearest(from: "M", direction: .moveLeft, cards: cards) == "L") + #expect(CanvasSpatialNavigation.nearest(from: "L", direction: .moveUp, cards: cards) == nil) + #expect(CanvasSpatialNavigation.nearest(from: "L", direction: .moveDown, cards: cards) == nil) + } + + // MARK: - Three-in-a-column (vertical strip) + + @Test func verticalStripNavigatesCorrectly() { + let cards = [ + Entry(id: "T", center: CGPoint(x: 0, y: 0)), + Entry(id: "M", center: CGPoint(x: 0, y: 200)), + Entry(id: "B", center: CGPoint(x: 0, y: 400)), + ] + #expect(CanvasSpatialNavigation.nearest(from: "T", direction: .moveDown, cards: cards) == "M") + #expect(CanvasSpatialNavigation.nearest(from: "M", direction: .moveDown, cards: cards) == "B") + #expect(CanvasSpatialNavigation.nearest(from: "B", direction: .moveUp, cards: cards) == "M") + #expect(CanvasSpatialNavigation.nearest(from: "M", direction: .moveUp, cards: cards) == "T") + #expect(CanvasSpatialNavigation.nearest(from: "T", direction: .moveLeft, cards: cards) == nil) + #expect(CanvasSpatialNavigation.nearest(from: "T", direction: .moveRight, cards: cards) == nil) + } + + // MARK: - Asymmetric grid (3 columns, 2 rows) + + @Test func wideGridNavigation() { + // A(0,0) B(200,0) C(400,0) + // D(0,200) E(200,200) F(400,200) + let cards = [ + Entry(id: "A", center: CGPoint(x: 0, y: 0)), + Entry(id: "B", center: CGPoint(x: 200, y: 0)), + Entry(id: "C", center: CGPoint(x: 400, y: 0)), + Entry(id: "D", center: CGPoint(x: 0, y: 200)), + Entry(id: "E", center: CGPoint(x: 200, y: 200)), + Entry(id: "F", center: CGPoint(x: 400, y: 200)), + ] + #expect(CanvasSpatialNavigation.nearest(from: "B", direction: .moveDown, cards: cards) == "E") + #expect(CanvasSpatialNavigation.nearest(from: "E", direction: .moveUp, cards: cards) == "B") + #expect(CanvasSpatialNavigation.nearest(from: "A", direction: .moveRight, cards: cards) == "B") + #expect(CanvasSpatialNavigation.nearest(from: "C", direction: .moveLeft, cards: cards) == "B") + #expect(CanvasSpatialNavigation.nearest(from: "D", direction: .moveRight, cards: cards) == "E") + #expect(CanvasSpatialNavigation.nearest(from: "F", direction: .moveLeft, cards: cards) == "E") + } + + // MARK: - Cards at same primary-axis position + + @Test func twoCardsDirectlyBelowPicksNearest() { + let cards = [ + Entry(id: "O", center: CGPoint(x: 100, y: 0)), + Entry(id: "N", center: CGPoint(x: 100, y: 100)), + Entry(id: "F", center: CGPoint(x: 100, y: 300)), + ] + let result = CanvasSpatialNavigation.nearest(from: "O", direction: .moveDown, cards: cards) + #expect(result == "N") + } + + // MARK: - Slight offset (waterfall-like layout) + + @Test func waterfallLayoutNavigatesDown() { + // Waterfall: second column is slightly offset vertically. + let cards = [ + Entry(id: "A", center: CGPoint(x: 0, y: 0)), + Entry(id: "B", center: CGPoint(x: 200, y: 30)), + Entry(id: "C", center: CGPoint(x: 0, y: 250)), + Entry(id: "D", center: CGPoint(x: 200, y: 220)), + ] + #expect(CanvasSpatialNavigation.nearest(from: "A", direction: .moveRight, cards: cards) == "B") + #expect(CanvasSpatialNavigation.nearest(from: "A", direction: .moveDown, cards: cards) == "C") + #expect(CanvasSpatialNavigation.nearest(from: "B", direction: .moveDown, cards: cards) == "D") + } +} -- 2.51.2 From 29de4e5f3bfccc1a7256ce02b9d5c31e6ffd45a5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 26 Jun 2026 22:31:54 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20route=20Canvas=20arrow=20navigatio?= =?UTF-8?q?n=20through=20reducer=E2=86=92command=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu bar keyboard shortcuts intercept events before SwiftUI's .onKeyPress(), so the previous approach never received the key events. Switch to the existing CanvasCommandRequest channel: the reducer now sends .navigate(direction) commands when Canvas is active, and CanvasView fulfills them in fulfillCommandRequest. --- .../Canvas/Models/CanvasFocusRequest.swift | 1 + .../Models/CanvasSpatialNavigation.swift | 2 +- .../Canvas/Views/CanvasView+Focus.swift | 2 + .../Features/Canvas/Views/CanvasView.swift | 48 ------------------- .../RepositoriesFeature+CoreReducer.swift | 15 ++++-- 5 files changed, 16 insertions(+), 52 deletions(-) diff --git a/supacode/Features/Canvas/Models/CanvasFocusRequest.swift b/supacode/Features/Canvas/Models/CanvasFocusRequest.swift index d52e57f3..092aacf2 100644 --- a/supacode/Features/Canvas/Models/CanvasFocusRequest.swift +++ b/supacode/Features/Canvas/Models/CanvasFocusRequest.swift @@ -26,6 +26,7 @@ struct CanvasCommandRequest: Equatable, Sendable { case organize case tile case selectAll + case navigate(CanvasNavigationDirection) } let id: Int diff --git a/supacode/Features/Canvas/Models/CanvasSpatialNavigation.swift b/supacode/Features/Canvas/Models/CanvasSpatialNavigation.swift index 685938fe..9c5ba371 100644 --- a/supacode/Features/Canvas/Models/CanvasSpatialNavigation.swift +++ b/supacode/Features/Canvas/Models/CanvasSpatialNavigation.swift @@ -1,6 +1,6 @@ import CoreGraphics -enum CanvasNavigationDirection { +enum CanvasNavigationDirection: Equatable, Sendable { case moveUp, moveDown, moveLeft, moveRight } diff --git a/supacode/Features/Canvas/Views/CanvasView+Focus.swift b/supacode/Features/Canvas/Views/CanvasView+Focus.swift index fe500216..a3a546b4 100644 --- a/supacode/Features/Canvas/Views/CanvasView+Focus.swift +++ b/supacode/Features/Canvas/Views/CanvasView+Focus.swift @@ -17,6 +17,8 @@ extension CanvasView { tileCardsWithFit() case .selectAll: selectAllCards() + case .navigate(let direction): + navigateCard(direction) } onCommandConsumed(request.id) } diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index f08145c2..1df7353d 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -97,22 +97,6 @@ struct CanvasView: View { for: AppShortcuts.CommandID.expandCanvasCard, in: resolvedKeybindings ) - let selectNextWorktreeShortcut = AppShortcuts.resolvedShortcut( - for: AppShortcuts.CommandID.selectNextWorktree, - in: resolvedKeybindings - ) - let selectPreviousWorktreeShortcut = AppShortcuts.resolvedShortcut( - for: AppShortcuts.CommandID.selectPreviousWorktree, - in: resolvedKeybindings - ) - let selectNextShelfBookShortcut = AppShortcuts.resolvedShortcut( - for: AppShortcuts.CommandID.selectNextShelfBook, - in: resolvedKeybindings - ) - let selectPreviousShelfBookShortcut = AppShortcuts.resolvedShortcut( - for: AppShortcuts.CommandID.selectPreviousShelfBook, - in: resolvedKeybindings - ) let _ = configReloadCounter CanvasScrollContainer( offset: $canvasOffset, @@ -256,38 +240,6 @@ struct CanvasView: View { toggleExpandFocusedCard() return .handled } - .onKeyPress( - selectPreviousWorktreeShortcut?.keyEquivalent ?? AppShortcuts.selectPreviousWorktree.keyEquivalent, - phases: .down - ) { keyPress in - guard let shortcut = selectPreviousWorktreeShortcut else { return .ignored } - guard keyPress.modifiers == shortcut.modifiers else { return .ignored } - return navigateCard(.moveUp) ? .handled : .ignored - } - .onKeyPress( - selectNextWorktreeShortcut?.keyEquivalent ?? AppShortcuts.selectNextWorktree.keyEquivalent, - phases: .down - ) { keyPress in - guard let shortcut = selectNextWorktreeShortcut else { return .ignored } - guard keyPress.modifiers == shortcut.modifiers else { return .ignored } - return navigateCard(.moveDown) ? .handled : .ignored - } - .onKeyPress( - selectPreviousShelfBookShortcut?.keyEquivalent ?? AppShortcuts.selectPreviousShelfBook.keyEquivalent, - phases: .down - ) { keyPress in - guard let shortcut = selectPreviousShelfBookShortcut else { return .ignored } - guard keyPress.modifiers == shortcut.modifiers else { return .ignored } - return navigateCard(.moveLeft) ? .handled : .ignored - } - .onKeyPress( - selectNextShelfBookShortcut?.keyEquivalent ?? AppShortcuts.selectNextShelfBook.keyEquivalent, - phases: .down - ) { keyPress in - guard let shortcut = selectNextShelfBookShortcut else { return .ignored } - guard keyPress.modifiers == shortcut.modifiers else { return .ignored } - return navigateCard(.moveRight) ? .handled : .ignored - } .onChange(of: expandedTabID) { _, newValue in onExpandedChange(newValue != nil) } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 28cd78ca..180f538f 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -426,10 +426,16 @@ extension RepositoriesFeature { } case .selectNextShelfBook: + if state.isShowingCanvas { + return .send(.requestCanvasCommand(.navigate(.moveRight))) + } guard let book = shelfBook(atOffset: 1, state: state) else { return .none } return shelfBookSelectionEffect(for: book) case .selectPreviousShelfBook: + if state.isShowingCanvas { + return .send(.requestCanvasCommand(.navigate(.moveLeft))) + } guard let book = shelfBook(atOffset: -1, state: state) else { return .none } return shelfBookSelectionEffect(for: book) @@ -621,8 +627,9 @@ extension RepositoriesFeature { } case .selectNextWorktree: - // Canvas handles directional navigation in CanvasView.onKeyPress. - guard !state.isShowingCanvas else { return .none } + if state.isShowingCanvas { + return .send(.requestCanvasCommand(.navigate(.moveDown))) + } // In Shelf, the vertical arrow pair maps to tab navigation // within the open book — horizontal (← / →) is already book // navigation, so the two axes match the Shelf layout. @@ -635,7 +642,9 @@ extension RepositoriesFeature { return .send(.selectWorktree(id, focusTerminal: true)) case .selectPreviousWorktree: - guard !state.isShowingCanvas else { return .none } + if state.isShowingCanvas { + return .send(.requestCanvasCommand(.navigate(.moveUp))) + } if state.isShelfActive, let worktree = state.selectedTerminalWorktree { return .run { _ in await terminalClient.send(.performBindingAction(worktree, action: "previous_tab")) -- 2.51.2 From ebd00cdb6bb25bcbbd0689f4c93133a426052c2c Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 26 Jun 2026 23:04:42 +0900 Subject: [PATCH 03/12] ux: minimal scroll-to-reveal instead of zoom-to-fill on card nav Replace focusViewport (which recomputes scale and centers the card) with scrollToRevealCard: keeps the current zoom level and only pans the canvas when the target card is partially outside the viewport. Cards already in view don't trigger any canvas movement at all. --- .../Features/Canvas/Views/CanvasView.swift | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 1df7353d..08e98dbc 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -896,7 +896,7 @@ struct CanvasView: View { let allTabIDs = collectVisibleTabIDs(from: activeStates) if let first = allTabIDs.first { focusSingleCard(first, states: activeStates) - focusViewport(on: first) + scrollToRevealCard(first) } return !allTabIDs.isEmpty } @@ -919,10 +919,57 @@ struct CanvasView: View { } focusSingleCard(targetTabID, states: activeStates) - focusViewport(on: targetTabID) + scrollToRevealCard(targetTabID) return true } + private func scrollToRevealCard(_ tabID: TerminalTabID) { + guard viewportSize.width > 0, viewportSize.height > 0 else { return } + let cardKey = tabID.rawValue.uuidString + guard let layout = layoutStore.cardLayouts[cardKey] else { return } + + let margin: CGFloat = 20 + let cardHeight = layout.size.height + titleBarHeight + let scaledHalfW = layout.size.width / 2 * canvasScale + let scaledHalfH = cardHeight / 2 * canvasScale + let screenCenter = screenPosition(for: layout.position) + + let cardMinX = screenCenter.x - scaledHalfW + let cardMaxX = screenCenter.x + scaledHalfW + let cardMinY = screenCenter.y - scaledHalfH + let cardMaxY = screenCenter.y + scaledHalfH + + let viewMinX: CGFloat = 0 + let viewMaxX = viewportSize.width + let viewMinY: CGFloat = 0 + let viewMaxY = viewportSize.height - bottomToolbarReserve + + var deltaX: CGFloat = 0 + var deltaY: CGFloat = 0 + + if cardMinX < viewMinX + margin { + deltaX = (viewMinX + margin) - cardMinX + } else if cardMaxX > viewMaxX - margin { + deltaX = (viewMaxX - margin) - cardMaxX + } + + if cardMinY < viewMinY + margin { + deltaY = (viewMinY + margin) - cardMinY + } else if cardMaxY > viewMaxY - margin { + deltaY = (viewMaxY - margin) - cardMaxY + } + + guard deltaX != 0 || deltaY != 0 else { return } + + let newOffset = CGSize( + width: canvasOffset.width + deltaX, + height: canvasOffset.height + deltaY + ) + canvasOffset = newOffset + lastCanvasOffset = newOffset + focusViewportAnimationID &+= 1 + } + private func cardEntries( from states: [WorktreeTerminalState] ) -> [CanvasSpatialNavigation.CardEntry] { -- 2.51.2 From 35733a41bde150e7424c26857790e83f2391c6dd Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 26 Jun 2026 23:16:40 +0900 Subject: [PATCH 04/12] fix: animate canvas pan when revealing off-screen cards The previous focusViewportAnimationID mechanism only animated SwiftUI child views, not the canvas offset itself. Use withAnimation to drive the offset change so the pan is visually smooth. --- supacode/Features/Canvas/Views/CanvasView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 08e98dbc..dc306aa8 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -965,9 +965,10 @@ struct CanvasView: View { width: canvasOffset.width + deltaX, height: canvasOffset.height + deltaY ) - canvasOffset = newOffset - lastCanvasOffset = newOffset - focusViewportAnimationID &+= 1 + withAnimation(.easeInOut(duration: 0.22)) { + canvasOffset = newOffset + lastCanvasOffset = newOffset + } } private func cardEntries( -- 2.51.2 From 378f367859c70674de88a34459735da8da5e6706 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 27 Jun 2026 00:23:02 +0900 Subject: [PATCH 05/12] fix: use timer-based offset interpolation for smooth canvas pan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI withAnimation doesn't propagate through NSViewRepresentable boundaries, and AnimatedExpandableCard's animatableData only covers expand progress — not position changes from canvas offset. Use a 120Hz Timer to manually interpolate the offset with an easeInOut curve, producing smooth panning when revealing off-screen cards. --- .../Canvas/Views/CanvasSupportViews.swift | 51 +++++++++++++++++++ .../Features/Canvas/Views/CanvasView.swift | 9 ++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasSupportViews.swift b/supacode/Features/Canvas/Views/CanvasSupportViews.swift index f8f8a44a..7749c4bd 100644 --- a/supacode/Features/Canvas/Views/CanvasSupportViews.swift +++ b/supacode/Features/Canvas/Views/CanvasSupportViews.swift @@ -87,6 +87,57 @@ class CanvasScrollCoordinator { } } +@MainActor +@Observable +final class CanvasOffsetAnimator { + private var timer: Timer? + private var startOffset: CGSize = .zero + private var targetOffset: CGSize = .zero + private var startTime: CFTimeInterval = 0 + private var duration: CFTimeInterval = 0 + private var onUpdate: (@MainActor (CGSize) -> Void)? + + func animate( + from start: CGSize, + to target: CGSize, + duration: CFTimeInterval = 0.22, + onUpdate: @escaping @MainActor (CGSize) -> Void + ) { + cancel() + self.startOffset = start + self.targetOffset = target + self.startTime = CACurrentMediaTime() + self.duration = duration + self.onUpdate = onUpdate + + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.tick() } + } + } + + private func tick() { + let elapsed = CACurrentMediaTime() - startTime + let progress = min(elapsed / duration, 1.0) + let eased = progress < 0.5 ? 2 * progress * progress : 1 - pow(-2 * progress + 2, 2) / 2 + + let current = CGSize( + width: startOffset.width + (targetOffset.width - startOffset.width) * eased, + height: startOffset.height + (targetOffset.height - startOffset.height) * eased + ) + onUpdate?(current) + + if progress >= 1.0 { + cancel() + } + } + + func cancel() { + timer?.invalidate() + timer = nil + onUpdate = nil + } +} + /// Pure zoom math, extracted for testability. enum CanvasZoomMath { static let minScale: CGFloat = 0.25 diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index dc306aa8..3109d50c 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -41,6 +41,7 @@ struct CanvasView: View { /// The tab currently expanded in place (near-fullscreen overlay) on canvas, /// or nil when no card is expanded. @State var expandedTabID: TerminalTabID? + @State var offsetAnimator = CanvasOffsetAnimator() let minCardWidth: CGFloat = 300 let minCardHeight: CGFloat = 200 @@ -961,13 +962,13 @@ struct CanvasView: View { guard deltaX != 0 || deltaY != 0 else { return } - let newOffset = CGSize( + let target = CGSize( width: canvasOffset.width + deltaX, height: canvasOffset.height + deltaY ) - withAnimation(.easeInOut(duration: 0.22)) { - canvasOffset = newOffset - lastCanvasOffset = newOffset + offsetAnimator.animate(from: canvasOffset, to: target) { [self] current in + canvasOffset = current + lastCanvasOffset = current } } -- 2.51.2 From d7a58e06044e54458aff712f541ce672a451f7a2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 27 Jun 2026 00:30:44 +0900 Subject: [PATCH 06/12] ux: animate viewport pan+zoom when focusing cards from nav list Generalize CanvasOffsetAnimator into CanvasViewportAnimator that interpolates both offset and scale simultaneously. focusViewport (triggered by left nav clicks) now smoothly transitions instead of jumping. The arrow-key reveal animation uses the same animator with scale held constant. --- .../Canvas/Views/CanvasSupportViews.swift | 38 ++++++++++++------- .../Canvas/Views/CanvasView+Focus.swift | 13 ++++--- .../Features/Canvas/Views/CanvasView.swift | 10 +++-- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasSupportViews.swift b/supacode/Features/Canvas/Views/CanvasSupportViews.swift index 7749c4bd..9ce926c5 100644 --- a/supacode/Features/Canvas/Views/CanvasSupportViews.swift +++ b/supacode/Features/Canvas/Views/CanvasSupportViews.swift @@ -89,23 +89,28 @@ class CanvasScrollCoordinator { @MainActor @Observable -final class CanvasOffsetAnimator { +final class CanvasViewportAnimator { + struct Snapshot { + var offset: CGSize + var scale: CGFloat + } + private var timer: Timer? - private var startOffset: CGSize = .zero - private var targetOffset: CGSize = .zero + private var startSnapshot = Snapshot(offset: .zero, scale: 1) + private var targetSnapshot = Snapshot(offset: .zero, scale: 1) private var startTime: CFTimeInterval = 0 private var duration: CFTimeInterval = 0 - private var onUpdate: (@MainActor (CGSize) -> Void)? + private var onUpdate: (@MainActor (Snapshot) -> Void)? func animate( - from start: CGSize, - to target: CGSize, - duration: CFTimeInterval = 0.22, - onUpdate: @escaping @MainActor (CGSize) -> Void + from start: Snapshot, + to target: Snapshot, + duration: CFTimeInterval = 0.25, + onUpdate: @escaping @MainActor (Snapshot) -> Void ) { cancel() - self.startOffset = start - self.targetOffset = target + self.startSnapshot = start + self.targetSnapshot = target self.startTime = CACurrentMediaTime() self.duration = duration self.onUpdate = onUpdate @@ -120,9 +125,12 @@ final class CanvasOffsetAnimator { let progress = min(elapsed / duration, 1.0) let eased = progress < 0.5 ? 2 * progress * progress : 1 - pow(-2 * progress + 2, 2) / 2 - let current = CGSize( - width: startOffset.width + (targetOffset.width - startOffset.width) * eased, - height: startOffset.height + (targetOffset.height - startOffset.height) * eased + let current = Snapshot( + offset: CGSize( + width: lerp(startSnapshot.offset.width, targetSnapshot.offset.width, eased), + height: lerp(startSnapshot.offset.height, targetSnapshot.offset.height, eased) + ), + scale: lerp(startSnapshot.scale, targetSnapshot.scale, eased) ) onUpdate?(current) @@ -136,6 +144,10 @@ final class CanvasOffsetAnimator { timer = nil onUpdate = nil } + + private func lerp(_ start: CGFloat, _ end: CGFloat, _ fraction: CGFloat) -> CGFloat { + start + (end - start) * fraction + } } /// Pure zoom math, extracted for testability. diff --git a/supacode/Features/Canvas/Views/CanvasView+Focus.swift b/supacode/Features/Canvas/Views/CanvasView+Focus.swift index a3a546b4..c0195297 100644 --- a/supacode/Features/Canvas/Views/CanvasView+Focus.swift +++ b/supacode/Features/Canvas/Views/CanvasView+Focus.swift @@ -92,11 +92,14 @@ extension CanvasView { width: viewportSize.width / 2 - layout.position.x * targetScale, height: (viewportSize.height - bottomToolbarReserve) / 2 - layout.position.y * targetScale ) - canvasScale = targetScale - canvasOffset = targetOffset - lastCanvasScale = targetScale - lastCanvasOffset = targetOffset - focusViewportAnimationID &+= 1 + let start = CanvasViewportAnimator.Snapshot(offset: canvasOffset, scale: canvasScale) + let end = CanvasViewportAnimator.Snapshot(offset: targetOffset, scale: targetScale) + viewportAnimator.animate(from: start, to: end) { [self] snapshot in + canvasOffset = snapshot.offset + lastCanvasOffset = snapshot.offset + canvasScale = snapshot.scale + lastCanvasScale = snapshot.scale + } } func handleSelectionShieldTap( diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 3109d50c..8b2a2a9b 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -41,7 +41,7 @@ struct CanvasView: View { /// The tab currently expanded in place (near-fullscreen overlay) on canvas, /// or nil when no card is expanded. @State var expandedTabID: TerminalTabID? - @State var offsetAnimator = CanvasOffsetAnimator() + @State var viewportAnimator = CanvasViewportAnimator() let minCardWidth: CGFloat = 300 let minCardHeight: CGFloat = 200 @@ -966,9 +966,11 @@ struct CanvasView: View { width: canvasOffset.width + deltaX, height: canvasOffset.height + deltaY ) - offsetAnimator.animate(from: canvasOffset, to: target) { [self] current in - canvasOffset = current - lastCanvasOffset = current + let start = CanvasViewportAnimator.Snapshot(offset: canvasOffset, scale: canvasScale) + let end = CanvasViewportAnimator.Snapshot(offset: target, scale: canvasScale) + viewportAnimator.animate(from: start, to: end) { [self] snapshot in + canvasOffset = snapshot.offset + lastCanvasOffset = snapshot.offset } } -- 2.51.2 From 5ff83eacd3f07cd127cb9ce04bcdef8e8319fa21 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 27 Jun 2026 00:40:50 +0900 Subject: [PATCH 07/12] docs: add card navigation shortcut to help popover and canvas docs --- docs/components/canvas.md | 10 ++++++++++ .../Canvas/Views/CanvasHelpButton.swift | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/components/canvas.md b/docs/components/canvas.md index 6fdce076..e36da24f 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -43,6 +43,7 @@ Selection controls: | Operation | How | |-----------|-----| | Focus (enter) a card | Single-click its body | +| Navigate between cards | `⌘⌃↑` / `⌘⌃↓` / `⌘⌃←` / `⌘⌃→` — jump to the nearest card in that direction (spatial, based on card positions) | | Move a card | Drag its title bar | | Resize a card | Drag its edges/corners | | Expand / restore a card | `⌘⌥E`, double-click the title bar, or the title-bar expand button — fills the viewport with that card; click the dimmed background or `⌘⌥E` again to restore | @@ -115,6 +116,15 @@ performance. - `windowTintMode` / repository colors — card and nav tinting. - `showRunButtonInToolbar` — whether the Run button appears in the Canvas toolbar. +## Keyboard card navigation + +`⌘⌃↑` / `⌘⌃↓` / `⌘⌃←` / `⌘⌃→` move focus to the **nearest card** in +that direction, based on the 2D card positions on the board. If the target card +is partially off-screen, the canvas pans smoothly to reveal it without changing +the zoom level. These are the same key combos used for worktree/book navigation +in Normal and Shelf views — in Canvas they switch to spatial navigation +automatically. + ## When to recommend Canvas vs Shelf - **Canvas** = spatial, see-everything, broadcast to many. Best when you're diff --git a/supacode/Features/Canvas/Views/CanvasHelpButton.swift b/supacode/Features/Canvas/Views/CanvasHelpButton.swift index b3269e29..829d44bd 100644 --- a/supacode/Features/Canvas/Views/CanvasHelpButton.swift +++ b/supacode/Features/Canvas/Views/CanvasHelpButton.swift @@ -49,6 +49,16 @@ struct CanvasHelpButton: View { for: AppShortcuts.CommandID.expandCanvasCard, in: resolvedKeybindings ) + let navUpDown = [ + AppShortcuts.display(for: AppShortcuts.CommandID.selectPreviousWorktree, in: resolvedKeybindings), + AppShortcuts.display(for: AppShortcuts.CommandID.selectNextWorktree, in: resolvedKeybindings), + ].compactMap { $0 } + let navLeftRight = [ + AppShortcuts.display(for: AppShortcuts.CommandID.selectPreviousShelfBook, in: resolvedKeybindings), + AppShortcuts.display(for: AppShortcuts.CommandID.selectNextShelfBook, in: resolvedKeybindings), + ].compactMap { $0 } + let navKeys = (navUpDown + navLeftRight).joined(separator: " / ") + return VStack(alignment: .leading, spacing: 14) { Text("Canvas Navigation") .font(.headline) @@ -70,6 +80,13 @@ struct CanvasHelpButton: View { detail: expandShortcut.map { "\($0), or the card's title-bar button" } ?? "Use the card's title-bar button" ) + if !navKeys.isEmpty { + canvasHelpRow( + icon: "arrow.up.arrow.down.arrow.left.arrow.right", + title: "Navigate between cards", + detail: "\(navKeys) — jump to the nearest card in that direction" + ) + } } } .padding() -- 2.51.2 From d117a126507ddb10cbe326b8af229bfee8ae47af Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 27 Jun 2026 00:45:49 +0900 Subject: [PATCH 08/12] fix: use correct SF Symbol for card navigation help row --- supacode/Features/Canvas/Views/CanvasHelpButton.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Canvas/Views/CanvasHelpButton.swift b/supacode/Features/Canvas/Views/CanvasHelpButton.swift index 829d44bd..3a4d5c1d 100644 --- a/supacode/Features/Canvas/Views/CanvasHelpButton.swift +++ b/supacode/Features/Canvas/Views/CanvasHelpButton.swift @@ -82,7 +82,7 @@ struct CanvasHelpButton: View { ) if !navKeys.isEmpty { canvasHelpRow( - icon: "arrow.up.arrow.down.arrow.left.arrow.right", + icon: "arrow.up.and.down.and.arrow.left.and.right", title: "Navigate between cards", detail: "\(navKeys) — jump to the nearest card in that direction" ) -- 2.51.2 From c8b5502927333e7c599ed990435cb0e7c514fd5b Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 27 Jun 2026 01:38:29 +0900 Subject: [PATCH 09/12] fix: harden Canvas navigation follow-ups --- .../Canvas/Views/CanvasSupportViews.swift | 16 +++++-- .../Canvas/Views/CanvasView+Focus.swift | 1 + supacodeTests/RepositoriesFeatureTests.swift | 45 +++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasSupportViews.swift b/supacode/Features/Canvas/Views/CanvasSupportViews.swift index 9ce926c5..383c8e72 100644 --- a/supacode/Features/Canvas/Views/CanvasSupportViews.swift +++ b/supacode/Features/Canvas/Views/CanvasSupportViews.swift @@ -87,6 +87,14 @@ class CanvasScrollCoordinator { } } +private final class CanvasViewportAnimationTimerBox { + nonisolated(unsafe) var timer: Timer? + + deinit { + timer?.invalidate() + } +} + @MainActor @Observable final class CanvasViewportAnimator { @@ -95,7 +103,7 @@ final class CanvasViewportAnimator { var scale: CGFloat } - private var timer: Timer? + @ObservationIgnored private let timerBox = CanvasViewportAnimationTimerBox() private var startSnapshot = Snapshot(offset: .zero, scale: 1) private var targetSnapshot = Snapshot(offset: .zero, scale: 1) private var startTime: CFTimeInterval = 0 @@ -115,7 +123,7 @@ final class CanvasViewportAnimator { self.duration = duration self.onUpdate = onUpdate - timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { [weak self] _ in + timerBox.timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.tick() } } } @@ -140,8 +148,8 @@ final class CanvasViewportAnimator { } func cancel() { - timer?.invalidate() - timer = nil + timerBox.timer?.invalidate() + timerBox.timer = nil onUpdate = nil } diff --git a/supacode/Features/Canvas/Views/CanvasView+Focus.swift b/supacode/Features/Canvas/Views/CanvasView+Focus.swift index c0195297..85fa2cb8 100644 --- a/supacode/Features/Canvas/Views/CanvasView+Focus.swift +++ b/supacode/Features/Canvas/Views/CanvasView+Focus.swift @@ -260,6 +260,7 @@ extension CanvasView { } func deactivateCanvas() { + viewportAnimator.cancel() expandedTabID = nil let activeStates = terminalManager.activeWorktreeStates for state in activeStates { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 47a8fa21..9288fcbc 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -42,6 +42,51 @@ struct RepositoriesFeatureTests { } } + @Test func selectNextShelfBookRoutesToRightNavigationInCanvas() async { + await assertCanvasNavigationActionRoutesToCommand( + action: .selectNextShelfBook, + command: .navigate(.moveRight) + ) + } + + @Test func selectPreviousShelfBookRoutesToLeftNavigationInCanvas() async { + await assertCanvasNavigationActionRoutesToCommand( + action: .selectPreviousShelfBook, + command: .navigate(.moveLeft) + ) + } + + @Test func selectNextWorktreeRoutesToDownNavigationInCanvas() async { + await assertCanvasNavigationActionRoutesToCommand( + action: .selectNextWorktree, + command: .navigate(.moveDown) + ) + } + + @Test func selectPreviousWorktreeRoutesToUpNavigationInCanvas() async { + await assertCanvasNavigationActionRoutesToCommand( + action: .selectPreviousWorktree, + command: .navigate(.moveUp) + ) + } + + private func assertCanvasNavigationActionRoutesToCommand( + action: RepositoriesFeature.Action, + command: CanvasCommandRequest.Command + ) async { + var initialState = RepositoriesFeature.State() + initialState.selection = .canvas + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } + + await store.send(action) + await store.receive(\.requestCanvasCommand) { + $0.nextCanvasCommandRequestID = 1 + $0.pendingCanvasCommandRequest = CanvasCommandRequest(id: 1, command: command) + } + } + @Test func refreshWorktreesSetsRefreshingStateUntilLoadCompletes() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) -- 2.51.2 From 2137ef3268a32e13c902c9de05c811c245450633 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 27 Jun 2026 01:54:01 +0900 Subject: [PATCH 10/12] fix: keep Canvas tile navigation stable --- .../Canvas/Views/CanvasSupportViews.swift | 66 +++++++++++ .../Features/Canvas/Views/CanvasView.swift | 105 ++++++++---------- supacodeTests/CanvasZoomMathTests.swift | 57 ++++++++++ 3 files changed, 169 insertions(+), 59 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasSupportViews.swift b/supacode/Features/Canvas/Views/CanvasSupportViews.swift index 383c8e72..a6816748 100644 --- a/supacode/Features/Canvas/Views/CanvasSupportViews.swift +++ b/supacode/Features/Canvas/Views/CanvasSupportViews.swift @@ -194,6 +194,72 @@ enum CanvasZoomMath { } } +enum CanvasViewportMath { + struct FitResult: Equatable { + let scale: CGFloat + let offset: CGSize + } + + static func fit( + bounds: CGRect, + viewport: CGSize, + bottomReserve: CGFloat, + padding: CGFloat + ) -> FitResult? { + guard viewport.width > 0, viewport.height > 0, + !bounds.isNull, bounds.width > 0, bounds.height > 0, + bounds.width.isFinite, bounds.height.isFinite + else { + return nil + } + + let visibleWidth = viewport.width + let visibleHeight = max(1, viewport.height - bottomReserve) + let paddedWidth = max(1, bounds.width + padding * 2) + let paddedHeight = max(1, bounds.height + padding * 2) + let scale = max(0.25, min(1.0, min(visibleWidth / paddedWidth, visibleHeight / paddedHeight))) + + return FitResult( + scale: scale, + offset: CGSize( + width: visibleWidth / 2 - bounds.midX * scale, + height: visibleHeight / 2 - bounds.midY * scale + ) + ) + } + + static func revealDelta( + for cardRect: CGRect, + viewport: CGSize, + bottomReserve: CGFloat, + margin: CGFloat + ) -> CGSize { + guard viewport.width > 0, viewport.height > 0 else { return .zero } + + let viewMinX: CGFloat = 0 + let viewMaxX = viewport.width + let viewMinY: CGFloat = 0 + let viewMaxY = max(0, viewport.height - bottomReserve) + + var deltaX: CGFloat = 0 + var deltaY: CGFloat = 0 + + if cardRect.minX < viewMinX { + deltaX = (viewMinX + margin) - cardRect.minX + } else if cardRect.maxX > viewMaxX { + deltaX = (viewMaxX - margin) - cardRect.maxX + } + + if cardRect.minY < viewMinY { + deltaY = (viewMinY + margin) - cardRect.minY + } else if cardRect.maxY > viewMaxY { + deltaY = (viewMaxY - margin) - cardRect.maxY + } + + return CGSize(width: deltaX, height: deltaY) + } +} + class CanvasScrollContainerView: NSView { var scrollCoordinator: CanvasScrollCoordinator? /// When false (a card is expanded), the container ignores scroll/zoom/ diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 8b2a2a9b..e637871f 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -700,38 +700,27 @@ struct CanvasView: View { let keys = collectCardKeys(from: terminalManager.activeWorktreeStates) guard !keys.isEmpty else { return } - // Bounding box of all cards in canvas coordinates - var minX = CGFloat.infinity - var minY = CGFloat.infinity - var maxX = -CGFloat.infinity - var maxY = -CGFloat.infinity + var bounds = CGRect.null for key in keys { guard let layout = layoutStore.cardLayouts[key] else { continue } - let halfW = layout.size.width / 2 - let halfH = (layout.size.height + titleBarHeight) / 2 - minX = min(minX, layout.position.x - halfW) - minY = min(minY, layout.position.y - halfH) - maxX = max(maxX, layout.position.x + halfW) - maxY = max(maxY, layout.position.y + halfH) + bounds = bounds.union(cardRect(for: layout)) } - guard minX.isFinite else { return } - - let padding: CGFloat = 30 - let bboxW = maxX - minX + padding * 2 - let bboxH = maxY - minY + padding * 2 - let bboxCenterX = (minX + maxX) / 2 - let bboxCenterY = (minY + maxY) / 2 - - let newScale = max(0.25, min(1.0, min(canvasSize.width / bboxW, canvasSize.height / bboxH))) + guard + let fit = CanvasViewportMath.fit( + bounds: bounds, + viewport: canvasSize, + bottomReserve: bottomToolbarReserve, + padding: 30 + ) + else { + return + } - canvasOffset = CGSize( - width: canvasSize.width / 2 - bboxCenterX * newScale, - height: (canvasSize.height - bottomToolbarReserve) / 2 - bboxCenterY * newScale - ) - canvasScale = newScale - lastCanvasScale = newScale + canvasOffset = fit.offset + canvasScale = fit.scale + lastCanvasScale = fit.scale lastCanvasOffset = canvasOffset } @@ -929,42 +918,19 @@ struct CanvasView: View { let cardKey = tabID.rawValue.uuidString guard let layout = layoutStore.cardLayouts[cardKey] else { return } - let margin: CGFloat = 20 - let cardHeight = layout.size.height + titleBarHeight - let scaledHalfW = layout.size.width / 2 * canvasScale - let scaledHalfH = cardHeight / 2 * canvasScale - let screenCenter = screenPosition(for: layout.position) - - let cardMinX = screenCenter.x - scaledHalfW - let cardMaxX = screenCenter.x + scaledHalfW - let cardMinY = screenCenter.y - scaledHalfH - let cardMaxY = screenCenter.y + scaledHalfH - - let viewMinX: CGFloat = 0 - let viewMaxX = viewportSize.width - let viewMinY: CGFloat = 0 - let viewMaxY = viewportSize.height - bottomToolbarReserve - - var deltaX: CGFloat = 0 - var deltaY: CGFloat = 0 - - if cardMinX < viewMinX + margin { - deltaX = (viewMinX + margin) - cardMinX - } else if cardMaxX > viewMaxX - margin { - deltaX = (viewMaxX - margin) - cardMaxX - } - - if cardMinY < viewMinY + margin { - deltaY = (viewMinY + margin) - cardMinY - } else if cardMaxY > viewMaxY - margin { - deltaY = (viewMaxY - margin) - cardMaxY - } + let cardRect = screenRect(for: layout) + let delta = CanvasViewportMath.revealDelta( + for: cardRect, + viewport: viewportSize, + bottomReserve: bottomToolbarReserve, + margin: 20 + ) - guard deltaX != 0 || deltaY != 0 else { return } + guard delta != .zero else { return } let target = CGSize( - width: canvasOffset.width + deltaX, - height: canvasOffset.height + deltaY + width: canvasOffset.width + delta.width, + height: canvasOffset.height + delta.height ) let start = CanvasViewportAnimator.Snapshot(offset: canvasOffset, scale: canvasScale) let end = CanvasViewportAnimator.Snapshot(offset: target, scale: canvasScale) @@ -974,6 +940,27 @@ struct CanvasView: View { } } + private func cardRect(for layout: CanvasCardLayout) -> 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 screenRect(for layout: CanvasCardLayout) -> CGRect { + let rect = cardRect(for: layout) + return CGRect( + x: rect.minX * canvasScale + canvasOffset.width, + y: rect.minY * canvasScale + canvasOffset.height, + width: rect.width * canvasScale, + height: rect.height * canvasScale + ) + } + private func cardEntries( from states: [WorktreeTerminalState] ) -> [CanvasSpatialNavigation.CardEntry] { diff --git a/supacodeTests/CanvasZoomMathTests.swift b/supacodeTests/CanvasZoomMathTests.swift index 89de2b65..5240a673 100644 --- a/supacodeTests/CanvasZoomMathTests.swift +++ b/supacodeTests/CanvasZoomMathTests.swift @@ -116,3 +116,60 @@ struct CanvasZoomMathTests { #expect(precise.scale > 1.0) } } + +struct CanvasViewportMathTests { + @Test func fitKeepsFullHeightTileCardsInsideRevealViewport() throws { + let viewport = CGSize(width: 1600, height: 900) + let bottomReserve: CGFloat = 50 + let leftCard = CGRect(x: 20, y: 20, width: 773, height: 860) + let rightCard = CGRect(x: 807, y: 20, width: 773, height: 860) + let bounds = leftCard.union(rightCard) + + let fit = try #require( + CanvasViewportMath.fit( + bounds: bounds, + viewport: viewport, + bottomReserve: bottomReserve, + padding: 30 + ) + ) + + for card in [leftCard, rightCard] { + let screenRect = CGRect( + x: card.minX * fit.scale + fit.offset.width, + y: card.minY * fit.scale + fit.offset.height, + width: card.width * fit.scale, + height: card.height * fit.scale + ) + let delta = CanvasViewportMath.revealDelta( + for: screenRect, + viewport: viewport, + bottomReserve: bottomReserve, + margin: 20 + ) + #expect(delta == .zero) + } + } + + @Test func fullyVisibleCardInsideMarginDoesNotReveal() { + let delta = CanvasViewportMath.revealDelta( + for: CGRect(x: 10, y: 10, width: 100, height: 100), + viewport: CGSize(width: 200, height: 200), + bottomReserve: 0, + margin: 20 + ) + + #expect(delta == .zero) + } + + @Test func partiallyOffscreenCardRevealsWithMargin() { + let delta = CanvasViewportMath.revealDelta( + for: CGRect(x: -5, y: 50, width: 100, height: 100), + viewport: CGSize(width: 200, height: 200), + bottomReserve: 0, + margin: 20 + ) + + #expect(delta == CGSize(width: 25, height: 0)) + } +} -- 2.51.2 From 2a40fa0aeb632ccb9f2c06630ff1445ff012c0bb Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 27 Jun 2026 09:40:44 +0900 Subject: [PATCH 11/12] tweak: tighten Canvas fit margins --- supacode/Features/Canvas/Views/CanvasView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index e637871f..15f83fa7 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -51,11 +51,13 @@ struct CanvasView: View { let cardSpacing: CGFloat = 20 /// Tighter gap for the Tile layout. It lives in the scaled-up tile frame, so /// the on-screen gap shrinks further as more cards are tiled (gap × scale). - let tileCardSpacing: CGFloat = 14 + let tileCardSpacing: CGFloat = 10 + /// Outer margin kept when fitting the whole canvas into the visible viewport. + let viewportFitPadding: CGFloat = 12 /// Reserved height at the bottom of the viewport for the help button and /// layout toolbar so cards don't sit underneath them after auto-fit. /// Cards end up shifted upward by half of this amount. - let bottomToolbarReserve: CGFloat = 50 + let bottomToolbarReserve: CGFloat = 40 /// Margin kept on every side of a card temporarily expanded to near-fullscreen. let expandPadding: CGFloat = 40 /// Shared animation for expand / restore / relayout. Matches the easeInOut @@ -712,7 +714,7 @@ struct CanvasView: View { bounds: bounds, viewport: canvasSize, bottomReserve: bottomToolbarReserve, - padding: 30 + padding: viewportFitPadding ) else { return -- 2.51.2 From abceefe1c9562c94f538194cc05aeaf0f03022dd Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 27 Jun 2026 09:47:10 +0900 Subject: [PATCH 12/12] tweak: adjust Canvas tile spacing --- supacode/Features/Canvas/Views/CanvasView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 15f83fa7..7069b5e8 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -51,7 +51,7 @@ struct CanvasView: View { let cardSpacing: CGFloat = 20 /// Tighter gap for the Tile layout. It lives in the scaled-up tile frame, so /// the on-screen gap shrinks further as more cards are tiled (gap × scale). - let tileCardSpacing: CGFloat = 10 + let tileCardSpacing: CGFloat = 12 /// Outer margin kept when fitting the whole canvas into the visible viewport. let viewportFitPadding: CGFloat = 12 /// Reserved height at the bottom of the viewport for the help button and