From 01341d983c4387f144fc9017460e0fd1aaeac89b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 03:57:02 +0000 Subject: [PATCH] fix(macos): sane chat window placement --- .../Sources/Clawdis/WebChatSwiftUI.swift | 15 +++++- .../macos/Sources/Clawdis/WebChatWindow.swift | 15 +++++- .../Sources/Clawdis/WindowPlacement.swift | 54 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/WindowPlacement.swift diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index fa81c19ca..540d71879 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -163,7 +163,14 @@ final class WebChatSwiftUIWindowController { private func reposition(using anchorProvider: () -> NSRect?) { guard let window else { return } - guard let anchor = anchorProvider() else { return } + guard let anchor = anchorProvider() else { + window.setFrame( + WindowPlacement.topRightFrame( + size: WebChatSwiftUILayout.panelSize, + padding: WebChatSwiftUILayout.anchorPadding), + display: false) + return + } let screen = NSScreen.screens.first { screen in screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) } ?? NSScreen.main @@ -227,6 +234,7 @@ final class WebChatSwiftUIWindowController { window.backgroundColor = .windowBackgroundColor window.isOpaque = true window.center() + WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize) window.minSize = NSSize(width: 880, height: 680) return window case .panel: @@ -246,6 +254,11 @@ final class WebChatSwiftUIWindowController { panel.isOpaque = false panel.contentViewController = contentViewController panel.becomesKeyOnlyIfNeeded = true + panel.setFrame( + WindowPlacement.topRightFrame( + size: WebChatSwiftUILayout.panelSize, + padding: WebChatSwiftUILayout.anchorPadding), + display: false) return panel } } diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index 48c377745..9f445455e 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -91,6 +91,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N window.title = "Clawd Web Chat" window.contentView = wrappedContent window.center() + WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatLayout.windowSize) window.minSize = NSSize(width: 880, height: 680) return window case .panel: @@ -110,6 +111,11 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N panel.isOpaque = false panel.contentView = wrappedContent panel.becomesKeyOnlyIfNeeded = true + panel.setFrame( + WindowPlacement.topRightFrame( + size: WebChatLayout.panelSize, + padding: WebChatLayout.anchorPadding), + display: false) return panel } } @@ -339,7 +345,14 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N private func repositionPanel(using anchorProvider: () -> NSRect?) { guard let panel = self.window else { return } - guard let anchor = anchorProvider() else { return } + guard let anchor = anchorProvider() else { + panel.setFrame( + WindowPlacement.topRightFrame( + size: WebChatLayout.panelSize, + padding: WebChatLayout.anchorPadding), + display: false) + return + } var frame = panel.frame let screen = NSScreen.screens.first { screen in diff --git a/apps/macos/Sources/Clawdis/WindowPlacement.swift b/apps/macos/Sources/Clawdis/WindowPlacement.swift new file mode 100644 index 000000000..e069d6219 --- /dev/null +++ b/apps/macos/Sources/Clawdis/WindowPlacement.swift @@ -0,0 +1,54 @@ +import AppKit + +@MainActor +enum WindowPlacement { + static func centeredFrame(size: NSSize, on screen: NSScreen? = NSScreen.main) -> NSRect { + let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero) + if bounds == .zero { + return NSRect(origin: .zero, size: size) + } + + let clampedWidth = min(size.width, bounds.width) + let clampedHeight = min(size.height, bounds.height) + + let x = round(bounds.minX + (bounds.width - clampedWidth) / 2) + let y = round(bounds.minY + (bounds.height - clampedHeight) / 2) + return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) + } + + static func topRightFrame( + size: NSSize, + padding: CGFloat, + on screen: NSScreen? = NSScreen.main) -> NSRect + { + let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero) + if bounds == .zero { + return NSRect(origin: .zero, size: size) + } + + let clampedWidth = min(size.width, bounds.width) + let clampedHeight = min(size.height, bounds.height) + + let x = round(bounds.maxX - clampedWidth - padding) + let y = round(bounds.maxY - clampedHeight - padding) + return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) + } + + static func ensureOnScreen( + window: NSWindow, + defaultSize: NSSize, + fallback: ((NSScreen?) -> NSRect)? = nil) + { + let frame = window.frame + let targetScreens = NSScreen.screens.isEmpty ? [NSScreen.main].compactMap { $0 } : NSScreen.screens + let isVisibleSomewhere = targetScreens.contains { screen in + frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12)) + } + + if isVisibleSomewhere { return } + + let screen = NSScreen.main ?? targetScreens.first + let next = fallback?(screen) ?? centeredFrame(size: defaultSize, on: screen) + window.setFrame(next, display: false) + } +}