import Foundation /// Pure 4-state FSM for island open/close logic. /// No AppKit / AppState dependencies — communicates via `onTransition`. @MainActor final class IslandStateMachine { enum State: Equatable { case hidden // island invisible (notch size) case petit // compact island (notch + ears) case home // expanded, overview case coucou // expanded, greeting animation } private(set) var state: State = .hidden /// Fired on every transition: (from, to) var onTransition: ((State, State) -> Void)? /// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island. var isHeldOpen: (() -> Bool)? /// home → petit delay (seconds), kept in sync with the auto-close preference. var homeToPetitDelay: TimeInterval = 15 { didSet { guard homeToPetitDelay != oldValue, state == .home, homeCollapseWork != nil else { return } scheduleHomeCollapse() } } /// petit → hidden delay (seconds). Override for debug. var petitToHiddenDelay: TimeInterval = 60 /// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse. var greetAutoCollapseDelay: TimeInterval = 0.6 /// coucou → petit delay when mouse is hovering over the greeting. var greetHoverCollapseDelay: TimeInterval = 10 private var petitHideWork: DispatchWorkItem? private var homeCollapseWork: DispatchWorkItem? private var greetCollapseWork: DispatchWorkItem? // MARK: – Inputs /// App launched or debug "launch greeting" func launch() { cancelTimers() transition(to: .coucou) } /// Mouse entered the island notch area func mouseEntered() { switch state { case .hidden: if isHeldOpen?() == true { // Island already expanded by an external call — sync FSM state without transition state = .home } else { cancelTimers() transition(to: .petit) } case .petit: petitHideWork?.cancel() petitHideWork = nil case .home: homeCollapseWork?.cancel() homeCollapseWork = nil case .coucou: // Mouse hovering during greeting — cancel short auto-collapse, extend to hover delay scheduleGreetCollapse(delay: greetHoverCollapseDelay) } } /// Mouse left the island notch area func mouseLeft() { switch state { case .hidden: break case .petit: schedulePetitHide() case .home: if isHeldOpen?() != true { scheduleHomeCollapse() } case .coucou: if isHeldOpen?() != true { // Interrupt greeting immediately → compact (overrides 10s auto-collapse) greetCollapseWork?.cancel(); greetCollapseWork = nil transition(to: .petit) } } } /// Compact island clicked. /// Also accepts `.hidden`: after an alert the island can be on screen while the /// FSM never saw the mouse enter (it was already there), and the click must still open it. func click() { guard state == .petit || state == .hidden else { return } cancelTimers() transition(to: .home) } /// The app hid the island on its own (e.g. `AppState.syncMode()` when the last /// task ends). Mirror it without side effects, so the next hover peeks again /// instead of being swallowed by a FSM that still thinks the island is `.petit`. func hiddenExternally() { guard state == .petit else { return } cancelTimers() state = .hidden } /// The app expanded the island externally (hookExpand for an alert). /// Cancel timers and sync state to `.home` without firing `onTransition`, so the /// next hover/mouseLeft behave correctly instead of collapsing the island. func openedExternally() { cancelTimers() guard state != .home && state != .coucou else { return } state = .home } /// The app folded the island itself (Escape, Settings, OK button, auto-close). /// Move to `.petit` right away so hover and click keep working; waiting for the /// 15 s home timer left the island compact on screen while the FSM still said `.home`. func collapse() { guard state == .home || state == .coucou else { return } cancelTimers() transition(to: .petit) } /// Greeting animation finished (called at T.end ≈ 4.60 s). /// Schedules auto-collapse. Does not override a longer hover timer already running. func greetComplete() { guard state == .coucou else { return } // If mouse entered before this fires (hover timer already running), don't override it if greetCollapseWork == nil { scheduleGreetCollapse(delay: greetAutoCollapseDelay) } } private func scheduleGreetCollapse(delay: TimeInterval) { greetCollapseWork?.cancel() let item = DispatchWorkItem { [weak self] in guard let self, self.state == .coucou else { return } self.transition(to: .petit) } greetCollapseWork = item DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) } /// Non-alert work event: show compact from hidden (HookServer reveal) func reveal() { guard state == .hidden else { return } cancelTimers() transition(to: .petit) schedulePetitHide() } // MARK: – Timers private func schedulePetitHide() { petitHideWork?.cancel() let item = DispatchWorkItem { [weak self] in guard let self, self.state == .petit, !(self.isHeldOpen?() ?? false) else { return } self.transition(to: .hidden) } petitHideWork = item DispatchQueue.main.asyncAfter(deadline: .now() + petitToHiddenDelay, execute: item) } private func scheduleHomeCollapse() { homeCollapseWork?.cancel() let item = DispatchWorkItem { [weak self] in guard let self, self.state == .home, !(self.isHeldOpen?() ?? false) else { return } self.transition(to: .petit) } homeCollapseWork = item DispatchQueue.main.asyncAfter(deadline: .now() + homeToPetitDelay, execute: item) } func cancelTimers() { petitHideWork?.cancel(); petitHideWork = nil homeCollapseWork?.cancel(); homeCollapseWork = nil greetCollapseWork?.cancel(); greetCollapseWork = nil } private func transition(to new: State) { guard new != state else { return } let old = state state = new onTransition?(old, new) } }