Files
openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift
2026-03-02 11:32:20 +00:00

111 lines
4.4 KiB
Swift

import AppKit
import QuartzCore
import SwiftUI
extension VoiceWakeOverlayController {
func present() {
if !self.enableUI || ProcessInfo.processInfo.isRunningTests {
if !self.model.isVisible {
self.model.isVisible = true
}
return
}
self.ensureWindow()
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
let target = self.targetFrame()
OverlayPanelFactory.present(
window: self.window,
isVisible: &self.model.isVisible,
target: target,
onFirstPresent: {
self.logger.log(
level: .info,
"overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
// Keep the status item in listening mode until we explicitly dismiss the overlay.
AppStateStore.shared.triggerVoiceEars(ttl: nil)
}) { window in
self.updateWindowFrame(animate: true)
window.orderFrontRegardless()
}
}
private func ensureWindow() {
if self.window != nil { return }
let borderPad = self.closeOverflow
let panel = OverlayPanelFactory.makePanel(
contentRect: NSRect(x: 0, y: 0, width: self.width + borderPad * 2, height: 60 + borderPad * 2),
level: Self.preferredWindowLevel,
hasShadow: false)
let host = NSHostingView(rootView: VoiceWakeOverlayView(controller: self))
host.translatesAutoresizingMaskIntoConstraints = false
panel.contentView = host
self.hostingView = host
self.window = panel
}
/// Reassert window ordering when other panels are shown.
func bringToFrontIfVisible() {
guard self.model.isVisible, let window = self.window else { return }
window.level = Self.preferredWindowLevel
window.orderFrontRegardless()
}
func targetFrame() -> NSRect {
guard let screen = NSScreen.main else { return .zero }
let height = self.measuredHeight()
let size = NSSize(width: self.width + self.closeOverflow * 2, height: height + self.closeOverflow * 2)
let visible = screen.visibleFrame
let origin = CGPoint(
x: visible.maxX - size.width,
y: visible.maxY - size.height)
return NSRect(origin: origin, size: size)
}
func updateWindowFrame(animate: Bool = false) {
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
}
func measuredHeight() -> CGFloat {
let attributed = self.model.attributed.length > 0 ? self.model.attributed : self
.makeAttributed(from: self.model.text)
let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth
let textInset = NSSize(width: 2, height: 6)
let lineFragmentPadding: CGFloat = 0
let containerWidth = max(1, maxWidth - (textInset.width * 2) - (lineFragmentPadding * 2))
let storage = NSTextStorage(attributedString: attributed)
let container = NSTextContainer(containerSize: CGSize(width: containerWidth, height: .greatestFiniteMagnitude))
container.lineFragmentPadding = lineFragmentPadding
container.lineBreakMode = .byWordWrapping
let layout = NSLayoutManager()
layout.addTextContainer(container)
storage.addLayoutManager(layout)
_ = layout.glyphRange(for: container)
let used = layout.usedRect(for: container)
let contentHeight = ceil(used.height + (textInset.height * 2))
let total = contentHeight + self.verticalPadding * 2
self.model.isOverflowing = total > self.maxHeight
return max(self.minHeight, min(total, self.maxHeight))
}
func dismissTargetFrame(for frame: NSRect, reason: DismissReason, outcome: SendOutcome) -> NSRect? {
switch (reason, outcome) {
case (.empty, _):
let scale: CGFloat = 0.95
let newSize = NSSize(width: frame.size.width * scale, height: frame.size.height * scale)
let dx = (frame.size.width - newSize.width) / 2
let dy = (frame.size.height - newSize.height) / 2
return NSRect(x: frame.origin.x + dx, y: frame.origin.y + dy, width: newSize.width, height: newSize.height)
case (.explicit, .sent):
return frame.offsetBy(dx: 8, dy: 6)
default:
return frame
}
}
}