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
|
|
@ -2674,9 +2674,11 @@ final class GhosttySurfaceScrollView: NSView {
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
private var windowObservers: [NSObjectProtocol] = []
|
private var windowObservers: [NSObjectProtocol] = []
|
||||||
private var isLiveScrolling = false
|
private var isLiveScrolling = false
|
||||||
private var lastSentRow: Int?
|
private var lastSentRow: Int?
|
||||||
private var isActive = true
|
private var isActive = true
|
||||||
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
|
private var activeDropZone: DropZone?
|
||||||
|
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
|
||||||
|
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private static var flashCounts: [UUID: Int] = [:]
|
private static var flashCounts: [UUID: Int] = [:]
|
||||||
private static var drawCounts: [UUID: Int] = [:]
|
private static var drawCounts: [UUID: Int] = [:]
|
||||||
|
|
@ -2908,6 +2910,9 @@ final class GhosttySurfaceScrollView: NSView {
|
||||||
surfaceView.pushTargetSurfaceSize(targetSize)
|
surfaceView.pushTargetSurfaceSize(targetSize)
|
||||||
documentView.frame.size.width = scrollView.bounds.width
|
documentView.frame.size.width = scrollView.bounds.width
|
||||||
inactiveOverlayView.frame = bounds
|
inactiveOverlayView.frame = bounds
|
||||||
|
if let zone = activeDropZone {
|
||||||
|
dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size)
|
||||||
|
}
|
||||||
flashOverlayView.frame = bounds
|
flashOverlayView.frame = bounds
|
||||||
updateFlashPath()
|
updateFlashPath()
|
||||||
synchronizeScrollView()
|
synchronizeScrollView()
|
||||||
|
|
@ -2966,31 +2971,96 @@ final class GhosttySurfaceScrollView: NSView {
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDropZoneOverlay(zone: DropZone?) {
|
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
|
||||||
CATransaction.begin()
|
let padding: CGFloat = 4
|
||||||
CATransaction.setDisableActions(true)
|
switch zone {
|
||||||
if let zone {
|
case .center:
|
||||||
let padding: CGFloat = 4
|
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
|
||||||
let size = bounds.size
|
case .left:
|
||||||
let frame: CGRect
|
return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
|
||||||
switch zone {
|
case .right:
|
||||||
case .center:
|
return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
|
||||||
frame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
|
case .top:
|
||||||
case .left:
|
return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
|
||||||
frame = CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
|
case .bottom:
|
||||||
case .right:
|
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
|
||||||
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:
|
private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||||
frame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
|
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
|
||||||
}
|
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
|
||||||
dropZoneOverlayView.frame = frame
|
abs(lhs.size.width - rhs.size.width) <= epsilon &&
|
||||||
dropZoneOverlayView.isHidden = false
|
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||||
} else {
|
}
|
||||||
dropZoneOverlayView.isHidden = true
|
|
||||||
|
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() {
|
func triggerFlash() {
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,9 @@ class TerminalController {
|
||||||
case "seed_drag_pasteboard_fileurl":
|
case "seed_drag_pasteboard_fileurl":
|
||||||
return seedDragPasteboardFileURL()
|
return seedDragPasteboardFileURL()
|
||||||
|
|
||||||
|
case "seed_drag_pasteboard_tabtransfer":
|
||||||
|
return seedDragPasteboardTabTransfer()
|
||||||
|
|
||||||
case "clear_drag_pasteboard":
|
case "clear_drag_pasteboard":
|
||||||
return clearDragPasteboard()
|
return clearDragPasteboard()
|
||||||
|
|
||||||
|
|
@ -6273,6 +6276,7 @@ class TerminalController {
|
||||||
simulate_type <text> - Insert text into the current first responder (test-only)
|
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)
|
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_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)
|
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)
|
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)
|
activate_app - Bring app + main window to front (test-only)
|
||||||
|
|
@ -6509,6 +6513,15 @@ class TerminalController {
|
||||||
return "OK"
|
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 {
|
private func clearDragPasteboard() -> String {
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
_ = NSPasteboard(name: .drag).clearContents()
|
_ = NSPasteboard(name: .drag).clearContents()
|
||||||
|
|
|
||||||
|
|
@ -839,6 +839,12 @@ class cmux:
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
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:
|
def clear_drag_pasteboard(self) -> None:
|
||||||
"""Clear NSDrag pasteboard in the app process (debug builds only)."""
|
"""Clear NSDrag pasteboard in the app process (debug builds only)."""
|
||||||
response = self._send_command("clear_drag_pasteboard")
|
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