Animate terminal drop overlay and add stale tabtransfer click regression

This commit is contained in:
Lawrence Chen 2026-02-19 15:22:30 -08:00
parent 1af5b02629
commit 1b2688233f
4 changed files with 256 additions and 27 deletions

View file

@ -2674,9 +2674,11 @@ final class GhosttySurfaceScrollView: NSView {
private var observers: [NSObjectProtocol] = []
private var windowObservers: [NSObjectProtocol] = []
private var isLiveScrolling = false
private var lastSentRow: Int?
private var isActive = true
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
private var lastSentRow: Int?
private var isActive = true
private var activeDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
#if DEBUG
private static var flashCounts: [UUID: Int] = [:]
private static var drawCounts: [UUID: Int] = [:]
@ -2908,6 +2910,9 @@ final class GhosttySurfaceScrollView: NSView {
surfaceView.pushTargetSurfaceSize(targetSize)
documentView.frame.size.width = scrollView.bounds.width
inactiveOverlayView.frame = bounds
if let zone = activeDropZone {
dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size)
}
flashOverlayView.frame = bounds
updateFlashPath()
synchronizeScrollView()
@ -2966,31 +2971,96 @@ final class GhosttySurfaceScrollView: NSView {
CATransaction.commit()
}
func setDropZoneOverlay(zone: DropZone?) {
CATransaction.begin()
CATransaction.setDisableActions(true)
if let zone {
let padding: CGFloat = 4
let size = bounds.size
let frame: CGRect
switch zone {
case .center:
frame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
case .left:
frame = CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .right:
frame = CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .top:
frame = CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
case .bottom:
frame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
}
dropZoneOverlayView.frame = frame
dropZoneOverlayView.isHidden = false
} else {
dropZoneOverlayView.isHidden = true
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
let padding: CGFloat = 4
switch zone {
case .center:
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
case .left:
return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .right:
return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .top:
return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
case .bottom:
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
}
}
private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
func setDropZoneOverlay(zone: DropZone?) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.setDropZoneOverlay(zone: zone)
}
return
}
let previousZone = activeDropZone
activeDropZone = zone
let previousFrame = dropZoneOverlayView.frame
if let zone {
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
let isSameFrame = Self.rectApproximatelyEqual(previousFrame, targetFrame)
let needsFrameUpdate = !isSameFrame
let zoneChanged = previousZone != zone
if !dropZoneOverlayView.isHidden && !needsFrameUpdate && !zoneChanged {
return
}
dropZoneOverlayAnimationGeneration &+= 1
dropZoneOverlayView.layer?.removeAllAnimations()
if dropZoneOverlayView.isHidden {
dropZoneOverlayView.frame = targetFrame
dropZoneOverlayView.alphaValue = 0
dropZoneOverlayView.isHidden = false
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
dropZoneOverlayView.animator().alphaValue = 1
}
return
}
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
if needsFrameUpdate {
dropZoneOverlayView.animator().frame = targetFrame
}
if dropZoneOverlayView.alphaValue < 1 {
dropZoneOverlayView.animator().alphaValue = 1
}
}
} else {
guard !dropZoneOverlayView.isHidden else { return }
dropZoneOverlayAnimationGeneration &+= 1
let animationGeneration = dropZoneOverlayAnimationGeneration
dropZoneOverlayView.layer?.removeAllAnimations()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.14
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
dropZoneOverlayView.animator().alphaValue = 0
} completionHandler: { [weak self] in
guard let self else { return }
guard self.dropZoneOverlayAnimationGeneration == animationGeneration else { return }
guard self.activeDropZone == nil else { return }
self.dropZoneOverlayView.isHidden = true
self.dropZoneOverlayView.alphaValue = 1
}
}
CATransaction.commit()
}
func triggerFlash() {

View file

@ -478,6 +478,9 @@ class TerminalController {
case "seed_drag_pasteboard_fileurl":
return seedDragPasteboardFileURL()
case "seed_drag_pasteboard_tabtransfer":
return seedDragPasteboardTabTransfer()
case "clear_drag_pasteboard":
return clearDragPasteboard()
@ -6273,6 +6276,7 @@ class TerminalController {
simulate_type <text> - Insert text into the current first responder (test-only)
simulate_file_drop <id|idx> <path[|path...]> - Simulate dropping file path(s) on terminal (test-only)
seed_drag_pasteboard_fileurl - Seed NSDrag pasteboard with public.file-url (test-only)
seed_drag_pasteboard_tabtransfer - Seed NSDrag pasteboard with tab transfer type (test-only)
clear_drag_pasteboard - Clear NSDrag pasteboard (test-only)
drop_hit_test <x 0-1> <y 0-1> - Hit-test file-drop overlay at normalised coords (test-only)
activate_app - Bring app + main window to front (test-only)
@ -6509,6 +6513,15 @@ class TerminalController {
return "OK"
}
private func seedDragPasteboardTabTransfer() -> String {
DispatchQueue.main.sync {
_ = NSPasteboard(name: .drag).declareTypes([
NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
], owner: nil)
}
return "OK"
}
private func clearDragPasteboard() -> String {
DispatchQueue.main.sync {
_ = NSPasteboard(name: .drag).clearContents()

View file

@ -839,6 +839,12 @@ class cmux:
if not response.startswith("OK"):
raise cmuxError(response)
def seed_drag_pasteboard_tabtransfer(self) -> None:
"""Seed NSDrag pasteboard with tab transfer type in the app process (debug builds only)."""
response = self._send_command("seed_drag_pasteboard_tabtransfer")
if not response.startswith("OK"):
raise cmuxError(response)
def clear_drag_pasteboard(self) -> None:
"""Clear NSDrag pasteboard in the app process (debug builds only)."""
response = self._send_command("clear_drag_pasteboard")

View file

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
Regression test: stale tab-transfer drag pasteboard state must not swallow real mouse clicks.
This uses real HID mouse events (CoreGraphics CGEvent), not XCUI element actions.
It seeds the drag pasteboard with `com.splittabbar.tabtransfer` to emulate stale
tab-drag state, then verifies:
1) A left click changes terminal focus to the clicked pane.
2) A real right click does not break terminal focus routing.
"""
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
from test_real_click_overlay_forwarding import (
app_name_for_bundle,
attempt_focus_via_real_clicks,
candidate_screen_points,
front_window_frame,
is_accessibility_error,
pick_top_bottom_terminal_panels,
post_click_with_cgevent,
)
def main() -> int:
socket_path = cmux.default_socket_path()
if not os.path.exists(socket_path):
print(f"SKIP: Socket not found at {socket_path}")
print("Tip: start cmux first (or set CMUX_TAG / CMUX_SOCKET_PATH).")
return 0
bundle_id = cmux.default_bundle_id()
try:
app_name = app_name_for_bundle(bundle_id)
except subprocess.CalledProcessError as e:
print(f"SKIP: Could not resolve app name for bundle {bundle_id}: {e}")
return 0
with cmux(socket_path) as client:
ws_id = None
try:
client.activate_app()
time.sleep(0.2)
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.3)
client.new_split("down")
time.sleep(0.5)
layout = client.layout_debug()
top_panel, bottom_panel = pick_top_bottom_terminal_panels(layout)
top_id = top_panel["panelId"]
bottom_id = bottom_panel["panelId"]
client.focus_surface_by_panel(top_id)
time.sleep(0.2)
if client.is_terminal_focused(bottom_id):
print("FAIL: bottom pane unexpectedly focused before click precondition")
return 1
win_x, win_y, _win_w, win_h = front_window_frame(app_name)
candidate_points = candidate_screen_points(win_x, win_y, win_h, bottom_panel)
# Baseline: real HID click routing must work before we can assert stale-pasteboard regression.
client.activate_app()
time.sleep(0.2)
baseline_ok, baseline_point = attempt_focus_via_real_clicks(client, bottom_id, candidate_points)
if not baseline_ok:
print("SKIP: real HID clicks are not routable on this host right now")
return 0
client.focus_surface_by_panel(top_id)
time.sleep(0.2)
if client.is_terminal_focused(bottom_id):
print("FAIL: could not restore top-pane precondition before stale-pasteboard check")
return 1
client.seed_drag_pasteboard_tabtransfer()
client.activate_app()
time.sleep(0.2)
focused, point = attempt_focus_via_real_clicks(client, bottom_id, candidate_points)
click_x, click_y = point
if not focused:
print("FAIL: real left click did not focus clicked pane under stale tabtransfer pasteboard")
print(
"baseline_point="
f"({baseline_point[0]:.1f}, {baseline_point[1]:.1f}) "
f"click_screen=({click_x:.1f}, {click_y:.1f})"
)
print(f"top_id={top_id} bottom_id={bottom_id}")
print(f"layout={layout}")
return 1
post_click_with_cgevent(click_x, click_y, right=True)
time.sleep(0.25)
if not client.is_terminal_focused(bottom_id):
print("FAIL: real right click disrupted terminal focus routing")
return 1
print("PASS: stale tabtransfer pasteboard preserves real left/right click routing")
print(f" focused_panel={bottom_id}")
return 0
finally:
try:
client.clear_drag_pasteboard()
except Exception:
pass
if ws_id:
try:
client.close_workspace(ws_id)
except Exception:
pass
if __name__ == "__main__":
try:
raise SystemExit(main())
except subprocess.CalledProcessError as e:
if is_accessibility_error(e):
print("SKIP: System Events click automation not allowed (Accessibility permission missing)")
raise SystemExit(0)
print(f"FAIL: osascript invocation failed: {e}")
if getattr(e, "stderr", None):
print(e.stderr.strip())
if getattr(e, "output", None):
print(e.output.strip())
raise SystemExit(1)
except cmuxError as e:
print(f"FAIL: {e}")
raise SystemExit(1)