Fix focus-on-scroll and harden reload scripts
This commit is contained in:
parent
46eefc733e
commit
4b77b1117d
6 changed files with 240 additions and 38 deletions
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue