From 1b2688233f51184c395b41e4ae5db10c93467883 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:22:30 -0800 Subject: [PATCH] Animate terminal drop overlay and add stale tabtransfer click regression --- Sources/GhosttyTerminalView.swift | 124 ++++++++++++---- Sources/TerminalController.swift | 13 ++ tests/cmux.py | 6 + .../test_real_click_tabtransfer_pasteboard.py | 140 ++++++++++++++++++ 4 files changed, 256 insertions(+), 27 deletions(-) create mode 100644 tests/test_real_click_tabtransfer_pasteboard.py diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0ca452c2..bf360148 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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() { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2d6b2bee..80c0a7f9 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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 - Insert text into the current first responder (test-only) simulate_file_drop - 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 - 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() diff --git a/tests/cmux.py b/tests/cmux.py index 417d0716..224825d2 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -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") diff --git a/tests/test_real_click_tabtransfer_pasteboard.py b/tests/test_real_click_tabtransfer_pasteboard.py new file mode 100644 index 00000000..2b72714e --- /dev/null +++ b/tests/test_real_click_tabtransfer_pasteboard.py @@ -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)