diff --git a/LilAgents/DockMagnificationSettings.swift b/LilAgents/DockMagnificationSettings.swift new file mode 100644 index 0000000..7129560 --- /dev/null +++ b/LilAgents/DockMagnificationSettings.swift @@ -0,0 +1,29 @@ +import Foundation + +/// User defaults for dock-like idle shrink (menu bar: "Shrink when idle" + delay). +enum DockMagnificationSettings { + private static let enabledKey = "dockShrinkWhenIdleEnabled" + private static let idleSecondsKey = "dockShrinkIdleSeconds" + + static let idlePresetsSeconds: [Int] = [10, 15, 20, 30, 60] + + static var isEnabled: Bool { + get { + if UserDefaults.standard.object(forKey: enabledKey) == nil { return true } + return UserDefaults.standard.bool(forKey: enabledKey) + } + set { UserDefaults.standard.set(newValue, forKey: enabledKey) } + } + + /// Delay after popover closes before shrinking (seconds). + static var idleSeconds: TimeInterval { + get { + let v = UserDefaults.standard.integer(forKey: idleSecondsKey) + if v <= 0 { return 20 } + return TimeInterval(v) + } + set { + UserDefaults.standard.set(Int(newValue), forKey: idleSecondsKey) + } + } +} diff --git a/LilAgents/LilAgentsApp.swift b/LilAgents/LilAgentsApp.swift index f1f22a4..06e896e 100644 --- a/LilAgents/LilAgentsApp.swift +++ b/LilAgents/LilAgentsApp.swift @@ -51,6 +51,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { soundItem.state = .on menu.addItem(soundItem) + let shrinkWhenIdleItem = NSMenuItem(title: "Shrink when idle", action: #selector(toggleDockShrinkWhenIdle(_:)), keyEquivalent: "") + shrinkWhenIdleItem.state = DockMagnificationSettings.isEnabled ? .on : .off + menu.addItem(shrinkWhenIdleItem) + + let shrinkAfterItem = NSMenuItem(title: "Shrink after", action: nil, keyEquivalent: "") + let shrinkAfterMenu = NSMenu() + for sec in DockMagnificationSettings.idlePresetsSeconds { + let it = NSMenuItem(title: "\(sec) seconds", action: #selector(setDockShrinkIdleDelay(_:)), keyEquivalent: "") + it.tag = sec + shrinkAfterMenu.addItem(it) + } + shrinkAfterItem.submenu = shrinkAfterMenu + menu.addItem(shrinkAfterItem) + // Provider submenu let providerItem = NSMenuItem(title: "Provider", action: nil, keyEquivalent: "") let providerMenu = NSMenu() @@ -106,6 +120,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { menu.addItem(quitItem) statusItem?.menu = menu + syncDockShrinkMenuItems() + } + + private func syncDockShrinkMenuItems() { + guard let menu = statusItem?.menu else { return } + menu.item(withTitle: "Shrink when idle")?.state = DockMagnificationSettings.isEnabled ? .on : .off + guard let sm = menu.item(withTitle: "Shrink after")?.submenu else { return } + let cur = Int(DockMagnificationSettings.idleSeconds) + let enabled = DockMagnificationSettings.isEnabled + for item in sm.items { + item.state = item.tag == cur ? .on : .off + item.isEnabled = enabled + } } // MARK: - Menu Actions @@ -221,6 +248,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { sender.state = WalkerCharacter.soundsEnabled ? .on : .off } + @objc func toggleDockShrinkWhenIdle(_ sender: NSMenuItem) { + DockMagnificationSettings.isEnabled.toggle() + sender.state = DockMagnificationSettings.isEnabled ? .on : .off + syncDockShrinkMenuItems() + controller?.characters.forEach { $0.applyDockMagnificationSettingsChanged() } + } + + @objc func setDockShrinkIdleDelay(_ sender: NSMenuItem) { + let sec = sender.tag + guard sec > 0 else { return } + DockMagnificationSettings.idleSeconds = TimeInterval(sec) + if let sm = sender.menu { + for item in sm.items { + item.state = item.tag == sec ? .on : .off + } + } + syncDockShrinkMenuItems() + } + @objc func quitApp() { NSApp.terminate(nil) } diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index 37176e8..c0c8efb 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -59,6 +59,15 @@ class WalkerCharacter { private var wasPopoverVisibleBeforeEnvironmentHide = false private var wasBubbleVisibleBeforeEnvironmentHide = false + // Dock-like idle shrink: smaller when unused; click snaps back to full size (like dock magnification). + private static let dockCompactScale: CGFloat = 0.56 + private var dockMagnification: CGFloat = 1.0 + private var dockMagnificationTarget: CGFloat = 1.0 + private var popoverDismissedAt: CFTimeInterval? + + private var effectiveDisplayHeight: CGFloat { displayHeight * dockMagnification } + private var effectiveDisplayWidth: CGFloat { displayWidth * dockMagnification } + init(videoName: String) { self.videoName = videoName } @@ -78,7 +87,7 @@ class WalkerCharacter { playerLayer = AVPlayerLayer(player: queuePlayer) playerLayer.videoGravity = .resizeAspect playerLayer.backgroundColor = NSColor.clear.cgColor - playerLayer.frame = CGRect(x: 0, y: 0, width: displayWidth, height: displayHeight) + playerLayer.frame = CGRect(x: 0, y: 0, width: effectiveDisplayWidth, height: effectiveDisplayHeight) let screen = NSScreen.main! let dockTopY = screen.visibleFrame.origin.y @@ -104,8 +113,10 @@ class WalkerCharacter { hostView.wantsLayer = true hostView.layer?.backgroundColor = NSColor.clear.cgColor hostView.layer?.addSublayer(playerLayer) + hostView.autoresizingMask = [.width, .height] window.contentView = hostView + popoverDismissedAt = CACurrentMediaTime() window.orderFrontRegardless() } @@ -169,9 +180,78 @@ class WalkerCharacter { } } + // MARK: - Dock magnification + + private func refreshDockMagnificationTarget(now: CFTimeInterval) { + if isOnboarding || isIdleForPopover { + dockMagnificationTarget = 1.0 + return + } + if !DockMagnificationSettings.isEnabled { + dockMagnificationTarget = 1.0 + return + } + if let t0 = popoverDismissedAt, now - t0 >= DockMagnificationSettings.idleSeconds { + dockMagnificationTarget = Self.dockCompactScale + } else { + dockMagnificationTarget = 1.0 + } + } + + private func tickDockMagnification() { + let now = CACurrentMediaTime() + refreshDockMagnificationTarget(now: now) + let delta = dockMagnificationTarget - dockMagnification + if abs(delta) < 0.003 { + dockMagnification = dockMagnificationTarget + return + } + let k: CGFloat = dockMagnificationTarget < dockMagnification ? 0.12 : 0.28 + dockMagnification += delta * k + } + + /// Horizontal center stays aligned with the full-size walk path; bottom stays anchored above the dock. + private func layoutWindowForDock(dockX: CGFloat, dockWidth: CGFloat, dockTopY: CGFloat) { + let w = effectiveDisplayWidth + let h = effectiveDisplayHeight + let travelFull = max(dockWidth - displayWidth, 0) + let leftFull = dockX + travelFull * positionProgress + currentFlipCompensation + let centerX = leftFull + displayWidth / 2 + let x = centerX - w / 2 + let fullPad = displayHeight * 0.15 + let y = dockTopY - fullPad + yOffset + + var frame = window.frame + frame.origin = NSPoint(x: x, y: y) + frame.size = NSSize(width: w, height: h) + window.setFrame(frame, display: true) + + if let host = window.contentView { + host.frame = NSRect(x: 0, y: 0, width: w, height: h) + } + playerLayer.frame = CGRect(x: 0, y: 0, width: w, height: h) + } + + private func snapDockMagnificationToFull() { + dockMagnification = 1.0 + dockMagnificationTarget = 1.0 + popoverDismissedAt = nil + } + + /// Call when menu toggles idle shrink off so windows return to full size immediately. + func applyDockMagnificationSettingsChanged() { + if !DockMagnificationSettings.isEnabled { + snapDockMagnificationToFull() + } + } + + // MARK: - Click Handling & Popover func handleClick() { + if DockMagnificationSettings.isEnabled, dockMagnification < 0.98 { + snapDockMagnificationToFull() + } if isOnboarding { openOnboardingPopover() return @@ -236,10 +316,12 @@ class WalkerCharacter { isPaused = true pauseEndTime = CACurrentMediaTime() + Double.random(in: 1.0...3.0) queuePlayer.seek(to: .zero) + popoverDismissedAt = CACurrentMediaTime() controller?.completeOnboarding() } func openPopover() { + popoverDismissedAt = nil // Close any other open popover if let siblings = controller?.characters { for sibling in siblings where sibling !== self && sibling.isIdleForPopover { @@ -304,6 +386,8 @@ class WalkerCharacter { func closePopover() { guard isIdleForPopover else { return } + popoverDismissedAt = CACurrentMediaTime() + popoverWindow?.orderOut(nil) removeEventMonitors() @@ -773,13 +857,11 @@ class WalkerCharacter { // MARK: - Frame Update func update(dockX: CGFloat, dockWidth: CGFloat, dockTopY: CGFloat) { + tickDockMagnification() currentTravelDistance = max(dockWidth - displayWidth, 0) + if isIdleForPopover { - let travelDistance = currentTravelDistance - let x = dockX + travelDistance * positionProgress + currentFlipCompensation - let bottomPadding = displayHeight * 0.15 - let y = dockTopY - bottomPadding + yOffset - window.setFrameOrigin(NSPoint(x: x, y: y)) + layoutWindowForDock(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY) updatePopoverPosition() updateThinkingBubble() return @@ -791,11 +873,7 @@ class WalkerCharacter { if now >= pauseEndTime { startWalk() } else { - let travelDistance = max(dockWidth - displayWidth, 0) - let x = dockX + travelDistance * positionProgress + currentFlipCompensation - let bottomPadding = displayHeight * 0.15 - let y = dockTopY - bottomPadding + yOffset - window.setFrameOrigin(NSPoint(x: x, y: y)) + layoutWindowForDock(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY) return } } @@ -820,10 +898,7 @@ class WalkerCharacter { return } - let x = dockX + travelDistance * positionProgress + currentFlipCompensation - let bottomPadding = displayHeight * 0.15 - let y = dockTopY - bottomPadding + yOffset - window.setFrameOrigin(NSPoint(x: x, y: y)) + layoutWindowForDock(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY) } updateThinkingBubble() diff --git a/lil-agents.xcodeproj/project.pbxproj b/lil-agents.xcodeproj/project.pbxproj index cd8b0ba..f6b3e2f 100644 --- a/lil-agents.xcodeproj/project.pbxproj +++ b/lil-agents.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ A10000010000000000000032 /* ShellEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000031 /* ShellEnvironment.swift */; }; A10000010000000000000033 /* CodexSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000032 /* CodexSession.swift */; }; A10000010000000000000034 /* CopilotSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000033 /* CopilotSession.swift */; }; + A10000010000000000000036 /* DockMagnificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000034 /* DockMagnificationSettings.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -43,6 +44,7 @@ A10000020000000000000031 /* ShellEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellEnvironment.swift; sourceTree = ""; }; A10000020000000000000032 /* CodexSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexSession.swift; sourceTree = ""; }; A10000020000000000000033 /* CopilotSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotSession.swift; sourceTree = ""; }; + A10000020000000000000034 /* DockMagnificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockMagnificationSettings.swift; sourceTree = ""; }; A10000030000000000000001 /* lil agents.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "lil agents.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -79,6 +81,7 @@ A10000020000000000000033 /* CopilotSession.swift */, A10000020000000000000023 /* TerminalView.swift */, A10000020000000000000024 /* WalkerCharacter.swift */, + A10000020000000000000034 /* DockMagnificationSettings.swift */, A10000020000000000000025 /* LilAgentsController.swift */, A10000020000000000000002 /* walk-bruce-01.mov */, A10000020000000000000005 /* walk-jazz-01.mov */, @@ -186,6 +189,7 @@ A10000010000000000000034 /* CopilotSession.swift in Sources */, A10000010000000000000023 /* TerminalView.swift in Sources */, A10000010000000000000024 /* WalkerCharacter.swift in Sources */, + A10000010000000000000036 /* DockMagnificationSettings.swift in Sources */, A10000010000000000000025 /* LilAgentsController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;