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.
This commit is contained in:
parent
4220c3808f
commit
9fd3cc2a6c
7 changed files with 398 additions and 2 deletions
|
|
@ -723,6 +723,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
sidebarState: sidebarState,
|
sidebarState: sidebarState,
|
||||||
sidebarSelectionState: sidebarSelectionState
|
sidebarSelectionState: sidebarSelectionState
|
||||||
)
|
)
|
||||||
|
installFileDropOverlay(on: window, tabManager: tabManager)
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
setActiveMainWindow(window)
|
setActiveMainWindow(window)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,121 @@ final class SidebarState: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - File Drop Overlay
|
||||||
|
|
||||||
|
/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to
|
||||||
|
/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's
|
||||||
|
/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply
|
||||||
|
/// embedded terminal views. This overlay sits above the entire content view hierarchy and
|
||||||
|
/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor.
|
||||||
|
///
|
||||||
|
/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks,
|
||||||
|
/// scrolls, and other interactions pass through normally.
|
||||||
|
final class FileDropOverlayView: NSView {
|
||||||
|
/// Fallback handler when no terminal is found under the drop point.
|
||||||
|
var onDrop: (([URL]) -> Bool)?
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { false }
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
registerForDraggedTypes([.fileURL, .URL, .string])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }
|
||||||
|
|
||||||
|
// MARK: Mouse forwarding – hide self so the event reaches views below.
|
||||||
|
|
||||||
|
private func forwardEvent(_ event: NSEvent) {
|
||||||
|
isHidden = true
|
||||||
|
window?.sendEvent(event)
|
||||||
|
isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
override func mouseUp(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
override func mouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
override func rightMouseDown(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
override func rightMouseUp(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
override func otherMouseDown(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
override func otherMouseUp(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
override func scrollWheel(with event: NSEvent) { forwardEvent(event) }
|
||||||
|
|
||||||
|
// MARK: NSDraggingDestination – only accept drops over terminal views.
|
||||||
|
|
||||||
|
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||||
|
return dragOperationForSender(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||||
|
return dragOperationForSender(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
||||||
|
guard let terminal = terminalUnderPoint(sender.draggingLocation) else { return false }
|
||||||
|
return terminal.performDragOperation(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dragOperationForSender(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||||
|
guard let types = sender.draggingPasteboard.types,
|
||||||
|
types.contains(where: { $0 == .fileURL || $0 == .URL || $0 == .string }),
|
||||||
|
terminalUnderPoint(sender.draggingLocation) != nil else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return .copy
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporarily hides self, hit-tests the window to find the GhosttyNSView under the cursor.
|
||||||
|
private func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
|
||||||
|
guard let window, let contentView = window.contentView,
|
||||||
|
let themeFrame = contentView.superview else { return nil }
|
||||||
|
isHidden = true
|
||||||
|
// hitTest expects the point in the receiver's superview's coordinate system.
|
||||||
|
// Converting to contentView's own coords would flip y (NSHostingView is flipped)
|
||||||
|
// causing top/bottom split targeting to be inverted.
|
||||||
|
let point = themeFrame.convert(windowPoint, from: nil)
|
||||||
|
let hitView = contentView.hitTest(point)
|
||||||
|
isHidden = false
|
||||||
|
|
||||||
|
var current: NSView? = hitView
|
||||||
|
while let view = current {
|
||||||
|
if let terminal = view as? GhosttyNSView { return terminal }
|
||||||
|
current = view.superview
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileDropOverlayKey: UInt8 = 0
|
||||||
|
|
||||||
|
/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support.
|
||||||
|
func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) {
|
||||||
|
guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil,
|
||||||
|
let contentView = window.contentView,
|
||||||
|
let themeFrame = contentView.superview else { return }
|
||||||
|
|
||||||
|
let overlay = FileDropOverlayView(frame: contentView.frame)
|
||||||
|
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
overlay.onDrop = { [weak tabManager] urls in
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
guard let tabManager, let terminal = tabManager.selectedWorkspace?.focusedTerminalPanel else { return false }
|
||||||
|
return terminal.hostedView.handleDroppedURLs(urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
themeFrame.addSubview(overlay, positioned: .above, relativeTo: contentView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
overlay.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
overlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
overlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
overlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
objc_setAssociatedObject(window, &fileDropOverlayKey, overlay, .OBJC_ASSOCIATION_RETAIN)
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@ObservedObject var updateViewModel: UpdateViewModel
|
@ObservedObject var updateViewModel: UpdateViewModel
|
||||||
let windowId: UUID
|
let windowId: UUID
|
||||||
|
|
@ -498,6 +613,7 @@ struct ContentView: View {
|
||||||
sidebarState: sidebarState,
|
sidebarState: sidebarState,
|
||||||
sidebarSelectionState: sidebarSelectionState
|
sidebarSelectionState: sidebarSelectionState
|
||||||
)
|
)
|
||||||
|
installFileDropOverlay(on: window, tabManager: tabManager)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2475,7 +2475,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func escapeDropForShell(_ value: String) -> String {
|
fileprivate static func escapeDropForShell(_ value: String) -> String {
|
||||||
var result = value
|
var result = value
|
||||||
for char in shellEscapeCharacters {
|
for char in shellEscapeCharacters {
|
||||||
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
|
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
|
||||||
|
|
@ -2531,6 +2531,18 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
// MARK: NSDraggingDestination
|
// MARK: NSDraggingDestination
|
||||||
|
|
||||||
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||||
|
#if DEBUG
|
||||||
|
let types = sender.draggingPasteboard.types ?? []
|
||||||
|
dlog("terminal.draggingEntered surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))")
|
||||||
|
#endif
|
||||||
|
guard let types = sender.draggingPasteboard.types else { return [] }
|
||||||
|
if Set(types).isDisjoint(with: Self.dropTypes) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return .copy
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||||
guard let types = sender.draggingPasteboard.types else { return [] }
|
guard let types = sender.draggingPasteboard.types else { return [] }
|
||||||
if Set(types).isDisjoint(with: Self.dropTypes) {
|
if Set(types).isDisjoint(with: Self.dropTypes) {
|
||||||
return []
|
return []
|
||||||
|
|
@ -2976,7 +2988,23 @@ final class GhosttySurfaceScrollView: NSView {
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if DEBUG
|
/// Handle file/URL drops from SwiftUI's .onDrop, forwarding to the terminal as shell-escaped paths.
|
||||||
|
func handleDroppedURLs(_ urls: [URL]) -> Bool {
|
||||||
|
guard !urls.isEmpty else { return false }
|
||||||
|
let content = urls
|
||||||
|
.map { GhosttyNSView.escapeDropForShell($0.path) }
|
||||||
|
.joined(separator: " ")
|
||||||
|
#if DEBUG
|
||||||
|
dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))")
|
||||||
|
#endif
|
||||||
|
if let window = surfaceView.window {
|
||||||
|
window.makeFirstResponder(surfaceView)
|
||||||
|
}
|
||||||
|
surfaceView.sendTextToSurface(content)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
/// Sends a synthetic Ctrl+D key press directly to the surface view.
|
/// Sends a synthetic Ctrl+D key press directly to the surface view.
|
||||||
/// This exercises the same key path as real keyboard input (ghostty_surface_key),
|
/// This exercises the same key path as real keyboard input (ghostty_surface_key),
|
||||||
/// unlike `sendText`, which bypasses key translation.
|
/// unlike `sendText`, which bypasses key translation.
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,9 @@ class TerminalController {
|
||||||
case "simulate_file_drop":
|
case "simulate_file_drop":
|
||||||
return simulateFileDrop(args)
|
return simulateFileDrop(args)
|
||||||
|
|
||||||
|
case "drop_hit_test":
|
||||||
|
return dropHitTest(args)
|
||||||
|
|
||||||
case "activate_app":
|
case "activate_app":
|
||||||
return activateApp()
|
return activateApp()
|
||||||
|
|
||||||
|
|
@ -6165,6 +6168,7 @@ class TerminalController {
|
||||||
simulate_shortcut <combo> - Simulate a keyDown shortcut (test-only)
|
simulate_shortcut <combo> - Simulate a keyDown shortcut (test-only)
|
||||||
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)
|
||||||
|
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)
|
||||||
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
|
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
|
||||||
read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
|
read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
|
||||||
|
|
@ -6373,6 +6377,62 @@ class TerminalController {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hit-tests the file-drop overlay's coordinate-to-terminal mapping.
|
||||||
|
/// Takes normalised (0-1) x,y within the content area where (0,0) is the
|
||||||
|
/// top-left corner and (1,1) is the bottom-right corner. Returns the
|
||||||
|
/// surface UUID of the terminal under that point, or "none".
|
||||||
|
private func dropHitTest(_ args: String) -> String {
|
||||||
|
let parts = args.split(separator: " ").map(String.init)
|
||||||
|
guard parts.count == 2,
|
||||||
|
let nx = Double(parts[0]), let ny = Double(parts[1]),
|
||||||
|
(0...1).contains(nx), (0...1).contains(ny) else {
|
||||||
|
return "ERROR: Usage: drop_hit_test <x 0-1> <y 0-1>"
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = "ERROR: No window"
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
guard let window = NSApp.mainWindow
|
||||||
|
?? NSApp.keyWindow
|
||||||
|
?? NSApp.windows.first(where: { win in
|
||||||
|
guard let raw = win.identifier?.rawValue else { return false }
|
||||||
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
||||||
|
}),
|
||||||
|
let contentView = window.contentView,
|
||||||
|
let themeFrame = contentView.superview else { return }
|
||||||
|
|
||||||
|
// Compute the point in contentView's own coordinate system.
|
||||||
|
// NSHostingView is flipped: (0,0) = top-left, matching our API.
|
||||||
|
let contentPoint = NSPoint(
|
||||||
|
x: contentView.bounds.width * nx,
|
||||||
|
y: contentView.bounds.height * ny
|
||||||
|
)
|
||||||
|
|
||||||
|
// hitTest expects the point in the receiver's superview's (themeFrame's)
|
||||||
|
// coordinate system. Use convert to handle the coordinate transform.
|
||||||
|
let hitPoint = contentView.convert(contentPoint, to: themeFrame)
|
||||||
|
|
||||||
|
// Temporarily hide the overlay so it doesn't intercept the hit test.
|
||||||
|
let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView
|
||||||
|
overlay?.isHidden = true
|
||||||
|
|
||||||
|
let hitView = contentView.hitTest(hitPoint)
|
||||||
|
|
||||||
|
overlay?.isHidden = false
|
||||||
|
|
||||||
|
var current: NSView? = hitView
|
||||||
|
while let view = current {
|
||||||
|
if let terminal = view as? GhosttyNSView,
|
||||||
|
let surfaceId = terminal.terminalSurface?.id {
|
||||||
|
result = surfaceId.uuidString.uppercased()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = view.superview
|
||||||
|
}
|
||||||
|
result = "none"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private func unescapeSocketText(_ input: String) -> String {
|
private func unescapeSocketText(_ input: String) -> String {
|
||||||
var out = ""
|
var out = ""
|
||||||
var escaping = false
|
var escaping = false
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,19 @@ struct WorkspaceContentView: View {
|
||||||
// AppKit-backed views can still intercept drags. Disable drop acceptance for them.
|
// AppKit-backed views can still intercept drags. Disable drop acceptance for them.
|
||||||
let _ = { workspace.bonsplitController.isInteractive = isTabActive }()
|
let _ = { workspace.bonsplitController.isInteractive = isTabActive }()
|
||||||
|
|
||||||
|
// Wire up file drop handling so bonsplit's PaneDragContainerView can forward
|
||||||
|
// Finder file drops to the correct terminal panel.
|
||||||
|
let _ = {
|
||||||
|
workspace.bonsplitController.onFileDrop = { [weak workspace] urls, paneId in
|
||||||
|
guard let workspace else { return false }
|
||||||
|
// Find the focused panel in this pane and drop the files into it.
|
||||||
|
guard let tabId = workspace.bonsplitController.selectedTab(inPane: paneId)?.id,
|
||||||
|
let panelId = workspace.panelIdFromSurfaceId(tabId),
|
||||||
|
let panel = workspace.panels[panelId] as? TerminalPanel else { return false }
|
||||||
|
return panel.hostedView.handleDroppedURLs(urls)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
|
BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
|
||||||
// Content for each tab in bonsplit
|
// Content for each tab in bonsplit
|
||||||
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
|
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
|
||||||
|
|
|
||||||
|
|
@ -811,6 +811,17 @@ class cmux:
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
||||||
|
def drop_hit_test(self, x: float, y: float) -> Optional[str]:
|
||||||
|
"""Hit-test the file-drop overlay at normalised (0-1) coords.
|
||||||
|
|
||||||
|
Returns the surface UUID string if a terminal is under the point, or None.
|
||||||
|
"""
|
||||||
|
response = self._send_command(f"drop_hit_test {x} {y}")
|
||||||
|
if response.startswith("ERROR"):
|
||||||
|
raise cmuxError(response)
|
||||||
|
val = response.strip()
|
||||||
|
return None if val == "none" else val
|
||||||
|
|
||||||
def activate_app(self) -> None:
|
def activate_app(self) -> None:
|
||||||
"""Bring app + main window to front (debug builds only)."""
|
"""Bring app + main window to front (debug builds only)."""
|
||||||
response = self._send_command("activate_app")
|
response = self._send_command("activate_app")
|
||||||
|
|
|
||||||
167
tests/test_file_drop_split_targeting.py
Normal file
167
tests/test_file_drop_split_targeting.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
#!/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())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue