diff --git a/CHANGELOG.md b/CHANGELOG.md index d59d2bf1426b..8afa4d71cbef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to cmux are documented here. - The Settings > Mobile pairing row says Open Pairing and no longer promises a Tailscale QR code, which pairing does not use ([#14817](https://github.com/manaflow-ai/cmux/pull/14817)) -- thanks @aliyansajid! ### Fixed +- In canvas mode, panes an agent or the CLI creates no longer scroll the canvas away from what you're watching, socket canvas commands apply without animation, and canvas pans and the overview toggle respect Reduce Motion ([#14939](https://github.com/manaflow-ai/cmux/pull/14939)) - Hovering a command palette or session index row no longer looks as strong as (or erases) the selection; terminal and browser find fields show a focus stroke; group header unread badges follow the Notification Badge color; and the feed's Deny and Allow Once buttons stay visible in both light and dark mode ([#14941](https://github.com/manaflow-ai/cmux/pull/14941)). - Hovering a sidebar workspace swaps its unread badge for the close button in the same frame, feed selection with j/k no longer eases between rows, clearing a notification closes the gap at once, and holding Command no longer animates unrelated right sidebar changes ([#14927](https://github.com/manaflow-ai/cmux/pull/14927)). - In a split, a pane you switch to no longer shows a dimmed frame before it brightens; the unfocused-pane dim now changes in the same frame as focus ([#14892](https://github.com/manaflow-ai/cmux/pull/14892)). diff --git a/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasRootView+Viewport.swift b/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasRootView+Viewport.swift index 83f89b39f806..390046dd414e 100644 --- a/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasRootView+Viewport.swift +++ b/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasRootView+Viewport.swift @@ -6,14 +6,16 @@ import CmuxCanvas extension CanvasRootView: CanvasViewportControlling { private static let discreteZoomAnimationKey = "cmux.canvas.discreteZoom" private static let discreteZoomAnimationDuration: TimeInterval = 0.2 + static let overviewAnimationDuration: TimeInterval = 0.3 public func modelDidChangeExternally(animated: Bool) { reconcilePanes() applyZOrder() recomputeDocumentGeometry() - if animated { + if shouldAnimate(animated) { + onMotionAnimationStarted?(Self.paneFrameAnimationDuration) NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.25 + context.duration = Self.paneFrameAnimationDuration context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) context.allowsImplicitAnimation = true for (paneID, paneView) in paneViews { @@ -52,18 +54,26 @@ extension CanvasRootView: CanvasViewportControlling { } public func zoom(by factor: CGFloat) { + zoom(by: factor, animated: true) + } + + public func zoom(by factor: CGFloat, animated: Bool) { // An explicit zoom invalidates the overview round-trip restore. overviewRestore = nil let target = min( max(scrollView.magnification * factor, scrollView.minMagnification), scrollView.maxMagnification ) - setMagnification(target) + setMagnification(target, animated: animated) } public func resetZoom() { + resetZoom(animated: true) + } + + public func resetZoom(animated: Bool) { overviewRestore = nil - setMagnification(1.0) + setMagnification(1.0, animated: animated) } public var currentMagnification: CGFloat { @@ -148,11 +158,11 @@ extension CanvasRootView: CanvasViewportControlling { } /// Applies `magnification`, keeping the current viewport center fixed. - private func setMagnification(_ magnification: CGFloat) { + private func setMagnification(_ magnification: CGFloat, animated: Bool) { cancelDiscreteZoomAnimation() guard magnification != scrollView.magnification else { return } let center = currentCenterInCanvas - if shouldReduceMotionForDiscreteZoom() { + if !shouldAnimate(animated) { applyViewport(center: center, magnification: magnification, notifySettled: true) return } @@ -205,6 +215,7 @@ extension CanvasRootView: CanvasViewportControlling { layer.sublayerTransform = CATransform3DIdentity CATransaction.commit() + onMotionAnimationStarted?(Self.discreteZoomAnimationDuration) let animation = CABasicAnimation(keyPath: "sublayerTransform") animation.fromValue = NSValue(caTransform3D: compensation) animation.toValue = NSValue(caTransform3D: CATransform3DIdentity) @@ -249,17 +260,14 @@ extension CanvasRootView: CanvasViewportControlling { } public func toggleOverview() { + toggleOverview(animated: true) + } + + public func toggleOverview(animated: Bool) { cancelDiscreteZoomAnimation() if let restore = overviewRestore { overviewRestore = nil - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.3 - context.allowsImplicitAnimation = true - scrollView.animator().magnification = restore.magnification - scrollView.contentView.animator().setBoundsOrigin(restore.origin) - scrollView.reflectScrolledClipView(scrollView.contentView) - } - updateMinimap(reveal: true) + applyOverviewViewport(magnification: restore.magnification, origin: restore.origin, animated: animated) return } guard let content = model.contentBounds else { return } @@ -281,11 +289,24 @@ extension CanvasRootView: CanvasViewportControlling { x: docCenter.x - clipSize.width / 2, y: docCenter.y - clipSize.height / 2 ) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.3 - context.allowsImplicitAnimation = true - scrollView.animator().magnification = fit - scrollView.contentView.animator().setBoundsOrigin(targetOrigin) + applyOverviewViewport(magnification: fit, origin: targetOrigin, animated: animated) + } + + /// Moves to an overview (or back to the saved viewport), animated unless + /// the caller or Reduce Motion says otherwise. + private func applyOverviewViewport(magnification: CGFloat, origin: CGPoint, animated: Bool) { + if shouldAnimate(animated) { + onMotionAnimationStarted?(Self.overviewAnimationDuration) + NSAnimationContext.runAnimationGroup { context in + context.duration = Self.overviewAnimationDuration + context.allowsImplicitAnimation = true + scrollView.animator().magnification = magnification + scrollView.contentView.animator().setBoundsOrigin(origin) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + } else { + scrollView.magnification = magnification + scrollView.contentView.setBoundsOrigin(origin) scrollView.reflectScrolledClipView(scrollView.contentView) } updateMinimap(reveal: true) diff --git a/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasRootView.swift b/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasRootView.swift index 0570dd542ce3..bfec731a8944 100644 --- a/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasRootView.swift +++ b/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasRootView.swift @@ -58,7 +58,12 @@ public final class CanvasRootView: NSView { private var pendingViewportRestore: (canvasCenter: CGPoint, magnification: CGFloat)? var isDiscreteZoomAnimationActive = false var discreteZoomAnimationGeneration: UInt64 = 0 - var shouldReduceMotionForDiscreteZoom: () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion } + /// Reduce Motion gate for every canvas animation (pans, pane frames, + /// discrete zoom). Tests replace it. + var shouldReduceMotion: () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion } + /// Called with the duration whenever a viewport pan or pane-frame + /// animation actually starts. Tests observe it. + var onMotionAnimationStarted: ((TimeInterval) -> Void)? /// True while programmatically applying a saved viewport, so the scroll /// events that causes don't overwrite the saved value with transients. private var isApplyingSavedViewport = false @@ -66,6 +71,8 @@ public final class CanvasRootView: NSView { /// panes don't flicker on at the edge mid-flick. private static let lifecycleMarginFraction: CGFloat = 0.5 static let revealMargin: CGFloat = 24 + static let panAnimationDuration: TimeInterval = 0.28 + static let paneFrameAnimationDuration: TimeInterval = 0.25 static let overviewPadding: CGFloat = 48 struct DragSession { let paneID: CanvasPaneID @@ -264,8 +271,12 @@ public final class CanvasRootView: NSView { // viewport so switching back lands exactly where the user left off. pendingViewportRestore = saved applyPendingViewportRestoreIfPossible() - } else if let revealTarget = added.last { - revealPane(revealTarget, animated: true) + } else if let focusedPanelId, added.contains(focusedPanelId) { + // A pane that arrives through sync was created off the canvas + // gesture path (agent, CLI, socket). It pulls the viewport only + // when it also took focus, and without animation; one that did + // not take focus leaves the user's view where it is. + revealPane(focusedPanelId, animated: false) } } @@ -506,10 +517,17 @@ public final class CanvasRootView: NSView { setClipOrigin(target, animated: animated) } + /// Whether a requested animation should run. Reduce Motion turns every + /// canvas pan and pane-frame animation into an immediate move. + func shouldAnimate(_ requested: Bool) -> Bool { + requested && !shouldReduceMotion() + } + func setClipOrigin(_ origin: CGPoint, animated: Bool) { - if animated { + if shouldAnimate(animated) { + onMotionAnimationStarted?(Self.panAnimationDuration) NSAnimationContext.runAnimationGroup { context in - context.duration = 0.28 + context.duration = Self.panAnimationDuration context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) context.allowsImplicitAnimation = true scrollView.contentView.animator().setBoundsOrigin(origin) diff --git a/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasViewportControlling.swift b/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasViewportControlling.swift index e2b7b430e76f..4ef743df36c2 100644 --- a/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasViewportControlling.swift +++ b/Packages/macOS/CmuxCanvasUI/Sources/CmuxCanvasUI/CanvasViewportControlling.swift @@ -12,11 +12,17 @@ public protocol CanvasViewportControlling: AnyObject { func revealPane(_ panelId: UUID, animated: Bool) /// Toggles between fit-all overview magnification and the previous zoom. func toggleOverview() + /// ``toggleOverview()`` with explicit motion; `false` jumps (socket verbs). + func toggleOverview(animated: Bool) /// Multiplies the magnification by `factor` (clamped), anchored at the /// viewport center. func zoom(by factor: CGFloat) + /// ``zoom(by:)`` with explicit motion; `false` jumps (socket verbs). + func zoom(by factor: CGFloat, animated: Bool) /// Returns to 100% magnification, anchored at the viewport center. func resetZoom() + /// ``resetZoom()`` with explicit motion; `false` jumps (socket verbs). + func resetZoom(animated: Bool) /// Centers the viewport on `center` (canvas coordinates) and, when /// `magnification` is non-nil, sets the magnification (clamped to the /// scroll view's range). A nil magnification keeps the current zoom. @@ -29,3 +35,10 @@ public protocol CanvasViewportControlling: AnyObject { /// automation verb) and animates pane views to their new frames. func modelDidChangeExternally(animated: Bool) } + +extension CanvasViewportControlling { + /// Conformers without motion (test spies) ignore `animated`. + public func toggleOverview(animated: Bool) { toggleOverview() } + public func zoom(by factor: CGFloat, animated: Bool) { zoom(by: factor) } + public func resetZoom(animated: Bool) { resetZoom() } +} diff --git a/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasViewportMotionTests.swift b/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasViewportMotionTests.swift new file mode 100644 index 000000000000..82efa58c0ce3 --- /dev/null +++ b/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasViewportMotionTests.swift @@ -0,0 +1,229 @@ +import AppKit +import Foundation +import Testing +import CmuxCanvas +@testable import CmuxCanvasUI + +/// Viewport and pane-frame motion: panes the user did not create must not +/// pull the viewport, and Reduce Motion turns every pan and frame animation +/// into an immediate move. +@MainActor +@Suite("Canvas viewport motion") +struct CanvasViewportMotionTests { + @Test func unfocusedAddedPaneDoesNotMoveViewport() throws { + let panelA = UUID() + let panelB = UUID() + let root = makeRoot(panels: [panelA], focused: panelA) + defer { root.teardown() } + root.shouldReduceMotion = { false } + var animations: [TimeInterval] = [] + root.onMotionAnimationStarted = { animations.append($0) } + let originBefore = visibleRect(root).origin + + root.sync(descriptors: descriptors([panelA, panelB]), focusedPanelId: panelA, isWorkspaceVisible: true) + let paneB = try #require(root.model.frame(of: panelB)) + #expect(!visibleRect(root).contains(root.documentRect(fromCanvas: paneB))) + settleAnimations() + + #expect(visibleRect(root).origin == originBefore) + #expect(animations.isEmpty) + } + + @Test func focusedAddedPaneIsRevealedWithoutAnimation() throws { + let panelA = UUID() + let panelB = UUID() + let root = makeRoot(panels: [panelA], focused: panelA) + defer { root.teardown() } + root.shouldReduceMotion = { false } + var animations: [TimeInterval] = [] + root.onMotionAnimationStarted = { animations.append($0) } + + root.sync(descriptors: descriptors([panelA, panelB]), focusedPanelId: panelB, isWorkspaceVisible: true) + + let paneB = try #require(root.model.frame(of: panelB)) + #expect(visibleRect(root).contains(root.documentRect(fromCanvas: paneB))) + #expect(animations.isEmpty) + } + + @Test func revealPaneAnimatesWhenMotionIsAllowed() throws { + let panelA = UUID() + let panelB = UUID() + let root = makeRoot(panels: [panelA, panelB], frames: farApartFrames(panelA, panelB), focused: panelA) + defer { root.teardown() } + root.shouldReduceMotion = { false } + var animations: [TimeInterval] = [] + root.onMotionAnimationStarted = { animations.append($0) } + + root.revealPane(panelB, animated: true) + + #expect(animations == [CanvasRootView.panAnimationDuration]) + } + + @Test func reduceMotionRevealsPaneImmediately() throws { + let panelA = UUID() + let panelB = UUID() + let root = makeRoot(panels: [panelA, panelB], frames: farApartFrames(panelA, panelB), focused: panelA) + defer { root.teardown() } + root.shouldReduceMotion = { true } + var animations: [TimeInterval] = [] + root.onMotionAnimationStarted = { animations.append($0) } + + root.revealPane(panelB, animated: true) + + let paneB = try #require(root.model.frame(of: panelB)) + #expect(visibleRect(root).contains(root.documentRect(fromCanvas: paneB))) + #expect(animations.isEmpty) + } + + @Test func reduceMotionAppliesExternalFrameChangesImmediately() throws { + let panelA = UUID() + let root = makeRoot(panels: [panelA], focused: panelA) + defer { root.teardown() } + root.shouldReduceMotion = { true } + var animations: [TimeInterval] = [] + root.onMotionAnimationStarted = { animations.append($0) } + let paneView = try #require(root.paneViews[CanvasPaneID(rawValue: panelA)]) + + root.model.setFrame(CGRect(x: 40, y: 30, width: 500, height: 300), for: panelA) + root.modelDidChangeExternally(animated: true) + + let target = try #require(root.model.frame(of: panelA)) + #expect(paneView.frame == root.documentRect(fromCanvas: target)) + #expect(animations.isEmpty) + } + + @Test func externalFrameChangesAnimateWhenMotionIsAllowed() throws { + let panelA = UUID() + let root = makeRoot(panels: [panelA], focused: panelA) + defer { root.teardown() } + root.shouldReduceMotion = { false } + var animations: [TimeInterval] = [] + root.onMotionAnimationStarted = { animations.append($0) } + + root.model.setFrame(CGRect(x: 40, y: 30, width: 500, height: 300), for: panelA) + root.modelDidChangeExternally(animated: true) + + #expect(animations == [CanvasRootView.paneFrameAnimationDuration]) + } + + @Test func reduceMotionTogglesOverviewImmediately() throws { + let panelA = UUID() + let panelB = UUID() + let root = makeRoot(panels: [panelA, panelB], frames: farApartFrames(panelA, panelB), focused: panelA) + defer { root.teardown() } + root.shouldReduceMotion = { true } + var animations: [TimeInterval] = [] + root.onMotionAnimationStarted = { animations.append($0) } + let magnificationBefore = root.currentMagnification + + root.toggleOverview() + + let paneB = try #require(root.model.frame(of: panelB)) + #expect(root.currentMagnification < magnificationBefore) + #expect(visibleRect(root).contains(root.documentRect(fromCanvas: paneB))) + + root.toggleOverview() + + #expect(abs(root.currentMagnification - magnificationBefore) < 0.0001) + #expect(animations.isEmpty) + } + + @Test func overviewAnimatesWhenMotionIsAllowed() throws { + let panelA = UUID() + let panelB = UUID() + let root = makeRoot(panels: [panelA, panelB], frames: farApartFrames(panelA, panelB), focused: panelA) + defer { root.teardown() } + root.shouldReduceMotion = { false } + var animations: [TimeInterval] = [] + root.onMotionAnimationStarted = { animations.append($0) } + + root.toggleOverview() + + #expect(animations == [CanvasRootView.overviewAnimationDuration]) + } + + @Test func unanimatedOverviewAndZoomSkipAnimationWhenMotionIsAllowed() throws { + let panelA = UUID() + let panelB = UUID() + let root = makeRoot(panels: [panelA, panelB], frames: farApartFrames(panelA, panelB), focused: panelA) + defer { root.teardown() } + root.shouldReduceMotion = { false } + var animations: [TimeInterval] = [] + root.onMotionAnimationStarted = { animations.append($0) } + let magnificationBefore = root.currentMagnification + + root.toggleOverview(animated: false) + #expect(root.currentMagnification < magnificationBefore) + root.toggleOverview(animated: false) + root.zoom(by: 0.8, animated: false) + + #expect(abs(root.currentMagnification - magnificationBefore * 0.8) < 0.0001) + #expect(!root.isDiscreteZoomAnimationActive) + #expect(animations.isEmpty) + } + + // MARK: Helpers + + private func farApartFrames(_ first: UUID, _ second: UUID) -> [UUID: CGRect] { + [ + first: CGRect(x: 0, y: 0, width: 640, height: 360), + second: CGRect(x: 1_600, y: 0, width: 640, height: 360), + ] + } + + private func visibleRect(_ root: CanvasRootView) -> CGRect { + root.scrollView.contentView.documentVisibleRect + } + + /// Lets any running implicit animation finish (the canvas pan is 0.28 s). + private func settleAnimations() { + RunLoop.main.run(until: Date().addingTimeInterval(0.5)) + } + + private func descriptors(_ panels: [UUID]) -> [CanvasPaneDescriptor] { + panels.map { panel in + CanvasPaneDescriptor( + id: panel, + tab: CanvasTabChrome(id: panel, title: "Pane", iconSystemName: nil), + isFocused: false, + closeActionLabel: "", + makeMount: { _ in TestMount() } + ) + } + } + + private func makeRoot( + panels: [UUID], + frames: [UUID: CGRect] = [:], + focused: UUID + ) -> CanvasRootView { + let model = CanvasModel(metricsProvider: { + CanvasMetrics(gap: 16, snapThreshold: 8, minPaneSize: CanvasSize(width: 120, height: 80)) + }) + model.restoreFrames(panels.map { panel in + (id: panel, frame: frames[panel] ?? CGRect(x: 0, y: 0, width: 640, height: 360)) + }) + let root = CanvasRootView( + model: model, + commandScrollHintText: "", + minimapAccessibilityLabel: "", + minimapAccessibilityHelp: "", + callbacks: CanvasHostCallbacks( + onFocusPanel: { _ in }, + onClosePanel: { _ in }, + onLayoutChanged: {} + ), + themeProvider: { + CanvasTheme(canvasBackground: .windowBackgroundColor, paneBackground: .windowBackgroundColor) + }, + minimapClock: ContinuousClock() + ) + let host = NSView(frame: CGRect(x: 0, y: 0, width: 800, height: 500)) + root.frame = host.bounds + host.addSubview(root) + root.layoutSubtreeIfNeeded() + root.sync(descriptors: descriptors(panels), focusedPanelId: focused, isWorkspaceVisible: true) + root.layoutSubtreeIfNeeded() + return root + } +} diff --git a/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasViewportZoomTests.swift b/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasViewportZoomTests.swift index 85a4ec7e76bc..6d9b43f6dba6 100644 --- a/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasViewportZoomTests.swift +++ b/Packages/macOS/CmuxCanvasUI/Tests/CmuxCanvasUITests/CanvasViewportZoomTests.swift @@ -9,7 +9,7 @@ import CmuxCanvas struct CanvasViewportZoomTests { @Test func discreteZoomOutAnimatesThenCommitsAroundCurrentCenter() throws { let root = makeRoot() - root.shouldReduceMotionForDiscreteZoom = { false } + root.shouldReduceMotion = { false } root.setViewport(center: CGPoint(x: 420, y: 180), magnification: 1, notifySettled: false) let centerBefore = root.currentCenterInCanvas @@ -26,7 +26,7 @@ struct CanvasViewportZoomTests { @Test func reduceMotionZoomAppliesImmediately() throws { let root = makeRoot() - root.shouldReduceMotionForDiscreteZoom = { true } + root.shouldReduceMotion = { true } root.setViewport(center: CGPoint(x: 420, y: 180), magnification: 1, notifySettled: false) let centerBefore = root.currentCenterInCanvas @@ -40,7 +40,7 @@ struct CanvasViewportZoomTests { @Test func repeatedDiscreteZoomOutClampsAtMinimumWithoutStackingAnimations() throws { let root = makeRoot() - root.shouldReduceMotionForDiscreteZoom = { false } + root.shouldReduceMotion = { false } root.setViewport(center: CGPoint(x: 420, y: 180), magnification: 1, notifySettled: false) for _ in 0..<12 { @@ -53,7 +53,7 @@ struct CanvasViewportZoomTests { @Test func overviewCancelsPendingDiscreteZoomCompletion() throws { let root = makeRoot() - root.shouldReduceMotionForDiscreteZoom = { false } + root.shouldReduceMotion = { false } root.setViewport(center: CGPoint(x: 420, y: 180), magnification: 1, notifySettled: false) root.zoom(by: 0.8) @@ -77,7 +77,7 @@ struct CanvasViewportZoomTests { (panelA, CGRect(x: 0, y: 0, width: 640, height: 360)), (panelB, CGRect(x: 1_600, y: 0, width: 640, height: 360)), ]) - root.shouldReduceMotionForDiscreteZoom = { false } + root.shouldReduceMotion = { false } root.setViewport(center: CGPoint(x: 320, y: 180), magnification: 1, notifySettled: false) root.zoom(by: 0.8) diff --git a/Sources/Canvas/CanvasAction.swift b/Sources/Canvas/CanvasAction.swift index 8e444543deec..586a96ee44b8 100644 --- a/Sources/Canvas/CanvasAction.swift +++ b/Sources/Canvas/CanvasAction.swift @@ -78,8 +78,10 @@ struct CanvasActionExecutor { /// Runs the action. Returns `false` when the action does not apply /// (for example a canvas-only action while the workspace is in splits). + /// Keyboard, palette and menu callers animate; socket verbs pass + /// `animated: false` so an agent's change lands in place. @discardableResult - func perform(_ action: CanvasAction) -> Bool { + func perform(_ action: CanvasAction, animated: Bool = true) -> Bool { switch action { case .toggleLayout: workspace.toggleCanvasLayout() @@ -87,23 +89,23 @@ struct CanvasActionExecutor { case .revealFocusedPane: guard workspace.layoutMode == .canvas, let panelId = workspace.focusedPanelId else { return false } - workspace.canvasModel.viewport?.revealPane(panelId, animated: true) + workspace.canvasModel.viewport?.revealPane(panelId, animated: animated) return true case .toggleOverview: guard workspace.layoutMode == .canvas else { return false } - workspace.canvasModel.viewport?.toggleOverview() + workspace.canvasModel.viewport?.toggleOverview(animated: animated) return true case .zoomIn: guard workspace.layoutMode == .canvas else { return false } - workspace.canvasModel.viewport?.zoom(by: Self.zoomStepFactor) + workspace.canvasModel.viewport?.zoom(by: Self.zoomStepFactor, animated: animated) return true case .zoomOut: guard workspace.layoutMode == .canvas else { return false } - workspace.canvasModel.viewport?.zoom(by: 1 / Self.zoomStepFactor) + workspace.canvasModel.viewport?.zoom(by: 1 / Self.zoomStepFactor, animated: animated) return true case .zoomReset: guard workspace.layoutMode == .canvas else { return false } - workspace.canvasModel.viewport?.resetZoom() + workspace.canvasModel.viewport?.resetZoom(animated: animated) return true case .alignment(let command): guard workspace.layoutMode == .canvas else { return false } @@ -113,7 +115,7 @@ struct CanvasActionExecutor { reference: workspace.focusedPanelId ) if changed { - workspace.canvasModel.viewport?.modelDidChangeExternally(animated: true) + workspace.canvasModel.viewport?.modelDidChangeExternally(animated: animated) } return changed } diff --git a/Sources/Canvas/Workspace+CanvasLayout.swift b/Sources/Canvas/Workspace+CanvasLayout.swift index 0334810231c9..4378ce143df0 100644 --- a/Sources/Canvas/Workspace+CanvasLayout.swift +++ b/Sources/Canvas/Workspace+CanvasLayout.swift @@ -232,12 +232,14 @@ extension Workspace { /// as a tab of an existing pane), the automation counterpart to the /// canvas "new pane" gesture. Returns the new surface/panel UUID, or `nil` /// when creation fails (e.g. no focused bonsplit pane, or the browser is - /// disabled). Must be called in canvas mode. + /// disabled). Must be called in canvas mode. `animated` controls the + /// reveal pan; socket callers pass `false`. @discardableResult func openNewCanvasPane( type: CanvasNewPaneType, focus: Bool = true, - direction: CanvasDirection? = nil + direction: CanvasDirection? = nil, + animated: Bool = true ) -> UUID? { guard layoutMode == .canvas else { return nil } guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil } @@ -273,7 +275,7 @@ extension Workspace { ) focusPanel(newPanelId) canvasModel.viewport?.modelDidChangeExternally(animated: false) - canvasModel.viewport?.revealPane(newPanelId, animated: true) + canvasModel.viewport?.revealPane(newPanelId, animated: animated) return newPanelId } diff --git a/Sources/TerminalController+ControlCanvasContext.swift b/Sources/TerminalController+ControlCanvasContext.swift index bc2cb7b8b3d5..c963a2eddbb5 100644 --- a/Sources/TerminalController+ControlCanvasContext.swift +++ b/Sources/TerminalController+ControlCanvasContext.swift @@ -6,6 +6,8 @@ import Foundation /// Canvas-domain witnesses. Reads snapshot the workspace's `canvasModel`; /// mutations route through `CanvasActionExecutor` / the model so the socket /// shares one execution path with shortcuts, the palette, and the View menu. +/// Socket verbs never animate: an agent's change lands in place instead of +/// sliding panes or the viewport under the user. extension TerminalController: ControlCanvasContext { /// The routing twin used by every canvas verb: TabManager, then workspace. func resolveCanvasWorkspace(routing: ControlRoutingSelectors) -> Workspace? { @@ -91,7 +93,7 @@ extension TerminalController: ControlCanvasContext { CGRect(x: frame.x, y: frame.y, width: frame.width, height: frame.height), for: surfaceID ) - ws.canvasModel.viewport?.modelDidChangeExternally(animated: true) + ws.canvasModel.viewport?.modelDidChangeExternally(animated: false) return .ok(mode: ws.layoutMode.rawValue) } @@ -103,7 +105,7 @@ extension TerminalController: ControlCanvasContext { return .workspaceNotFound } guard ws.layoutMode == .canvas else { return .notCanvasMode } - CanvasActionExecutor(workspace: ws).perform(.alignment(command.alignmentCommand)) + CanvasActionExecutor(workspace: ws).perform(.alignment(command.alignmentCommand), animated: false) return .ok(mode: ws.layoutMode.rawValue) } @@ -121,7 +123,7 @@ extension TerminalController: ControlCanvasContext { guard ws.canvasModel.frame(of: target) != nil else { return .paneNotFound(target) } - ws.canvasModel.viewport?.revealPane(target, animated: true) + ws.canvasModel.viewport?.revealPane(target, animated: false) return .ok(mode: ws.layoutMode.rawValue) } @@ -132,7 +134,7 @@ extension TerminalController: ControlCanvasContext { return .workspaceNotFound } guard ws.layoutMode == .canvas else { return .notCanvasMode } - ws.canvasModel.viewport?.toggleOverview() + ws.canvasModel.viewport?.toggleOverview(animated: false) return .ok(mode: ws.layoutMode.rawValue) } @@ -147,11 +149,11 @@ extension TerminalController: ControlCanvasContext { let executor = CanvasActionExecutor(workspace: ws) switch direction { case .zoomIn: - executor.perform(.zoomIn) + executor.perform(.zoomIn, animated: false) case .zoomOut: - executor.perform(.zoomOut) + executor.perform(.zoomOut, animated: false) case .reset: - executor.perform(.zoomReset) + executor.perform(.zoomReset, animated: false) } return .ok(mode: ws.layoutMode.rawValue) } @@ -168,7 +170,7 @@ extension TerminalController: ControlCanvasContext { guard ws.canvasModel.frame(of: surfaceID) != nil else { return .paneNotFound(surfaceID) } guard ws.canvasModel.frame(of: targetSurfaceID) != nil else { return .paneNotFound(targetSurfaceID) } if ws.canvasModel.joinPanel(surfaceID, withPaneContaining: targetSurfaceID) { - ws.canvasModel.viewport?.modelDidChangeExternally(animated: true) + ws.canvasModel.viewport?.modelDidChangeExternally(animated: false) ws.focusPanel(surfaceID) } return .ok(mode: ws.layoutMode.rawValue) @@ -184,9 +186,9 @@ extension TerminalController: ControlCanvasContext { guard ws.layoutMode == .canvas else { return .notCanvasMode } guard ws.canvasModel.frame(of: surfaceID) != nil else { return .paneNotFound(surfaceID) } if ws.canvasModel.breakOutPanel(surfaceID) { - ws.canvasModel.viewport?.modelDidChangeExternally(animated: true) + ws.canvasModel.viewport?.modelDidChangeExternally(animated: false) ws.focusPanel(surfaceID) - ws.canvasModel.viewport?.revealPane(surfaceID, animated: true) + ws.canvasModel.viewport?.revealPane(surfaceID, animated: false) } return .ok(mode: ws.layoutMode.rawValue) } @@ -237,7 +239,7 @@ extension TerminalController: ControlCanvasContext { case "simulator": paneType = .simulator default: paneType = .terminal } - guard let surfaceID = ws.openNewCanvasPane(type: paneType, focus: true) else { + guard let surfaceID = ws.openNewCanvasPane(type: paneType, focus: true, animated: false) else { return .tabManagerUnavailable } return .created(mode: ws.layoutMode.rawValue, surfaceID: surfaceID)