diff --git a/Clippy Shared/CGImageExtensions.swift b/Clippy Shared/CGImageExtensions.swift index a5850f9..ca7ab0d 100644 --- a/Clippy Shared/CGImageExtensions.swift +++ b/Clippy Shared/CGImageExtensions.swift @@ -3,10 +3,12 @@ // Clippy // // Created by Devran on 08.09.19. +// Updated by JordanE on 01.07.26. // Copyright © 2019 Devran. All rights reserved. // import Foundation +import CoreGraphics extension CGImage { static func mergeImages(_ images: [CGImage]) -> CGImage? { diff --git a/Clippy macOS/AgentController.swift b/Clippy macOS/AgentController.swift index d8d0951..a667533 100644 --- a/Clippy macOS/AgentController.swift +++ b/Clippy macOS/AgentController.swift @@ -3,13 +3,16 @@ // Clippy macOS // // Created by Devran on 07.09.19. +// Updated by JordanE on 01.07.26. // Copyright © 2019 Devran. All rights reserved. // + import Cocoa import AVKit import SpriteKit + class AgentController { var isMuted = false var player: AVPlayer = { @@ -22,6 +25,17 @@ class AgentController { var delegate: AgentControllerDelegate? var isHidden = true + var randomAnimationsEnabled = false { + didSet { + if randomAnimationsEnabled { + scheduleNextRandomAnimation() + } else { + cancelRandomAnimations() + } + } + } + private var randomAnimationTimer: Timer? + init() { } @@ -37,6 +51,10 @@ class AgentController { self.agent = agent showInitialFrame() delegate?.didLoadAgent(agent: agent) + + if randomAnimationsEnabled { + scheduleNextRandomAnimation() + } } func audioActionForFrame(frame: AgentFrame) -> SKAction? { @@ -96,4 +114,38 @@ class AgentController { func show() { delegate?.handleShow() } + + private func scheduleNextRandomAnimation() { + cancelRandomAnimations() + + let randomInterval = TimeInterval.random(in: 30...300) + + randomAnimationTimer = Timer.scheduledTimer(withTimeInterval: randomInterval, repeats: false) { [weak self] _ in + self?.performRandomAnimation() + } + } + + private func performRandomAnimation() { + guard randomAnimationsEnabled, !isHidden else { + if randomAnimationsEnabled { + scheduleNextRandomAnimation() + } + return + } + + animate() + + if randomAnimationsEnabled { + scheduleNextRandomAnimation() + } + } + + private func cancelRandomAnimations() { + randomAnimationTimer?.invalidate() + randomAnimationTimer = nil + } + + deinit { + cancelRandomAnimations() + } } diff --git a/Clippy macOS/AgentViewController.swift b/Clippy macOS/AgentViewController.swift index 56ed1aa..b9b96b0 100644 --- a/Clippy macOS/AgentViewController.swift +++ b/Clippy macOS/AgentViewController.swift @@ -3,15 +3,21 @@ // Clippy // // Created by Devran on 04.09.19. +// Updated by JordanE on 01.07.26. // Copyright © 2019 Devran. All rights reserved. // + + import AppKit + + class AgentViewController: NSViewController { var agentController: AgentController var agentView: AgentView - + + init() { agentView = AgentView() agentController = AgentController(agentView: agentView) @@ -19,38 +25,48 @@ class AgentViewController: NSViewController { super.init(nibName: nil, bundle: nil) agentController.delegate = self } - + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + override func loadView() { let size = CGSize(width: 100, height: 200) view = NSView(frame: CGRect(origin: CGPoint.zero, size: size)) view.wantsLayer = true view.layer?.backgroundColor = .clear } - + + override func viewDidLoad() { super.viewDidLoad() view.addSubview(agentView) setupConstraints() setupTrackingArea() } - - + + + override func viewDidAppear() { super.viewDidAppear() view.window?.makeFirstResponder(self) - + + let lastUsedName = (NSApplication.shared.delegate as? AppDelegate)?.lastUsedAgent let name = lastUsedName ?? Agent.randomAgentName() if let name = name { try? agentController.load(name: name) agentController.show() } + + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + agentController.randomAnimationsEnabled = appDelegate.isRandomAnimationsEnabled + } } - + + func setupConstraints() { agentView.translatesAutoresizingMaskIntoConstraints = false agentView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true @@ -58,7 +74,8 @@ class AgentViewController: NSViewController { view.rightAnchor.constraint(equalTo: agentView.rightAnchor).isActive = true view.bottomAnchor.constraint(equalTo: agentView.bottomAnchor).isActive = true } - + + func setupTrackingArea() { let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .inVisibleRect, .activeAlways] let trackingArea = NSTrackingArea(rect: view.frame, options: options, owner: self, userInfo: nil) @@ -66,106 +83,195 @@ class AgentViewController: NSViewController { } } + + extension AgentViewController { override func mouseEntered(with event: NSEvent) { self.view.superview?.window?.alphaValue = 1.0 } - + + override func mouseExited(with event: NSEvent) { - self.view.superview?.window?.alphaValue = 0.5 + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + if appDelegate.isTransparencyEnabled { + self.view.superview?.window?.alphaValue = 0.5 + } + } } - + + @objc func animateAction() { agentController.animate() } - + + @objc func chooseAssistantAction() { guard let name = Agent.randomAgentName() else { return } try? agentController.load(name: name) } - + + override var acceptsFirstResponder: Bool { return true } - + + override func becomeFirstResponder() -> Bool { return true } - + + override func keyDown(with event: NSEvent) { guard let agent = agentController.agent else { super.keyDown(with: event) return } switch Int(event.keyCode) { - case 49: // Spacebar + case 49: agentController.animate() - case 36: // Return + case 36: guard let name = Agent.randomAgentName() else { return } try? agentController.load(name: name) agentController.show() - case 124: // Arrow Right Key + case 124: guard let animation = agent.findAnimation("LookLeft") else { break } agentController.play(animation: animation) - case 123: // Arrow Left Key + case 123: guard let animation = agent.findAnimation("LookRight") else { break } agentController.play(animation: animation) - case 126: // Arrow Up Key + case 126: guard let animation = agent.findAnimation("LookUp") else { break } agentController.play(animation: animation) - case 125: // Arrow Down Key + case 125: guard let animation = agent.findAnimation("LookDown") else { break } agentController.play(animation: animation) default: super.keyDown(with: event) } } - + + override func mouseDown(with event: NSEvent) { if event.clickCount == 2 { agentController.animate() } } - + + + func createOptionsSubmenu() -> NSMenu { + let optionsMenu = NSMenu(title: "Options") + + + let muteItem = NSMenuItem(title: "Mute", action: #selector(toggleMuteAction(sender:)), keyEquivalent: "") + if let isMuted = AppDelegate.agentController?.isMuted { + muteItem.state = isMuted ? .on : .off + } + optionsMenu.addItem(muteItem) + + + let transparencyItem = NSMenuItem(title: "Transparency When Inactive", action: #selector(toggleTransparencyAction(sender:)), keyEquivalent: "") + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + transparencyItem.state = appDelegate.isTransparencyEnabled ? .on : .off + } + optionsMenu.addItem(transparencyItem) + + let randomAnimationsItem = NSMenuItem(title: "Random Animations", action: #selector(toggleRandomAnimationsAction(sender:)), keyEquivalent: "") + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + randomAnimationsItem.state = appDelegate.isRandomAnimationsEnabled ? .on : .off + } + optionsMenu.addItem(randomAnimationsItem) + + let alwaysOnTopItem = NSMenuItem(title: "Always On Top", action: #selector(toggleAlwaysOnTopAction(sender:)), keyEquivalent: "") + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + alwaysOnTopItem.state = appDelegate.isAlwaysOnTop ? .on : .off + } + optionsMenu.addItem(alwaysOnTopItem) + + + return optionsMenu + } + + override func rightMouseDown(with event: NSEvent) { guard let _ = agentController.agent else { return } - + + let menu = NSMenu(title: "Agent") + + + let optionsMenuItem = NSMenuItem(title: "Options", action: nil, keyEquivalent: "") + optionsMenuItem.submenu = createOptionsSubmenu() + + let menuItems = [ - NSMenuItem(title: "Hide", - action: #selector(hideAction(sender:)), - keyEquivalent: ""), + optionsMenuItem, NSMenuItem.separator(), - NSMenuItem(title: "Options…", - action: nil, - keyEquivalent: ""), NSMenuItem(title: "Choose Assistant…", action: #selector(chooseAssistantAction), keyEquivalent: ""), NSMenuItem(title: "Animate!", action: #selector(animateAction), + keyEquivalent: ""), + NSMenuItem.separator(), + NSMenuItem(title: "Hide", + action: #selector(hideAction(sender:)), keyEquivalent: "") ] - + + for (index, menuItem) in menuItems.enumerated() { menu.insertItem(menuItem, at: index) } NSMenu.popUpContextMenu(menu, with: event, for: agentView) } - + + @objc func hideAction(sender: AnyObject) { agentController.hide() } + + + @objc func toggleMuteAction(sender: AnyObject) { + guard let menuItem = sender as? NSMenuItem else { return } + guard let isMuted = AppDelegate.agentController?.isMuted else { return } + let newValue = !isMuted + AppDelegate.agentController?.isMuted = newValue + menuItem.state = newValue ? .on : .off + + + NotificationCenter.default.post(name: .muteDidChange, object: nil) + } + + + @objc func toggleTransparencyAction(sender: AnyObject) { + guard let menuItem = sender as? NSMenuItem else { return } + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + + + appDelegate.isTransparencyEnabled = !appDelegate.isTransparencyEnabled + menuItem.state = appDelegate.isTransparencyEnabled ? .on : .off + + + if let window = view.window, !window.isKeyWindow { + window.alphaValue = appDelegate.isTransparencyEnabled ? 0.5 : 1.0 + } + } - @objc func optionsAction(sender: AnyObject) { - let viewController = BalloonViewController(nibName: nil, bundle: nil) - print(viewController) - let popOver = NSPopover() - popOver.behavior = .semitransient - popOver.contentSize = CGSize(width: 200, height: 300) - popOver.animates = true - popOver.contentViewController = viewController - let rect = self.view.frame - popOver.show(relativeTo: rect, of: view, preferredEdge: NSRectEdge.maxY) + @objc func toggleRandomAnimationsAction(sender: AnyObject) { + guard let menuItem = sender as? NSMenuItem else { return } + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + + + appDelegate.isRandomAnimationsEnabled = !appDelegate.isRandomAnimationsEnabled + menuItem.state = appDelegate.isRandomAnimationsEnabled ? .on : .off + } + + @objc func toggleAlwaysOnTopAction(sender: AnyObject) { + guard let menuItem = sender as? NSMenuItem else { return } + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + + + appDelegate.isAlwaysOnTop = !appDelegate.isAlwaysOnTop + menuItem.state = appDelegate.isAlwaysOnTop ? .on : .off } } diff --git a/Clippy macOS/AgentWindow.swift b/Clippy macOS/AgentWindow.swift index 187da87..7b299a3 100644 --- a/Clippy macOS/AgentWindow.swift +++ b/Clippy macOS/AgentWindow.swift @@ -3,11 +3,15 @@ // Clippy macOS // // Created by Devran on 07.09.19. +// Updated by JordanE on 01.07.26. // Copyright © 2019 Devran. All rights reserved. // + import Cocoa + + class AgentWindow: NSWindow { override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) @@ -23,23 +27,31 @@ class AgentWindow: NSWindow { } else { backgroundColor = .clear } - - /// Fixes glitches + + hasShadow = false isOpaque = true delegate = self } - + + override var canBecomeKey: Bool { return true } } + + extension AgentWindow: NSWindowDelegate { func windowDidResignKey(_ notification: Notification) { - alphaValue = 0.5 + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + if appDelegate.isTransparencyEnabled { + alphaValue = 0.5 + } + } } - + + func windowDidBecomeKey(_ notification: Notification) { alphaValue = 1.0 } diff --git a/Clippy macOS/AppDelegate.swift b/Clippy macOS/AppDelegate.swift index 8c95d42..a30daed 100644 --- a/Clippy macOS/AppDelegate.swift +++ b/Clippy macOS/AppDelegate.swift @@ -3,11 +3,15 @@ // Clippy macOS // // Created by Devran on 03.09.19. +// Updated by JordanE on 01.07.26. // Copyright © 2019 Devran. All rights reserved. // + import Cocoa + + class AppDelegate: NSObject, NSApplicationDelegate { let applicationName = "Clippy" var window: NSWindow? @@ -15,9 +19,38 @@ class AppDelegate: NSObject, NSApplicationDelegate { var agentsMenuItem: NSMenuItem? static var agentController: AgentController? var lastUsedAgent: String? + + + var isTransparencyEnabled: Bool = true { + didSet { + UserDefaults.standard.set(isTransparencyEnabled, forKey: "transparencyEnabled") + NotificationCenter.default.post(name: .transparencyDidChange, object: nil) + } + } + var isRandomAnimationsEnabled: Bool = false { + didSet { + UserDefaults.standard.set(isRandomAnimationsEnabled, forKey: "randomAnimationsEnabled") + AppDelegate.agentController?.randomAnimationsEnabled = isRandomAnimationsEnabled + NotificationCenter.default.post(name: .randomAnimationsDidChange, object: nil) + } + } + + var isAlwaysOnTop: Bool = true { + didSet { + UserDefaults.standard.set(isAlwaysOnTop, forKey: "alwaysOnTop") + updateWindowLevel() + NotificationCenter.default.post(name: .alwaysOnTopDidChange, object: nil) + } + } + + func applicationDidFinishLaunching(_ aNotification: Notification) { - + isTransparencyEnabled = UserDefaults.standard.object(forKey: "transparencyEnabled") as? Bool ?? true + isRandomAnimationsEnabled = UserDefaults.standard.object(forKey: "randomAnimationsEnabled") as? Bool ?? false + isAlwaysOnTop = UserDefaults.standard.object(forKey: "alwaysOnTop") as? Bool ?? true + + window = AgentWindow(contentRect: CGRect.zero, styleMask: [], backing: .buffered, defer: true) window?.title = applicationName window?.contentViewController = AgentViewController() @@ -26,31 +59,37 @@ class AppDelegate: NSObject, NSApplicationDelegate { } window?.center() + updateWindowLevel() setupStatusBar() } - + + func applicationWillTerminate(_ aNotification: Notification) { - // Insert code here to tear down your application } - + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } - + + func setupStatusBar() { let statusBar = NSStatusBar.system statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength) if let button = statusItem?.button { button.title = "📎" } - + + setupStatusBarMenu() } - + + func createAgentsMenu() -> NSMenu { let agentsMenu = NSMenu(title: "Agents") let agentNames = Agent.agentNames() - + + if agentNames.isEmpty { agentsMenu.addItem(withTitle: "No Agents found.", action: nil, @@ -71,15 +110,32 @@ class AppDelegate: NSObject, NSApplicationDelegate { keyEquivalent: "") return agentsMenu } - + + func setupStatusBarMenu() { - // Status bar menu let statusBarMenu = NSMenu(title: "Clippy") + statusBarMenu.delegate = self agentsMenuItem = NSMenuItem(title: "Agents", action: nil, keyEquivalent: "") - + + statusBarMenu.addItem(withTitle: "Show", action: #selector(showAction(sender:)), keyEquivalent: "") statusBarMenu.addItem(withTitle: "Hide", action: #selector(hideAction(sender:)), keyEquivalent: "") statusBarMenu.addItem(withTitle: "Mute", action: #selector(toggleMuteAction(sender:)), keyEquivalent: "") + + + let transparencyItem = NSMenuItem(title: "Transparency When Inactive", action: #selector(toggleTransparencyAction(sender:)), keyEquivalent: "") + transparencyItem.state = isTransparencyEnabled ? .on : .off + statusBarMenu.addItem(transparencyItem) + + let randomAnimationsItem = NSMenuItem(title: "Random Animations", action: #selector(toggleRandomAnimationsAction(sender:)), keyEquivalent: "") + randomAnimationsItem.state = isRandomAnimationsEnabled ? .on : .off + statusBarMenu.addItem(randomAnimationsItem) + + let alwaysOnTopItem = NSMenuItem(title: "Always On Top", action: #selector(toggleAlwaysOnTopAction(sender:)), keyEquivalent: "") + alwaysOnTopItem.state = isAlwaysOnTop ? .on : .off + statusBarMenu.addItem(alwaysOnTopItem) + + statusBarMenu.addItem(NSMenuItem.separator()) guard let menuItem = agentsMenuItem else { return } statusBarMenu.addItem(menuItem) @@ -88,33 +144,40 @@ class AppDelegate: NSObject, NSApplicationDelegate { keyEquivalent: "") statusBarMenu.addItem(NSMenuItem.separator()) statusBarMenu.addItem(withTitle: "Quit \(applicationName)", action: #selector(quitAction(sender:)), keyEquivalent: "") - - // Agents menu + + statusBarMenu.setSubmenu(createAgentsMenu(), for: menuItem) - + + statusItem?.menu = statusBarMenu } - + + @objc func quitAction(sender: AnyObject) { NSApplication.shared.terminate(self) } - + + @objc func reloadAction(sender: AnyObject) { agentsMenuItem?.submenu = createAgentsMenu() } - + + @objc func openFolderAction(sender: AnyObject) { NSWorkspace.shared.open(Agent.agentsURL()) } - + + @objc func hideAction(sender: AnyObject) { AppDelegate.agentController?.hide() } - + + @objc func showAction(sender: AnyObject) { window?.makeKeyAndOrderFront(self) } - + + @objc func toggleMuteAction(sender: AnyObject) { guard let menuItem = sender as? NSMenuItem else { return } guard let isMuted = AppDelegate.agentController?.isMuted else { return } @@ -122,11 +185,41 @@ class AppDelegate: NSObject, NSApplicationDelegate { AppDelegate.agentController?.isMuted = newValue menuItem.state = newValue ? .on : .off } + + + @objc func toggleTransparencyAction(sender: AnyObject) { + guard let menuItem = sender as? NSMenuItem else { return } + isTransparencyEnabled = !isTransparencyEnabled + menuItem.state = isTransparencyEnabled ? .on : .off + + + if let agentWindow = window, !agentWindow.isKeyWindow { + agentWindow.alphaValue = isTransparencyEnabled ? 0.5 : 1.0 + } + } + + @objc func toggleRandomAnimationsAction(sender: AnyObject) { + guard let menuItem = sender as? NSMenuItem else { return } + isRandomAnimationsEnabled = !isRandomAnimationsEnabled + menuItem.state = isRandomAnimationsEnabled ? .on : .off + } + + @objc func toggleAlwaysOnTopAction(sender: AnyObject) { + guard let menuItem = sender as? NSMenuItem else { return } + isAlwaysOnTop = !isAlwaysOnTop + menuItem.state = isAlwaysOnTop ? .on : .off + } + func updateWindowLevel() { + window?.level = isAlwaysOnTop ? .floating : .normal + } + + @objc func selectAgent(sender: AnyObject) { guard let menuItem = sender as? NSMenuItem else { return } let name = menuItem.title.lowercased() - + + if let isVisible = window?.isVisible, isVisible == true { try? AppDelegate.agentController?.load(name: name) if let animation = AppDelegate.agentController?.agent?.findAnimation("Show") { @@ -136,7 +229,35 @@ class AppDelegate: NSObject, NSApplicationDelegate { lastUsedAgent = name window?.makeKeyAndOrderFront(self) } - + + agentsMenuItem?.submenu = createAgentsMenu() } } + + +extension AppDelegate: NSMenuDelegate { + func menuNeedsUpdate(_ menu: NSMenu) { + for item in menu.items { + if item.title == "Mute" { + if let isMuted = AppDelegate.agentController?.isMuted { + item.state = isMuted ? .on : .off + } + } else if item.title == "Transparency When Inactive" { + item.state = isTransparencyEnabled ? .on : .off + } else if item.title == "Random Animations" { + item.state = isRandomAnimationsEnabled ? .on : .off + } else if item.title == "Always On Top" { + item.state = isAlwaysOnTop ? .on : .off + } + } + } +} + + +extension Notification.Name { + static let transparencyDidChange = Notification.Name("transparencyDidChange") + static let muteDidChange = Notification.Name("muteDidChange") + static let randomAnimationsDidChange = Notification.Name("randomAnimationsDidChange") + static let alwaysOnTopDidChange = Notification.Name("alwaysOnTopDidChange") +} diff --git a/Clippy.xcodeproj/project.pbxproj b/Clippy.xcodeproj/project.pbxproj index b7adc4e..7100eae 100644 --- a/Clippy.xcodeproj/project.pbxproj +++ b/Clippy.xcodeproj/project.pbxproj @@ -518,6 +518,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = macOSAppIcon; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 2; @@ -541,6 +542,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = macOSAppIcon; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 2;