a92bf02477
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1172 lines
48 KiB
Swift
1172 lines
48 KiB
Swift
import AppKit
|
||
import Combine
|
||
import SwiftUI
|
||
|
||
@MainActor
|
||
final class IslandWindowController: NSWindowController {
|
||
|
||
private var islandPanel: IslandPanel!
|
||
private var state: AppState { AppState.shared }
|
||
|
||
// State machine (replaces all hover/absence/auto-close timers)
|
||
let fsm = IslandStateMachine()
|
||
|
||
private var wasInIsland = false
|
||
private var frameTimer: Timer?
|
||
private var keyMonitor: Any?
|
||
private var viewSubscription: AnyCancellable?
|
||
|
||
// Confused recovery timer (set by handleDizzy)
|
||
private var confusedRecoveryTimer: DispatchWorkItem?
|
||
|
||
// Suppress peek sound on next reveal (e.g. musicReveal)
|
||
var silentNextReveal = false
|
||
|
||
// Finished-pin timer
|
||
private var finishedPinTimer: DispatchWorkItem?
|
||
|
||
// Bot-head hover (love emote — mirrors prototype botHover())
|
||
private var hoverTimer: DispatchWorkItem?
|
||
private var botHoverTimer: DispatchWorkItem?
|
||
private var botHovering: Bool = false
|
||
private var lastLoveTime: Double = 0
|
||
private var botHoverStartPos: CGPoint = .zero
|
||
|
||
// Window attach drag (M8)
|
||
private var attachDragStart: NSPoint? = nil
|
||
private var pendingIslandClick = false // any island click → expand on mouseUp
|
||
private var inAttachDrag = false
|
||
private var dragGhostPanel: NSPanel? = nil
|
||
private var dragGhostSize: CGFloat = 0
|
||
private var ghostCurrentOrigin: NSPoint = .zero
|
||
private var highlightPanel: NSPanel? = nil
|
||
private var highlightWindowPid: pid_t = 0
|
||
|
||
// Notch real dimensions (set on init)
|
||
private var notchW: CGFloat = IslandConst.notchWidth
|
||
private var notchH: CGFloat = IslandConst.notchHeight
|
||
private var hasNotch = true
|
||
|
||
// Island-local key monitor (active only when island is key window)
|
||
private var localKeyMonitor: Any?
|
||
|
||
convenience init() {
|
||
let screen = Self.notchScreen() ?? NSScreen.main!
|
||
let geometry = Self.screenGeometry(for: screen)
|
||
let nW = geometry.width
|
||
let nH = geometry.height
|
||
|
||
// Tall, transparent panel: the chat can grow down to the full screen height
|
||
// and widen; clicks outside the island still pass through (pollFrame).
|
||
let sf = screen.frame
|
||
let panelW: CGFloat = 1100
|
||
let panelH: CGFloat = sf.height
|
||
let panel = IslandPanel(
|
||
contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH,
|
||
width: panelW, height: panelH),
|
||
styleMask: [.borderless, .nonactivatingPanel],
|
||
backing: .buffered, defer: false
|
||
)
|
||
panel.notchWidth = nW
|
||
panel.notchHeight = nH
|
||
|
||
self.init(window: panel)
|
||
self.islandPanel = panel
|
||
self.notchW = nW
|
||
self.notchH = nH
|
||
self.hasNotch = geometry.hasNotch
|
||
setupPanel(screen: screen)
|
||
}
|
||
|
||
private func setupPanel(screen: NSScreen) {
|
||
guard let panel = window as? IslandPanel else { return }
|
||
panel.backgroundColor = .clear
|
||
panel.isOpaque = false
|
||
panel.hasShadow = false
|
||
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 3)
|
||
panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
|
||
panel.ignoresMouseEvents = true
|
||
|
||
// Propagate real notch dimensions to AppState
|
||
AppState.shared.notchWidth = notchW
|
||
AppState.shared.notchHeight = notchH
|
||
AppState.shared.hasNotch = hasNotch
|
||
|
||
let contentSize = panel.contentRect(forFrameRect: panel.frame).size
|
||
|
||
// Apple-recommended pattern: put NSHostingView and drag destination as siblings
|
||
// inside a common superview, rather than embedding one inside the other.
|
||
let container = NSView(frame: NSRect(origin: .zero, size: contentSize))
|
||
container.autoresizingMask = [.width, .height]
|
||
|
||
let hosting = NSHostingView(rootView: IslandRootView().environmentObject(AppState.shared))
|
||
hosting.frame = NSRect(origin: .zero, size: contentSize)
|
||
hosting.autoresizingMask = [.width, .height]
|
||
|
||
// FileDropNSView sits below the hosting view (hitTest returns nil → no mouse interference).
|
||
// AppKit routes NSDraggingDestination events to registered views independently of hitTest.
|
||
let dropView = FileDropNSView(frame: NSRect(origin: .zero, size: contentSize))
|
||
dropView.autoresizingMask = [.width, .height]
|
||
dropView.onDragEntered = { [weak self] loc in
|
||
Task { @MainActor in
|
||
let iLoc = self?.windowToIsland(loc) ?? CGPoint(x: 320, y: 88)
|
||
AppState.shared.fileDragOver = true
|
||
// enterZone sets isActive=true BEFORE hookExpand triggers re-render,
|
||
// so IslandContainer sees isActive=true when state.view becomes .upload.
|
||
UploadSequenceEngine.shared.enterZone(x: iLoc.x, y: iLoc.y)
|
||
NotificationCenter.default.post(name: .hookExpand, object: IslandView.upload)
|
||
NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(1))
|
||
}
|
||
}
|
||
dropView.onDragUpdated = { [weak self] loc in
|
||
Task { @MainActor in
|
||
let iLoc = self?.windowToIsland(loc) ?? CGPoint(x: 320, y: 88)
|
||
UploadSequenceEngine.shared.updateCursor(x: iLoc.x, y: iLoc.y)
|
||
}
|
||
}
|
||
dropView.onDragExited = {
|
||
Task { @MainActor in
|
||
AppState.shared.fileDragOver = false
|
||
// Do NOT collapse — drag session still active; island stays open.
|
||
NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(0))
|
||
UploadSequenceEngine.shared.exitZone()
|
||
}
|
||
}
|
||
dropView.onFilesDropped = { urls in
|
||
Task { @MainActor in
|
||
await FileDropHandler.handle(urls: urls, state: AppState.shared)
|
||
}
|
||
}
|
||
|
||
container.addSubview(hosting) // z-bottom: SwiftUI + mouse events
|
||
container.addSubview(dropView) // z-top: drag only (hitTest→nil, transparent to mouse)
|
||
panel.contentView = container
|
||
|
||
startPolling()
|
||
startKeyMonitor()
|
||
startLocalKeyMonitor()
|
||
startHotKeys()
|
||
wireFSM()
|
||
|
||
// Make panel key whenever the prompt/chat view becomes active
|
||
// (nonactivatingPanel never auto-becomes key, but TextField needs it)
|
||
viewSubscription = state.$view
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] newView in
|
||
guard let self else { return }
|
||
if newView == .prompt {
|
||
self.islandPanel.makeKey()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - FSM wiring
|
||
|
||
private func wireFSM() {
|
||
fsm.onTransition = { [weak self] from, to in
|
||
guard let self else { return }
|
||
switch to {
|
||
case .hidden:
|
||
self.setMode(.hidden)
|
||
|
||
case .petit:
|
||
if from == .coucou {
|
||
// Fire interrupt first so canvas collapse starts before mode change
|
||
NotificationCenter.default.post(name: .greetingInterrupt, object: nil)
|
||
} else if from == .hidden {
|
||
if self.silentNextReveal {
|
||
self.silentNextReveal = false
|
||
} else {
|
||
SoundEngine.shared.play("peek")
|
||
}
|
||
}
|
||
// setMode BEFORE changing view: onChange(of: state.view) guards on .expanded,
|
||
// so setting view while already compact won't trigger a spurious open animation.
|
||
self.setMode(.compact)
|
||
if from == .coucou { self.state.view = self.defaultView() }
|
||
// Start 60s hide timer if mouse is not currently over the island
|
||
if !self.wasInIsland { self.fsm.mouseLeft() }
|
||
|
||
case .home:
|
||
self.expand(to: self.defaultView())
|
||
// Start collapse timer if mouse not currently hovering
|
||
if !self.wasInIsland {
|
||
self.fsm.mouseLeft()
|
||
}
|
||
|
||
case .coucou:
|
||
self.expand(to: .greeting)
|
||
}
|
||
}
|
||
|
||
// FSM observes greetComplete notification
|
||
NotificationCenter.default.addObserver(
|
||
forName: .greetComplete, object: nil, queue: .main
|
||
) { [weak self] _ in
|
||
self?.fsm.greetComplete()
|
||
}
|
||
|
||
// Held open: a pending approval, or a chat/terminal tab on screen (typing with
|
||
// the pointer elsewhere must not fold it away; ⌃ in the header closes it).
|
||
fsm.isHeldOpen = {
|
||
let s = AppState.shared
|
||
return s.pendingApproval != nil || (s.mode == .expanded && s.view.isTall)
|
||
}
|
||
}
|
||
|
||
// MARK: - 60 Hz polling loop
|
||
|
||
private func startPolling() {
|
||
frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||
guard let self else { return }
|
||
Task { @MainActor in self.pollFrame() }
|
||
}
|
||
RunLoop.main.add(frameTimer!, forMode: .common)
|
||
}
|
||
|
||
private func pollFrame() {
|
||
guard let panel = window as? IslandPanel else { return }
|
||
|
||
let mouse = NSEvent.mouseLocation
|
||
|
||
// Convert mouse to panel-local coords (macOS: origin bottom-left)
|
||
let pf = panel.frame
|
||
let local = CGPoint(x: mouse.x - pf.minX, y: mouse.y - pf.minY)
|
||
|
||
// Island rect in panel coords
|
||
let islandRect = panel.currentIslandFrame(nw: notchW, nh: notchH)
|
||
// On a screen without a notch, the resting bar must not intercept clicks
|
||
// in the app window immediately below the menu bar.
|
||
let hoverRect = !hasNotch && state.mode != .expanded
|
||
? islandRect : islandRect.insetBy(dx: -6, dy: -6)
|
||
// While the chat handle is dragged, count the pointer as inside (no collapse).
|
||
let inIsland = hoverRect.contains(local) || state.chatDragHeight != nil
|
||
|
||
// Toggle click-through
|
||
let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil
|
||
|| state.chatDragHeight != nil
|
||
if panel.ignoresMouseEvents == shouldAcceptMouse {
|
||
panel.ignoresMouseEvents = !shouldAcceptMouse
|
||
if shouldAcceptMouse, let cv = panel.contentView {
|
||
panel.invalidateCursorRects(for: cv)
|
||
}
|
||
}
|
||
|
||
// Mouse in screen coords (Y flipped, origin top-left) for Bot look-at
|
||
let screenH = panel.screen?.frame.height ?? NSScreen.main!.frame.height
|
||
let newPos = CGPoint(x: mouse.x - (panel.screen?.frame.minX ?? 0), y: screenH - mouse.y)
|
||
let cur = AppState.shared.mousePosition
|
||
if abs(newPos.x - cur.x) > 1 || abs(newPos.y - cur.y) > 1 {
|
||
AppState.shared.mousePosition = newPos
|
||
}
|
||
|
||
// AppState can hide the island by itself (last task ended): keep the FSM in step.
|
||
if state.mode == .hidden && fsm.state == .petit { fsm.hiddenExternally() }
|
||
|
||
// Feed FSM hover enter/leave
|
||
if inIsland && !wasInIsland {
|
||
guard !inAttachDrag else { wasInIsland = inIsland; return }
|
||
// If in coucou: tell greeting to stay open (tc → infinity)
|
||
if fsm.state == .coucou {
|
||
NotificationCenter.default.post(name: .greetingHover, object: nil)
|
||
}
|
||
fsm.mouseEntered()
|
||
}
|
||
if !inIsland && wasInIsland {
|
||
fsm.mouseLeft()
|
||
}
|
||
wasInIsland = inIsland
|
||
|
||
// Bot-head hover (love emote)
|
||
let overBot = state.mode == .expanded && state.stateOverride == nil && isBotHit(local)
|
||
if overBot && !botHovering { botHoverIn(mousePos: NSEvent.mouseLocation) }
|
||
if !overBot && botHovering { botHoverOut() }
|
||
botHovering = overBot
|
||
if botHovering {
|
||
let m = NSEvent.mouseLocation
|
||
let dist = hypot(m.x - botHoverStartPos.x, m.y - botHoverStartPos.y)
|
||
if dist > 40 {
|
||
botHoverStartPos = m
|
||
botHoverTimer?.cancel()
|
||
scheduleLoveTimer()
|
||
}
|
||
}
|
||
|
||
// Ghost Mochi follows cursor + window highlight during drag (60 Hz, no throttle)
|
||
if inAttachDrag {
|
||
updateDragGhost()
|
||
updateWindowHighlight()
|
||
}
|
||
}
|
||
|
||
private var lastMouse: CGPoint = .zero
|
||
|
||
// MARK: - Bot-head hover (love emote — mirrors prototype botHover())
|
||
|
||
private func botHoverIn(mousePos: CGPoint) {
|
||
guard state.mode == .expanded, state.stateOverride == nil else { return }
|
||
guard CACurrentMediaTime() - lastLoveTime > 6 else { return }
|
||
botHoverStartPos = mousePos
|
||
NotificationCenter.default.post(name: .botBlink, object: nil)
|
||
NotificationCenter.default.post(name: .botSetTgEs, object: CGFloat(1.08))
|
||
SoundEngine.shared.play("hover")
|
||
scheduleLoveTimer()
|
||
}
|
||
|
||
private func botHoverOut() {
|
||
botHoverTimer?.cancel()
|
||
NotificationCenter.default.post(name: .botSetTgEs, object: CGFloat(1))
|
||
}
|
||
|
||
private func scheduleLoveTimer() {
|
||
botHoverTimer?.cancel()
|
||
let item = DispatchWorkItem { [weak self] in
|
||
guard let self, self.botHovering, self.state.stateOverride == nil else { return }
|
||
guard CACurrentMediaTime() - self.lastLoveTime > 6 else { return }
|
||
self.lastLoveTime = CACurrentMediaTime()
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.love)
|
||
SoundEngine.shared.play("love")
|
||
}
|
||
botHoverTimer = item
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.9, execute: item)
|
||
}
|
||
|
||
private func scheduleHover(after delay: TimeInterval, action: @escaping () -> Void) {
|
||
hoverTimer?.cancel()
|
||
let item = DispatchWorkItem(block: action)
|
||
hoverTimer = item
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
|
||
}
|
||
|
||
// MARK: - Mode transitions
|
||
|
||
private func modeLevel(_ m: IslandMode) -> Int {
|
||
switch m { case .hidden: return 0; case .compact: return 1; case .expanded: return 2 }
|
||
}
|
||
|
||
func setMode(_ mode: IslandMode) {
|
||
let prev = state.mode
|
||
guard mode != prev else { return }
|
||
let shrinking = modeLevel(mode) < modeLevel(prev)
|
||
let anim: Animation = shrinking
|
||
? .timingCurve(0.45, 0, 0.2, 1, duration: 0.34)
|
||
: .spring(response: 0.5, dampingFraction: 0.72)
|
||
withAnimation(anim) { state.mode = mode }
|
||
if mode == .expanded { SoundEngine.shared.play("open") }
|
||
if prev == .expanded {
|
||
SoundEngine.shared.play("close")
|
||
if fsm.isHeldOpen?() != true { state.isPinned = false }
|
||
}
|
||
}
|
||
|
||
func expand(to view: IslandView) {
|
||
state.view = view
|
||
if state.mode == .expanded {
|
||
// Already expanded — just switch view
|
||
} else {
|
||
setMode(.expanded)
|
||
}
|
||
state.lastActivity = .now
|
||
}
|
||
|
||
/// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too;
|
||
/// only a pending approval still keeps the island open.
|
||
func collapse(byUser: Bool = false) {
|
||
if byUser {
|
||
guard state.pendingApproval == nil else { return }
|
||
} else {
|
||
guard fsm.isHeldOpen?() != true else { return }
|
||
}
|
||
state.isPinned = false
|
||
finishedPinTimer?.cancel()
|
||
// Keep the FSM in step with what is on screen (home/coucou → petit now).
|
||
fsm.collapse()
|
||
setMode(.compact)
|
||
window?.resignKey()
|
||
}
|
||
|
||
// MARK: - Global hot keys (Carbon)
|
||
|
||
private func startHotKeys() {
|
||
HotKeyCenter.shared.start { [weak self] action in
|
||
self?.handleHotKey(action)
|
||
}
|
||
}
|
||
|
||
func handleHotKey(_ action: ShortcutAction) {
|
||
switch action {
|
||
case .toggleIsland:
|
||
if state.mode == .expanded {
|
||
collapse(byUser: true)
|
||
} else {
|
||
islandPanel.makeKey()
|
||
expand(to: defaultView())
|
||
}
|
||
|
||
case .openChat:
|
||
islandPanel.makeKey()
|
||
expand(to: .prompt)
|
||
|
||
case .goToAlert:
|
||
if state.pendingApproval != nil {
|
||
islandPanel.makeKey()
|
||
expand(to: .approval)
|
||
} else if state.pendingQuestion != nil {
|
||
islandPanel.makeKey()
|
||
expand(to: .question)
|
||
} else {
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.annoyed)
|
||
SoundEngine.shared.play("error")
|
||
}
|
||
|
||
case .jumpToTerminal:
|
||
#if !APPSTORE
|
||
performJumpToTerminal()
|
||
#endif
|
||
|
||
case .attachFrontWindow:
|
||
#if !APPSTORE
|
||
performAttachFrontWindow()
|
||
#endif
|
||
|
||
case .nextPill:
|
||
cyclePill(by: +1)
|
||
|
||
case .prevPill:
|
||
cyclePill(by: -1)
|
||
|
||
case .muteToggle:
|
||
state.soundEnabled.toggle()
|
||
if state.soundEnabled { SoundEngine.shared.play("tick") }
|
||
NotificationCenter.default.post(
|
||
name: .triggerEmote,
|
||
object: state.soundEnabled ? BotEmote.happy : BotEmote.annoyed)
|
||
|
||
case .desktopToggle:
|
||
DesktopMochiController.shared.flyOutOrHome()
|
||
|
||
case .wardrobeToggle:
|
||
if state.mode == .expanded && state.view == .wardrobe {
|
||
collapse()
|
||
} else {
|
||
islandPanel.makeKey()
|
||
expand(to: .wardrobe)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Island-local shortcuts
|
||
|
||
private func startLocalKeyMonitor() {
|
||
localKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||
guard let self, self.islandPanel.isKeyWindow else { return event }
|
||
return self.handleIslandKey(event) ? nil : event
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
private func handleIslandKey(_ event: NSEvent) -> Bool {
|
||
let raw = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||
let cmd = raw == .command
|
||
|
||
// ⌘→ — next pill
|
||
if cmd && event.keyCode == 124 { cyclePill(by: +1); return true }
|
||
// ⌘← — previous pill
|
||
if cmd && event.keyCode == 123 { cyclePill(by: -1); return true }
|
||
// ⌘↓ — navigate list down
|
||
if cmd && event.keyCode == 125 { navigateCard(by: +1); return true }
|
||
// ⌘↑ — navigate list up
|
||
if cmd && event.keyCode == 126 { navigateCard(by: -1); return true }
|
||
// ⌘O — open selected card item
|
||
if cmd && event.keyCode == 31 { openCardSelection(); return true }
|
||
// ⌘E — toggle diff
|
||
if cmd && event.keyCode == 14 && state.view == .overview {
|
||
NotificationCenter.default.post(name: .islandToggleDiff, object: nil)
|
||
return true
|
||
}
|
||
// ⌘↩ — send chat message
|
||
if cmd && event.keyCode == 36 && state.view == .prompt {
|
||
NotificationCenter.default.post(name: .islandSendMessage, object: nil)
|
||
return true
|
||
}
|
||
// ⌘K — new conversation
|
||
if cmd && event.keyCode == 40 && state.view == .prompt {
|
||
NotificationCenter.default.post(name: .islandNewConversation, object: nil)
|
||
return true
|
||
}
|
||
// ⌘, — open Settings
|
||
if cmd && event.keyCode == 43 {
|
||
NotificationCenter.default.post(name: .openFullSettings, object: nil)
|
||
return true
|
||
}
|
||
// ⌘P — pin / unpin
|
||
if cmd && event.keyCode == 35 {
|
||
state.isPinned.toggle()
|
||
return true
|
||
}
|
||
// ⌘1–⌘9 — switch to pill by number
|
||
let digitCodes: [UInt16: Int] = [18:1,19:2,20:3,21:4,23:5,22:6,26:7,28:8,25:9]
|
||
if cmd, let n = digitCodes[event.keyCode] {
|
||
switchToPill(number: n); return true
|
||
}
|
||
// ⎋ Escape — belongs to the shell in a term.macOS tab (vim, claude, less…).
|
||
if event.keyCode == 53 && state.view == .terminal { return false }
|
||
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
|
||
if event.keyCode == 53 && raw.isEmpty {
|
||
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
|
||
if !consumed && state.mode == .expanded && !state.isPinned {
|
||
collapse()
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// MARK: - Pill cycling helpers
|
||
|
||
private func cyclePill(by delta: Int) {
|
||
guard !state.tasks.isEmpty else { return }
|
||
let ids = state.tasks.map { $0.id }
|
||
let cur = ids.firstIndex(of: state.focusId ?? "") ?? 0
|
||
state.setFocus(ids[(cur + delta + ids.count) % ids.count])
|
||
state.cardSelection = nil
|
||
expand(to: .overview)
|
||
}
|
||
|
||
private func switchToPill(number: Int) {
|
||
guard number >= 1, number <= state.tasks.count else { return }
|
||
state.setFocus(state.tasks[number - 1].id)
|
||
state.cardSelection = nil
|
||
expand(to: .overview)
|
||
}
|
||
|
||
private func navigateCard(by delta: Int) {
|
||
guard state.cardItemCount > 0 else { return }
|
||
state.cardSelection = ShortcutLogic.navigate(
|
||
selection: state.cardSelection, delta: delta, itemCount: state.cardItemCount)
|
||
}
|
||
|
||
private func openCardSelection() {
|
||
guard state.cardSelection != nil else { return }
|
||
NotificationCenter.default.post(name: .islandActivateCardSelection, object: nil)
|
||
}
|
||
|
||
// MARK: - Terminal jump
|
||
|
||
#if !APPSTORE
|
||
private func performJumpToTerminal() {
|
||
guard state.focusTask != nil else {
|
||
SoundEngine.shared.play("error")
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.annoyed)
|
||
return
|
||
}
|
||
let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2",
|
||
"net.kovidgoyal.kitty", "com.mitchellh.ghostty"]
|
||
let activated = terminalBundleIds.compactMap { id in
|
||
NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id }
|
||
}.first.map { $0.activate(options: .activateIgnoringOtherApps) }
|
||
if activated == nil {
|
||
NSWorkspace.shared.open(
|
||
URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
|
||
}
|
||
collapse()
|
||
}
|
||
|
||
private func performAttachFrontWindow() {
|
||
guard let app = state.lastExternalApp else {
|
||
SoundEngine.shared.play("error"); return
|
||
}
|
||
guard let ctx = WindowContextCapture.captureActive(from: app) else {
|
||
SoundEngine.shared.play("error"); return
|
||
}
|
||
state.promptContext = ctx
|
||
SoundEngine.shared.play("approve")
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
|
||
islandPanel.makeKey()
|
||
expand(to: .prompt)
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Keyboard (Escape closes)
|
||
|
||
private func startKeyMonitor() {
|
||
keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||
Task { @MainActor in
|
||
guard let self = self else { return }
|
||
if event.keyCode == 53 { // Escape
|
||
if self.state.mode == .expanded && !self.state.isPinned {
|
||
self.collapse()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Hook server expand requests (alerts only)
|
||
NotificationCenter.default.addObserver(forName: .hookExpand, object: nil, queue: .main) { [weak self] note in
|
||
guard let self, let view = note.object as? IslandView else { return }
|
||
self.fsm.openedExternally()
|
||
self.expand(to: view)
|
||
}
|
||
|
||
// Hook server compact reveal (non-alert work events: session start, tool use, etc.)
|
||
NotificationCenter.default.addObserver(forName: .hookReveal, object: nil, queue: .main) { [weak self] _ in
|
||
guard let self else { return }
|
||
self.fsm.reveal()
|
||
}
|
||
|
||
// Music started playing: reveal silently (no peek sound)
|
||
NotificationCenter.default.addObserver(forName: .musicReveal, object: nil, queue: .main) { [weak self] _ in
|
||
guard let self else { return }
|
||
self.silentNextReveal = true
|
||
self.fsm.reveal()
|
||
self.silentNextReveal = false
|
||
}
|
||
|
||
// Collapse requests from views (OK button, etc.)
|
||
NotificationCenter.default.addObserver(forName: .islandCollapse, object: nil, queue: .main) { [weak self] note in
|
||
let byUser = (note.object as? String) == "user"
|
||
MainActor.assumeIsolated { self?.collapse(byUser: byUser) }
|
||
}
|
||
|
||
// Wardrobe open/close from desktop Mochi right-click (does NOT post .hookExpand)
|
||
NotificationCenter.default.addObserver(forName: .openWardrobeFromDesktop, object: nil, queue: .main) { [weak self] _ in
|
||
guard let self else { return }
|
||
if self.state.mode == .expanded && self.state.view == .wardrobe {
|
||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||
self.state.view = .overview
|
||
}
|
||
} else {
|
||
self.expand(to: .wardrobe)
|
||
}
|
||
}
|
||
|
||
// .botDizzy — posted by BotEngine.slap() on 3rd hit; show confused view + recover after 3.3s
|
||
NotificationCenter.default.addObserver(forName: .botDizzy, object: nil, queue: .main) { [weak self] _ in
|
||
self?.handleDizzy()
|
||
}
|
||
|
||
// Window attach drag.
|
||
// Uses MainActor.assumeIsolated (synchronous) to avoid race with pollFrame().
|
||
// Global mouseUp is the reliable fallback when cursor is outside our panel frame.
|
||
NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
|
||
guard let self else { return event }
|
||
MainActor.assumeIsolated {
|
||
guard self.wasInIsland else { return }
|
||
self.pendingIslandClick = true
|
||
self.hoverTimer?.cancel()
|
||
self.botHoverTimer?.cancel()
|
||
self.botHovering = false
|
||
// Drag only starts when clicking directly on the bot head
|
||
guard self.isBotHit(event.locationInWindow) else { return }
|
||
// Notch Mochi is invisible when on desktop — no drag, no slap
|
||
guard !self.state.mochiOnDesktop else { return }
|
||
self.attachDragStart = NSEvent.mouseLocation
|
||
// Post slap only when expanded
|
||
guard self.state.mode == .expanded else { return }
|
||
NotificationCenter.default.post(name: .triggerSlap, object: nil)
|
||
}
|
||
return event
|
||
}
|
||
NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { [weak self] event in
|
||
guard let self else { return event }
|
||
MainActor.assumeIsolated {
|
||
guard let start = self.attachDragStart, !self.inAttachDrag else { return }
|
||
let m = NSEvent.mouseLocation
|
||
guard hypot(m.x - start.x, m.y - start.y) > 3 else { return }
|
||
self.inAttachDrag = true
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.love)
|
||
self.showDragGhost()
|
||
}
|
||
return event
|
||
}
|
||
|
||
// mouseUp — local (cursor still in panel) + global (cursor moved outside panel frame)
|
||
let finishDrag: @Sendable () -> Void = { [weak self] in
|
||
Task { @MainActor in
|
||
guard let self, self.inAttachDrag else { return }
|
||
let mouse = NSEvent.mouseLocation
|
||
self.inAttachDrag = false
|
||
self.attachDragStart = nil
|
||
self.state.stateOverride = nil
|
||
|
||
#if !APPSTORE
|
||
let windowCtx = self.windowContextAtPoint(mouse)
|
||
let inNotchZone = self.window?.frame.contains(mouse) == true
|
||
|
||
if let ctx = windowCtx {
|
||
// Drop on a window → attach context as before
|
||
self.hideDragGhost()
|
||
self.state.promptContext = ctx
|
||
SoundEngine.shared.play("approve")
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
|
||
self.expand(to: .prompt)
|
||
} else if !inNotchZone {
|
||
// Drop outside notch zone → install Mochi on the desktop.
|
||
// Prevent hideDragGhost from closing the ghost panel so we can promote it.
|
||
let ghost = self.dragGhostPanel
|
||
self.dragGhostPanel = nil // nil first so hideDragGhost skips close
|
||
self.hideDragGhost() // resets isDraggingBot, closes highlight panel
|
||
DesktopMochiController.shared.install(ghostPanel: ghost, at: mouse)
|
||
} else {
|
||
// Drop back in notch zone → Mochi returns to notch
|
||
self.hideDragGhost()
|
||
}
|
||
#else
|
||
let inNotchZoneAS = self.window?.frame.contains(mouse) == true
|
||
if !inNotchZoneAS {
|
||
let ghost = self.dragGhostPanel
|
||
self.dragGhostPanel = nil
|
||
self.hideDragGhost()
|
||
DesktopMochiController.shared.install(ghostPanel: ghost, at: mouse)
|
||
} else {
|
||
self.hideDragGhost()
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in
|
||
guard let self else { return event }
|
||
MainActor.assumeIsolated {
|
||
let hadPendingClick = self.pendingIslandClick
|
||
let wasDragging = self.inAttachDrag
|
||
self.pendingIslandClick = false
|
||
if wasDragging {
|
||
finishDrag()
|
||
} else {
|
||
self.attachDragStart = nil
|
||
if hadPendingClick && self.state.mode != .expanded {
|
||
if self.fsm.state == .home {
|
||
// FSM already thinks it's open (e.g. the view folded it): just reopen.
|
||
self.expand(to: self.defaultView())
|
||
} else {
|
||
self.fsm.click() // FSM petit/hidden→home; onTransition calls expand(to:)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return event
|
||
}
|
||
NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { _ in
|
||
finishDrag()
|
||
}
|
||
|
||
NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { [weak self] event in
|
||
guard let self else { return event }
|
||
MainActor.assumeIsolated {
|
||
guard self.wasInIsland, self.isBotHit(event.locationInWindow) else { return }
|
||
guard !self.state.mochiOnDesktop else { return }
|
||
if self.state.mode == .expanded && self.state.view == .wardrobe {
|
||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||
self.state.view = .overview
|
||
}
|
||
} else {
|
||
self.expand(to: .wardrobe)
|
||
}
|
||
}
|
||
return event
|
||
}
|
||
|
||
// Track last external app for window context capture
|
||
let ourBundle = Bundle.main.bundleIdentifier ?? ""
|
||
NSWorkspace.shared.notificationCenter.addObserver(
|
||
forName: NSWorkspace.didActivateApplicationNotification,
|
||
object: nil, queue: .main
|
||
) { [weak self] note in
|
||
guard let self else { return }
|
||
if let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||
app.bundleIdentifier != ourBundle {
|
||
self.state.lastExternalApp = app
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Drag ghost window (Mochi follows cursor during drag)
|
||
|
||
private func showDragGhost() {
|
||
guard dragGhostPanel == nil else { return }
|
||
// Same size as compact bot: diameter=20 → canvasSize≈33, scale 2× for grab comfort
|
||
let canvasSize: CGFloat = 40 / 0.6 // ~67
|
||
dragGhostSize = canvasSize
|
||
|
||
let mouse = NSEvent.mouseLocation
|
||
let s = dragGhostSize
|
||
ghostCurrentOrigin = NSPoint(x: mouse.x - s/2, y: mouse.y - s/2)
|
||
|
||
let panel = NSPanel(
|
||
contentRect: NSRect(x: ghostCurrentOrigin.x, y: ghostCurrentOrigin.y, width: s, height: s),
|
||
styleMask: [.borderless, .nonactivatingPanel],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
panel.backgroundColor = .clear
|
||
panel.isOpaque = false
|
||
panel.hasShadow = false
|
||
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 4)
|
||
panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
|
||
panel.ignoresMouseEvents = true
|
||
|
||
let hosting = NSHostingView(
|
||
rootView: GhostBotView(canvasSize: canvasSize)
|
||
)
|
||
hosting.frame = NSRect(x: 0, y: 0, width: s, height: s)
|
||
panel.contentView = hosting
|
||
panel.alphaValue = 0
|
||
panel.orderFront(nil)
|
||
dragGhostPanel = panel
|
||
AppState.shared.isDraggingBot = true
|
||
|
||
// Fade + scale-in handled by GhostBotView SwiftUI animation;
|
||
// also fade in the window itself for extra smoothness
|
||
NSAnimationContext.runAnimationGroup { ctx in
|
||
ctx.duration = 0.18
|
||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||
panel.animator().alphaValue = 1
|
||
}
|
||
}
|
||
|
||
private func hideDragGhost() {
|
||
dragGhostPanel?.close()
|
||
dragGhostPanel = nil
|
||
highlightPanel?.close()
|
||
highlightPanel = nil
|
||
highlightWindowPid = 0
|
||
AppState.shared.isDraggingBot = false
|
||
}
|
||
|
||
private func updateDragGhost() {
|
||
guard let panel = dragGhostPanel else { return }
|
||
let s = dragGhostSize
|
||
let mouse = NSEvent.mouseLocation
|
||
// Direct follow — bot is "held", no trailing lag
|
||
ghostCurrentOrigin = NSPoint(x: mouse.x - s/2, y: mouse.y - s/2)
|
||
panel.setFrameOrigin(ghostCurrentOrigin)
|
||
}
|
||
|
||
// MARK: - Window highlight overlay (white border on target window during drag)
|
||
|
||
private func updateWindowHighlight() {
|
||
let mouse = NSEvent.mouseLocation
|
||
guard let (appKitBounds, pid) = windowBoundsAtScreenPoint(mouse) else {
|
||
// Fade out + close if no window under cursor
|
||
if let old = highlightPanel {
|
||
let captured = old
|
||
highlightPanel = nil
|
||
highlightWindowPid = 0
|
||
NSAnimationContext.runAnimationGroup({ ctx in
|
||
ctx.duration = 0.12
|
||
ctx.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||
captured.animator().alphaValue = 0
|
||
}, completionHandler: { captured.close() })
|
||
}
|
||
return
|
||
}
|
||
|
||
if pid == highlightWindowPid, let existing = highlightPanel {
|
||
// Same window — just track position (windows rarely move, instant is fine)
|
||
existing.setFrame(appKitBounds, display: false)
|
||
} else {
|
||
// New window — close old immediately, fade-in new
|
||
highlightPanel?.close()
|
||
highlightPanel = nil
|
||
highlightWindowPid = pid
|
||
|
||
let panel = NSPanel(
|
||
contentRect: appKitBounds,
|
||
styleMask: [.borderless, .nonactivatingPanel],
|
||
backing: .buffered, defer: false
|
||
)
|
||
panel.backgroundColor = .clear
|
||
panel.isOpaque = false
|
||
panel.hasShadow = false
|
||
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 2)
|
||
panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
|
||
panel.ignoresMouseEvents = true
|
||
|
||
let hosting = NSHostingView(rootView:
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.stroke(Color.white.opacity(0.75), lineWidth: 3)
|
||
.shadow(color: Color.white.opacity(0.5), radius: 16)
|
||
.padding(2)
|
||
.ignoresSafeArea()
|
||
)
|
||
hosting.frame = CGRect(origin: .zero, size: appKitBounds.size)
|
||
hosting.autoresizingMask = [.width, .height]
|
||
panel.contentView = hosting
|
||
panel.alphaValue = 0
|
||
panel.orderFront(nil)
|
||
highlightPanel = panel
|
||
|
||
NSAnimationContext.runAnimationGroup { ctx in
|
||
ctx.duration = 0.14
|
||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||
panel.animator().alphaValue = 1
|
||
}
|
||
}
|
||
}
|
||
|
||
private func windowBoundsAtScreenPoint(_ screenPoint: NSPoint) -> (CGRect, pid_t)? {
|
||
guard let screen = window?.screen ?? NSScreen.main else { return nil }
|
||
let screenMaxY = screen.frame.maxY
|
||
let cgPoint = CGPoint(x: screenPoint.x, y: screenMaxY - screenPoint.y)
|
||
|
||
guard let list = CGWindowListCopyWindowInfo(
|
||
[.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
|
||
) as? [[String: Any]] else { return nil }
|
||
|
||
let ourBundle = Bundle.main.bundleIdentifier ?? ""
|
||
for info in list {
|
||
guard let b = info[kCGWindowBounds as String] as? [String: Any],
|
||
let x = b["X"] as? CGFloat, let y = b["Y"] as? CGFloat,
|
||
let w = b["Width"] as? CGFloat, let h = b["Height"] as? CGFloat else { continue }
|
||
guard CGRect(x: x, y: y, width: w, height: h).contains(cgPoint) else { continue }
|
||
let pid = info[kCGWindowOwnerPID as String] as? pid_t ?? 0
|
||
guard let app = NSRunningApplication(processIdentifier: pid),
|
||
app.bundleIdentifier != ourBundle,
|
||
app.activationPolicy == .regular else { continue }
|
||
// CG → AppKit: flip Y
|
||
return (CGRect(x: x, y: screenMaxY - y - h, width: w, height: h), pid)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MARK: - Window context at screen point (for drag-attach)
|
||
|
||
func windowContextAtPoint(_ screenPoint: NSPoint) -> PromptContext? {
|
||
let screen = window?.screen ?? NSScreen.main
|
||
// CGWindowList uses top-left origin; NSEvent.mouseLocation uses bottom-left
|
||
let screenMaxY = screen?.frame.maxY ?? NSScreen.main!.frame.maxY
|
||
let cgPoint = CGPoint(x: screenPoint.x, y: screenMaxY - screenPoint.y)
|
||
|
||
guard let windowList = CGWindowListCopyWindowInfo(
|
||
[.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
|
||
) as? [[String: Any]] else { return nil }
|
||
|
||
let ourBundle = Bundle.main.bundleIdentifier ?? ""
|
||
|
||
for info in windowList {
|
||
guard let b = info[kCGWindowBounds as String] as? [String: Any],
|
||
let x = b["X"] as? CGFloat, let y = b["Y"] as? CGFloat,
|
||
let w = b["Width"] as? CGFloat, let h = b["Height"] as? CGFloat else { continue }
|
||
guard CGRect(x: x, y: y, width: w, height: h).contains(cgPoint) else { continue }
|
||
|
||
let pid = info[kCGWindowOwnerPID as String] as? pid_t ?? 0
|
||
guard let app = NSRunningApplication(processIdentifier: pid),
|
||
app.bundleIdentifier != ourBundle,
|
||
app.activationPolicy == .regular else { continue }
|
||
|
||
return WindowContextCapture.captureActive(from: app)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MARK: - Coordinate conversion: window (AppKit, y-up) → island coords (y-down, 0,0 = island top-left)
|
||
|
||
func windowToIsland(_ loc: CGPoint) -> CGPoint {
|
||
let panelH = window?.frame.height ?? 320
|
||
let panelW = window?.frame.width ?? 720
|
||
let islandLeft = (panelW - IslandConst.expandedWidth) / 2
|
||
// Island is glued to panel top; its bottom in AppKit = panelH - 176
|
||
return CGPoint(
|
||
x: loc.x - islandLeft,
|
||
y: panelH - loc.y // AppKit y is from bottom; island y from top
|
||
)
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
func defaultView() -> IslandView {
|
||
if state.pendingApproval != nil { return .approval }
|
||
return state.tasks.isEmpty ? .empty : .overview
|
||
}
|
||
|
||
func baseMode() -> IslandMode {
|
||
guard state.isPresent else { return .hidden }
|
||
return state.tasks.isEmpty ? .hidden : .compact
|
||
}
|
||
|
||
// MARK: - Activity reset (call on any user interaction in island)
|
||
|
||
func resetActivity() {
|
||
state.lastActivity = .now
|
||
}
|
||
|
||
// MARK: - Finished task pin (5.2s)
|
||
|
||
func pinForFinished(taskId: String) {
|
||
state.isPinned = true
|
||
finishedPinTimer?.cancel()
|
||
let item = DispatchWorkItem { [weak self] in
|
||
guard let self else { return }
|
||
self.state.removeTask(id: taskId)
|
||
self.state.isPinned = false
|
||
self.collapse()
|
||
}
|
||
finishedPinTimer = item
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.2, execute: item)
|
||
}
|
||
|
||
// MARK: - Dizzy recovery (triggered by BotEngine.slap via .botDizzy)
|
||
|
||
private func handleDizzy() {
|
||
let prevView = state.view
|
||
state.stateOverride = .dizzy
|
||
expand(to: .confused)
|
||
confusedRecoveryTimer?.cancel()
|
||
let recovery = DispatchWorkItem { [weak self] in
|
||
guard let self else { return }
|
||
self.state.stateOverride = nil
|
||
if self.state.view == .confused {
|
||
let fallback = self.state.tasks.isEmpty ? IslandView.empty : .overview
|
||
self.state.view = (prevView == .confused) ? fallback : prevView
|
||
}
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
|
||
}
|
||
confusedRecoveryTimer = recovery
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.3, execute: recovery)
|
||
}
|
||
|
||
// MARK: - Bot hit test (for slap trigger)
|
||
|
||
private func isBotHit(_ windowPoint: CGPoint) -> Bool {
|
||
let s = AppState.shared
|
||
let panelH = window?.frame.height ?? 320
|
||
let panelW = window?.frame.width ?? 720
|
||
let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view,
|
||
progress: s.uploadProgress, nw: notchW, nh: notchH)
|
||
// Chat view resizes dynamically — AppState.chatIslandHeight
|
||
let islandH: CGFloat
|
||
if s.mode == .expanded && s.view.isTall {
|
||
islandH = s.chatIslandHeight
|
||
} else {
|
||
islandH = fixedH
|
||
}
|
||
let islandMinX = (panelW - islandW) / 2
|
||
let (cx, cy, diameter, _) = botPosition(mode: s.mode, view: s.view,
|
||
islandW: islandW, islandH: islandH,
|
||
uploadProgress: s.uploadProgress, hasNotch: s.hasNotch)
|
||
let radius = (diameter / 0.6) / 2
|
||
// botPosition cy is from island TOP; panel AppKit coords have y=0 at bottom
|
||
// island top in AppKit coords = panelH (island glued to top of panel/screen)
|
||
let botX = islandMinX + cx
|
||
let botY = panelH - cy
|
||
let dx = windowPoint.x - botX
|
||
let dy = windowPoint.y - botY
|
||
return dx*dx + dy*dy <= radius * radius
|
||
}
|
||
|
||
// MARK: - Notch detection (static)
|
||
|
||
static func notchScreen() -> NSScreen? {
|
||
NSScreen.screens.first { $0.safeAreaInsets.top > 0 }
|
||
}
|
||
|
||
static func screenGeometry(for screen: NSScreen) -> IslandScreenGeometry {
|
||
let visibleMenuBarHeight = screen.frame.maxY - screen.visibleFrame.maxY
|
||
// visibleFrame includes the menu bar only while it is visible. Keep a
|
||
// small resting bar when menus auto-hide or the app is in full screen.
|
||
let menuBarHeight = visibleMenuBarHeight > 0
|
||
? visibleMenuBarHeight : NSStatusBar.system.thickness
|
||
return IslandScreenGeometry(
|
||
screenWidth: screen.frame.width, safeAreaTop: screen.safeAreaInsets.top,
|
||
auxiliaryLeftWidth: screen.auxiliaryTopLeftArea?.width,
|
||
auxiliaryRightWidth: screen.auxiliaryTopRightArea?.width,
|
||
menuBarHeight: menuBarHeight
|
||
)
|
||
}
|
||
|
||
nonisolated func cleanup() {
|
||
// Called explicitly before release if needed
|
||
}
|
||
}
|
||
|
||
// MARK: - IslandPanel
|
||
|
||
final class IslandPanel: NSPanel {
|
||
var notchWidth: CGFloat = IslandConst.notchWidth
|
||
var notchHeight: CGFloat = IslandConst.notchHeight
|
||
|
||
override var canBecomeKey: Bool { true }
|
||
override var canBecomeMain: Bool { false }
|
||
|
||
/// Allow panel to sit in the menu bar / notch area — don't let macOS push it down.
|
||
override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect {
|
||
return frameRect
|
||
}
|
||
|
||
func currentIslandFrame(nw: CGFloat, nh: CGFloat) -> CGRect {
|
||
let s = AppState.shared
|
||
let (w, fixedH) = islandSize(mode: s.mode, view: s.view,
|
||
progress: s.uploadProgress, nw: nw, nh: nh)
|
||
let h: CGFloat
|
||
if s.mode == .expanded && s.view.isTall {
|
||
h = s.chatIslandHeight
|
||
} else {
|
||
h = fixedH
|
||
}
|
||
return CGRect(x: (frame.width - w) / 2, y: frame.height - h, width: w, height: h)
|
||
}
|
||
}
|
||
|
||
// MARK: - Ghost bot view (animated scale-in on appear)
|
||
|
||
struct GhostBotView: View {
|
||
let canvasSize: CGFloat
|
||
@State private var scale: CGFloat = 0.35
|
||
|
||
var body: some View {
|
||
BotCanvasView(state: AppState.shared)
|
||
.frame(width: canvasSize, height: canvasSize)
|
||
.scaleEffect(scale)
|
||
.onAppear {
|
||
withAnimation(.spring(response: 0.28, dampingFraction: 0.55)) {
|
||
scale = 1.0
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Notification names
|
||
|
||
extension Notification.Name {
|
||
static let triggerEmote = Notification.Name("notchBuddy.triggerEmote")
|
||
static let triggerSlap = Notification.Name("notchBuddy.triggerSlap")
|
||
static let botDizzy = Notification.Name("notchBuddy.botDizzy")
|
||
static let botGreet = Notification.Name("notchBuddy.botGreet")
|
||
static let botBlink = Notification.Name("notchBuddy.botBlink")
|
||
static let botSetTgEs = Notification.Name("notchBuddy.botSetTgEs")
|
||
static let botGulp = Notification.Name("notchBuddy.botGulp")
|
||
static let botMorphTo = Notification.Name("notchBuddy.botMorphTo")
|
||
static let islandAction = Notification.Name("notchBuddy.islandAction")
|
||
static let islandCollapse = Notification.Name("notchBuddy.islandCollapse")
|
||
static let islandSendMessage = Notification.Name("notchBuddy.islandSendMessage")
|
||
static let islandNewConversation = Notification.Name("notchBuddy.islandNewConversation")
|
||
static let islandToggleDiff = Notification.Name("notchBuddy.islandToggleDiff")
|
||
static let islandActivateCardSelection = Notification.Name("notchBuddy.islandActivateCardSelection")
|
||
static let openFullSettings = Notification.Name("notchBuddy.openFullSettings")
|
||
static let hookReveal = Notification.Name("notchBuddy.hookReveal")
|
||
static let musicReveal = Notification.Name("notchBuddy.musicReveal")
|
||
// Greeting ↔ IslandWindowController
|
||
static let greetComplete = Notification.Name("notchBuddy.greetComplete")
|
||
static let greetingHover = Notification.Name("notchBuddy.greetingHover")
|
||
static let greetingInterrupt = Notification.Name("notchBuddy.greetingInterrupt")
|
||
static let openWardrobeFromDesktop = Notification.Name("notchBuddy.openWardrobeFromDesktop")
|
||
}
|
||
|
||
// MARK: - islandSize (takes real notch dimensions)
|
||
|
||
@MainActor
|
||
func islandSize(mode: IslandMode, view: IslandView,
|
||
progress: Double = 0,
|
||
nw: CGFloat = IslandConst.notchWidth,
|
||
nh: CGFloat = IslandConst.notchHeight) -> (CGFloat, CGFloat) {
|
||
switch mode {
|
||
case .hidden: return (nw, nh)
|
||
case .compact: return (nw + 160, nh)
|
||
case .expanded:
|
||
let layout = IslandConst.viewLayouts[view]!
|
||
// BroV: the chat has its own width (Settings → Чат).
|
||
if view.isTall { return (AppState.shared.chatWidth, layout.height) }
|
||
return (IslandConst.expandedWidth, layout.height)
|
||
}
|
||
}
|