diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index faebff12..b4f806e2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -113,6 +113,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @objc func clearUpdatePillOverride(_ sender: Any?) { updateViewModel.overrideState = nil } +#endif @objc func copyUpdateLogs(_ sender: Any?) { let logText = UpdateLogStore.shared.snapshot() @@ -126,7 +127,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent pasteboard.clearContents() pasteboard.setString(payload, forType: .string) } - #endif + @objc func copyFocusLogs(_ sender: Any?) { + let logText = FocusLogStore.shared.snapshot() + let payload: String + if logText.isEmpty { + payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())" + } else { + payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())" + } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } #if DEBUG @objc func openDebugScrollbackTab(_ sender: Any?) { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 36f58551..951bf2ff 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5,6 +5,7 @@ import Metal import QuartzCore import Combine import Darwin +import Sentry private enum GhosttyPasteboardHelper { private static let selectionPasteboard = NSPasteboard( @@ -89,6 +90,64 @@ class GhosttyApp { private var displayLinkUsers = 0 private let displayLinkLock = NSLock() + // Scroll lag tracking + private(set) var isScrolling = false + private var scrollLagSampleCount = 0 + private var scrollLagTotalMs: Double = 0 + private var scrollLagMaxMs: Double = 0 + private let scrollLagThresholdMs: Double = 25 // Alert if tick takes >25ms during scroll + private var scrollEndTimer: DispatchWorkItem? + + func markScrollActivity(hasMomentum: Bool, momentumEnded: Bool) { + // Cancel any pending scroll-end timer + scrollEndTimer?.cancel() + scrollEndTimer = nil + + if momentumEnded { + // Trackpad momentum ended - scrolling is done + endScrollSession() + } else if hasMomentum { + // Trackpad scrolling with momentum - wait for momentum to end + isScrolling = true + } else { + // Mouse wheel or non-momentum scroll - use timeout + isScrolling = true + let timer = DispatchWorkItem { [weak self] in + self?.endScrollSession() + } + scrollEndTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: timer) + } + } + + private func endScrollSession() { + guard isScrolling else { return } + isScrolling = false + + // Report accumulated lag stats if any exceeded threshold + if scrollLagSampleCount > 0 { + let avgLag = scrollLagTotalMs / Double(scrollLagSampleCount) + let maxLag = scrollLagMaxMs + let samples = scrollLagSampleCount + let threshold = scrollLagThresholdMs + if maxLag > threshold { + SentrySDK.capture(message: "Scroll lag detected") { scope in + scope.setLevel(.warning) + scope.setContext(value: [ + "samples": samples, + "avg_ms": String(format: "%.2f", avgLag), + "max_ms": String(format: "%.2f", maxLag), + "threshold_ms": threshold + ], key: "scroll_lag") + } + } + // Reset stats + scrollLagSampleCount = 0 + scrollLagTotalMs = 0 + scrollLagMaxMs = 0 + } + } + private init() { initializeGhostty() } @@ -232,8 +291,18 @@ class GhosttyApp { func tick() { guard let app = app else { return } + + let start = CACurrentMediaTime() ghostty_app_tick(app) AppDelegate.shared?.tabManager?.tickRender() + let elapsedMs = (CACurrentMediaTime() - start) * 1000 + + // Track lag during scrolling + if isScrolling { + scrollLagSampleCount += 1 + scrollLagTotalMs += elapsedMs + scrollLagMaxMs = max(scrollLagMaxMs, elapsedMs) + } } func retainDisplayLink() { @@ -696,14 +765,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) { - let layerScale = view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 - guard view.bounds.width > 0 && view.bounds.height > 0 else { - return (layerScale, layerScale, layerScale) - } - let backingBounds = view.convertToBacking(view.bounds) - let xScale = backingBounds.width / view.bounds.width - let yScale = backingBounds.height / view.bounds.height - return (xScale, yScale, layerScale) + let layerScale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + return (layerScale, layerScale, layerScale) } func attachToView(_ view: GhosttyNSView) { @@ -907,6 +970,18 @@ final class TerminalSurface: Identifiable, ObservableObject { // MARK: - Ghostty Surface View class GhosttyNSView: NSView, NSUserInterfaceValidations { + private static let focusDebugEnabled: Bool = { + if ProcessInfo.processInfo.environment["CMUX_FOCUS_DEBUG"] == "1" { + return true + } + return UserDefaults.standard.bool(forKey: "cmuxFocusDebug") + }() + fileprivate static func focusLog(_ message: String) { + guard focusDebugEnabled else { return } + FocusLogStore.shared.append(message) + NSLog("[FOCUSDBG] %@", message) + } + weak var terminalSurface: TerminalSurface? private var surfaceAttached = false var scrollbar: GhosttyScrollbar? @@ -921,6 +996,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var eventMonitor: Any? private var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? + private var lastSurfaceSize: CGSize = .zero + private var lastContentScale: CGSize = .zero + private var lastLayerScale: CGFloat = 0 + private var hasSurfaceMetrics = false + private var lastScrollEventTime: CFTimeInterval = 0 override func makeBackingLayer() -> CALayer { let metalLayer = CAMetalLayer() @@ -1003,10 +1083,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let location = convert(event.locationInWindow, from: nil) guard hitTest(location) == self else { return event } - if window.firstResponder !== self { - window.makeFirstResponder(self) - } - + Self.focusLog("localEventScrollWheel: window=\(ObjectIdentifier(window)) firstResponder=\(String(describing: window.firstResponder))") return event } @@ -1014,6 +1091,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { terminalSurface = surface tabId = surface.tabId surfaceAttached = false + hasSurfaceMetrics = false attachSurfaceIfNeeded() } @@ -1076,10 +1154,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func updateSurfaceSize() { guard let terminalSurface = terminalSurface else { return } guard bounds.width > 0 && bounds.height > 0 else { return } - let backingBounds = convertToBacking(bounds) - let xScale = backingBounds.width / bounds.width - let yScale = backingBounds.height / bounds.height - let layerScale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + let layerScale = window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + let xScale = layerScale + let yScale = layerScale + if hasSurfaceMetrics { + let sameSize = nearlyEqual(lastSurfaceSize.width, bounds.width, epsilon: 0.01) + && nearlyEqual(lastSurfaceSize.height, bounds.height, epsilon: 0.01) + let sameScale = nearlyEqual(lastContentScale.width, xScale) + && nearlyEqual(lastContentScale.height, yScale) + && nearlyEqual(lastLayerScale, layerScale) + if sameSize && sameScale { + return + } + } + lastSurfaceSize = bounds.size + lastContentScale = CGSize(width: xScale, height: yScale) + lastLayerScale = layerScale + hasSurfaceMetrics = true terminalSurface.updateSize( width: bounds.width, height: bounds.height, @@ -1089,6 +1180,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } + private func nearlyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool { + abs(lhs - rhs) <= epsilon + } + // Convenience accessor for the ghostty surface private var surface: ghostty_surface_t? { terminalSurface?.surface @@ -1132,6 +1227,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if result, let surface = surface { + let now = CACurrentMediaTime() + let deltaMs = (now - lastScrollEventTime) * 1000 + Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") onFocus?() #if DEBUG if let terminalSurface { @@ -1603,7 +1701,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func scrollWheel(with event: NSEvent) { guard let surface = surface else { return } - terminalSurface?.setFocus(true) + lastScrollEventTime = CACurrentMediaTime() + Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))") var x = event.scrollingDeltaX var y = event.scrollingDeltaY let precision = event.hasPreciseScrollingDeltas @@ -1636,6 +1735,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } mods |= momentum << 1 + // Track scroll state for lag detection + let hasMomentum = event.momentumPhase != [] && event.momentumPhase != .mayBegin + let momentumEnded = event.momentumPhase == .ended || event.momentumPhase == .cancelled + GhosttyApp.shared.markScrollActivity(hasMomentum: hasMomentum, momentumEnded: momentumEnded) + ghostty_surface_mouse_scroll( surface, x, @@ -1735,14 +1839,15 @@ private final class GhosttyScrollView: NSScrollView { return } - if window?.firstResponder !== surfaceView { - window?.makeFirstResponder(surfaceView) - } - if let surface = surfaceView.terminalSurface?.surface, ghostty_surface_mouse_captured(surface) { + GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: mouseCaptured -> surface scroll") + if window?.firstResponder !== surfaceView { + window?.makeFirstResponder(surfaceView) + } surfaceView.scrollWheel(with: event) } else { + GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: super scroll") super.scrollWheel(with: event) } } @@ -1897,17 +2002,8 @@ final class GhosttySurfaceScrollView: NSView { override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } - override var acceptsFirstResponder: Bool { true } - - override func becomeFirstResponder() -> Bool { - window?.makeFirstResponder(surfaceView) - return true - } - - override func resignFirstResponder() -> Bool { - _ = surfaceView.resignFirstResponder() - return true - } + // Avoid stealing focus on scroll; focus is managed explicitly by the surface view. + override var acceptsFirstResponder: Bool { false } override func layout() { super.layout() diff --git a/Sources/Update/UpdateLogStore.swift b/Sources/Update/UpdateLogStore.swift index d79969c8..66fa68e9 100644 --- a/Sources/Update/UpdateLogStore.swift +++ b/Sources/Update/UpdateLogStore.swift @@ -63,3 +63,66 @@ final class UpdateLogStore { } } } + +final class FocusLogStore { + static let shared = FocusLogStore() + + private let queue = DispatchQueue(label: "cmuxterm.focus.log") + private var entries: [String] = [] + private let maxEntries = 400 + private let logURL: URL + private let formatter: ISO8601DateFormatter + + private init() { + formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let logsDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + logURL = logsDir.appendingPathComponent("Logs/cmuxterm-focus.log") + ensureLogFile() + } + + func append(_ message: String) { + #if DEBUG + let timestamp = formatter.string(from: Date()) + let line = "[\(timestamp)] \(message)" + queue.async { [weak self] in + guard let self else { return } + entries.append(line) + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + appendToFile(line: line) + } + #endif + } + + func snapshot() -> String { + queue.sync { + entries.joined(separator: "\n") + } + } + + func logPath() -> String { + logURL.path + } + + private func ensureLogFile() { + let directory = logURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: logURL.path) { + try? Data().write(to: logURL) + } + } + + private func appendToFile(line: String) { + let data = Data((line + "\n").utf8) + if let handle = try? FileHandle(forWritingTo: logURL) { + try? handle.seekToEnd() + try? handle.write(contentsOf: data) + try? handle.close() + } else { + try? data.write(to: logURL, options: .atomic) + } + } +} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9928fdac..7f0ba6ea 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -138,13 +138,16 @@ struct cmuxApp: App { appDelegate.clearUpdatePillOverride(nil) } } +#endif CommandMenu("Update Logs") { Button("Copy Update Logs") { appDelegate.copyUpdateLogs(nil) } + Button("Copy Focus Logs") { + appDelegate.copyFocusLogs(nil) + } } -#endif #if DEBUG CommandMenu("Debug") { @@ -258,6 +261,16 @@ struct cmuxApp: App { } .keyboardShortcut("[", modifiers: [.command, .shift]) + Button("Back") { + tabManager.navigateBack() + } + .keyboardShortcut("[", modifiers: .command) + + Button("Forward") { + tabManager.navigateForward() + } + .keyboardShortcut("]", modifiers: .command) + Button("Next Tab") { tabManager.selectNextTab() } diff --git a/scripts/reload.sh b/scripts/reload.sh index cfbea96b..a615e75b 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -1,10 +1,19 @@ #!/usr/bin/env bash set -euo pipefail -APP_PATH="/Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmuxterm DEV.app" - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build pkill -x "cmuxterm DEV" || true sleep 0.2 +APP_PATH="$( + find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/cmuxterm DEV.app" -print0 \ + | xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \ + | sort -nr \ + | head -n 1 \ + | cut -d' ' -f2- +)" +if [[ -z "${APP_PATH}" ]]; then + echo "cmuxterm DEV.app not found in DerivedData" >&2 + exit 1 +fi open "$APP_PATH" osascript -e 'tell application "cmuxterm DEV" to activate' || true diff --git a/scripts/reloadp.sh b/scripts/reloadp.sh index aa248f05..c00c8a1e 100755 --- a/scripts/reloadp.sh +++ b/scripts/reloadp.sh @@ -1,10 +1,19 @@ #!/usr/bin/env bash set -euo pipefail -APP_PATH="/Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmuxterm.app" - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Release -destination 'platform=macOS' build pkill -x cmuxterm || true sleep 0.2 +APP_PATH="$( + find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/cmuxterm.app" -print0 \ + | xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \ + | sort -nr \ + | head -n 1 \ + | cut -d' ' -f2- +)" +if [[ -z "${APP_PATH}" ]]; then + echo "cmuxterm.app not found in DerivedData" >&2 + exit 1 +fi open "$APP_PATH" osascript -e 'tell application "cmuxterm" to activate' || true