cmux/tests/test_file_drop_split_targeting.py
Lawrence Chen 9fd3cc2a6c Add file drop support from Finder into terminal splits
Nested NSHostingController layers (from bonsplit's SinglePaneWrapper)
prevent AppKit's NSDraggingDestination routing from reaching terminal
views. Install a transparent FileDropOverlayView on the window's theme
frame that intercepts file drags and forwards drops to the GhosttyNSView
under the cursor. Mouse events pass through via a hide-send-unhide
pattern.

Fix y-axis inversion in split targeting: hitTest expects coordinates in
the receiver's superview's coordinate system, not the receiver's own.
Converting to contentView's coords flipped y because NSHostingView is
flipped, causing top/bottom split drops to land in the wrong terminal.

Also adds bonsplit onFileDrop API, PaneDragContainerView, and
drop_hit_test socket command for testing coordinate-to-terminal mapping.
2026-02-17 21:55:31 -08:00

167 lines
6 KiB
Python

#!/usr/bin/env python3
"""
Regression test: file drops in vertical splits target the correct terminal.
When the window has a vertical split (top/bottom), a file drop over the top
terminal must be routed to the top terminal (not the bottom one), and vice
versa. A coordinate-system bug (y-axis inversion in hitTest) previously
caused drops to land in the wrong pane.
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux
def surface_ids_from_layout(layout: dict):
"""Extract panel IDs keyed by vertical position from layout_debug output.
Returns (top_surface_id, bottom_surface_id) based on pane frame y-origins.
Bonsplit's pane frames use a top-left origin (flipped) coordinate system,
so smaller y = higher on screen = top pane.
"""
panels = layout.get("selectedPanels", [])
if len(panels) < 2:
return None, None
def y_origin(p):
frame = p.get("paneFrame")
if frame is None:
return 0
return frame.get("y", 0)
# Sort ascending by y: smallest y = top pane visually
sorted_panels = sorted(panels, key=y_origin)
top_id = sorted_panels[0].get("panelId")
bottom_id = sorted_panels[1].get("panelId")
return top_id, bottom_id
def main() -> int:
with cmux() as client:
try:
client.activate_app()
except Exception:
pass
# Start with a single terminal surface.
surfaces = client.list_surfaces()
if not surfaces:
client.new_workspace()
time.sleep(0.3)
surfaces = client.list_surfaces()
if not surfaces:
print("FAIL: no surfaces available")
return 1
# Create a vertical (top/bottom) split.
client.new_split("down")
time.sleep(0.5)
layout = client.layout_debug()
top_panel_id, bottom_panel_id = surface_ids_from_layout(layout)
if not top_panel_id or not bottom_panel_id:
print("FAIL: could not determine top/bottom panel IDs from layout")
print(f"layout: {layout}")
return 1
if top_panel_id == bottom_panel_id:
print("FAIL: top and bottom panel IDs are the same")
return 1
# Test the hit-test mapping directly: given a point in the top/bottom
# half, does it resolve to *different* terminals in the expected order?
# drop_hit_test uses content-area coordinates: (0,0)=top-left, (1,1)=bottom-right.
# Hit-test near the vertical centre of the top pane (y ≈ 0.25).
top_hit = client.drop_hit_test(0.5, 0.25)
# Hit-test near the vertical centre of the bottom pane (y ≈ 0.75).
bottom_hit = client.drop_hit_test(0.5, 0.75)
if top_hit is None:
print("FAIL: drop_hit_test returned 'none' for top region")
return 1
if bottom_hit is None:
print("FAIL: drop_hit_test returned 'none' for bottom region")
return 1
if top_hit == bottom_hit:
print("FAIL: top and bottom hit test returned the same surface")
print(f" top_hit={top_hit} bottom_hit={bottom_hit}")
return 1
# Verify the mapping is not inverted: the top hit should correspond to
# the top pane and the bottom hit to the bottom pane.
# Cross-check via layout_debug pane frames (flipped coords: smaller y = top).
panels = layout.get("selectedPanels", [])
panel_to_y = {}
for p in panels:
pid = p.get("panelId")
frame = p.get("paneFrame")
if pid and frame:
panel_to_y[pid] = frame.get("y", 0)
# drop_hit_test returns uppercase UUIDs; panelId may differ in case.
def normalise(uuid_str):
return uuid_str.upper() if uuid_str else ""
top_y = panel_to_y.get(normalise(top_hit), panel_to_y.get(top_hit))
bottom_y = panel_to_y.get(normalise(bottom_hit), panel_to_y.get(bottom_hit))
if top_y is None or bottom_y is None:
print("FAIL: could not find hit-test surface IDs in layout panel map")
print(f" top_hit={top_hit} bottom_hit={bottom_hit}")
print(f" panel_to_y={panel_to_y}")
return 1
# In flipped coords: top pane has smaller y
if top_y >= bottom_y:
print("FAIL: y-axis is inverted — top hit resolved to bottom pane")
print(f" top_hit={top_hit} (y={top_y}) bottom_hit={bottom_hit} (y={bottom_y})")
return 1
print("PASS: vertical split drop targeting is correct")
print(f" top_hit={top_hit} bottom_hit={bottom_hit}")
# Also test horizontal split targeting.
# Close the bottom pane and create a horizontal split instead.
# First, close all extra surfaces to get back to 1.
surfaces = client.list_surfaces()
if len(surfaces) > 1:
# Focus and close the non-first surface
for _, sid, is_focused in surfaces[1:]:
try:
client.close_surface(sid)
except Exception:
pass
time.sleep(0.3)
client.new_split("right")
time.sleep(0.5)
# Hit-test left half and right half
left_hit = client.drop_hit_test(0.25, 0.5)
right_hit = client.drop_hit_test(0.75, 0.5)
if left_hit is None:
print("FAIL: drop_hit_test returned 'none' for left region")
return 1
if right_hit is None:
print("FAIL: drop_hit_test returned 'none' for right region")
return 1
if left_hit == right_hit:
print("FAIL: left and right hit test returned the same surface")
print(f" left_hit={left_hit} right_hit={right_hit}")
return 1
print("PASS: horizontal split drop targeting is correct")
print(f" left_hit={left_hit} right_hit={right_hit}")
return 0
if __name__ == "__main__":
raise SystemExit(main())