Animate terminal drop overlay and add stale tabtransfer click regression
This commit is contained in:
parent
1af5b02629
commit
1b2688233f
4 changed files with 256 additions and 27 deletions
|
|
@ -2676,6 +2676,8 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
private var isLiveScrolling = false
|
||||
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] = [:]
|
||||
|
|
@ -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 {
|
||||
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
|
||||
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)
|
||||
return 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)
|
||||
return 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)
|
||||
return 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)
|
||||
return 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)
|
||||
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
|
||||
}
|
||||
dropZoneOverlayView.frame = frame
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
dropZoneOverlayView.isHidden = true
|
||||
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
140
tests/test_real_click_tabtransfer_pasteboard.py
Normal file
140
tests/test_real_click_tabtransfer_pasteboard.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue