53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
6.5 KiB
Swift
185 lines
6.5 KiB
Swift
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). Override for debug.
|
||
var homeToPetitDelay: TimeInterval = 15
|
||
/// 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)
|
||
}
|
||
|
||
}
|