Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,21 @@ 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
/// Extra viewport fraction kept rendering around the visible rect so
/// 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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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() }
}
Loading
Loading