diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 44bbe27ac..6879147ce 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -408,11 +408,19 @@ class TerminalWindow: NSWindow { return } - // Orient based on the top left of the primary monitor - let frame = screen.visibleFrame - setFrameOrigin(.init( - x: frame.minX + CGFloat(x), - y: frame.maxY - (CGFloat(y) + frame.height))) + // Convert top-left coordinates to bottom-left origin using our utility extension + let origin = screen.origin( + fromTopLeftOffsetX: CGFloat(x), + offsetY: CGFloat(y), + windowSize: frame.size) + + // Clamp the origin to ensure the window stays fully visible on screen + var safeOrigin = origin + let vf = screen.visibleFrame + safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) + safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) + + setFrameOrigin(safeOrigin) } private func hideWindowButtons() { diff --git a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift index 675e0b2ec..f46106004 100644 --- a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift @@ -41,4 +41,20 @@ extension NSScreen { // know any other situation this is true. return safeAreaInsets.top > 0 } + + /// Converts top-left offset coordinates to bottom-left origin coordinates for window positioning. + /// - Parameters: + /// - x: X offset from top-left corner + /// - y: Y offset from top-left corner + /// - windowSize: Size of the window to be positioned + /// - Returns: CGPoint suitable for setFrameOrigin that positions the window as requested + func origin(fromTopLeftOffsetX x: CGFloat, offsetY y: CGFloat, windowSize: CGSize) -> CGPoint { + let vf = visibleFrame + + // Convert top-left coordinates to bottom-left origin + let originX = vf.minX + x + let originY = vf.maxY - y - windowSize.height + + return CGPoint(x: originX, y: originY) + } } diff --git a/macos/Tests/NSScreenTests.swift b/macos/Tests/NSScreenTests.swift new file mode 100644 index 000000000..f7431bf05 --- /dev/null +++ b/macos/Tests/NSScreenTests.swift @@ -0,0 +1,99 @@ +// +// WindowPositionTests.swift +// GhosttyTests +// +// Tests for window positioning coordinate conversion functionality. +// + +import Testing +import AppKit +@testable import Ghostty + +struct NSScreenExtensionTests { + /// Test positive coordinate conversion from top-left to bottom-left + @Test func testPositiveCoordinateConversion() async throws { + // Mock screen with 1000x800 visible frame starting at (0, 100) + let mockScreenFrame = NSRect(x: 0, y: 100, width: 1000, height: 800) + let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame) + + // Mock window size + let windowSize = CGSize(width: 400, height: 300) + + // Test top-left positioning: x=15, y=15 + let origin = mockScreen.origin( + fromTopLeftOffsetX: 15, + offsetY: 15, + windowSize: windowSize) + + // Expected: x = 0 + 15 = 15, y = (100 + 800) - 15 - 300 = 585 + #expect(origin.x == 15) + #expect(origin.y == 585) + } + + /// Test zero coordinates (exact top-left corner) + @Test func testZeroCoordinates() async throws { + let mockScreenFrame = NSRect(x: 0, y: 100, width: 1000, height: 800) + let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame) + let windowSize = CGSize(width: 400, height: 300) + + let origin = mockScreen.origin( + fromTopLeftOffsetX: 0, + offsetY: 0, + windowSize: windowSize) + + // Expected: x = 0, y = (100 + 800) - 0 - 300 = 600 + #expect(origin.x == 0) + #expect(origin.y == 600) + } + + /// Test with offset screen (not starting at origin) + @Test func testOffsetScreen() async throws { + // Secondary monitor at position (1440, 0) with 1920x1080 resolution + let mockScreenFrame = NSRect(x: 1440, y: 0, width: 1920, height: 1080) + let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame) + let windowSize = CGSize(width: 600, height: 400) + + let origin = mockScreen.origin( + fromTopLeftOffsetX: 100, + offsetY: 50, + windowSize: windowSize) + + // Expected: x = 1440 + 100 = 1540, y = (0 + 1080) - 50 - 400 = 630 + #expect(origin.x == 1540) + #expect(origin.y == 630) + } + + /// Test large coordinates + @Test func testLargeCoordinates() async throws { + let mockScreenFrame = NSRect(x: 0, y: 0, width: 1920, height: 1080) + let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame) + let windowSize = CGSize(width: 400, height: 300) + + let origin = mockScreen.origin( + fromTopLeftOffsetX: 500, + offsetY: 200, + windowSize: windowSize) + + // Expected: x = 0 + 500 = 500, y = (0 + 1080) - 200 - 300 = 580 + #expect(origin.x == 500) + #expect(origin.y == 580) + } +} + +/// Mock NSScreen class for testing coordinate conversion +private class MockNSScreen: NSScreen { + private let mockVisibleFrame: NSRect + + init(visibleFrame: NSRect) { + self.mockVisibleFrame = visibleFrame + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var visibleFrame: NSRect { + return mockVisibleFrame + } +}