diff --git a/Assets.xcassets/AppIconDark.imageset/Contents.json b/Assets.xcassets/AppIconDark.imageset/Contents.json index ef554911..9d77dac3 100644 --- a/Assets.xcassets/AppIconDark.imageset/Contents.json +++ b/Assets.xcassets/AppIconDark.imageset/Contents.json @@ -1,12 +1,13 @@ { - "images" : [ + "images": [ { - "filename" : "AppIconDark.png", - "idiom" : "universal" + "filename": "AppIconDark.png", + "idiom": "universal", + "scale": "2x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } -} +} \ No newline at end of file diff --git a/Assets.xcassets/AppIconLight.imageset/Contents.json b/Assets.xcassets/AppIconLight.imageset/Contents.json index c2e50ab0..b10f4a34 100644 --- a/Assets.xcassets/AppIconLight.imageset/Contents.json +++ b/Assets.xcassets/AppIconLight.imageset/Contents.json @@ -1,12 +1,13 @@ { - "images" : [ + "images": [ { - "filename" : "AppIconLight.png", - "idiom" : "universal" + "filename": "AppIconLight.png", + "idiom": "universal", + "scale": "2x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } -} +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c564fa..04ca7ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Each ship now deletes promoted release candidates older than the two most recent, so the releases page stops accumulating 110 MB prereleases. ### Fixed +- Idle CPU with an open window dropped from roughly 12 to 20 percent of a core to about 1 percent. The workspace pane overlay kept a display-rate animation timeline running for the life of every window; it now mounts one only while an attention flash is animating. +- The dock icon no longer costs 32 MB of resident memory. The light and dark icon assets are declared as 512pt @2x, so AppKit decodes them at 1024 pixels instead of rasterizing a 2048 pixel copy. +- Closing a workspace no longer leaves its Workspace object alive through the sidebar row's hover closure. - Pending review comments survive session restore and remain available for retry or copying when the source terminal cannot accept them. - Incoming notifications no longer reset a valid keyboard selection in the Notifications page. - Socket and typing-lag CI jobs cache the DerivedData paths they actually build into. diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index 13142aa4..908ff06f 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -804,9 +804,12 @@ struct TabItemView: View, Equatable { .onTapGesture { updateSelection() } - .onHover { hovering in - isHovering = hovering - } + // Hover tracking lives in a leaf view that captures only the state + // binding. An `.onHover` closure here captures the whole row value, + // including `tab`, and SwiftUI's context-menu responder tree kept + // those closures alive after the row was gone: closed Workspace + // objects stayed resident (7 live for 1 open workspace in a heap dump). + .background(SidebarRowHoverProbe(isHovering: $isHovering)) .accessibilityElement(children: .combine) .accessibilityLabel(Text(accessibilityTitle)) .accessibilityHint(Text(accessibilityHintText)) @@ -2151,3 +2154,17 @@ private struct SidebarMetadataMarkdownBlockRow: View { ) } } + +/// Leaf view whose only captured state is a `Binding`, so the hover +/// closure never retains the sidebar row (and its `Workspace`) after removal. +private struct SidebarRowHoverProbe: View { + @Binding var isHovering: Bool + + var body: some View { + Color.clear + .contentShape(Rectangle()) + .onHover { hovering in + isHovering = hovering + } + } +} diff --git a/Sources/WindowOverlayControllers.swift b/Sources/WindowOverlayControllers.swift index 013eb03d..3b415284 100644 --- a/Sources/WindowOverlayControllers.swift +++ b/Sources/WindowOverlayControllers.swift @@ -591,6 +591,9 @@ final class WindowTmuxWorkspacePaneOverlayController: NSObject { private let model = TmuxWorkspacePaneOverlayModel() private let hostingView: NSHostingView private var installConstraints: [NSLayoutConstraint] = [] + /// Identifies the flash whose end-of-animation re-render is pending, so a + /// newer flash or a clear does not get overwritten by a stale callback. + private var flashSettleGeneration: UInt64 = 0 init(window: NSWindow) { self.window = window @@ -599,7 +602,8 @@ final class WindowTmuxWorkspacePaneOverlayController: NSObject { unreadRects: [], flashRect: nil, flashStartedAt: nil, - flashReason: nil + flashReason: nil, + isFlashActive: false ) ) super.init() @@ -652,26 +656,59 @@ final class WindowTmuxWorkspacePaneOverlayController: NSObject { guard ensureInstalled() else { return } if let state { model.apply(state) - hostingView.rootView = TmuxWorkspacePaneOverlayView( - unreadRects: model.unreadRects, + renderModel(flashActive: TmuxWorkspacePaneOverlayView.isFlashActive( flashRect: model.flashRect, - flashStartedAt: model.flashStartedAt, - flashReason: model.flashReason - ) + flashStartedAt: model.flashStartedAt + )) containerView.alphaValue = 1 containerView.isHidden = false + scheduleFlashSettleIfNeeded() } else { + flashSettleGeneration &+= 1 model.clear() hostingView.rootView = TmuxWorkspacePaneOverlayView( unreadRects: [], flashRect: nil, flashStartedAt: nil, - flashReason: nil + flashReason: nil, + isFlashActive: false ) containerView.alphaValue = 0 containerView.isHidden = true } } + + private func renderModel(flashActive: Bool) { + hostingView.rootView = TmuxWorkspacePaneOverlayView( + unreadRects: model.unreadRects, + flashRect: model.flashRect, + flashStartedAt: model.flashStartedAt, + flashReason: model.flashReason, + isFlashActive: flashActive + ) + } + + /// The overlay view only mounts its animation timeline while a flash is + /// active, and SwiftUI does not re-evaluate the root view on its own when + /// the flash window elapses. Re-render once just after the flash ends so + /// the timeline is torn down instead of running for the life of the window. + private func scheduleFlashSettleIfNeeded() { + guard let flashStartedAt = model.flashStartedAt, + TmuxWorkspacePaneOverlayView.isFlashActive( + flashRect: model.flashRect, + flashStartedAt: flashStartedAt + ) else { return } + flashSettleGeneration &+= 1 + let generation = flashSettleGeneration + let remaining = FocusFlashPattern.duration - Date().timeIntervalSince(flashStartedAt) + DispatchQueue.main.asyncAfter(deadline: .now() + max(0, remaining) + 0.05) { [weak self] in + guard let self, self.flashSettleGeneration == generation else { return } + // Explicitly inactive: the flash window has elapsed on the monotonic + // dispatch clock, so do not re-read the wall clock here (a backwards + // clock step would otherwise leave the timeline mounted). + self.renderModel(flashActive: false) + } + } } @MainActor diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 8343616d..fee840d0 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -150,31 +150,59 @@ struct TmuxWorkspacePaneOverlayView: View { let flashRect: CGRect? let flashStartedAt: Date? let flashReason: WorkspaceAttentionFlashReason? + /// Stored, not derived from `Date()` in `body`: the controller sets it when a + /// flash starts and clears it when the flash window ends. A stored input that + /// changes is what makes SwiftUI re-evaluate the body and drop the timeline; + /// a body that only re-reads the clock would not be re-run for an otherwise + /// identical root view. + let isFlashActive: Bool + + /// True only while a flash is still within its animation window. The + /// display-rate `TimelineView(.animation)` below is mounted only in that + /// state: an always-on animation timeline kept every window's overlay + /// hosting view re-laying out on every frame while idle (measured 12 to + /// 22 percent CPU with one idle terminal). Unread rings are static and + /// draw once without a timeline. + static func isFlashActive(flashRect: CGRect?, flashStartedAt: Date?, now: Date = Date()) -> Bool { + guard flashRect != nil, let flashStartedAt else { return false } + return now.timeIntervalSince(flashStartedAt) < FocusFlashPattern.duration + } var body: some View { - TimelineView(.animation) { timeline in - Canvas { context, _ in - for rect in unreadRects { - drawUnreadRing(in: &context, rect: rect) + Group { + if isFlashActive { + TimelineView(.animation) { timeline in + canvas(at: timeline.date) } - - guard let flashRect, - let flashStartedAt else { return } - let elapsed = timeline.date.timeIntervalSince(flashStartedAt) - let opacity = FocusFlashPattern.opacity(at: elapsed) - guard opacity > 0.001 else { return } - drawFlashRing( - in: &context, - rect: flashRect, - opacity: opacity, - reason: flashReason ?? .notificationArrival - ) + } else { + canvas(at: nil) } } .allowsHitTesting(false) .frame(maxWidth: .infinity, maxHeight: .infinity) } + private func canvas(at date: Date?) -> some View { + Canvas { context, _ in + for rect in unreadRects { + drawUnreadRing(in: &context, rect: rect) + } + + guard let date, + let flashRect, + let flashStartedAt else { return } + let elapsed = date.timeIntervalSince(flashStartedAt) + let opacity = FocusFlashPattern.opacity(at: elapsed) + guard opacity > 0.001 else { return } + drawFlashRing( + in: &context, + rect: flashRect, + opacity: opacity, + reason: flashReason ?? .notificationArrival + ) + } + } + private func drawUnreadRing(in context: inout GraphicsContext, rect: CGRect) { guard let path = ringPath(for: rect) else { return } var glowContext = context diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index ce9a05b7..96a902a6 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -1697,6 +1697,45 @@ final class MarkdownPanelPointerObserverViewTests: XCTestCase { @MainActor final class TmuxWorkspacePaneOverlayTests: XCTestCase { + func testOverlayMountsAnimationTimelineOnlyWhileFlashIsWithinItsDuration() { + let start = Date(timeIntervalSince1970: 1_000) + let rect = CGRect(x: 0, y: 0, width: 10, height: 10) + XCTAssertFalse( + TmuxWorkspacePaneOverlayView.isFlashActive(flashRect: nil, flashStartedAt: nil, now: start), + "No flash: the overlay must render statically, never with a display-rate timeline" + ) + XCTAssertFalse( + TmuxWorkspacePaneOverlayView.isFlashActive(flashRect: rect, flashStartedAt: nil, now: start), + "A flash rect without a start time is not an active flash" + ) + XCTAssertTrue( + TmuxWorkspacePaneOverlayView.isFlashActive( + flashRect: rect, flashStartedAt: start, + now: start.addingTimeInterval(FocusFlashPattern.duration / 2) + ) + ) + XCTAssertFalse( + TmuxWorkspacePaneOverlayView.isFlashActive( + flashRect: rect, flashStartedAt: start, + now: start.addingTimeInterval(FocusFlashPattern.duration + 0.01) + ), + "Once the flash pattern has finished, the timeline must be torn down" + ) + } + + func testAppearanceAppIconsDecodeAtRetinaDockSizeNotDoubleIt() { + for name in ["AppIconDark", "AppIconLight"] { + guard let icon = NSImage(named: name) else { + XCTFail("missing \(name) in the built bundle") + continue + } + let maxPixels = icon.representations.map { max($0.pixelsWide, $0.pixelsHigh) }.max() ?? 0 + XCTAssertEqual(maxPixels, 1024, "\(name) largest representation") + XCTAssertEqual(icon.size.width, 512, accuracy: 0.5, + "\(name) must be a 512pt @2x image so AppKit does not rasterize a 2048px Dock icon") + } + } + func testTmuxWorkspacePaneOverlayModelTracksFlashReason() { let model = TmuxWorkspacePaneOverlayModel() let initialState = TmuxWorkspacePaneOverlayRenderState(