import AppKit import ClawdbotIPC extension CanvasWindowController { // MARK: - Window static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow { switch presentation { case .window: let window = NSWindow( contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize), styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false) window.title = "Clawdbot Canvas" window.isReleasedWhenClosed = false window.contentView = contentView window.center() window.minSize = NSSize(width: 880, height: 680) return window case .panel: let panel = CanvasPanel( contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize), styleMask: [.borderless, .resizable], backing: .buffered, defer: false) // Keep Canvas below the Voice Wake overlay panel. panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1) panel.hasShadow = true panel.isMovable = false panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.titleVisibility = .hidden panel.titlebarAppearsTransparent = true panel.backgroundColor = .clear panel.isOpaque = false panel.contentView = contentView panel.becomesKeyOnlyIfNeeded = true panel.hidesOnDeactivate = false panel.minSize = CanvasLayout.minPanelSize return panel } } func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) { guard case .panel = self.presentation, let window else { return } self.repositionPanel(using: anchorProvider) window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) window.makeFirstResponder(self.webView) VoiceWakeOverlayController.shared.bringToFrontIfVisible() self.onVisibilityChanged?(true) } func repositionPanel(using anchorProvider: () -> NSRect?) { guard let panel = self.window else { return } let anchor = anchorProvider() let targetScreen = Self.screen(forAnchor: anchor) ?? Self.screenContainingMouseCursor() ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey) let restoredIsValid = if let restored, let targetScreen { Self.isFrameMeaningfullyVisible(restored, on: targetScreen) } else { restored != nil } var frame = if let restored, restoredIsValid { restored } else { Self.defaultTopRightFrame(panel: panel, screen: targetScreen) } // Apply agent placement as partial overrides: // - If agent provides x/y, override origin. // - If agent provides width/height, override size. // - If agent provides only size, keep the remembered origin. if let placement = self.preferredPlacement { if let x = placement.x { frame.origin.x = x } if let y = placement.y { frame.origin.y = y } if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) } if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) } } self.setPanelFrame(frame, on: targetScreen) } static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect { let w = max(CanvasLayout.minPanelSize.width, panel.frame.width) let h = max(CanvasLayout.minPanelSize.height, panel.frame.height) return WindowPlacement.topRightFrame( size: NSSize(width: w, height: h), padding: CanvasLayout.defaultPadding, on: screen) } func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) { guard let panel = self.window else { return } guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else { panel.setFrame(frame, display: false) self.persistFrameIfPanel() return } let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame) panel.setFrame(constrained, display: false) self.persistFrameIfPanel() } static func screen(forAnchor anchor: NSRect?) -> NSScreen? { guard let anchor else { return nil } let center = NSPoint(x: anchor.midX, y: anchor.midY) return NSScreen.screens.first { screen in screen.frame.contains(anchor.origin) || screen.frame.contains(center) } } static func screenContainingMouseCursor() -> NSScreen? { let point = NSEvent.mouseLocation return NSScreen.screens.first { $0.frame.contains(point) } } static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool { frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12)) } static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect { if bounds == .zero { return frame } var next = frame next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width) next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height) let maxX = bounds.maxX - next.size.width let maxY = bounds.maxY - next.size.height next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY next.origin.x = round(next.origin.x) next.origin.y = round(next.origin.y) return next } // MARK: - NSWindowDelegate func windowWillClose(_: Notification) { self.onVisibilityChanged?(false) } func windowDidMove(_: Notification) { self.persistFrameIfPanel() } func windowDidEndLiveResize(_: Notification) { self.persistFrameIfPanel() } func persistFrameIfPanel() { guard case .panel = self.presentation, let window else { return } Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey) } }