Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user