diff --git a/LilAgents/CharacterPack.swift b/LilAgents/CharacterPack.swift new file mode 100644 index 0000000..a44b979 --- /dev/null +++ b/LilAgents/CharacterPack.swift @@ -0,0 +1,135 @@ +import AppKit + +struct CharacterConfig { + let videoName: String + let displayName: String + let accelStart: CFTimeInterval + let fullSpeedStart: CFTimeInterval + let decelStart: CFTimeInterval + let walkStop: CFTimeInterval + let walkAmountRange: ClosedRange + let yOffset: CGFloat + let flipXOffset: CGFloat + let characterColor: NSColor + let initialPosition: CGFloat + let initialPauseRange: ClosedRange +} + +struct CharacterPack { + let id: String + let name: String + let characters: [CharacterConfig] + let thinkingPhrases: [String] + let completionPhrases: [String] + let onboardingGreeting: String + let onboardingWelcome: String + + // MARK: - Presets + + static let `default` = CharacterPack( + id: "default", + name: "Bruce & Jazz", + characters: [ + CharacterConfig( + videoName: "walk-bruce-01", displayName: "Bruce", + accelStart: 3.0, fullSpeedStart: 3.75, decelStart: 8.0, walkStop: 8.5, + walkAmountRange: 0.4...0.65, yOffset: -3, flipXOffset: 0, + characterColor: NSColor(red: 0.4, green: 0.72, blue: 0.55, alpha: 1.0), + initialPosition: 0.3, initialPauseRange: 0.5...2.0 + ), + CharacterConfig( + videoName: "walk-jazz-01", displayName: "Jazz", + accelStart: 3.9, fullSpeedStart: 4.5, decelStart: 8.0, walkStop: 8.75, + walkAmountRange: 0.35...0.6, yOffset: -7, flipXOffset: -9, + characterColor: NSColor(red: 1.0, green: 0.4, blue: 0.0, alpha: 1.0), + initialPosition: 0.7, initialPauseRange: 8.0...14.0 + ), + ], + thinkingPhrases: [ + "hmm...", "thinking...", "one sec...", "ok hold on", + "let me check", "working on it", "almost...", "bear with me", + "on it!", "gimme a sec", "brb", "processing...", + "hang tight", "just a moment", "figuring it out", + "crunching...", "reading...", "looking..." + ], + completionPhrases: [ + "done!", "all set!", "ready!", "here you go", "got it!", + "finished!", "ta-da!", "voila!" + ], + onboardingGreeting: "hi!", + onboardingWelcome: """ + hey! we're bruce and jazz — your lil dock agents. + + click either of us to open a Claude AI chat. we'll walk around while you work and let you know when Claude's thinking. + + check the menu bar icon (top right) for themes, sounds, and more options. + + click anywhere outside to dismiss, then click us again to start chatting. + """ + ) + + static let droids = CharacterPack( + id: "droids", + name: "Droids", + characters: [ + CharacterConfig( + videoName: "walk-r2d2-01", displayName: "R2-Do2", + accelStart: 3.0, fullSpeedStart: 3.75, decelStart: 8.0, walkStop: 8.5, + walkAmountRange: 0.4...0.65, yOffset: 30, flipXOffset: 0, + characterColor: NSColor(red: 0.2, green: 0.5, blue: 0.85, alpha: 1.0), + initialPosition: 0.2, initialPauseRange: 0.5...2.0 + ), + CharacterConfig( + videoName: "walk-c3po-01", displayName: "C-3POa", + accelStart: 3.9, fullSpeedStart: 4.5, decelStart: 8.0, walkStop: 8.75, + walkAmountRange: 0.35...0.6, yOffset: 30, flipXOffset: -9, + characterColor: NSColor(red: 0.85, green: 0.72, blue: 0.2, alpha: 1.0), + initialPosition: 0.5, initialPauseRange: 8.0...14.0 + ), + CharacterConfig( + videoName: "walk-bb8-01", displayName: "BB-Gr8", + accelStart: 3.0, fullSpeedStart: 3.75, decelStart: 8.0, walkStop: 8.5, + walkAmountRange: 0.45...0.7, yOffset: 30, flipXOffset: 0, + characterColor: NSColor(red: 0.92, green: 0.5, blue: 0.15, alpha: 1.0), + initialPosition: 0.8, initialPauseRange: 4.0...8.0 + ), + ], + thinkingPhrases: [ + "boop beep...", "searching feelings...", "consulting the Force...", "hold on...", + "recalculating hyperspace...", "scanning...", "almost there...", "patience you must have", + "on it, master!", "standby...", "computing...", "processing...", + "stay on target...", "one moment...", "the Force is strong...", + "beep bwoop...", "accessing archives...", "calculating odds..." + ], + completionPhrases: [ + "the Force is with you!", "mission complete!", "ready, master!", "these are the droids!", + "done it is!", "I have a good feeling!", "roger roger!", "may the Force..." + ], + onboardingGreeting: "beep boop!", + onboardingWelcome: """ + greetings! we're R2-Do2, C-3POa, and BB-Gr8 — your lil dock droids. + + click any of us to open a Claude AI chat. we'll patrol the dock while you work and let you know when the Force is computing. + + check the menu bar icon (top right) for themes, sounds, and more options. + + click anywhere outside to dismiss, then click us again to start chatting. may the Force be with you! + """ + ) + + static let allPacks: [CharacterPack] = [.default, .droids] + static var current: CharacterPack = .default + + // MARK: - Persistence + + private static let userDefaultsKey = "selectedCharacterPack" + + static func loadSaved() -> CharacterPack { + let id = UserDefaults.standard.string(forKey: userDefaultsKey) ?? "default" + return allPacks.first { $0.id == id } ?? .default + } + + static func saveCurrent() { + UserDefaults.standard.set(current.id, forKey: userDefaultsKey) + } +} diff --git a/LilAgents/LilAgentsApp.swift b/LilAgents/LilAgentsApp.swift index 8540e69..3cd1dd6 100644 --- a/LilAgents/LilAgentsApp.swift +++ b/LilAgents/LilAgentsApp.swift @@ -30,20 +30,22 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Menu Bar func setupMenuBar() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + if statusItem == nil { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + } if let button = statusItem?.button { button.image = NSImage(named: "MenuBarIcon") ?? NSImage(systemSymbolName: "figure.walk", accessibilityDescription: "lil agents") } let menu = NSMenu() - let char1Item = NSMenuItem(title: "Bruce", action: #selector(toggleChar1), keyEquivalent: "1") - char1Item.state = .on - menu.addItem(char1Item) - - let char2Item = NSMenuItem(title: "Jazz", action: #selector(toggleChar2), keyEquivalent: "2") - char2Item.state = .on - menu.addItem(char2Item) + // Dynamic character toggle items + for (i, config) in CharacterPack.current.characters.enumerated() { + let item = NSMenuItem(title: config.displayName, action: #selector(toggleCharacter(_:)), keyEquivalent: "\(i + 1)") + item.tag = i + item.state = .on + menu.addItem(item) + } menu.addItem(NSMenuItem.separator()) @@ -63,6 +65,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { themeItem.submenu = themeMenu menu.addItem(themeItem) + // Character pack submenu + let packItem = NSMenuItem(title: "Characters", action: nil, keyEquivalent: "") + let packMenu = NSMenu() + for (i, pack) in CharacterPack.allPacks.enumerated() { + let item = NSMenuItem(title: pack.name, action: #selector(switchPack(_:)), keyEquivalent: "") + item.tag = i + item.state = pack.id == CharacterPack.current.id ? .on : .off + packMenu.addItem(item) + } + packItem.submenu = packMenu + menu.addItem(packItem) + // Display submenu let displayItem = NSMenuItem(title: "Display", action: nil, keyEquivalent: "") let displayMenu = NSMenu() @@ -130,6 +144,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @objc func switchPack(_ sender: NSMenuItem) { + let idx = sender.tag + guard idx < CharacterPack.allPacks.count else { return } + + controller?.switchPack(CharacterPack.allPacks[idx]) + + // Rebuild menu to reflect new character names + setupMenuBar() + } + @objc func switchDisplay(_ sender: NSMenuItem) { let idx = sender.tag controller?.pinnedScreenIndex = idx @@ -141,22 +165,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - @objc func toggleChar1(_ sender: NSMenuItem) { - guard let chars = controller?.characters, chars.count > 0 else { return } - let char = chars[0] - if char.window.isVisible { - char.window.orderOut(nil) - char.queuePlayer.pause() - sender.state = .off - } else { - char.window.orderFrontRegardless() - sender.state = .on - } - } - - @objc func toggleChar2(_ sender: NSMenuItem) { - guard let chars = controller?.characters, chars.count > 1 else { return } - let char = chars[1] + @objc func toggleCharacter(_ sender: NSMenuItem) { + let idx = sender.tag + guard let chars = controller?.characters, idx < chars.count else { return } + let char = chars[idx] if char.window.isVisible { char.window.orderOut(nil) char.queuePlayer.pause() diff --git a/LilAgents/LilAgentsController.swift b/LilAgents/LilAgentsController.swift index 0f56934..2bbead7 100644 --- a/LilAgents/LilAgentsController.swift +++ b/LilAgents/LilAgentsController.swift @@ -8,38 +8,8 @@ class LilAgentsController { private static let onboardingKey = "hasCompletedOnboarding" func start() { - let char1 = WalkerCharacter(videoName: "walk-bruce-01") - char1.accelStart = 3.0 - char1.fullSpeedStart = 3.75 - char1.decelStart = 8.0 - char1.walkStop = 8.5 - char1.walkAmountRange = 0.4...0.65 - - let char2 = WalkerCharacter(videoName: "walk-jazz-01") - char2.accelStart = 3.9 - char2.fullSpeedStart = 4.5 - char2.decelStart = 8.0 - char2.walkStop = 8.75 - char2.walkAmountRange = 0.35...0.6 - char1.yOffset = -3 - char2.yOffset = -7 - char1.characterColor = NSColor(red: 0.4, green: 0.72, blue: 0.55, alpha: 1.0) - char2.characterColor = NSColor(red: 1.0, green: 0.4, blue: 0.0, alpha: 1.0) - - char1.flipXOffset = 0 - char2.flipXOffset = -9 - - char1.positionProgress = 0.3 - char2.positionProgress = 0.7 - - char1.pauseEndTime = CACurrentMediaTime() + Double.random(in: 0.5...2.0) - char2.pauseEndTime = CACurrentMediaTime() + Double.random(in: 8.0...14.0) - - char1.setup() - char2.setup() - - characters = [char1, char2] - characters.forEach { $0.controller = self } + CharacterPack.current = CharacterPack.loadSaved() + loadCharacters(from: CharacterPack.current) setupDebugLine() startDisplayLink() @@ -49,16 +19,45 @@ class LilAgentsController { } } + func loadCharacters(from pack: CharacterPack) { + for config in pack.characters { + let char = WalkerCharacter(videoName: config.videoName) + char.applyConfig(config) + char.setup() + characters.append(char) + } + characters.forEach { $0.controller = self } + } + + func switchPack(_ pack: CharacterPack) { + characters.forEach { char in + char.claudeSession?.terminate() + char.claudeSession = nil + char.popoverWindow?.orderOut(nil) + char.popoverWindow = nil + char.terminalView = nil + char.thinkingBubbleWindow?.orderOut(nil) + char.thinkingBubbleWindow = nil + char.window.orderOut(nil) + } + characters.removeAll() + + CharacterPack.current = pack + CharacterPack.saveCurrent() + + loadCharacters(from: pack) + } + private func triggerOnboarding() { - guard let bruce = characters.first else { return } - bruce.isOnboarding = true - // Show "hi!" bubble after a short delay so the character is visible first + guard let first = characters.first else { return } + let greeting = CharacterPack.current.onboardingGreeting + first.isOnboarding = true DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - bruce.currentPhrase = "hi!" - bruce.showingCompletion = true - bruce.completionBubbleExpiry = CACurrentMediaTime() + 600 // stays until clicked - bruce.showBubble(text: "hi!", isCompletion: true) - bruce.playCompletionSound() + first.currentPhrase = greeting + first.showingCompletion = true + first.completionBubbleExpiry = CACurrentMediaTime() + 600 + first.showBubble(text: greeting, isCompletion: true) + first.playCompletionSound() } } diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index c789008..c073e09 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -3,6 +3,7 @@ import AppKit class WalkerCharacter { let videoName: String + var displayName: String = "" var window: NSWindow! var playerLayer: AVPlayerLayer! var queuePlayer: AVQueuePlayer! @@ -59,6 +60,20 @@ class WalkerCharacter { self.videoName = videoName } + func applyConfig(_ config: CharacterConfig) { + displayName = config.displayName + accelStart = config.accelStart + fullSpeedStart = config.fullSpeedStart + decelStart = config.decelStart + walkStop = config.walkStop + walkAmountRange = config.walkAmountRange + yOffset = config.yOffset + flipXOffset = config.flipXOffset + characterColor = config.characterColor + positionProgress = config.initialPosition + pauseEndTime = CACurrentMediaTime() + Double.random(in: config.initialPauseRange) + } + // MARK: - Setup func setup() { @@ -136,15 +151,7 @@ class WalkerCharacter { // Show static welcome message instead of Claude terminal terminalView?.inputField.isEditable = false terminalView?.inputField.placeholderString = "" - let welcome = """ - hey! we're bruce and jazz — your lil dock agents. - - click either of us to open a Claude AI chat. we'll walk around while you work and let you know when Claude's thinking. - - check the menu bar icon (top right) for themes, sounds, and more options. - - click anywhere outside to dismiss, then click us again to start chatting. - """ + let welcome = CharacterPack.current.onboardingWelcome terminalView?.appendStreamingText(welcome) terminalView?.endStreaming() @@ -393,18 +400,8 @@ class WalkerCharacter { // MARK: - Thinking Bubble - private static let thinkingPhrases = [ - "hmm...", "thinking...", "one sec...", "ok hold on", - "let me check", "working on it", "almost...", "bear with me", - "on it!", "gimme a sec", "brb", "processing...", - "hang tight", "just a moment", "figuring it out", - "crunching...", "reading...", "looking..." - ] - - private static let completionPhrases = [ - "done!", "all set!", "ready!", "here you go", "got it!", - "finished!", "ta-da!", "voila!" - ] + private static var thinkingPhrases: [String] { CharacterPack.current.thinkingPhrases } + private static var completionPhrases: [String] { CharacterPack.current.completionPhrases } private var lastPhraseUpdate: CFTimeInterval = 0 var currentPhrase = "" diff --git a/LilAgents/walk-bb8-01.mov b/LilAgents/walk-bb8-01.mov new file mode 100644 index 0000000..6a4bc76 Binary files /dev/null and b/LilAgents/walk-bb8-01.mov differ diff --git a/LilAgents/walk-c3po-01.mov b/LilAgents/walk-c3po-01.mov new file mode 100644 index 0000000..0883e80 Binary files /dev/null and b/LilAgents/walk-c3po-01.mov differ diff --git a/LilAgents/walk-r2d2-01.mov b/LilAgents/walk-r2d2-01.mov new file mode 100644 index 0000000..cf25879 Binary files /dev/null and b/LilAgents/walk-r2d2-01.mov differ diff --git a/lil-agents.xcodeproj/project.pbxproj b/lil-agents.xcodeproj/project.pbxproj index 5716aa1..4d467d4 100644 --- a/lil-agents.xcodeproj/project.pbxproj +++ b/lil-agents.xcodeproj/project.pbxproj @@ -10,7 +10,11 @@ A10000010000000000000001 /* LilAgentsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000001 /* LilAgentsApp.swift */; }; A10000010000000000000002 /* walk-bruce-01.mov in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000002 /* walk-bruce-01.mov */; }; A10000010000000000000003 /* walk-jazz-01.mov in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000005 /* walk-jazz-01.mov */; }; + A10000010000000000000004 /* walk-bb8-01.mov in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000006 /* walk-bb8-01.mov */; }; A10000010000000000000005 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000007 /* Assets.xcassets */; }; + A10000010000000000000026 /* CharacterPack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000026 /* CharacterPack.swift */; }; + A10000010000000000000027 /* walk-r2d2-01.mov in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000027 /* walk-r2d2-01.mov */; }; + A10000010000000000000028 /* walk-c3po-01.mov in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000028 /* walk-c3po-01.mov */; }; A10000010000000000000010 /* Sounds in Resources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000010 /* Sounds */; }; A10000010000000000000020 /* PopoverTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000020 /* PopoverTheme.swift */; }; A10000010000000000000021 /* CharacterContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000021 /* CharacterContentView.swift */; }; @@ -27,6 +31,10 @@ A10000020000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A10000020000000000000004 /* LilAgents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LilAgents.entitlements; sourceTree = ""; }; A10000020000000000000005 /* walk-jazz-01.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = "walk-jazz-01.mov"; sourceTree = ""; }; + A10000020000000000000006 /* walk-bb8-01.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = "walk-bb8-01.mov"; sourceTree = ""; }; + A10000020000000000000026 /* CharacterPack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterPack.swift; sourceTree = ""; }; + A10000020000000000000027 /* walk-r2d2-01.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = "walk-r2d2-01.mov"; sourceTree = ""; }; + A10000020000000000000028 /* walk-c3po-01.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = "walk-c3po-01.mov"; sourceTree = ""; }; A10000020000000000000007 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A10000020000000000000010 /* Sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Sounds; sourceTree = ""; }; A10000020000000000000020 /* PopoverTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoverTheme.swift; sourceTree = ""; }; @@ -68,8 +76,12 @@ A10000020000000000000023 /* TerminalView.swift */, A10000020000000000000024 /* WalkerCharacter.swift */, A10000020000000000000025 /* LilAgentsController.swift */, + A10000020000000000000026 /* CharacterPack.swift */, A10000020000000000000002 /* walk-bruce-01.mov */, A10000020000000000000005 /* walk-jazz-01.mov */, + A10000020000000000000027 /* walk-r2d2-01.mov */, + A10000020000000000000028 /* walk-c3po-01.mov */, + A10000020000000000000006 /* walk-bb8-01.mov */, A10000020000000000000007 /* Assets.xcassets */, A10000020000000000000010 /* Sounds */, A10000020000000000000003 /* Info.plist */, @@ -152,6 +164,9 @@ files = ( A10000010000000000000002 /* walk-bruce-01.mov in Resources */, A10000010000000000000003 /* walk-jazz-01.mov in Resources */, + A10000010000000000000027 /* walk-r2d2-01.mov in Resources */, + A10000010000000000000028 /* walk-c3po-01.mov in Resources */, + A10000010000000000000004 /* walk-bb8-01.mov in Resources */, A10000010000000000000005 /* Assets.xcassets in Resources */, A10000010000000000000010 /* Sounds in Resources */, ); @@ -171,6 +186,7 @@ A10000010000000000000023 /* TerminalView.swift in Sources */, A10000010000000000000024 /* WalkerCharacter.swift in Sources */, A10000010000000000000025 /* LilAgentsController.swift in Sources */, + A10000010000000000000026 /* CharacterPack.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };