├── .gitattributes ├── Klic ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppIcon-1024.png │ │ ├── AppIcon-128.png │ │ ├── AppIcon-16.png │ │ ├── AppIcon-256.png │ │ ├── AppIcon-32 1.png │ │ ├── AppIcon-32.png │ │ ├── AppIcon-512.png │ │ ├── AppIcon-64.png │ │ ├── AppIcon-1024 1.png │ │ ├── AppIcon-256 1.png │ │ ├── AppIcon-512 1.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Utilities │ ├── NotificationNames.swift │ ├── Logger.swift │ ├── UserPreferences.swift │ └── InputManager.swift ├── Klic.entitlements ├── Views │ ├── ContentView.swift │ ├── AboutView.swift │ ├── InputOverlayView.swift │ ├── ConfigurationView.swift │ ├── KeyboardVisualizer.swift │ └── MouseVisualizer.swift ├── Models │ └── InputEvent.swift ├── Monitors │ ├── KeyboardMonitor.swift │ └── MouseMonitor.swift └── KlicApp.swift ├── KlicTests └── KlicTests.swift ├── KlicUITests ├── KlicUITestsLaunchTests.swift └── KlicUITests.swift ├── LICENSE ├── README.md └── specs └── klic_unified_specs.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /Klic/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Klic/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-32 1.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-64.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-1024 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-1024 1.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-256 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-256 1.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-512 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/klic/HEAD/Klic/Assets.xcassets/AppIcon.appiconset/AppIcon-512 1.png -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /KlicTests/KlicTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | 3 | struct KlicTests { 4 | 5 | @Test func example() async throws { 6 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /Klic/Utilities/NotificationNames.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Notification.Name { 4 | static let ReconfigureOverlayPosition = Notification.Name("ReconfigureOverlayPosition") 5 | static let MinimalDisplayModeChanged = Notification.Name("MinimalDisplayModeChanged") 6 | static let InputTypesChanged = Notification.Name("InputTypesChanged") 7 | static let ShowOverlayDemo = Notification.Name("ShowOverlayDemo") 8 | } -------------------------------------------------------------------------------- /Klic/Klic.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.device.camera 10 | 11 | com.apple.security.device.audio-input 12 | 13 | com.apple.security.personal-information.location 14 | 15 | com.apple.security.automation.apple-events 16 | 17 | com.apple.security.temporary-exception.apple-events 18 | 19 | com.apple.systemevents 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /KlicUITests/KlicUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class KlicUITestsLaunchTests: XCTestCase { 4 | 5 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 6 | true 7 | } 8 | 9 | override func setUpWithError() throws { 10 | continueAfterFailure = false 11 | } 12 | 13 | @MainActor 14 | func testLaunch() throws { 15 | let app = XCUIApplication() 16 | app.launch() 17 | 18 | // Insert steps here to perform after app launch but before taking a screenshot, 19 | // such as logging into a test account or navigating somewhere in the app 20 | 21 | let attachment = XCTAttachment(screenshot: app.screenshot()) 22 | attachment.name = "Launch Screen" 23 | attachment.lifetime = .keepAlways 24 | add(attachment) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /KlicUITests/KlicUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class KlicUITests: XCTestCase { 4 | 5 | override func setUpWithError() throws { 6 | // Put setup code here. This method is called before the invocation of each test method in the class. 7 | 8 | // In UI tests it is usually best to stop immediately when a failure occurs. 9 | continueAfterFailure = false 10 | 11 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 12 | } 13 | 14 | override func tearDownWithError() throws { 15 | // Put teardown code here. This method is called after the invocation of each test method in the class. 16 | } 17 | 18 | @MainActor 19 | func testExample() throws { 20 | // UI tests must launch the application that they test. 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | @MainActor 28 | func testLaunchPerformance() throws { 29 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 30 | // This measures how long it takes to launch your application. 31 | measure(metrics: [XCTApplicationLaunchMetric()]) { 32 | XCUIApplication().launch() 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Klic/Utilities/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os.log 3 | 4 | // Custom logger with enhanced formatting and subsystem organization 5 | enum Logger { 6 | // Define subsystems 7 | static let app = OSLog(subsystem: "com.klic.app", category: "Application") 8 | static let keyboard = OSLog(subsystem: "com.klic.app", category: "Keyboard") 9 | static let mouse = OSLog(subsystem: "com.klic.app", category: "Mouse") 10 | static let overlay = OSLog(subsystem: "com.klic.app", category: "Overlay") 11 | 12 | // Set log levels 13 | private static var logLevels: [OSLog: OSLogType] = [ 14 | app: .info, 15 | keyboard: .info, 16 | mouse: .info, 17 | overlay: .info 18 | ] 19 | 20 | // MARK: - Logging Methods 21 | 22 | static func debug(_ message: String, log: OSLog) { 23 | guard logLevels[log] == .debug else { return } 24 | os_log("[DEBUG] %{public}@", log: log, type: .debug, message) 25 | } 26 | 27 | static func info(_ message: String, log: OSLog) { 28 | guard logLevels[log] == .debug || logLevels[log] == .info else { return } 29 | os_log("[INFO] %{public}@", log: log, type: .info, message) 30 | } 31 | 32 | static func warning(_ message: String, log: OSLog) { 33 | guard logLevels[log] != .error else { return } 34 | os_log("[WARNING] %{public}@", log: log, type: .default, message) 35 | } 36 | 37 | static func error(_ message: String, log: OSLog) { 38 | os_log("[ERROR] %{public}@", log: log, type: .error, message) 39 | } 40 | 41 | static func exception(_ message: String, error: Error, log: OSLog) { 42 | os_log("[EXCEPTION] %{public}@: %{public}@", log: log, type: .fault, message, error.localizedDescription) 43 | } 44 | } 45 | 46 | // Extension to make testing for log level easier 47 | extension OSLogType: @retroactive Equatable { 48 | public static func == (lhs: OSLogType, rhs: OSLogType) -> Bool { 49 | return lhs.rawValue == rhs.rawValue 50 | } 51 | } -------------------------------------------------------------------------------- /Klic/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon-1024 1.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "idiom" : "universal", 17 | "platform" : "ios", 18 | "size" : "1024x1024" 19 | }, 20 | { 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "tinted" 25 | } 26 | ], 27 | "idiom" : "universal", 28 | "platform" : "ios", 29 | "size" : "1024x1024" 30 | }, 31 | { 32 | "filename" : "AppIcon-16.png", 33 | "idiom" : "mac", 34 | "scale" : "1x", 35 | "size" : "16x16" 36 | }, 37 | { 38 | "filename" : "AppIcon-32 1.png", 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "16x16" 42 | }, 43 | { 44 | "filename" : "AppIcon-32.png", 45 | "idiom" : "mac", 46 | "scale" : "1x", 47 | "size" : "32x32" 48 | }, 49 | { 50 | "filename" : "AppIcon-64.png", 51 | "idiom" : "mac", 52 | "scale" : "2x", 53 | "size" : "32x32" 54 | }, 55 | { 56 | "filename" : "AppIcon-128.png", 57 | "idiom" : "mac", 58 | "scale" : "1x", 59 | "size" : "128x128" 60 | }, 61 | { 62 | "filename" : "AppIcon-256 1.png", 63 | "idiom" : "mac", 64 | "scale" : "2x", 65 | "size" : "128x128" 66 | }, 67 | { 68 | "filename" : "AppIcon-256.png", 69 | "idiom" : "mac", 70 | "scale" : "1x", 71 | "size" : "256x256" 72 | }, 73 | { 74 | "filename" : "AppIcon-512 1.png", 75 | "idiom" : "mac", 76 | "scale" : "2x", 77 | "size" : "256x256" 78 | }, 79 | { 80 | "filename" : "AppIcon-512.png", 81 | "idiom" : "mac", 82 | "scale" : "1x", 83 | "size" : "512x512" 84 | }, 85 | { 86 | "filename" : "AppIcon-1024.png", 87 | "idiom" : "mac", 88 | "scale" : "2x", 89 | "size" : "512x512" 90 | } 91 | ], 92 | "info" : { 93 | "author" : "xcode", 94 | "version" : 1 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Klic/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | struct ContentView: View { 5 | @ObservedObject var inputManager = InputManager.shared 6 | @State private var isMinimalMode: Bool = false 7 | @State private var showWelcomeAlert: Bool = false 8 | 9 | // MARK: - Body 10 | 11 | var body: some View { 12 | ZStack { 13 | // Background 14 | Color.clear 15 | .ignoresSafeArea() 16 | 17 | // Input visualizers 18 | VStack(spacing: 12) { 19 | // Keyboard visualizer 20 | if !inputManager.keyboardEvents.isEmpty { 21 | KeyboardVisualizer(events: inputManager.keyboardEvents) 22 | .padding(.horizontal, isMinimalMode ? 8 : 16) 23 | } 24 | 25 | // Mouse visualizer 26 | if !inputManager.mouseEvents.isEmpty { 27 | MouseVisualizer(events: inputManager.mouseEvents) 28 | .padding(.horizontal, isMinimalMode ? 8 : 16) 29 | } 30 | } 31 | .padding(.vertical, 16) 32 | } 33 | .onAppear { 34 | // Start monitoring inputs 35 | inputManager.startMonitoring() 36 | 37 | // Load user preferences 38 | isMinimalMode = UserDefaults.standard.bool(forKey: "minimalMode") 39 | 40 | // Check if this is the first launch 41 | let hasLaunchedBefore = UserDefaults.standard.bool(forKey: "hasLaunchedBefore") 42 | if !hasLaunchedBefore { 43 | // Show welcome information 44 | showWelcomeAlert = true 45 | UserDefaults.standard.set(true, forKey: "hasLaunchedBefore") 46 | } 47 | } 48 | .alert("Welcome to Klic!", isPresented: $showWelcomeAlert) { 49 | Button("OK") { 50 | showWelcomeAlert = false 51 | } 52 | } message: { 53 | Text("Klic is running in your menu bar. Click the keyboard icon to access settings and see a demo of the app.\n\nYou'll need to grant accessibility permissions for Klic to work properly.") 54 | } 55 | } 56 | } 57 | 58 | // MARK: - Preview 59 | 60 | struct ContentView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | ContentView() 63 | } 64 | } -------------------------------------------------------------------------------- /Klic/Views/AboutView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AboutView: View { 4 | private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" 5 | private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" 6 | 7 | var body: some View { 8 | VStack(spacing: 20) { 9 | // App logo 10 | Image(nsImage: NSImage(systemSymbolName: "keyboard.fill", accessibilityDescription: "Klic")!) 11 | .resizable() 12 | .aspectRatio(contentMode: .fit) 13 | .frame(width: 120, height: 120) 14 | .foregroundStyle(LinearGradient( 15 | colors: [.blue, .purple], 16 | startPoint: .topLeading, 17 | endPoint: .bottomTrailing 18 | )) 19 | .symbolEffect(.pulse) 20 | 21 | // App name and version 22 | VStack(spacing: 8) { 23 | Text("Klic") 24 | .font(.system(size: 36, weight: .bold, design: .rounded)) 25 | .foregroundColor(.primary) 26 | 27 | Text("Version \(appVersion) (\(buildNumber))") 28 | .font(.system(size: 14, weight: .medium, design: .rounded)) 29 | .foregroundColor(.secondary) 30 | } 31 | 32 | // App description 33 | Text("A next-gen input visualizer for streamers") 34 | .font(.system(size: 16, weight: .regular, design: .rounded)) 35 | .foregroundColor(.secondary) 36 | .multilineTextAlignment(.center) 37 | .padding(.horizontal, 20) 38 | 39 | Divider() 40 | .padding(.horizontal, 40) 41 | 42 | // Credits 43 | VStack(spacing: 10) { 44 | Text("© 2023-2025 @nuancedev") 45 | .font(.system(size: 12, weight: .medium, design: .rounded)) 46 | .foregroundColor(.secondary) 47 | 48 | Link("nuanc.me", destination: URL(string: "https://nuanc.me")!) 49 | .font(.system(size: 12, weight: .medium, design: .rounded)) 50 | } 51 | } 52 | .padding(30) 53 | .frame(width: 400, height: 400) 54 | .background(Color(.windowBackgroundColor)) 55 | } 56 | } 57 | 58 | #Preview { 59 | AboutView() 60 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Klic - Next-Gen Input Visualizer for macOS 2 | 3 | A sleek, minimal input visualization app that displays keyboard shortcuts, trackpad gestures, and mouse movements in an elegant overlay. Built specifically for streamers, presenters, and educators. 4 | 5 | ![image](https://github.com/user-attachments/assets/8c0c7e61-75d2-43f4-9ca8-d4df62d8ece9) 6 | 7 | 8 | ## ✨ Features 9 | 10 | ### Input Visualization 11 | 12 | - **Keyboard**: Real-time display of key presses with elegant animations for modifiers and combinations 13 | - **Trackpad (WIP)**: Multi-touch visualization showing finger positions, gestures, and pressure sensitivity 14 | - **Mouse**: Cursor movement trails, click visualization, and scroll wheel actions 15 | - **Shortcuts**: Special highlighting for keyboard shortcuts with connecting animations 16 | 17 | ![Screenshot 2025-03-31 at 21 47 57](https://github.com/user-attachments/assets/ea7fc9ce-80b1-4071-9d6e-2de79f170ab2) 18 | 19 | 20 | ### Core Features 21 | 22 | - **Intelligent Display**: Overlays appear only when inputs are detected and fade away automatically 23 | - **Next-Gen Design**: Premium glass effect with subtle gradients inspired by Vercel, Linear, and Arc 24 | - **Minimal Footprint**: Low CPU/GPU usage for seamless streaming and recording 25 | - **Smart Positioning**: Elegant overlay that never gets in the way of your content 26 | - **Gesture Recognition (WIP)**: Advanced visualization for swipes, pinches, rotations, and multi-finger gestures 27 | - **Momentum Detection**: Special indicators for momentum scrolling and inertia events 28 | - **Batch Input Handling**: Intelligent grouping of rapid inputs to prevent visual clutter 29 | - **Dark Mode**: Seamless integration with macOS appearance settings 30 | 31 | ## 💻 Requirements 32 | 33 | - macOS 12.0 (Monterey) or later 34 | - Apple Silicon or Intel processor 35 | - Accessibility permissions for input monitoring 36 | 37 | ## 🚀 Getting Started 38 | 39 | 1. Download the latest version from the [releases page](https://github.com/nuance-dev/Klic/releases) 40 | 2. Grant accessibility permissions when prompted 41 | 3. Use the menu bar icon to access settings and customize your experience 42 | 4. Start streaming or recording with your inputs beautifully visualized 43 | 5. Use the demo mode to showcase different input types 44 | 45 | ## 🛠 Technical Details 46 | 47 | - Built with SwiftUI and Metal for optimal rendering performance 48 | - Uses Apple's accessibility framework for secure input monitoring 49 | - Optimized for Apple Silicon with minimal resource usage 50 | - Sandboxed for enhanced security 51 | - Intelligent event filtering to handle rapid inputs 52 | 53 | ## 📝 License 54 | 55 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 56 | 57 | ## 🔗 Links 58 | 59 | - Website: [Nuance](https://nuanc.me) 60 | - Issues: [GitHub Issues](https://github.com/nuance-dev/Klic/issues) 61 | - Updates: [@NuanceDev](https://twitter.com/Nuancedev) 62 | -------------------------------------------------------------------------------- /Klic/Models/InputEvent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Represents the different types of input events that can be visualized 5 | enum InputEventType { 6 | case keyDown 7 | case keyUp 8 | case mouseMove 9 | case mouseDown 10 | case mouseUp 11 | case mouseScroll 12 | } 13 | 14 | /// Represents a mouse button 15 | enum MouseButton: String, Equatable { 16 | case left 17 | case right 18 | case middle 19 | case extra1 20 | case extra2 21 | case other 22 | } 23 | 24 | /// Represents a mouse event 25 | struct MouseEvent: Equatable { 26 | let position: CGPoint 27 | let button: MouseButton? 28 | let scrollDelta: CGPoint? 29 | let isDown: Bool 30 | let isDoubleClick: Bool 31 | let isMomentumScroll: Bool 32 | 33 | init(position: CGPoint, button: MouseButton? = nil, scrollDelta: CGPoint? = nil, isDown: Bool = false, isDoubleClick: Bool = false, isMomentumScroll: Bool = false) { 34 | self.position = position 35 | self.button = button 36 | self.scrollDelta = scrollDelta 37 | self.isDown = isDown 38 | self.isDoubleClick = isDoubleClick 39 | self.isMomentumScroll = isMomentumScroll 40 | } 41 | } 42 | 43 | /// Represents a keyboard event 44 | struct KeyboardEvent: Equatable { 45 | let key: String 46 | let keyCode: Int 47 | let isDown: Bool 48 | let modifiers: [KeyModifier] 49 | let characters: String? 50 | let isRepeat: Bool 51 | 52 | var isModifierKey: Bool { 53 | return KeyModifier.allCases.contains { $0.keyCode == keyCode } 54 | } 55 | } 56 | 57 | /// The main input event model that encapsulates all types of input events 58 | struct InputEvent: Identifiable, Equatable { 59 | let id: UUID 60 | let timestamp: Date 61 | let type: EventType 62 | 63 | // Event-specific data 64 | let keyboardEvent: KeyboardEvent? 65 | let mouseEvent: MouseEvent? 66 | 67 | enum EventType { 68 | case keyboard 69 | case mouse 70 | } 71 | 72 | // Factory method for keyboard events 73 | static func keyboardEvent(event: KeyboardEvent) -> InputEvent { 74 | return InputEvent( 75 | id: UUID(), 76 | timestamp: Date(), 77 | type: .keyboard, 78 | keyboardEvent: event, 79 | mouseEvent: nil 80 | ) 81 | } 82 | 83 | // Factory method for mouse events 84 | static func mouseEvent(event: MouseEvent) -> InputEvent { 85 | return InputEvent( 86 | id: UUID(), 87 | timestamp: Date(), 88 | type: .mouse, 89 | keyboardEvent: nil, 90 | mouseEvent: event 91 | ) 92 | } 93 | } 94 | 95 | // MARK: - Supporting Types 96 | 97 | enum KeyModifier: String, CaseIterable { 98 | case shift 99 | case control 100 | case option 101 | case command 102 | case function 103 | case capsLock 104 | 105 | var keyCode: Int { 106 | switch self { 107 | case .shift: return 56 108 | case .control: return 59 109 | case .option: return 58 110 | case .command: return 55 111 | case .function: return 63 112 | case .capsLock: return 57 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /Klic/Utilities/UserPreferences.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // Enum for overlay position options is now in KlicApp.swift 5 | 6 | struct UserPreferences { 7 | // Keys for user preferences 8 | static let overlayOpacityKey = "overlayOpacity" 9 | static let minimalDisplayModeKey = "minimalDisplayMode" 10 | static let showKeyboardInputKey = "showKeyboardInput" 11 | static let showMouseInputKey = "showMouseInput" 12 | static let autoHideDelayKey = "autoHideDelay" 13 | 14 | // Default values 15 | static let defaultOverlayOpacity: Double = 0.85 16 | static let defaultAutoHideDelay: Double = 1.5 17 | 18 | // MARK: - Overlay Opacity 19 | 20 | // Get overlay opacity preference 21 | static func getOverlayOpacity() -> Double { 22 | let value = UserDefaults.standard.double(forKey: overlayOpacityKey) 23 | return value > 0 ? value : defaultOverlayOpacity 24 | } 25 | 26 | // Set overlay opacity preference 27 | static func setOverlayOpacity(_ value: Double) { 28 | UserDefaults.standard.set(value, forKey: overlayOpacityKey) 29 | } 30 | 31 | // MARK: - Input Type Settings 32 | 33 | // Get keyboard input visibility 34 | static func getShowKeyboardInput() -> Bool { 35 | let exists = UserDefaults.standard.object(forKey: showKeyboardInputKey) != nil 36 | return exists ? UserDefaults.standard.bool(forKey: showKeyboardInputKey) : true 37 | } 38 | 39 | // Set keyboard input visibility 40 | static func setShowKeyboardInput(_ value: Bool) { 41 | UserDefaults.standard.set(value, forKey: showKeyboardInputKey) 42 | } 43 | 44 | // Get mouse input visibility 45 | static func getShowMouseInput() -> Bool { 46 | let exists = UserDefaults.standard.object(forKey: showMouseInputKey) != nil 47 | return exists ? UserDefaults.standard.bool(forKey: showMouseInputKey) : true 48 | } 49 | 50 | // Set mouse input visibility 51 | static func setShowMouseInput(_ value: Bool) { 52 | UserDefaults.standard.set(value, forKey: showMouseInputKey) 53 | } 54 | 55 | // MARK: - Auto-Hide Delay 56 | 57 | // Get auto-hide delay 58 | static func getAutoHideDelay() -> Double { 59 | let value = UserDefaults.standard.double(forKey: autoHideDelayKey) 60 | return value > 0 ? value : defaultAutoHideDelay 61 | } 62 | 63 | // Set auto-hide delay 64 | static func setAutoHideDelay(_ value: Double) { 65 | UserDefaults.standard.set(value, forKey: autoHideDelayKey) 66 | } 67 | 68 | // MARK: - Minimal Display Mode 69 | 70 | // Get minimal display mode 71 | static func getMinimalDisplayMode() -> Bool { 72 | return UserDefaults.standard.bool(forKey: minimalDisplayModeKey) 73 | } 74 | 75 | // Set minimal display mode 76 | static func setMinimalDisplayMode(_ value: Bool) { 77 | UserDefaults.standard.set(value, forKey: minimalDisplayModeKey) 78 | NotificationCenter.default.post(name: .MinimalDisplayModeChanged, object: nil) 79 | } 80 | 81 | // MARK: - Register Default Values 82 | 83 | static func registerDefaults() { 84 | UserDefaults.standard.register(defaults: [ 85 | overlayOpacityKey: defaultOverlayOpacity, 86 | minimalDisplayModeKey: false, 87 | showKeyboardInputKey: true, 88 | showMouseInputKey: true, 89 | autoHideDelayKey: defaultAutoHideDelay 90 | ]) 91 | } 92 | } -------------------------------------------------------------------------------- /specs/klic_unified_specs.md: -------------------------------------------------------------------------------- 1 | # Klic - Next-Gen Input Visualizer for macOS 2 | 3 | ## Overview 4 | Klic is a modern, minimal input visualization app for macOS that displays keyboard shortcuts and mouse movements in a beautiful overlay at the bottom center of the screen. Designed with a next-gen aesthetic inspired by Vercel, Linear, and Apple, Klic provides streamers, presenters, and educators with an elegant way to showcase their inputs. 5 | 6 | ## Core Features 7 | 8 | ### 1. Keyboard Visualization 9 | - Real-time display of keyboard inputs with elegant animations 10 | - Special visualization for modifier keys (Command, Option, Shift, Control) 11 | - Combination shortcuts displayed with connecting lines/animations 12 | - Minimal, clean typography for key labels 13 | - Subtle animations for keypress and release 14 | 15 | ### 2. Mouse Visualization 16 | - Cursor position tracking with elegant trail effect 17 | - Click visualization (left, right, middle buttons) 18 | - Scroll wheel actions displayed as directional indicators 19 | - Mouse movement speed represented through trail intensity 20 | 21 | ### 3. UI/UX Design Principles 22 | - Floating overlay with fixed position at bottom center 23 | - Adjustable opacity 24 | - Dark mode with subtle accent colors 25 | - Minimal, distraction-free design 26 | - Adaptive sizing based on screen resolution 27 | 28 | ## Technical Architecture 29 | 30 | ### Core Components 31 | 32 | 1. **Input Monitoring System** 33 | - `KeyboardMonitor`: Captures keyboard events using macOS APIs 34 | - `MouseMonitor`: Monitors cursor position, clicks, and scroll actions 35 | 36 | 2. **Visualization Layer** 37 | - `KeyboardVisualizer`: Renders keyboard inputs with animations 38 | - `MouseVisualizer`: Shows cursor movements and actions 39 | - `InputOverlayView`: Manages the overall overlay appearance 40 | 41 | 3. **Settings & Configuration** 42 | - User preferences for opacity and appearance 43 | - Control over which input types to display 44 | 45 | ### Technical Requirements 46 | 47 | 1. **System Access** 48 | - Accessibility permissions for input monitoring 49 | - Screen recording permissions for overlay positioning 50 | 51 | 2. **Performance Considerations** 52 | - Low CPU/GPU usage to minimize impact on other applications 53 | - Efficient rendering using Metal/SwiftUI 54 | - Minimal memory footprint 55 | 56 | 3. **Compatibility** 57 | - macOS 12.0+ (Monterey and newer) 58 | - Support for Apple Silicon and Intel processors 59 | 60 | ## Design Language 61 | 62 | - **Typography**: SF Pro Display, clean and minimal 63 | - **Color Palette**: Dark background (#121212) with accent colors 64 | - **Animations**: Subtle, fluid transitions with spring physics 65 | - **Shapes**: Rounded rectangles with subtle shadows 66 | - **Spacing**: Generous whitespace, golden ratio proportions 67 | 68 | ## Completed Improvements 69 | 70 | ### 1. Fixed Input Visualization 71 | - **Improved brief overlay display**: The overlay now appears briefly and automatically fades away after input is detected. 72 | - **Smart timing for auto-hide**: Each input type now has its own timer to cleanly fade out (1.5 seconds) after no new inputs are detected. 73 | - **Limited visible inputs**: Reduced the number of simultaneously visible keyboard events (6) and mouse events (3) to prevent visual clutter. 74 | - **Fixed menu bar "Show Overlay" functionality**: Added demo mode with proper examples of keyboard shortcuts and mouse clicks. 75 | 76 | ### 2. Next-Gen Design and UI 77 | - **Modern glass effect**: Implemented a premium dark glass material with subtle inner glow and refined gradients. 78 | - **Dynamic containers**: Containers now only appear when relevant inputs are detected rather than showing all container types at once. 79 | - **Improved visualization aesthetics**: Added subtle shadows and refined the overall look with inspiration from Vercel, Linear, and Arc. 80 | - **Removed info/settings button**: Settings are now only accessible from the menu bar for cleaner visualization. 81 | - **Completely hidden window controls**: Fixed window control visibility by properly hiding title bar and window controls. 82 | 83 | ### 3. Fixed Keyboard Shortcut Display 84 | - **Improved modifier key handling**: Fixed combination display for keys like Cmd+Shift+R. 85 | - **Enhanced key capsule design**: Updated with a modern look and subtle animations. 86 | - **Proper key event clearing**: Keys now disappear after a brief period rather than staying visible indefinitely. 87 | - **Smart key grouping**: Related key inputs are now grouped together for better visual clarity. 88 | 89 | ### 4. Technical Improvements 90 | - **Optimized event processing**: Improved filtering of rapid input events to prevent visual clutter. 91 | - **Better event timing**: Added precise timing for event display and removal. 92 | - **Fixed permission handling**: Better detection and handling of accessibility permissions. 93 | - **Improved window management**: Fixed issues with window appearance and controls. 94 | 95 | ## Next Steps for Future Development 96 | 1. **Additional style themes**: Add customizable themes for different streaming environments 97 | 2. **Enhanced keyboard shortcut display**: Add special visualization for application-specific shortcuts 98 | 3. **Performance optimizations**: Further optimize CPU/GPU usage for prolonged streaming sessions 99 | 4. **Haptic feedback integration**: Add optional haptic feedback for key input detection -------------------------------------------------------------------------------- /Klic/Views/InputOverlayView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // Add a transparent NSView layer that ignores mouse events 4 | struct MouseEventPassthrough: NSViewRepresentable { 5 | func makeNSView(context: Context) -> NSView { 6 | let view = NSView() 7 | view.wantsLayer = true 8 | view.layer?.backgroundColor = NSColor.clear.cgColor 9 | 10 | // Make the view pass-through for mouse events 11 | view.alphaValue = 0.0 12 | 13 | return view 14 | } 15 | 16 | func updateNSView(_ nsView: NSView, context: Context) { 17 | // Nothing to update 18 | } 19 | } 20 | 21 | struct InputOverlayView: View { 22 | @ObservedObject var inputManager: InputManager 23 | 24 | // Design constants 25 | private let containerRadius: CGFloat = 24 // Slightly larger for more premium feel 26 | private let maximumVisibleKeyboardEvents = 6 27 | private let overlaySpacing: CGFloat = 16 28 | private let containerPadding: CGFloat = 14 29 | 30 | // Animation states 31 | @State private var isAppearing = false 32 | 33 | // Computed properties to determine what should be shown 34 | private var shouldShowKeyboard: Bool { 35 | inputManager.activeInputTypes.contains(.keyboard) && !inputManager.keyboardEvents.isEmpty 36 | } 37 | 38 | private var shouldShowMouse: Bool { 39 | inputManager.activeInputTypes.contains(.mouse) && !inputManager.mouseEvents.isEmpty 40 | } 41 | 42 | // Filter to limit keyboard events for better display 43 | private var filteredKeyboardEvents: [InputEvent] { 44 | Array(inputManager.keyboardEvents.prefix(maximumVisibleKeyboardEvents)) 45 | } 46 | 47 | var body: some View { 48 | ZStack { 49 | // Add transparent layer that ignores mouse events 50 | MouseEventPassthrough() 51 | .allowsHitTesting(false) 52 | 53 | VStack(spacing: 0) { 54 | Spacer() 55 | 56 | // Only show container when there are active inputs to display 57 | if shouldShowKeyboard || shouldShowMouse { 58 | HStack(spacing: overlaySpacing) { 59 | // Individual visualizers will only show up when needed 60 | 61 | // Keyboard visualizer with elegant transitions 62 | if shouldShowKeyboard { 63 | KeyboardVisualizer(events: filteredKeyboardEvents) 64 | .frame(height: 65) 65 | .transition(createInsertionTransition()) 66 | .id("keyboard-\(inputManager.keyboardEvents.count)") 67 | } 68 | 69 | // Mouse visualizer 70 | if shouldShowMouse { 71 | MouseVisualizer(events: inputManager.mouseEvents) 72 | .frame(width: 100, height: 65) 73 | .transition(createInsertionTransition()) 74 | .id("mouse-\(inputManager.mouseEvents.count)") 75 | } 76 | } 77 | .padding(containerPadding) 78 | .background( 79 | ZStack { 80 | // Premium glass effect 81 | RoundedRectangle(cornerRadius: containerRadius) 82 | .fill(.ultraThinMaterial) 83 | .opacity(0.9) 84 | 85 | // Subtle inner glow 86 | RoundedRectangle(cornerRadius: containerRadius) 87 | .stroke(Color.white.opacity(0.1), lineWidth: 0.5) 88 | } 89 | ) 90 | .cornerRadius(containerRadius) 91 | .shadow(color: Color.black.opacity(0.15), radius: 10, x: 0, y: 5) 92 | .opacity(inputManager.overlayOpacity) 93 | .transition(.opacity) 94 | .allowsHitTesting(false) // Disable hit testing for the container 95 | } 96 | 97 | Spacer().frame(height: 30) 98 | } 99 | } 100 | .edgesIgnoringSafeArea(.all) 101 | .frame(maxWidth: .infinity, maxHeight: .infinity) 102 | .allowsHitTesting(false) // Disable hit testing for the entire view 103 | } 104 | 105 | // Create a more elegant insertion transition 106 | private func createInsertionTransition() -> AnyTransition { 107 | let insertion = AnyTransition.scale(scale: 0.95) 108 | .combined(with: .opacity) 109 | return insertion 110 | } 111 | } 112 | 113 | // Custom view to create a true blur effect background 114 | struct BlurEffectView: NSViewRepresentable { 115 | let material: NSVisualEffectView.Material 116 | let blendingMode: NSVisualEffectView.BlendingMode 117 | 118 | func makeNSView(context: Context) -> NSVisualEffectView { 119 | let visualEffectView = NSVisualEffectView() 120 | visualEffectView.material = material 121 | visualEffectView.blendingMode = blendingMode 122 | visualEffectView.state = .active 123 | visualEffectView.wantsLayer = true 124 | 125 | // Add subtle animation to the blur when it appears 126 | let animation = CABasicAnimation(keyPath: "opacity") 127 | animation.fromValue = 0.0 128 | animation.toValue = 1.0 129 | animation.duration = 0.3 130 | visualEffectView.layer?.add(animation, forKey: "fadeIn") 131 | 132 | return visualEffectView 133 | } 134 | 135 | func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) { 136 | visualEffectView.material = material 137 | visualEffectView.blendingMode = blendingMode 138 | } 139 | } 140 | 141 | // The ConfigurationView is now moved to a separate file since it's accessed from the menu bar 142 | 143 | #Preview { 144 | let inputManager = InputManager() 145 | inputManager.activeInputTypes = [.keyboard, .mouse] 146 | return InputOverlayView(inputManager: inputManager) 147 | .environmentObject(inputManager) 148 | } -------------------------------------------------------------------------------- /Klic/Views/ConfigurationView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | // Add this comment to indicate we're using the global OverlayPosition 5 | // OverlayPosition is now defined in KlicApp.swift 6 | 7 | struct ConfigurationView: View { 8 | @Binding var opacity: Double 9 | @Environment(\.dismiss) private var dismiss 10 | @ObservedObject private var inputManager = InputManager.shared 11 | 12 | // Theme settings 13 | @State private var selectedTheme: OverlayTheme = .dark 14 | 15 | // Input display options 16 | @State private var showKeyboardInputs: Bool 17 | @State private var showMouseInputs: Bool 18 | 19 | // Behavior settings 20 | @State private var autoHideDelay: Double 21 | 22 | @State private var minimalDisplayMode: Bool = UserPreferences.getMinimalDisplayMode() 23 | 24 | enum OverlayTheme: String, CaseIterable, Identifiable { 25 | case dark = "Dark" 26 | case light = "Light" 27 | case vibrant = "Vibrant" 28 | 29 | var id: String { self.rawValue } 30 | } 31 | 32 | init(opacity: Binding) { 33 | self._opacity = opacity 34 | 35 | // Default to true if the key doesn't exist 36 | let keyboardExists = UserDefaults.standard.object(forKey: "showKeyboardInputs") != nil 37 | let keyboard = UserDefaults.standard.bool(forKey: "showKeyboardInputs") 38 | _showKeyboardInputs = State(initialValue: keyboardExists ? keyboard : true) 39 | 40 | let mouseExists = UserDefaults.standard.object(forKey: "showMouseInputs") != nil 41 | let mouse = UserDefaults.standard.bool(forKey: "showMouseInputs") 42 | _showMouseInputs = State(initialValue: mouseExists ? mouse : true) 43 | 44 | let delay = UserDefaults.standard.double(forKey: "autoHideDelay") 45 | _autoHideDelay = State(initialValue: delay == 0 ? 1.5 : delay) 46 | } 47 | 48 | var body: some View { 49 | VStack(spacing: 0) { 50 | // Header with dismiss button 51 | HStack { 52 | Text("Klic Settings") 53 | .font(.system(size: 18, weight: .bold, design: .rounded)) 54 | 55 | Spacer() 56 | 57 | Button { 58 | dismiss() 59 | } label: { 60 | Image(systemName: "xmark.circle.fill") 61 | .font(.system(size: 20)) 62 | .foregroundColor(.secondary) 63 | } 64 | .buttonStyle(.plain) 65 | } 66 | .padding(.horizontal) 67 | .padding(.top, 24) 68 | .padding(.bottom, 12) 69 | 70 | ScrollView { 71 | VStack(spacing: 24) { 72 | // Appearance section 73 | SettingsSectionView(title: "Appearance") { 74 | VStack(spacing: 16) { 75 | // Opacity slider 76 | VStack(alignment: .leading, spacing: 8) { 77 | HStack { 78 | Text("Overlay Opacity") 79 | .font(.system(size: 14, weight: .medium)) 80 | 81 | Spacer() 82 | 83 | Text("\(Int(opacity * 100))%") 84 | .font(.system(size: 14, weight: .medium)) 85 | .foregroundColor(.secondary) 86 | } 87 | 88 | HStack { 89 | Image(systemName: "circle.fill") 90 | .font(.system(size: 10)) 91 | .foregroundColor(.secondary.opacity(0.5)) 92 | 93 | Slider(value: $opacity, in: 0.3...1.0) { editing in 94 | if !editing { 95 | // Save the opacity preference 96 | UserPreferences.setOverlayOpacity(opacity) 97 | 98 | // Update the input manager 99 | inputManager.updateOpacity(opacity) 100 | } 101 | } 102 | .frame(maxWidth: .infinity) 103 | 104 | Image(systemName: "circle.fill") 105 | .font(.system(size: 14)) 106 | .foregroundColor(.secondary) 107 | } 108 | } 109 | 110 | Divider() 111 | 112 | // Minimal display mode toggle 113 | Toggle(isOn: $minimalDisplayMode) { 114 | VStack(alignment: .leading, spacing: 4) { 115 | Text("Minimal Display Mode") 116 | .font(.system(size: 14, weight: .medium)) 117 | 118 | Text("Show only essential information") 119 | .font(.system(size: 12)) 120 | .foregroundColor(.secondary) 121 | } 122 | } 123 | .toggleStyle(SwitchToggleStyle(tint: Color.accentColor)) 124 | .onChange(of: minimalDisplayMode) { oldValue, newValue in 125 | UserPreferences.setMinimalDisplayMode(newValue) 126 | } 127 | } 128 | } 129 | 130 | // Input Types section 131 | SettingsSectionView(title: "Input Types") { 132 | VStack(spacing: 12) { 133 | // Keyboard toggle 134 | Toggle(isOn: $showKeyboardInputs) { 135 | VStack(alignment: .leading, spacing: 4) { 136 | Text("Keyboard") 137 | .font(.system(size: 14, weight: .medium)) 138 | 139 | Text("Show keyboard shortcuts and key presses") 140 | .font(.system(size: 12)) 141 | .foregroundColor(.secondary) 142 | } 143 | } 144 | .toggleStyle(SwitchToggleStyle(tint: Color.accentColor)) 145 | .onChange(of: showKeyboardInputs) { oldValue, newValue in 146 | UserPreferences.setShowKeyboardInput(newValue) 147 | updateInputVisibility() 148 | } 149 | 150 | Divider() 151 | 152 | // Mouse toggle 153 | Toggle(isOn: $showMouseInputs) { 154 | VStack(alignment: .leading, spacing: 4) { 155 | Text("Mouse") 156 | .font(.system(size: 14, weight: .medium)) 157 | 158 | Text("Show mouse clicks and movements") 159 | .font(.system(size: 12)) 160 | .foregroundColor(.secondary) 161 | } 162 | } 163 | .toggleStyle(SwitchToggleStyle(tint: Color.accentColor)) 164 | .onChange(of: showMouseInputs) { oldValue, newValue in 165 | UserPreferences.setShowMouseInput(newValue) 166 | updateInputVisibility() 167 | } 168 | } 169 | } 170 | 171 | // Behavior section 172 | SettingsSectionView(title: "Behavior") { 173 | VStack(alignment: .leading, spacing: 8) { 174 | HStack { 175 | Text("Auto-hide Delay") 176 | .font(.system(size: 14, weight: .medium)) 177 | 178 | Spacer() 179 | 180 | Text("\(String(format: "%.1f", autoHideDelay))s") 181 | .font(.system(size: 14, weight: .medium)) 182 | .foregroundColor(.secondary) 183 | } 184 | 185 | Slider(value: $autoHideDelay, in: 0.5...5.0, step: 0.5) { editing in 186 | if !editing { 187 | // Save the auto-hide delay preference 188 | UserPreferences.setAutoHideDelay(autoHideDelay) 189 | 190 | // Update the input manager 191 | inputManager.setAutoHideDelay(autoHideDelay) 192 | } 193 | } 194 | } 195 | } 196 | 197 | // Demo section 198 | SettingsSectionView(title: "Demo") { 199 | Button { 200 | // Show demo overlay 201 | inputManager.showDemoMode() 202 | } label: { 203 | HStack { 204 | Text("Show Demo Overlay") 205 | .font(.system(size: 14, weight: .medium)) 206 | 207 | Spacer() 208 | 209 | Image(systemName: "arrow.right.circle.fill") 210 | .font(.system(size: 16)) 211 | } 212 | .padding(.vertical, 4) 213 | } 214 | .buttonStyle(.plain) 215 | } 216 | } 217 | .padding(.horizontal) 218 | .padding(.bottom, 24) 219 | } 220 | } 221 | .frame(width: 400, height: 500) 222 | } 223 | 224 | private func updateInputVisibility() { 225 | // Set visibility of different input types in the InputManager 226 | inputManager.setInputTypeVisibility( 227 | keyboard: showKeyboardInputs, 228 | mouse: showMouseInputs 229 | ) 230 | } 231 | } 232 | 233 | // Custom section view for consistent styling 234 | struct SettingsSectionView: View { 235 | let title: String 236 | let content: Content 237 | 238 | init(title: String, @ViewBuilder content: () -> Content) { 239 | self.title = title 240 | self.content = content() 241 | } 242 | 243 | var body: some View { 244 | VStack(alignment: .leading, spacing: 12) { 245 | Text(title) 246 | .font(.system(size: 13, weight: .semibold, design: .rounded)) 247 | .foregroundColor(.secondary) 248 | .padding(.horizontal, 2) 249 | 250 | VStack { 251 | content 252 | } 253 | .padding() 254 | .background(Color.primary.opacity(0.05)) 255 | .cornerRadius(10) 256 | } 257 | } 258 | } 259 | 260 | // Custom radio button for position selection 261 | struct RadioButton: View { 262 | let title: String 263 | let subtitle: String 264 | let isSelected: Bool 265 | let action: () -> Void 266 | 267 | var body: some View { 268 | Button(action: action) { 269 | HStack(spacing: 12) { 270 | // Radio circle indicator 271 | ZStack { 272 | Circle() 273 | .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.5), lineWidth: 1.5) 274 | .frame(width: 20, height: 20) 275 | 276 | if isSelected { 277 | Circle() 278 | .fill(Color.accentColor) 279 | .frame(width: 12, height: 12) 280 | } 281 | } 282 | 283 | VStack(alignment: .leading, spacing: 2) { 284 | Text(title) 285 | .font(.system(size: 14, weight: .medium)) 286 | .foregroundColor(.primary) 287 | 288 | Text(subtitle) 289 | .font(.system(size: 12)) 290 | .foregroundColor(.secondary) 291 | .lineLimit(1) 292 | } 293 | 294 | Spacer() 295 | } 296 | } 297 | .buttonStyle(.plain) 298 | } 299 | } 300 | 301 | // Add static shared instance for easy access from multiple views 302 | extension InputManager { 303 | static let shared = InputManager() 304 | } 305 | 306 | #Preview { 307 | ConfigurationView(opacity: .constant(0.9)) 308 | } -------------------------------------------------------------------------------- /Klic/Monitors/KeyboardMonitor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Cocoa 3 | import Combine 4 | 5 | class KeyboardMonitor: ObservableObject { 6 | @Published var currentEvents: [InputEvent] = [] 7 | @Published var isMonitoring: Bool = false 8 | 9 | private var eventTap: CFMachPort? 10 | private var runLoopSource: CFRunLoopSource? 11 | private let eventSubject = PassthroughSubject() 12 | private var cancellables = Set() 13 | 14 | // Add tracking properties to prevent duplicate events 15 | private var lastProcessedKeyCode: Int = -1 16 | private var lastProcessedModifiers: [KeyModifier] = [] 17 | private var lastProcessedTime: Date = Date.distantPast 18 | private var lastProcessedIsDown: Bool = false 19 | private let duplicateThreshold: TimeInterval = 0.1 // Increased threshold to better detect duplicates 20 | 21 | // Keep track of recently processed events to better filter duplicates 22 | private var recentlyProcessedEvents: [(keyCode: Int, modifiers: [KeyModifier], isDown: Bool, timestamp: Date)] = [] 23 | private let maxRecentEvents = 10 24 | 25 | // Map of key codes to character representations 26 | private let keyCodeMap: [Int: String] = [ 27 | 0: "a", 1: "s", 2: "d", 3: "f", 4: "h", 5: "g", 6: "z", 7: "x", 28 | 8: "c", 9: "v", 11: "b", 12: "q", 13: "w", 14: "e", 15: "r", 29 | 16: "y", 17: "t", 18: "1", 19: "2", 20: "3", 21: "4", 22: "6", 30 | 23: "5", 24: "=", 25: "9", 26: "7", 27: "-", 28: "8", 29: "0", 31 | 30: "]", 31: "o", 32: "u", 33: "[", 34: "i", 35: "p", 36: "\r", 32 | 37: "l", 38: "j", 39: "'", 40: "k", 41: ";", 42: "\\", 43: ",", 33 | 44: "/", 45: "n", 46: "m", 47: ".", 48: "\t", 49: " ", 50: "`", 34 | 51: "\u{7f}", 53: "\u{1b}", 55: "⌘", 56: "⇧", 57: "⇪", 58: "⌥", 35 | 59: "⌃", 60: "⇧", 61: "⌥", 62: "⌃", 63: "fn", 36 | 65: ".", 67: "*", 69: "+", 71: "⌧", 75: "/", 76: "⏎", 78: "-", 37 | 81: "=", 82: "0", 83: "1", 84: "2", 85: "3", 86: "4", 87: "5", 38 | 88: "6", 89: "7", 91: "8", 92: "9", 39 | 96: "F5", 97: "F6", 98: "F7", 99: "F3", 100: "F8", 101: "F9", 40 | 103: "F11", 109: "F10", 111: "F12", 105: "F13", 107: "F14", 113: "F15", 41 | 114: "⇞", 115: "⇟", 116: "↖", 117: "↘", 118: "⌦", 119: "F4", 120: "F2", 42 | 121: "F1", 122: "F3", 123: "←", 124: "→", 125: "↓", 126: "↑" 43 | ] 44 | 45 | init() { 46 | setupSubscription() 47 | } 48 | 49 | deinit { 50 | stopMonitoring() 51 | } 52 | 53 | private func setupSubscription() { 54 | // Complete rewrite of the subscription logic to prevent duplicates 55 | eventSubject 56 | .receive(on: RunLoop.main) 57 | .sink { [weak self] event in 58 | guard let self = self else { return } 59 | 60 | // Extract keyboard event if present 61 | guard let newKeyEvent = event.keyboardEvent else { return } 62 | 63 | // For key-up events, always remove the corresponding key-down event 64 | if !newKeyEvent.isDown { 65 | // Remove any existing events with the same key code that are down 66 | self.currentEvents.removeAll { existingEvent in 67 | if let existingKey = existingEvent.keyboardEvent { 68 | return existingKey.keyCode == newKeyEvent.keyCode && existingKey.isDown 69 | } 70 | return false 71 | } 72 | 73 | // We don't need to show key-up events, just removing the key-down is enough 74 | return 75 | } 76 | 77 | // For key-down events, check if we already have this key 78 | let keyAlreadyExists = self.currentEvents.contains { existingEvent in 79 | if let existingKey = existingEvent.keyboardEvent { 80 | // Consider it a duplicate if: 81 | // 1. Same key code and state (down) 82 | // 2. Same modifiers (unless it's a modifier key itself) 83 | let sameCode = existingKey.keyCode == newKeyEvent.keyCode && existingKey.isDown 84 | 85 | // If it's a modifier key, we check more carefully 86 | if newKeyEvent.isModifierKey { 87 | return sameCode 88 | } else { 89 | return sameCode && Set(existingKey.modifiers) == Set(newKeyEvent.modifiers) 90 | } 91 | } 92 | return false 93 | } 94 | 95 | // Skip this event if we already have this key in the same state 96 | if keyAlreadyExists { 97 | Logger.debug("Skipping duplicate key event for key=\(newKeyEvent.key)", log: Logger.keyboard) 98 | return 99 | } 100 | 101 | // Add the new event 102 | self.currentEvents.append(event) 103 | 104 | // Limit events to 4 (further reduced to prevent clutter) 105 | if self.currentEvents.count > 4 { 106 | self.currentEvents.removeFirst(self.currentEvents.count - 4) 107 | } 108 | 109 | Logger.debug("Current key events: \(self.currentEvents.count)", log: Logger.keyboard) 110 | } 111 | .store(in: &cancellables) 112 | } 113 | 114 | // Add the createEventTap method 115 | private func createEventTap() -> CFMachPort? { 116 | // Create an event tap to monitor keyboard events 117 | let eventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) 118 | 119 | let eventTap = CGEvent.tapCreate( 120 | tap: .cgSessionEventTap, 121 | place: .headInsertEventTap, 122 | options: .defaultTap, 123 | eventsOfInterest: CGEventMask(eventMask), 124 | callback: { (proxy, type, event, refcon) -> Unmanaged? in 125 | // Get the keyboard monitor instance from refcon 126 | guard let refcon = refcon else { 127 | return Unmanaged.passRetained(event) 128 | } 129 | 130 | let keyboardMonitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() 131 | keyboardMonitor.handleCGEvent(type: type, event: event) 132 | 133 | // Pass the event through 134 | return Unmanaged.passRetained(event) 135 | }, 136 | userInfo: Unmanaged.passUnretained(self).toOpaque() 137 | ) 138 | 139 | if eventTap == nil { 140 | Logger.error("Failed to create event tap for keyboard monitoring", log: Logger.keyboard) 141 | } 142 | 143 | return eventTap 144 | } 145 | 146 | func startMonitoring() { 147 | Logger.debug("Starting keyboard monitoring", log: Logger.keyboard) 148 | 149 | guard !isMonitoring else { 150 | Logger.debug("Keyboard monitoring already active", log: Logger.keyboard) 151 | return 152 | } 153 | 154 | isMonitoring = true 155 | eventTap = createEventTap() 156 | 157 | if let eventTap = eventTap { 158 | Logger.debug("Created keyboard event tap successfully", log: Logger.keyboard) 159 | let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) 160 | CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) 161 | CGEvent.tapEnable(tap: eventTap, enable: true) 162 | 163 | // Remove the test event sending 164 | // sendTestEvent() 165 | } else { 166 | Logger.error("Failed to create keyboard event tap", log: Logger.keyboard) 167 | isMonitoring = false 168 | } 169 | } 170 | 171 | func stopMonitoring() { 172 | Logger.info("Stopping keyboard monitoring", log: Logger.keyboard) 173 | 174 | if let eventTap = eventTap { 175 | CGEvent.tapEnable(tap: eventTap, enable: false) 176 | } 177 | 178 | if let runLoopSource = runLoopSource { 179 | CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) 180 | } 181 | 182 | eventTap = nil 183 | runLoopSource = nil 184 | isMonitoring = false 185 | 186 | currentEvents.removeAll() 187 | } 188 | 189 | private func handleCGEvent(type: CGEventType, event: CGEvent) { 190 | let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode)) 191 | let flags = event.flags 192 | 193 | // Check if this is an arrow key 194 | let isArrowKey = keyCode == 123 || // Left arrow 195 | keyCode == 124 || // Right arrow 196 | keyCode == 125 || // Up arrow 197 | keyCode == 126 // Down arrow 198 | 199 | var modifiers: [KeyModifier] = [] 200 | if flags.contains(.maskCommand) { modifiers.append(.command) } 201 | if flags.contains(.maskShift) { modifiers.append(.shift) } 202 | if flags.contains(.maskAlternate) { modifiers.append(.option) } 203 | if flags.contains(.maskControl) { modifiers.append(.control) } 204 | 205 | // Only add function modifier if it's not an arrow key or there are other modifiers 206 | if flags.contains(.maskSecondaryFn) && (!isArrowKey || modifiers.count > 0) { 207 | modifiers.append(.function) 208 | } 209 | 210 | if flags.contains(.maskAlphaShift) { modifiers.append(.capsLock) } 211 | 212 | // Get proper characters with modifiers applied 213 | let characters = keyCodeToString(keyCode) 214 | 215 | let isRepeat = type == .keyDown && event.getIntegerValueField(.keyboardEventAutorepeat) == 1 216 | let isDown = type == .keyDown 217 | let currentTime = Date() 218 | 219 | // Skip auto-repeat events completely - they cause duplicate display issues 220 | if isRepeat { 221 | Logger.debug("Skipping auto-repeat event for key=\(characters)", log: Logger.keyboard) 222 | return 223 | } 224 | 225 | // Check for duplicate events - use very strict criteria 226 | let isDuplicate = isDuplicateEvent(keyCode: keyCode, modifiers: modifiers, isDown: isDown, currentTime: currentTime) 227 | 228 | if !isDuplicate { 229 | // Add to recent events tracking 230 | addToRecentEvents(keyCode: keyCode, modifiers: modifiers, isDown: isDown, timestamp: currentTime) 231 | 232 | // Create the input event 233 | let keyboardEvent = KeyboardEvent( 234 | key: characters, 235 | keyCode: keyCode, 236 | isDown: isDown, 237 | modifiers: modifiers, 238 | characters: characters, 239 | isRepeat: isRepeat 240 | ) 241 | let inputEvent = InputEvent.keyboardEvent(event: keyboardEvent) 242 | 243 | Logger.debug("Keyboard event: \(isDown ? "down" : "up") key=\(characters) keyCode=\(keyCode) modifiers=\(modifiers)", log: Logger.keyboard) 244 | 245 | // Send the new event 246 | eventSubject.send(inputEvent) 247 | 248 | // Update last processed values 249 | lastProcessedKeyCode = keyCode 250 | lastProcessedModifiers = modifiers 251 | lastProcessedTime = currentTime 252 | lastProcessedIsDown = isDown 253 | } else { 254 | Logger.debug("Ignored duplicate keyboard event for key=\(characters)", log: Logger.keyboard) 255 | } 256 | } 257 | 258 | // Make duplicate detection much stricter 259 | private func isDuplicateEvent(keyCode: Int, modifiers: [KeyModifier], isDown: Bool, currentTime: Date) -> Bool { 260 | // Super strict threshold for exact same key - 200ms 261 | if keyCode == lastProcessedKeyCode && 262 | isDown == lastProcessedIsDown && 263 | currentTime.timeIntervalSince(lastProcessedTime) < 0.2 { 264 | return true 265 | } 266 | 267 | // Also check against recent events with an even stricter timing 268 | for event in recentlyProcessedEvents.suffix(3) { // Only look at 3 most recent 269 | if event.keyCode == keyCode && 270 | event.isDown == isDown && 271 | currentTime.timeIntervalSince(event.timestamp) < 0.3 { 272 | return true 273 | } 274 | } 275 | 276 | return false 277 | } 278 | 279 | // Helper method to add an event to recent events 280 | private func addToRecentEvents(keyCode: Int, modifiers: [KeyModifier], isDown: Bool, timestamp: Date) { 281 | recentlyProcessedEvents.append((keyCode: keyCode, modifiers: modifiers, isDown: isDown, timestamp: timestamp)) 282 | 283 | // Trim the list if it gets too long 284 | if recentlyProcessedEvents.count > maxRecentEvents { 285 | recentlyProcessedEvents.removeFirst() 286 | } 287 | } 288 | 289 | private func keyCodeToString(_ keyCode: Int) -> String { 290 | return keyCodeMap[keyCode] ?? "key\(keyCode)" 291 | } 292 | 293 | // Helper function to check if a key is a modifier key 294 | private func isModifierKey(_ keyCode: Int) -> Bool { 295 | return keyCode == 55 || // command 296 | keyCode == 56 || // shift 297 | keyCode == 58 || // option 298 | keyCode == 59 || // control 299 | keyCode == 63 || // function 300 | keyCode == 57 // caps lock 301 | } 302 | 303 | // Helper function to get the CGEventFlags for a modifier 304 | private func flagForModifier(_ modifier: KeyModifier) -> CGEventFlags { 305 | switch modifier { 306 | case .command: return .maskCommand 307 | case .shift: return .maskShift 308 | case .option: return .maskAlternate 309 | case .control: return .maskControl 310 | case .function: return .maskSecondaryFn 311 | case .capsLock: return .maskAlphaShift 312 | } 313 | } 314 | } -------------------------------------------------------------------------------- /Klic/Monitors/MouseMonitor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Cocoa 3 | import Combine 4 | 5 | class MouseMonitor: ObservableObject { 6 | @Published var currentEvents: [InputEvent] = [] 7 | @Published var isMonitoring: Bool = false 8 | 9 | private var eventTap: CFMachPort? 10 | private var runLoopSource: CFRunLoopSource? 11 | private var cancellables = Set() 12 | 13 | // For tracking mouse movement 14 | private var lastMousePosition: NSPoint = .zero 15 | private var lastMouseTime: Date = Date() 16 | private let movementThreshold: CGFloat = 3.0 // Reduced threshold for more responsive movement detection 17 | 18 | // For tracking click state 19 | private var isLeftMouseDown = false 20 | private var isRightMouseDown = false 21 | private var lastClickTime: Date = Date.distantPast 22 | private let doubleClickThreshold: TimeInterval = 0.3 23 | 24 | // For debouncing movement events 25 | private var lastMovementEventTime: Date = Date.distantPast 26 | private let movementDebounceInterval: TimeInterval = 0.05 // 50ms debounce for smoother visualization 27 | 28 | init() { 29 | Logger.info("Initializing MouseMonitor", log: Logger.mouse) 30 | setupEventFiltering() 31 | } 32 | 33 | private func setupEventFiltering() { 34 | // Set up filtering to keep only recent movement events 35 | $currentEvents 36 | .receive(on: RunLoop.main) 37 | .sink { [weak self] events in 38 | guard let self = self else { return } 39 | 40 | // Filter movement events 41 | let movementEvents = events.filter { $0.mouseEvent != nil && $0.type == .mouse } 42 | 43 | // If we have too many movement events, remove the oldest ones 44 | if movementEvents.count > 3 { // Reduced from 5 to 3 for cleaner display 45 | // Keep only the most recent movements 46 | let toRemove = movementEvents.count - 3 47 | self.currentEvents.removeAll { event in 48 | event.mouseEvent != nil && event.type == .mouse && 49 | movementEvents.prefix(toRemove).contains { oldEvent in oldEvent.id == event.id } 50 | } 51 | } 52 | } 53 | .store(in: &cancellables) 54 | } 55 | 56 | func startMonitoring() { 57 | Logger.info("Starting mouse monitoring", log: Logger.mouse) 58 | 59 | // If already monitoring, stop first 60 | if isMonitoring { 61 | stopMonitoring() 62 | } 63 | 64 | // Create an event tap to monitor mouse events 65 | let eventMask = (1 << CGEventType.leftMouseDown.rawValue) | 66 | (1 << CGEventType.leftMouseUp.rawValue) | 67 | (1 << CGEventType.rightMouseDown.rawValue) | 68 | (1 << CGEventType.rightMouseUp.rawValue) | 69 | (1 << CGEventType.mouseMoved.rawValue) | 70 | (1 << CGEventType.scrollWheel.rawValue) | 71 | (1 << CGEventType.otherMouseDown.rawValue) | // Added for middle button 72 | (1 << CGEventType.otherMouseUp.rawValue) // Added for middle button 73 | 74 | guard let eventTap = CGEvent.tapCreate( 75 | tap: .cgSessionEventTap, 76 | place: .headInsertEventTap, 77 | options: .defaultTap, 78 | eventsOfInterest: CGEventMask(eventMask), 79 | callback: { (proxy, type, event, refcon) -> Unmanaged? in 80 | // Get the mouse monitor instance from refcon 81 | guard let refcon = refcon else { 82 | return Unmanaged.passRetained(event) 83 | } 84 | 85 | let mouseMonitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() 86 | mouseMonitor.handleMouseEvent(type: type, event: event) 87 | 88 | // Pass the event through 89 | return Unmanaged.passRetained(event) 90 | }, 91 | userInfo: Unmanaged.passUnretained(self).toOpaque() 92 | ) else { 93 | Logger.error("Failed to create event tap for mouse monitoring", log: Logger.mouse) 94 | return 95 | } 96 | 97 | self.eventTap = eventTap 98 | 99 | // Create a run loop source and add it to the main run loop 100 | runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) 101 | CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) 102 | CGEvent.tapEnable(tap: eventTap, enable: true) 103 | 104 | // Initialize last position 105 | let mouseLocation = NSEvent.mouseLocation 106 | lastMousePosition = mouseLocation 107 | lastMouseTime = Date() 108 | 109 | // Reset tracking state 110 | isLeftMouseDown = false 111 | isRightMouseDown = false 112 | 113 | isMonitoring = true 114 | Logger.info("Mouse monitoring started", log: Logger.mouse) 115 | } 116 | 117 | func stopMonitoring() { 118 | Logger.info("Stopping mouse monitoring", log: Logger.mouse) 119 | 120 | if let eventTap = eventTap { 121 | CGEvent.tapEnable(tap: eventTap, enable: false) 122 | } 123 | 124 | if let runLoopSource = runLoopSource { 125 | CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) 126 | } 127 | 128 | eventTap = nil 129 | runLoopSource = nil 130 | isMonitoring = false 131 | 132 | Logger.info("Mouse monitoring stopped", log: Logger.mouse) 133 | } 134 | 135 | private func handleMouseEvent(type: CGEventType, event: CGEvent) { 136 | let timestamp = Date() 137 | let location = NSEvent.mouseLocation 138 | 139 | switch type { 140 | case .leftMouseDown: 141 | isLeftMouseDown = true 142 | 143 | // Check for double click 144 | let isDoubleClick = timestamp.timeIntervalSince(lastClickTime) < doubleClickThreshold 145 | lastClickTime = timestamp 146 | 147 | let mouseEvent = MouseEvent( 148 | position: location, 149 | button: .left, 150 | scrollDelta: nil, 151 | isDown: true, 152 | isDoubleClick: isDoubleClick, 153 | isMomentumScroll: false 154 | ) 155 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 156 | DispatchQueue.main.async { 157 | // Remove any existing left mouse down events to avoid duplicates 158 | self.currentEvents.removeAll { $0.mouseEvent?.button == .left && $0.type == .mouse } 159 | self.currentEvents.append(inputEvent) 160 | } 161 | Logger.debug("Left mouse down at \(location)", log: Logger.mouse) 162 | 163 | case .leftMouseUp: 164 | isLeftMouseDown = false 165 | 166 | let mouseEvent = MouseEvent( 167 | position: location, 168 | button: .left, 169 | scrollDelta: nil, 170 | isDown: false, 171 | isDoubleClick: false, 172 | isMomentumScroll: false 173 | ) 174 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 175 | DispatchQueue.main.async { 176 | // Remove any existing left mouse up events to avoid duplicates 177 | self.currentEvents.removeAll { $0.mouseEvent?.button == .left && $0.type == .mouse } 178 | self.currentEvents.append(inputEvent) 179 | } 180 | Logger.debug("Left mouse up at \(location)", log: Logger.mouse) 181 | 182 | case .rightMouseDown: 183 | isRightMouseDown = true 184 | 185 | let mouseEvent = MouseEvent( 186 | position: location, 187 | button: .right, 188 | scrollDelta: nil, 189 | isDown: true, 190 | isDoubleClick: false, 191 | isMomentumScroll: false 192 | ) 193 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 194 | DispatchQueue.main.async { 195 | // Remove any existing right mouse down events to avoid duplicates 196 | self.currentEvents.removeAll { $0.mouseEvent?.button == .right && $0.type == .mouse } 197 | self.currentEvents.append(inputEvent) 198 | } 199 | Logger.debug("Right mouse down at \(location)", log: Logger.mouse) 200 | 201 | case .rightMouseUp: 202 | isRightMouseDown = false 203 | 204 | let mouseEvent = MouseEvent( 205 | position: location, 206 | button: .right, 207 | scrollDelta: nil, 208 | isDown: false, 209 | isDoubleClick: false, 210 | isMomentumScroll: false 211 | ) 212 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 213 | DispatchQueue.main.async { 214 | // Remove any existing right mouse up events to avoid duplicates 215 | self.currentEvents.removeAll { $0.mouseEvent?.button == .right && $0.type == .mouse } 216 | self.currentEvents.append(inputEvent) 217 | } 218 | Logger.debug("Right mouse up at \(location)", log: Logger.mouse) 219 | 220 | case .otherMouseDown: 221 | // Handle middle button or other mouse buttons 222 | let buttonNumber = event.getIntegerValueField(.mouseEventButtonNumber) 223 | 224 | // Button 2 is typically middle button 225 | if buttonNumber == 2 { 226 | let mouseEvent = MouseEvent( 227 | position: location, 228 | button: .middle, 229 | scrollDelta: nil, 230 | isDown: true, 231 | isDoubleClick: false, 232 | isMomentumScroll: false 233 | ) 234 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 235 | DispatchQueue.main.async { 236 | // Remove any existing middle mouse events to avoid duplicates 237 | self.currentEvents.removeAll { $0.mouseEvent?.button == .middle && $0.type == .mouse } 238 | self.currentEvents.append(inputEvent) 239 | } 240 | Logger.debug("Middle mouse down at \(location)", log: Logger.mouse) 241 | } else if buttonNumber == 3 { 242 | // Extra button 1 (often back button) 243 | let mouseEvent = MouseEvent( 244 | position: location, 245 | button: .extra1, 246 | scrollDelta: nil, 247 | isDown: true, 248 | isDoubleClick: false, 249 | isMomentumScroll: false 250 | ) 251 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 252 | DispatchQueue.main.async { 253 | self.currentEvents.append(inputEvent) 254 | } 255 | } else if buttonNumber == 4 { 256 | // Extra button 2 (often forward button) 257 | let mouseEvent = MouseEvent( 258 | position: location, 259 | button: .extra2, 260 | scrollDelta: nil, 261 | isDown: true, 262 | isDoubleClick: false, 263 | isMomentumScroll: false 264 | ) 265 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 266 | DispatchQueue.main.async { 267 | self.currentEvents.append(inputEvent) 268 | } 269 | } 270 | 271 | case .otherMouseUp: 272 | // Handle middle button or other mouse buttons up events 273 | let buttonNumber = event.getIntegerValueField(.mouseEventButtonNumber) 274 | 275 | if buttonNumber == 2 { 276 | let mouseEvent = MouseEvent( 277 | position: location, 278 | button: .middle, 279 | scrollDelta: nil, 280 | isDown: false, 281 | isDoubleClick: false, 282 | isMomentumScroll: false 283 | ) 284 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 285 | DispatchQueue.main.async { 286 | self.currentEvents.removeAll { $0.mouseEvent?.button == .middle && $0.type == .mouse } 287 | self.currentEvents.append(inputEvent) 288 | } 289 | Logger.debug("Middle mouse up at \(location)", log: Logger.mouse) 290 | } else if buttonNumber == 3 || buttonNumber == 4 { 291 | // Extra buttons up events 292 | let button: MouseButton = buttonNumber == 3 ? .extra1 : .extra2 293 | let mouseEvent = MouseEvent( 294 | position: location, 295 | button: button, 296 | scrollDelta: nil, 297 | isDown: false, 298 | isDoubleClick: false, 299 | isMomentumScroll: false 300 | ) 301 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 302 | DispatchQueue.main.async { 303 | self.currentEvents.append(inputEvent) 304 | } 305 | } 306 | 307 | case .mouseMoved: 308 | // Completely disable mouse movement events 309 | // We'll only track the position for other event types 310 | lastMousePosition = location 311 | lastMouseTime = timestamp 312 | 313 | // Comment out the previous movement tracking code 314 | /* 315 | // Check if we've moved enough to register a new event 316 | let distance = hypot(location.x - lastMousePosition.x, location.y - lastMousePosition.y) 317 | let timeDiff = timestamp.timeIntervalSince(lastMouseTime) 318 | let debounceTimeDiff = timestamp.timeIntervalSince(lastMovementEventTime) 319 | 320 | // Only register movement if: 321 | // 1. We've moved enough distance OR enough time has passed 322 | // 2. We're outside the debounce interval to prevent flooding 323 | if (distance > movementThreshold || timeDiff > 0.1) && debounceTimeDiff > movementDebounceInterval { 324 | // Calculate speed (pixels per second) 325 | let speed = CGFloat(distance / max(0.001, timeDiff)) 326 | 327 | // Create movement event 328 | let mouseEvent = MouseEvent( 329 | position: location, 330 | button: nil, 331 | scrollDelta: nil, 332 | isDown: false, 333 | isDoubleClick: false, 334 | isMomentumScroll: false 335 | ) 336 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 337 | 338 | DispatchQueue.main.async { 339 | // Remove any existing mouse movement events to distinguish from clicks 340 | self.currentEvents.removeAll { event in 341 | guard event.type == .mouse, 342 | let mouseEvent = event.mouseEvent, 343 | mouseEvent.scrollDelta == nil else { 344 | return false 345 | } 346 | // Check if it's a mouse move event (no button press) 347 | return mouseEvent.button == nil && !mouseEvent.isDown 348 | } 349 | // Add the new movement event 350 | self.currentEvents.append(inputEvent) 351 | } 352 | 353 | lastMousePosition = location 354 | lastMouseTime = timestamp 355 | lastMovementEventTime = timestamp 356 | Logger.debug("Mouse moved to \(location) with speed \(speed)", log: Logger.mouse) 357 | } 358 | */ 359 | 360 | case .scrollWheel: 361 | let deltaY = event.getDoubleValueField(.scrollWheelEventDeltaAxis1) 362 | let deltaX = event.getDoubleValueField(.scrollWheelEventDeltaAxis2) 363 | 364 | // Check if this is a momentum scroll event 365 | let phase = event.getIntegerValueField(.scrollWheelEventMomentumPhase) 366 | let isMomentum = phase != 0 367 | 368 | // Ignore momentum scrolling - only show actual user scroll actions 369 | if (abs(deltaY) > 0.1 || abs(deltaX) > 0.1) && !isMomentum { 370 | let mouseEvent = MouseEvent( 371 | position: location, 372 | button: nil, 373 | scrollDelta: CGPoint(x: deltaX, y: deltaY), 374 | isDown: false, 375 | isDoubleClick: false, 376 | isMomentumScroll: false // Never show momentum scrolling 377 | ) 378 | let inputEvent = InputEvent.mouseEvent(event: mouseEvent) 379 | 380 | DispatchQueue.main.async { 381 | // Remove all existing scroll events before adding the new one 382 | self.currentEvents.removeAll { event in 383 | guard event.type == .mouse, 384 | let mouseEvent = event.mouseEvent else { 385 | return false 386 | } 387 | return mouseEvent.scrollDelta != nil 388 | } 389 | self.currentEvents.append(inputEvent) 390 | 391 | // Schedule removal of this scroll event after a delay 392 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 393 | self.currentEvents.removeAll { event in 394 | guard event.type == .mouse, 395 | let mouseEvent = event.mouseEvent else { 396 | return false 397 | } 398 | return mouseEvent.scrollDelta != nil 399 | } 400 | } 401 | } 402 | 403 | Logger.debug("Mouse scrolled with delta (\(deltaX), \(deltaY)), momentum: \(isMomentum)", log: Logger.mouse) 404 | } 405 | 406 | default: 407 | break 408 | } 409 | } 410 | } -------------------------------------------------------------------------------- /Klic/Views/KeyboardVisualizer.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct KeyboardVisualizer: View { 4 | let events: [InputEvent] 5 | 6 | // Animation states 7 | @State private var isAnimating = false 8 | 9 | // Constants for design consistency 10 | private let keyPadding: CGFloat = 4 11 | private let keyCornerRadius: CGFloat = 6 12 | private let maxKeyCount = 6 13 | 14 | // Filtered events to limit display 15 | private var filteredEvents: [InputEvent] { 16 | Array(events.prefix(maxKeyCount)) 17 | } 18 | 19 | // Keyboard shortcut detection 20 | private var isShortcut: Bool { 21 | // We need to have at least one modifier key AND one regular key 22 | let hasModifier = filteredEvents.contains { event in 23 | guard let keyEvent = event.keyboardEvent else { return false } 24 | // Count either explicit modifiers list or actual modifier keys 25 | return !keyEvent.modifiers.isEmpty || keyEvent.isModifierKey 26 | } 27 | 28 | let hasRegularKey = filteredEvents.contains { event in 29 | guard let keyEvent = event.keyboardEvent else { return false } 30 | // Regular keys are ones that aren't modifiers themselves and are down 31 | return !keyEvent.isModifierKey && keyEvent.isDown 32 | } 33 | 34 | // Only consider as shortcut if we have both parts 35 | return hasModifier && hasRegularKey 36 | } 37 | 38 | @State private var isMinimalMode: Bool = false 39 | 40 | var body: some View { 41 | // Check for minimal mode on appearance 42 | let _ = onAppear { 43 | isMinimalMode = UserPreferences.getMinimalDisplayMode() 44 | 45 | // Listen for minimal mode changes 46 | NotificationCenter.default.addObserver( 47 | forName: .MinimalDisplayModeChanged, 48 | object: nil, 49 | queue: .main 50 | ) { _ in 51 | isMinimalMode = UserPreferences.getMinimalDisplayMode() 52 | } 53 | } 54 | 55 | if events.isEmpty { 56 | EmptyView() 57 | } else { 58 | if isMinimalMode { 59 | minimalKeyboardView 60 | } else { 61 | standardKeyboardView 62 | } 63 | } 64 | } 65 | 66 | // Standard keyboard visualization 67 | private var standardKeyboardView: some View { 68 | HStack(spacing: keyPadding) { 69 | if isShortcut { 70 | // Display as shortcut 71 | ShortcutVisualizer(events: filteredEvents) 72 | .transition(.asymmetric( 73 | insertion: .scale(scale: 0.9).combined(with: .opacity), 74 | removal: .opacity 75 | )) 76 | } else { 77 | // Display individual keys 78 | ForEach(filteredEvents) { event in 79 | if let keyEvent = event.keyboardEvent { 80 | KeyCapsuleView(keyEvent: keyEvent) 81 | .transition(.asymmetric( 82 | insertion: .scale(scale: 0.9).combined(with: .opacity), 83 | removal: .opacity 84 | )) 85 | } 86 | } 87 | } 88 | } 89 | .animation(.spring(response: 0.3, dampingFraction: 0.7), value: filteredEvents.count) 90 | .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShortcut) 91 | .onAppear { 92 | withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { 93 | isAnimating = true 94 | } 95 | } 96 | } 97 | 98 | // Minimal keyboard visualization 99 | private var minimalKeyboardView: some View { 100 | HStack(spacing: 2) { 101 | if isShortcut { 102 | // Display as minimal shortcut 103 | minimalShortcutView 104 | } else { 105 | // Display individual keys (max 3 in minimal mode) 106 | ForEach(filteredEvents.prefix(3)) { event in 107 | if let keyEvent = event.keyboardEvent { 108 | minimalKeyView(keyEvent: keyEvent) 109 | } 110 | } 111 | } 112 | } 113 | .padding(4) 114 | .background(Color.black.opacity(0.5)) 115 | .cornerRadius(4) 116 | .animation(.spring(response: 0.2, dampingFraction: 0.7), value: filteredEvents.count) 117 | } 118 | 119 | // Minimal key view 120 | private func minimalKeyView(keyEvent: KeyboardEvent) -> some View { 121 | let keyText = getKeyText(keyEvent) 122 | 123 | return Text(keyText) 124 | .font(.system(size: 12, weight: .medium, design: .rounded)) 125 | .foregroundColor(.white) 126 | .padding(.vertical, 2) 127 | .padding(.horizontal, 4) 128 | .background( 129 | RoundedRectangle(cornerRadius: 4) 130 | .fill(Color(.sRGB, red: 0.2, green: 0.2, blue: 0.25, opacity: 0.9)) 131 | ) 132 | } 133 | 134 | // Minimal shortcut view 135 | private var minimalShortcutView: some View { 136 | let shortcutText = getShortcutText() 137 | 138 | return Text(shortcutText) 139 | .font(.system(size: 12, weight: .medium, design: .rounded)) 140 | .foregroundColor(.white) 141 | .padding(.vertical, 2) 142 | .padding(.horizontal, 6) 143 | .background( 144 | RoundedRectangle(cornerRadius: 4) 145 | .fill(Color(.sRGB, red: 0.2, green: 0.2, blue: 0.25, opacity: 0.9)) 146 | ) 147 | } 148 | 149 | // Helper to get key text 150 | private func getKeyText(_ keyEvent: KeyboardEvent) -> String { 151 | if let char = keyEvent.characters { 152 | switch char { 153 | case "\r": return "↩" 154 | case "\t": return "⇥" 155 | case " ": return "␣" 156 | case "\u{1b}": return "⎋" 157 | case "\u{7f}": return "⌫" 158 | default: 159 | if char.count == 1 { 160 | return char.uppercased() 161 | } else { 162 | return char 163 | } 164 | } 165 | } else { 166 | return "•" 167 | } 168 | } 169 | 170 | // Helper to get shortcut text 171 | private func getShortcutText() -> String { 172 | var text = "" 173 | 174 | // First collect all currently active modifiers 175 | var activeModifiers: Set = [] 176 | 177 | // Find active modifier keys that are currently DOWN 178 | for event in filteredEvents { 179 | if let keyEvent = event.keyboardEvent, keyEvent.isDown { 180 | // Add explicit modifiers 181 | if !keyEvent.modifiers.isEmpty { 182 | activeModifiers.formUnion(keyEvent.modifiers) 183 | } 184 | 185 | // Add the key itself if it's a modifier key 186 | if keyEvent.isModifierKey { 187 | if let mod = KeyModifier.allCases.first(where: { $0.keyCode == keyEvent.keyCode }) { 188 | activeModifiers.insert(mod) 189 | } 190 | } 191 | } 192 | } 193 | 194 | // Find the most recent non-modifier key that is DOWN 195 | // Important: Use timestamp to ensure we get the latest key 196 | var latestKeyEvent: (event: InputEvent, keyEvent: KeyboardEvent)? = nil 197 | 198 | for event in filteredEvents { 199 | if let keyEvent = event.keyboardEvent, 200 | !keyEvent.isModifierKey, 201 | keyEvent.isDown { 202 | // Only update if we don't have one yet or this one is more recent 203 | if latestKeyEvent == nil || event.timestamp > latestKeyEvent!.event.timestamp { 204 | latestKeyEvent = (event, keyEvent) 205 | } 206 | } 207 | } 208 | 209 | // Check if we have an arrow key 210 | let isArrowKey = latestKeyEvent?.keyEvent.keyCode == 123 || // Left arrow 211 | latestKeyEvent?.keyEvent.keyCode == 124 || // Right arrow 212 | latestKeyEvent?.keyEvent.keyCode == 125 || // Down arrow 213 | latestKeyEvent?.keyEvent.keyCode == 126 // Up arrow 214 | 215 | // Only show function key if it's not an arrow key or if there are other modifiers besides function 216 | let showFnKey = !isArrowKey || (activeModifiers.count > 1 && activeModifiers.contains(.function)) 217 | 218 | // Process modifiers in standard order 219 | let orderedModifiers: [KeyModifier] = [.control, .option, .shift, .command] 220 | for modifier in orderedModifiers { 221 | if activeModifiers.contains(modifier) { 222 | switch modifier { 223 | case .command: text += "⌘" 224 | case .shift: text += "⇧" 225 | case .option: text += "⌥" 226 | case .control: text += "⌃" 227 | case .function: continue // Handle function key separately 228 | case .capsLock: text += "⇪" 229 | } 230 | } 231 | } 232 | 233 | // Add function key if needed 234 | if showFnKey && activeModifiers.contains(.function) { 235 | text += "fn" 236 | } 237 | 238 | // If we found a non-modifier key, add it 239 | if let (_, keyEvent) = latestKeyEvent { 240 | // Special key representation 241 | if let key = keyEvent.characters { 242 | switch key { 243 | case "\r": text += "↩" // return 244 | case "\t": text += "⇥" // tab 245 | case " ": text += "Space" 246 | case "\u{1b}": text += "Esc" 247 | case "\u{7f}": text += "⌫" // delete/backspace 248 | default: 249 | // For single character keys, use uppercase 250 | if key.count == 1 { 251 | text += key.uppercased() 252 | } else { 253 | text += key 254 | } 255 | } 256 | } else { 257 | // Fallback for keys with no character representation 258 | text += keyEvent.key 259 | } 260 | } 261 | 262 | return text 263 | } 264 | 265 | // Helper to check if a key event is part of a shortcut 266 | private func isShortcut(_ keyEvent: KeyboardEvent) -> Bool { 267 | return !keyEvent.modifiers.isEmpty 268 | } 269 | } 270 | 271 | struct ShortcutVisualizer: View { 272 | let events: [InputEvent] 273 | 274 | private var modifierKeys: [KeyboardEvent] { 275 | events.compactMap { event in 276 | guard let keyEvent = event.keyboardEvent else { 277 | return nil 278 | } 279 | // Consider a key as a modifier if it has modifiers OR it's a modifier key itself 280 | if !keyEvent.modifiers.isEmpty || keyEvent.isModifierKey { 281 | return keyEvent 282 | } 283 | return nil 284 | } 285 | } 286 | 287 | private var regularKeys: [KeyboardEvent] { 288 | events.compactMap { event in 289 | guard let keyEvent = event.keyboardEvent else { 290 | return nil 291 | } 292 | // A regular key is one that is not a modifier key itself and is being pressed 293 | if !keyEvent.isModifierKey && keyEvent.isDown { 294 | return keyEvent 295 | } 296 | return nil 297 | } 298 | } 299 | 300 | private var shortcutText: String { 301 | var text = "" 302 | 303 | // Add modifiers - collect all modifiers into a single set 304 | var allModifiers: Set = [] 305 | 306 | // First add modifiers from modifier keys 307 | for keyEvent in modifierKeys { 308 | allModifiers.formUnion(keyEvent.modifiers) 309 | 310 | // Also check if the key itself is a modifier key (e.g., Command, Shift) 311 | if keyEvent.isModifierKey { 312 | // Map key code to modifier 313 | for modifier in KeyModifier.allCases { 314 | if modifier.keyCode == keyEvent.keyCode { 315 | allModifiers.insert(modifier) 316 | } 317 | } 318 | } 319 | } 320 | 321 | // Check if we're dealing with arrow keys 322 | let hasArrowKey = regularKeys.contains { key in 323 | key.keyCode == 123 || // Left arrow 324 | key.keyCode == 124 || // Right arrow 325 | key.keyCode == 125 || // Down arrow 326 | key.keyCode == 126 // Up arrow 327 | } 328 | 329 | // Only show function key if not arrow key or if there are other modifiers besides function 330 | let showFnKey = !hasArrowKey || (allModifiers.count > 1 && allModifiers.contains(.function)) 331 | 332 | // Sort modifiers in the standard order: Ctrl, Option, Shift, Command 333 | let sortedModifiers = allModifiers.sorted { (a, b) -> Bool in 334 | let order: [KeyModifier] = [.control, .option, .shift, .command] 335 | return order.firstIndex(of: a) ?? 0 < order.firstIndex(of: b) ?? 0 336 | } 337 | 338 | // Add the modifiers in sorted order 339 | for modifier in sortedModifiers { 340 | if modifier == .function { 341 | continue // Skip function key here 342 | } 343 | 344 | switch modifier { 345 | case .command: text += "⌘" 346 | case .shift: text += "⇧" 347 | case .option: text += "⌥" 348 | case .control: text += "⌃" 349 | case .function: break // Handled separately 350 | case .capsLock: text += "⇪" 351 | } 352 | } 353 | 354 | // Add function key if needed 355 | if showFnKey && allModifiers.contains(.function) { 356 | text += "fn" 357 | } 358 | 359 | // Add regular keys 360 | if let regularKey = regularKeys.first { 361 | if regularKey.key.count == 1 { 362 | text += regularKey.key.uppercased() 363 | } else { 364 | text += regularKey.key 365 | } 366 | } 367 | 368 | return text 369 | } 370 | 371 | var body: some View { 372 | Text(shortcutText) 373 | .font(.system(size: 16, weight: .medium, design: .rounded)) 374 | .foregroundColor(.white) 375 | .padding(.vertical, 8) 376 | .padding(.horizontal, 12) 377 | .background( 378 | ZStack { 379 | // Background with subtle gradient 380 | RoundedRectangle(cornerRadius: 8) 381 | .fill( 382 | LinearGradient( 383 | colors: [ 384 | Color(.sRGB, red: 0.2, green: 0.2, blue: 0.25, opacity: 0.9), 385 | Color(.sRGB, red: 0.15, green: 0.15, blue: 0.2, opacity: 0.9) 386 | ], 387 | startPoint: .top, 388 | endPoint: .bottom 389 | ) 390 | ) 391 | 392 | // Subtle border 393 | RoundedRectangle(cornerRadius: 8) 394 | .strokeBorder( 395 | LinearGradient( 396 | colors: [ 397 | Color.white.opacity(0.3), 398 | Color.white.opacity(0.1) 399 | ], 400 | startPoint: .top, 401 | endPoint: .bottom 402 | ), 403 | lineWidth: 0.5 404 | ) 405 | } 406 | ) 407 | .shadow(color: Color.black.opacity(0.25), radius: 8, x: 0, y: 2) 408 | } 409 | } 410 | 411 | struct KeyCapsuleView: View { 412 | let keyEvent: KeyboardEvent 413 | 414 | @State private var isPressed = false 415 | 416 | private var keyText: String { 417 | if let char = keyEvent.characters { 418 | switch char { 419 | case "\r": return "return" 420 | case "\t": return "tab" 421 | case " ": return "space" 422 | case "\u{1b}": return "esc" 423 | case "\u{7f}": return "delete" 424 | default: 425 | if char.count == 1 { 426 | return char.uppercased() 427 | } else { 428 | return char 429 | } 430 | } 431 | } else { 432 | return "key" 433 | } 434 | } 435 | 436 | private var isSpecialKey: Bool { 437 | guard let char = keyEvent.characters else { return false } 438 | return char == "\r" || char == "\t" || char == " " || char == "\u{1b}" || char == "\u{7f}" 439 | } 440 | 441 | private var isModifierKey: Bool { 442 | return !keyEvent.modifiers.isEmpty 443 | } 444 | 445 | private var keyColor: Color { 446 | if isModifierKey { 447 | return Color(.sRGB, red: 0.3, green: 0.3, blue: 0.35, opacity: 0.9) 448 | } else if isSpecialKey { 449 | return Color(.sRGB, red: 0.25, green: 0.25, blue: 0.3, opacity: 0.9) 450 | } else { 451 | return Color(.sRGB, red: 0.2, green: 0.2, blue: 0.25, opacity: 0.9) 452 | } 453 | } 454 | 455 | var body: some View { 456 | Text(keyText) 457 | .font(.system(size: isSpecialKey ? 10 : 14, weight: .medium, design: .rounded)) 458 | .foregroundColor(.white) 459 | .padding(.vertical, 6) 460 | .padding(.horizontal, isSpecialKey ? 8 : (keyText.count > 1 ? 8 : 10)) 461 | .background( 462 | ZStack { 463 | // Background 464 | Capsule() 465 | .fill( 466 | LinearGradient( 467 | colors: [ 468 | keyColor, 469 | keyColor.opacity(0.85) 470 | ], 471 | startPoint: .top, 472 | endPoint: .bottom 473 | ) 474 | ) 475 | 476 | // Border 477 | Capsule() 478 | .strokeBorder( 479 | LinearGradient( 480 | colors: [ 481 | Color.white.opacity(0.3), 482 | Color.white.opacity(0.1) 483 | ], 484 | startPoint: .top, 485 | endPoint: .bottom 486 | ), 487 | lineWidth: 0.5 488 | ) 489 | } 490 | ) 491 | .shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 2) 492 | .scaleEffect(isPressed ? 0.9 : 1.0) 493 | .onAppear { 494 | // Animate key press when appearing 495 | withAnimation(.spring(response: 0.2, dampingFraction: 0.6)) { 496 | isPressed = true 497 | } 498 | 499 | // And then release 500 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 501 | withAnimation(.spring(response: 0.2, dampingFraction: 0.6)) { 502 | isPressed = false 503 | } 504 | } 505 | } 506 | } 507 | } 508 | 509 | #Preview { 510 | // Create a few key events for previewing 511 | let commandEvent = KeyboardEvent( 512 | key: "⌘", 513 | keyCode: 55, 514 | isDown: true, 515 | modifiers: [.command], 516 | characters: nil, 517 | isRepeat: false 518 | ) 519 | 520 | let rEvent = KeyboardEvent( 521 | key: "R", 522 | keyCode: 15, 523 | isDown: true, 524 | modifiers: [.command], 525 | characters: "r", 526 | isRepeat: false 527 | ) 528 | 529 | let events = [ 530 | InputEvent.keyboardEvent(event: commandEvent), 531 | InputEvent.keyboardEvent(event: rEvent) 532 | ] 533 | 534 | return KeyboardVisualizer(events: events) 535 | .frame(width: 500, height: 300) 536 | .background(Color.black.opacity(0.5)) 537 | .cornerRadius(12) 538 | } -------------------------------------------------------------------------------- /Klic/Views/MouseVisualizer.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - Supporting Types 4 | 5 | enum MouseActionType: Equatable { 6 | case click(button: MouseButton, isDoubleClick: Bool) 7 | case scroll(direction: ScrollDirection) 8 | case momentumScroll(direction: ScrollDirection) 9 | case move(isFast: Bool) 10 | case none 11 | } 12 | 13 | enum ScrollDirection: Equatable { 14 | case up 15 | case down 16 | case left 17 | case right 18 | } 19 | 20 | struct MouseVisualizer: View { 21 | let events: [InputEvent] 22 | 23 | // Animation states 24 | @State private var isAnimating = false 25 | @State private var pulseOpacity: Double = 0 26 | 27 | @State private var isMinimalMode: Bool = false 28 | 29 | // Get the latest mouse events of each type 30 | private var clickEvents: [InputEvent] { 31 | events.filter { $0.type == .mouse && $0.mouseEvent?.button != nil && $0.mouseEvent?.isDown == true } 32 | } 33 | 34 | private var moveEvents: [InputEvent] { 35 | events.filter { $0.type == .mouse && $0.mouseEvent?.button == nil && $0.mouseEvent?.scrollDelta == nil } 36 | } 37 | 38 | private var scrollEvents: [InputEvent] { 39 | events.filter { $0.type == .mouse && $0.mouseEvent?.scrollDelta != nil } 40 | } 41 | 42 | private var latestClickEvent: InputEvent? { 43 | clickEvents.first 44 | } 45 | 46 | private var latestScrollEvent: InputEvent? { 47 | scrollEvents.first 48 | } 49 | 50 | private var latestMoveEvent: InputEvent? { 51 | moveEvents.first 52 | } 53 | 54 | private var mouseActionType: MouseActionType { 55 | // First check for clicks as they should have priority 56 | if let clickEvent = latestClickEvent, let mouseEvent = clickEvent.mouseEvent { 57 | let button = mouseEvent.button ?? .left 58 | let isDoubleClick = mouseEvent.isDoubleClick 59 | 60 | return .click(button: button, isDoubleClick: isDoubleClick) 61 | } 62 | // Then check for scroll events 63 | else if let scrollEvent = latestScrollEvent, let mouseEvent = scrollEvent.mouseEvent, let delta = mouseEvent.scrollDelta { 64 | // Determine primary scroll direction 65 | let direction: ScrollDirection 66 | if abs(delta.y) > abs(delta.x) { 67 | direction = delta.y > 0 ? .down : .up 68 | } else { 69 | direction = delta.x > 0 ? .right : .left 70 | } 71 | 72 | // Check if this is momentum scrolling 73 | if mouseEvent.isMomentumScroll { 74 | return .momentumScroll(direction: direction) 75 | } else { 76 | return .scroll(direction: direction) 77 | } 78 | } 79 | // Finally check for move events - only if we have them and they're the most recent 80 | else if let moveEvent = latestMoveEvent, let _ = moveEvent.mouseEvent { 81 | // For simplicity, all movements are considered "slow" as we no longer track speed 82 | return .move(isFast: false) 83 | } 84 | 85 | return .none 86 | } 87 | 88 | var body: some View { 89 | // Check for minimal mode on appearance 90 | let _ = onAppear { 91 | isMinimalMode = UserPreferences.getMinimalDisplayMode() 92 | 93 | // Listen for minimal mode changes 94 | NotificationCenter.default.addObserver( 95 | forName: .MinimalDisplayModeChanged, 96 | object: nil, 97 | queue: .main 98 | ) { _ in 99 | isMinimalMode = UserPreferences.getMinimalDisplayMode() 100 | } 101 | } 102 | 103 | if events.isEmpty { 104 | EmptyView() 105 | } else { 106 | if isMinimalMode { 107 | minimalMouseView 108 | } else { 109 | standardMouseView 110 | } 111 | } 112 | } 113 | 114 | // Standard mouse visualization 115 | private var standardMouseView: some View { 116 | ZStack { 117 | // Only show content when there are events 118 | if !events.isEmpty { 119 | // Dynamic content based on mouse action 120 | Group { 121 | switch mouseActionType { 122 | case .click(let button, let isDoubleClick): 123 | MouseClickView(button: button, isDoubleClick: isDoubleClick, isAnimating: isAnimating) 124 | 125 | case .scroll(let direction): 126 | MouseScrollView(direction: direction, isMomentum: false, isAnimating: isAnimating) 127 | 128 | case .momentumScroll(let direction): 129 | MouseScrollView(direction: direction, isMomentum: true, isAnimating: isAnimating) 130 | 131 | case .move(let isFast): 132 | MouseMoveView(isFast: isFast, isAnimating: isAnimating) 133 | 134 | case .none: 135 | // Nothing to display 136 | EmptyView() 137 | } 138 | } 139 | .animation(.spring(response: 0.3, dampingFraction: 0.7), value: mouseActionType) 140 | 141 | // Action label at the bottom 142 | VStack { 143 | Spacer() 144 | 145 | if mouseActionType != .none { 146 | Text(actionDescription) 147 | .font(.system(size: 10, weight: .medium, design: .rounded)) 148 | .foregroundColor(.white.opacity(0.9)) 149 | .padding(.horizontal, 8) 150 | .padding(.vertical, 3) 151 | .background( 152 | Capsule() 153 | .fill(Color.black.opacity(0.5)) 154 | .overlay( 155 | Capsule() 156 | .strokeBorder(Color.white.opacity(0.2), lineWidth: 0.5) 157 | ) 158 | ) 159 | } 160 | } 161 | .padding(.bottom, 5) 162 | } 163 | } 164 | .frame(width: 100, height: 72) 165 | .onAppear { 166 | withAnimation(.easeIn(duration: 0.3)) { 167 | isAnimating = true 168 | } 169 | } 170 | } 171 | 172 | // Minimal mouse visualization 173 | private var minimalMouseView: some View { 174 | HStack(spacing: 4) { 175 | // Mouse icon 176 | Image(systemName: mouseActionIcon) 177 | .font(.system(size: 10)) 178 | .foregroundColor(.white) 179 | 180 | // Action text 181 | Text(minimalActionDescription) 182 | .font(.system(size: 10, weight: .medium, design: .rounded)) 183 | .foregroundColor(.white) 184 | } 185 | .padding(.vertical, 2) 186 | .padding(.horizontal, 6) 187 | .background( 188 | RoundedRectangle(cornerRadius: 4) 189 | .fill(Color.black.opacity(0.5)) 190 | ) 191 | } 192 | 193 | // Nice descriptive text for the current action 194 | private var actionDescription: String { 195 | switch mouseActionType { 196 | case .click(let button, let isDoubleClick): 197 | let clickType = isDoubleClick ? "Double Click" : "Click" 198 | 199 | switch button { 200 | case .left: return "Left \(clickType)" 201 | case .right: return "Right \(clickType)" 202 | case .middle: return "Middle \(clickType)" 203 | case .extra1: return "Back Button" 204 | case .extra2: return "Forward Button" 205 | case .other: return "Other Button \(clickType)" 206 | } 207 | 208 | case .scroll(let direction): 209 | switch direction { 210 | case .up: return "Scroll Up" 211 | case .down: return "Scroll Down" 212 | case .left: return "Scroll Left" 213 | case .right: return "Scroll Right" 214 | } 215 | 216 | case .momentumScroll(let direction): 217 | switch direction { 218 | case .up: return "Momentum Up" 219 | case .down: return "Momentum Down" 220 | case .left: return "Momentum Left" 221 | case .right: return "Momentum Right" 222 | } 223 | 224 | case .move(let isFast): 225 | return isFast ? "Fast Movement" : "Mouse Move" 226 | 227 | case .none: 228 | return "" 229 | } 230 | } 231 | 232 | // Minimal action description 233 | private var minimalActionDescription: String { 234 | switch mouseActionType { 235 | case .click(let button, let isDoubleClick): 236 | let doublePrefix = isDoubleClick ? "2×" : "" 237 | 238 | switch button { 239 | case .left: return "\(doublePrefix)Click" 240 | case .right: return "\(doublePrefix)Right" 241 | case .middle: return "\(doublePrefix)Middle" 242 | case .extra1: return "Back" 243 | case .extra2: return "Forward" 244 | case .other: return "Other Button" 245 | } 246 | 247 | case .scroll(let direction): 248 | switch direction { 249 | case .up: return "Scroll ↑" 250 | case .down: return "Scroll ↓" 251 | case .left: return "Scroll ←" 252 | case .right: return "Scroll →" 253 | } 254 | 255 | case .momentumScroll(let direction): 256 | switch direction { 257 | case .up: return "Mom ↑" 258 | case .down: return "Mom ↓" 259 | case .left: return "Mom ←" 260 | case .right: return "Mom →" 261 | } 262 | 263 | case .move(let isFast): 264 | return isFast ? "Fast" : "Move" 265 | 266 | case .none: 267 | return "" 268 | } 269 | } 270 | 271 | // Mouse icon for minimal mode 272 | private var mouseActionIcon: String { 273 | switch mouseActionType { 274 | case .click(let button, let isDoubleClick): 275 | let baseIcon: String 276 | 277 | switch button { 278 | case .left: baseIcon = "mouse.fill" 279 | case .right: baseIcon = "mouse.fill" 280 | case .middle: baseIcon = "mouse.fill" 281 | case .extra1: return "arrow.left.circle.fill" 282 | case .extra2: return "arrow.right.circle.fill" 283 | case .other: return "questionmark.circle.fill" 284 | } 285 | 286 | return isDoubleClick ? "2.circle" : baseIcon 287 | 288 | case .scroll(let direction): 289 | switch direction { 290 | case .up: return "chevron.up" 291 | case .down: return "chevron.down" 292 | case .left: return "chevron.left" 293 | case .right: return "chevron.right" 294 | } 295 | 296 | case .momentumScroll(let direction): 297 | switch direction { 298 | case .up: return "chevron.up.chevron.up" 299 | case .down: return "chevron.down.chevron.down" 300 | case .left: return "chevron.left.chevron.left" 301 | case .right: return "chevron.right.chevron.right" 302 | } 303 | 304 | case .move(let isFast): 305 | return isFast ? "hand.point.up.braille.fill" : "hand.point.up.fill" 306 | 307 | case .none: 308 | return "" 309 | } 310 | } 311 | } 312 | 313 | // MARK: - Subviews for different mouse actions 314 | 315 | struct MouseClickView: View { 316 | let button: MouseButton 317 | let isDoubleClick: Bool 318 | let isAnimating: Bool 319 | 320 | @State private var pulseScale: CGFloat = 1.0 321 | @State private var pulseOpacity: Double = 0.0 322 | 323 | var body: some View { 324 | ZStack { 325 | // Pulse effect for clicks 326 | Circle() 327 | .fill(clickColor.opacity(0.3)) 328 | .frame(width: 40, height: 40) 329 | .scaleEffect(pulseScale) 330 | .opacity(pulseOpacity) 331 | .onAppear { 332 | withAnimation(Animation.easeOut(duration: 0.5).repeatCount(isDoubleClick ? 2 : 1, autoreverses: true)) { 333 | pulseScale = 1.3 334 | pulseOpacity = 0.7 335 | } 336 | } 337 | 338 | // Mouse icon 339 | ZStack { 340 | // Mouse body 341 | RoundedRectangle(cornerRadius: 12) 342 | .fill( 343 | LinearGradient( 344 | colors: [Color(.sRGB, white: 0.9, opacity: 1), Color(.sRGB, white: 0.8, opacity: 1)], 345 | startPoint: .top, 346 | endPoint: .bottom 347 | ) 348 | ) 349 | .frame(width: 24, height: 36) 350 | .overlay( 351 | RoundedRectangle(cornerRadius: 12) 352 | .strokeBorder(Color.white.opacity(0.3), lineWidth: 0.5) 353 | ) 354 | 355 | // Button indicators 356 | VStack(spacing: 2) { 357 | // Left button 358 | RoundedRectangle(cornerRadius: 4) 359 | .fill(button == .left ? clickColor : Color.gray.opacity(0.3)) 360 | .frame(width: 10, height: 10) 361 | 362 | // Right button 363 | RoundedRectangle(cornerRadius: 4) 364 | .fill(button == .right ? clickColor : Color.gray.opacity(0.3)) 365 | .frame(width: 10, height: 10) 366 | 367 | // Middle button/scroll wheel 368 | if button == .middle { 369 | Circle() 370 | .fill(clickColor) 371 | .frame(width: 8, height: 8) 372 | } else { 373 | Circle() 374 | .fill(Color.gray.opacity(0.5)) 375 | .frame(width: 6, height: 6) 376 | } 377 | } 378 | .offset(y: -4) 379 | 380 | // Double-click indicator 381 | if isDoubleClick { 382 | Text("2×") 383 | .font(.system(size: 10, weight: .bold)) 384 | .foregroundColor(.white) 385 | .padding(4) 386 | .background(Circle().fill(Color.blue.opacity(0.7))) 387 | .offset(x: 12, y: -12) 388 | } 389 | 390 | // Extra buttons indicator 391 | if button == .extra1 || button == .extra2 { 392 | Image(systemName: button == .extra1 ? "arrow.left" : "arrow.right") 393 | .font(.system(size: 10, weight: .bold)) 394 | .foregroundColor(.white) 395 | .padding(4) 396 | .background(Circle().fill(Color.orange.opacity(0.7))) 397 | .offset(x: button == .extra1 ? -14 : 14, y: 0) 398 | } 399 | } 400 | .shadow(color: Color.black.opacity(0.2), radius: 2, x: 0, y: 2) 401 | } 402 | } 403 | 404 | private var clickColor: Color { 405 | switch button { 406 | case .left: return Color.blue 407 | case .right: return Color.orange 408 | case .middle: return Color.purple 409 | case .extra1, .extra2: return Color.green 410 | case .other: return Color.gray 411 | } 412 | } 413 | } 414 | 415 | struct MouseScrollView: View { 416 | let direction: ScrollDirection 417 | let isMomentum: Bool 418 | let isAnimating: Bool 419 | 420 | @State private var scrollAnimation = false 421 | 422 | var body: some View { 423 | ZStack { 424 | // Mouse with scroll wheel 425 | ZStack { 426 | // Mouse body 427 | RoundedRectangle(cornerRadius: 12) 428 | .fill( 429 | LinearGradient( 430 | colors: [Color(.sRGB, white: 0.9, opacity: 1), Color(.sRGB, white: 0.8, opacity: 1)], 431 | startPoint: .top, 432 | endPoint: .bottom 433 | ) 434 | ) 435 | .frame(width: 24, height: 36) 436 | .overlay( 437 | RoundedRectangle(cornerRadius: 12) 438 | .strokeBorder(Color.white.opacity(0.3), lineWidth: 0.5) 439 | ) 440 | 441 | // Scroll wheel 442 | ZStack { 443 | Circle() 444 | .fill(Color.gray.opacity(0.3)) 445 | .frame(width: 10, height: 10) 446 | 447 | // Scroll direction indicator 448 | Group { 449 | if direction == .up { 450 | Image(systemName: "chevron.up") 451 | .font(.system(size: 8, weight: .bold)) 452 | .foregroundColor(.white) 453 | .offset(y: scrollAnimation ? -2 : 0) 454 | } else if direction == .down { 455 | Image(systemName: "chevron.down") 456 | .font(.system(size: 8, weight: .bold)) 457 | .foregroundColor(.white) 458 | .offset(y: scrollAnimation ? 2 : 0) 459 | } else if direction == .left { 460 | Image(systemName: "chevron.left") 461 | .font(.system(size: 8, weight: .bold)) 462 | .foregroundColor(.white) 463 | .offset(x: scrollAnimation ? -2 : 0) 464 | } else if direction == .right { 465 | Image(systemName: "chevron.right") 466 | .font(.system(size: 8, weight: .bold)) 467 | .foregroundColor(.white) 468 | .offset(x: scrollAnimation ? 2 : 0) 469 | } 470 | } 471 | } 472 | .offset(y: -4) 473 | } 474 | .shadow(color: Color.black.opacity(0.2), radius: 2, x: 0, y: 2) 475 | 476 | // Momentum indicator 477 | if isMomentum { 478 | ZStack { 479 | Circle() 480 | .fill(Color.blue.opacity(0.2)) 481 | .frame(width: 40, height: 40) 482 | 483 | // Momentum trail 484 | HStack(spacing: 2) { 485 | ForEach(0..<3) { i in 486 | Circle() 487 | .fill(Color.blue.opacity(0.7 - Double(i) * 0.2)) 488 | .frame(width: 4, height: 4) 489 | } 490 | } 491 | .rotationEffect(directionAngle) 492 | .offset(x: 12) 493 | } 494 | } 495 | } 496 | .onAppear { 497 | withAnimation(Animation.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) { 498 | scrollAnimation = true 499 | } 500 | } 501 | } 502 | 503 | private var directionAngle: Angle { 504 | switch direction { 505 | case .up: return .degrees(270) 506 | case .down: return .degrees(90) 507 | case .left: return .degrees(180) 508 | case .right: return .degrees(0) 509 | } 510 | } 511 | } 512 | 513 | struct MouseMoveView: View { 514 | let isFast: Bool 515 | let isAnimating: Bool 516 | 517 | @State private var moveAnimation = false 518 | 519 | var body: some View { 520 | ZStack { 521 | // Movement trail 522 | ZStack { 523 | // Mouse cursor 524 | Image(systemName: "cursorarrow.fill") 525 | .font(.system(size: 24)) 526 | .foregroundColor(.white) 527 | .shadow(color: Color.black.opacity(0.3), radius: 2, x: 1, y: 1) 528 | 529 | // Movement trail 530 | if isFast { 531 | HStack(spacing: 2) { 532 | ForEach(0..<4) { i in 533 | Image(systemName: "cursorarrow.fill") 534 | .font(.system(size: 16 - CGFloat(i) * 3)) 535 | .foregroundColor(.white.opacity(0.7 - Double(i) * 0.2)) 536 | .offset(x: -CGFloat(i) * 6, y: CGFloat(i) * 3) 537 | } 538 | } 539 | .offset(x: moveAnimation ? 5 : 0, y: moveAnimation ? -3 : 0) 540 | } 541 | } 542 | .offset(x: moveAnimation ? 5 : -5, y: moveAnimation ? -5 : 5) 543 | } 544 | .onAppear { 545 | withAnimation(Animation.easeInOut(duration: isFast ? 0.3 : 0.7).repeatForever(autoreverses: true)) { 546 | moveAnimation = true 547 | } 548 | } 549 | } 550 | } 551 | 552 | #Preview { 553 | // Create test mouse events 554 | let mouseEvent = MouseEvent( 555 | position: CGPoint(x: 0.5, y: 0.5), 556 | button: .left, 557 | scrollDelta: nil, 558 | isDown: true, 559 | isDoubleClick: false, 560 | isMomentumScroll: false 561 | ) 562 | let clickEvent = InputEvent.mouseEvent(event: mouseEvent) 563 | 564 | ZStack { 565 | Color.black 566 | MouseVisualizer(events: [clickEvent]) 567 | } 568 | .frame(width: 200, height: 150) 569 | } 570 | -------------------------------------------------------------------------------- /Klic/Utilities/InputManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import SwiftUI 4 | 5 | class InputManager: ObservableObject { 6 | @Published var keyboardEvents: [InputEvent] = [] 7 | @Published var mouseEvents: [InputEvent] = [] 8 | @Published var allEvents: [InputEvent] = [] 9 | 10 | // Properties to control visibility and active input types 11 | @Published var isOverlayVisible: Bool = false 12 | @Published var overlayOpacity: Double = 0.0 13 | @Published var activeInputTypes: Set = [] 14 | 15 | // Add publishing of input visibility preferences 16 | @Published var showKeyboardInput: Bool = true 17 | @Published var showMouseInput: Bool = true 18 | 19 | // Maximum events to keep per input type 20 | private let maxKeyboardEvents = 6 21 | private let maxMouseEvents = 3 22 | 23 | private let keyboardMonitor = KeyboardMonitor() 24 | private let mouseMonitor = MouseMonitor() 25 | private var cancellables = Set() 26 | 27 | // Timer for auto-hiding the overlay 28 | private var visibilityTimer: Timer? 29 | private var eventTimers: [String: Timer] = [:] 30 | private var fadeOutDelay: TimeInterval = 1.5 // Shorter delay for better UX 31 | private let fadeInDuration: TimeInterval = 0.2 32 | private let fadeOutDuration: TimeInterval = 0.3 33 | 34 | // Smart filtering settings 35 | private var lastKeyPressTime: Date = Date.distantPast 36 | private var keyPressFrequency: TimeInterval = 0.5 // Adjusted based on typing speed 37 | private let keyPressThreshold: Int = 3 // Number of keypresses to consider "typing" 38 | private var consecutiveKeyPresses: Int = 0 39 | 40 | // Enum to track active input types 41 | enum InputType: Int, CaseIterable { 42 | case keyboard 43 | case mouse 44 | } 45 | 46 | // New property for managing user preference vs. actual opacity 47 | private var userOpacityPreference: Double = 0.9 48 | 49 | init() { 50 | Logger.info("Initializing InputManager", log: Logger.app) 51 | 52 | // Initialize visibility preferences from user defaults 53 | self.showKeyboardInput = UserPreferences.getShowKeyboardInput() 54 | self.showMouseInput = UserPreferences.getShowMouseInput() 55 | 56 | setupSubscriptions() 57 | 58 | // Listen for input type changes 59 | NotificationCenter.default.addObserver( 60 | forName: .InputTypesChanged, 61 | object: nil, 62 | queue: .main 63 | ) { [weak self] _ in 64 | guard let self = self else { return } 65 | // Update visibility settings from user defaults 66 | self.showKeyboardInput = UserPreferences.getShowKeyboardInput() 67 | self.showMouseInput = UserPreferences.getShowMouseInput() 68 | 69 | // Clear events for disabled input types 70 | if !self.showKeyboardInput { 71 | self.keyboardEvents = [] 72 | self.updateActiveInputTypes(adding: .keyboard, removing: true) 73 | } 74 | 75 | if !self.showMouseInput { 76 | self.mouseEvents = [] 77 | self.updateActiveInputTypes(adding: .mouse, removing: true) 78 | } 79 | 80 | self.updateAllEvents() 81 | } 82 | } 83 | 84 | private func setupSubscriptions() { 85 | // Subscribe to keyboard events 86 | keyboardMonitor.$currentEvents 87 | .receive(on: RunLoop.main) 88 | .removeDuplicates() 89 | .sink { [weak self] events in 90 | guard let self = self else { return } 91 | 92 | // Only process keyboard events if keyboard input is enabled 93 | if self.showKeyboardInput && !events.isEmpty { 94 | // Filter repeat events when typing fast to avoid clutter 95 | let filteredEvents = self.filterRepeatKeyEvents(events) 96 | 97 | // Clean up any duplicate events that might have slipped through 98 | let uniqueEvents = self.removeDuplicateEvents(filteredEvents) 99 | 100 | self.keyboardEvents = Array(uniqueEvents.prefix(self.maxKeyboardEvents)) 101 | self.updateActiveInputTypes(adding: .keyboard, removing: events.isEmpty) 102 | self.updateAllEvents() 103 | self.showOverlay() 104 | 105 | // Set a timer to clear this event type 106 | self.scheduleClearEventTimer(for: .keyboard) 107 | 108 | Logger.debug("Received \(events.count) keyboard events", log: Logger.app) 109 | } else if !self.showKeyboardInput && !self.keyboardEvents.isEmpty { 110 | // Clear keyboard events if keyboard input is disabled 111 | self.keyboardEvents = [] 112 | self.updateActiveInputTypes(adding: .keyboard, removing: true) 113 | self.updateAllEvents() 114 | } 115 | } 116 | .store(in: &cancellables) 117 | 118 | // Subscribe to mouse events 119 | mouseMonitor.$currentEvents 120 | .receive(on: RunLoop.main) 121 | .removeDuplicates() 122 | .sink { [weak self] events in 123 | guard let self = self else { return } 124 | if self.showMouseInput && !events.isEmpty { 125 | self.mouseEvents = Array(events.prefix(self.maxMouseEvents)) 126 | self.updateActiveInputTypes(adding: .mouse, removing: events.isEmpty) 127 | self.updateAllEvents() 128 | self.showOverlay() 129 | 130 | // Set a timer to clear this event type 131 | self.scheduleClearEventTimer(for: .mouse) 132 | 133 | Logger.debug("Received \(events.count) mouse events", log: Logger.app) 134 | } else if !self.showMouseInput && !self.mouseEvents.isEmpty { 135 | // Clear mouse events if mouse input is disabled 136 | self.mouseEvents = [] 137 | self.updateActiveInputTypes(adding: .mouse, removing: true) 138 | self.updateAllEvents() 139 | } 140 | } 141 | .store(in: &cancellables) 142 | } 143 | 144 | // Update the active input types set 145 | private func updateActiveInputTypes(adding type: InputType, removing: Bool = false) { 146 | if removing { 147 | activeInputTypes.remove(type) 148 | Logger.debug("Removed input type: \(type)", log: Logger.app) 149 | } else { 150 | activeInputTypes.insert(type) 151 | Logger.debug("Added input type: \(type)", log: Logger.app) 152 | } 153 | } 154 | 155 | // Clear a specific event type after a delay 156 | private func scheduleClearEventTimer(for inputType: InputType) { 157 | // Cancel any existing timer for this input type 158 | eventTimers["\(inputType)"]?.invalidate() 159 | 160 | // Create a new timer to clear events after delay 161 | eventTimers["\(inputType)"] = Timer.scheduledTimer(withTimeInterval: fadeOutDelay, repeats: false) { [weak self] _ in 162 | guard let self = self else { return } 163 | 164 | DispatchQueue.main.async { 165 | Logger.debug("Timer fired for input type: \(inputType)", log: Logger.app) 166 | 167 | switch inputType { 168 | case .keyboard: 169 | if !self.keyboardEvents.isEmpty { 170 | self.keyboardEvents = [] 171 | self.updateActiveInputTypes(adding: .keyboard, removing: true) 172 | self.updateAllEvents() 173 | } 174 | case .mouse: 175 | if !self.mouseEvents.isEmpty { 176 | // First clear scroll events immediately as they can persist 177 | self.mouseEvents.removeAll { event in 178 | guard let mouseEvent = event.mouseEvent else { return false } 179 | return mouseEvent.scrollDelta != nil 180 | } 181 | 182 | // Then clear all remaining mouse events 183 | self.mouseEvents = [] 184 | self.updateActiveInputTypes(adding: .mouse, removing: true) 185 | self.updateAllEvents() 186 | } 187 | } 188 | 189 | // If no active input types, hide the overlay 190 | if self.activeInputTypes.isEmpty { 191 | self.hideOverlay() 192 | } 193 | } 194 | } 195 | } 196 | 197 | // Show the overlay with smooth fade-in 198 | func showOverlay() { 199 | isOverlayVisible = true 200 | 201 | // Use animation for smooth transition 202 | withAnimation(.easeIn(duration: fadeInDuration)) { 203 | overlayOpacity = userOpacityPreference 204 | } 205 | } 206 | 207 | // Hide the overlay with smooth fade-out 208 | func hideOverlay() { 209 | // Use animation for smooth transition 210 | withAnimation(.easeOut(duration: fadeOutDuration)) { 211 | overlayOpacity = 0.0 212 | } 213 | 214 | // Schedule setting isOverlayVisible to false after animation completes 215 | DispatchQueue.main.asyncAfter(deadline: .now() + fadeOutDuration + 0.05) { [weak self] in 216 | guard let self = self else { return } 217 | if self.overlayOpacity == 0.0 { 218 | self.isOverlayVisible = false 219 | } 220 | } 221 | } 222 | 223 | // Filter repeated keyboard events during fast typing 224 | private func filterRepeatKeyEvents(_ events: [InputEvent]) -> [InputEvent] { 225 | let now = Date() 226 | let timeSinceLastPress = now.timeIntervalSince(lastKeyPressTime) 227 | 228 | // Adjust key press frequency based on typing speed 229 | if timeSinceLastPress < 0.2 { 230 | consecutiveKeyPresses += 1 231 | if consecutiveKeyPresses > keyPressThreshold { 232 | // Fast typing detected, reduce keyPressFrequency 233 | keyPressFrequency = max(0.05, keyPressFrequency * 0.9) 234 | } 235 | } else { 236 | // Reset consecutive count if not typing fast 237 | consecutiveKeyPresses = 0 238 | // Gradually restore normal keyPressFrequency 239 | keyPressFrequency = min(0.5, keyPressFrequency * 1.1) 240 | } 241 | 242 | lastKeyPressTime = now 243 | 244 | // Apply filtering based on calculated frequency and input types 245 | return events.filter { event in 246 | // Keep non-keyboard events 247 | if event.type != .keyboard { 248 | return true 249 | } 250 | 251 | // Keep special keys like modifiers always 252 | if let keyEvent = event.keyboardEvent, keyEvent.isModifierKey { 253 | return true 254 | } 255 | 256 | // Apply time-based filtering for normal keys during fast typing 257 | return true 258 | } 259 | } 260 | 261 | // Remove duplicate events to prevent clutter 262 | private func removeDuplicateEvents(_ events: [InputEvent]) -> [InputEvent] { 263 | var uniqueEvents: [InputEvent] = [] 264 | var seenKeys = Set() 265 | 266 | for event in events { 267 | // Create a unique identifier for the event 268 | let key = "\(event.id)" 269 | if !seenKeys.contains(key) { 270 | uniqueEvents.append(event) 271 | seenKeys.insert(key) 272 | } 273 | } 274 | 275 | return uniqueEvents 276 | } 277 | 278 | // Update the consolidated event list for rendering 279 | private func updateAllEvents() { 280 | allEvents = keyboardEvents + mouseEvents 281 | } 282 | 283 | // Public methods for controlling the overlay 284 | 285 | // Manually show the overlay (e.g., from menu command) 286 | func showOverlayManually() { 287 | Logger.info("Manually showing overlay", log: Logger.app) 288 | showOverlay() 289 | } 290 | 291 | // Start all monitors 292 | func startMonitoring() { 293 | Logger.info("Starting all input monitors", log: Logger.app) 294 | keyboardMonitor.startMonitoring() 295 | mouseMonitor.startMonitoring() 296 | } 297 | 298 | // Stop all monitors 299 | func stopMonitoring() { 300 | Logger.info("Stopping all input monitors", log: Logger.app) 301 | keyboardMonitor.stopMonitoring() 302 | mouseMonitor.stopMonitoring() 303 | } 304 | 305 | // Update opacity from user preference 306 | func updateOpacity(_ newOpacity: Double) { 307 | userOpacityPreference = newOpacity 308 | 309 | // If overlay is visible, update its opacity immediately 310 | if isOverlayVisible { 311 | withAnimation(.easeInOut(duration: 0.2)) { 312 | overlayOpacity = newOpacity 313 | } 314 | } 315 | } 316 | 317 | // Show demo mode with example inputs 318 | func showDemoMode() { 319 | Logger.info("Showing demo mode", log: Logger.app) 320 | 321 | // Stop monitoring to prevent real events from interfering 322 | stopMonitoring() 323 | 324 | // Clear any existing events 325 | keyboardEvents = [] 326 | mouseEvents = [] 327 | 328 | // Create welcome message using keyboard events - now "READY" instead of "WELCOME" 329 | let rKey = KeyboardEvent( 330 | key: "R", 331 | keyCode: 15, 332 | isDown: true, 333 | modifiers: [], 334 | characters: "R", 335 | isRepeat: false 336 | ) 337 | 338 | let eKey = KeyboardEvent( 339 | key: "E", 340 | keyCode: 14, 341 | isDown: true, 342 | modifiers: [], 343 | characters: "E", 344 | isRepeat: false 345 | ) 346 | 347 | let aKey = KeyboardEvent( 348 | key: "A", 349 | keyCode: 0, 350 | isDown: true, 351 | modifiers: [], 352 | characters: "A", 353 | isRepeat: false 354 | ) 355 | 356 | let dKey = KeyboardEvent( 357 | key: "D", 358 | keyCode: 2, 359 | isDown: true, 360 | modifiers: [], 361 | characters: "D", 362 | isRepeat: false 363 | ) 364 | 365 | let yKey = KeyboardEvent( 366 | key: "Y", 367 | keyCode: 16, 368 | isDown: true, 369 | modifiers: [], 370 | characters: "Y", 371 | isRepeat: false 372 | ) 373 | 374 | // Create a keyboard shortcut demo 375 | let cmdKey = KeyboardEvent( 376 | key: "Command", 377 | keyCode: 55, 378 | isDown: true, 379 | modifiers: [.command], 380 | characters: "⌘", 381 | isRepeat: false 382 | ) 383 | 384 | let shiftKey = KeyboardEvent( 385 | key: "Shift", 386 | keyCode: 56, 387 | isDown: true, 388 | modifiers: [.shift], 389 | characters: "⇧", 390 | isRepeat: false 391 | ) 392 | 393 | let sKey = KeyboardEvent( 394 | key: "S", 395 | keyCode: 1, 396 | isDown: true, 397 | modifiers: [.command, .shift], 398 | characters: "S", 399 | isRepeat: false 400 | ) 401 | 402 | // Create some mouse demo events 403 | let leftClick = MouseEvent( 404 | position: CGPoint(x: 400, y: 300), 405 | button: .left, 406 | scrollDelta: nil, 407 | isDown: true, 408 | isDoubleClick: false 409 | ) 410 | 411 | let rightClick = MouseEvent( 412 | position: CGPoint(x: 500, y: 300), 413 | button: .right, 414 | scrollDelta: nil, 415 | isDown: true, 416 | isDoubleClick: false 417 | ) 418 | 419 | // Immediately ensure overlay is visible 420 | isOverlayVisible = true 421 | showOverlay() 422 | 423 | // Show the READY message first 424 | keyboardEvents = [ 425 | InputEvent.keyboardEvent(event: rKey), 426 | InputEvent.keyboardEvent(event: eKey), 427 | InputEvent.keyboardEvent(event: aKey), 428 | InputEvent.keyboardEvent(event: dKey), 429 | InputEvent.keyboardEvent(event: yKey) 430 | ] 431 | 432 | // Update active input types 433 | activeInputTypes.insert(.keyboard) 434 | 435 | // Update the all events array 436 | updateAllEvents() 437 | 438 | // Cancel all timers first to ensure demo stays visible 439 | cancelAllEventTimers() 440 | 441 | // After a delay, show keyboard shortcut 442 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { 443 | self.keyboardEvents = [ 444 | InputEvent.keyboardEvent(event: cmdKey), 445 | InputEvent.keyboardEvent(event: shiftKey), 446 | InputEvent.keyboardEvent(event: sKey) 447 | ] 448 | self.updateAllEvents() 449 | } 450 | 451 | // After another delay, show mouse events 452 | DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) { 453 | self.mouseEvents = [ 454 | InputEvent.mouseEvent(event: leftClick), 455 | InputEvent.mouseEvent(event: rightClick) 456 | ] 457 | self.activeInputTypes.insert(.mouse) 458 | self.updateAllEvents() 459 | } 460 | 461 | // Schedule hiding after a longer delay 462 | DispatchQueue.main.asyncAfter(deadline: .now() + 6.0) { 463 | // Clear events gradually 464 | self.mouseEvents = [] 465 | self.updateActiveInputTypes(adding: .mouse, removing: true) 466 | self.updateAllEvents() 467 | 468 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 469 | self.keyboardEvents = [] 470 | self.updateActiveInputTypes(adding: .keyboard, removing: true) 471 | self.updateAllEvents() 472 | self.hideOverlay() 473 | 474 | // Restart monitoring after demo is done 475 | self.startMonitoring() 476 | } 477 | } 478 | } 479 | 480 | // Cancel all event timers to prevent premature hiding 481 | private func cancelAllEventTimers() { 482 | for (_, timer) in eventTimers { 483 | timer.invalidate() 484 | } 485 | eventTimers.removeAll() 486 | 487 | if let visibilityTimer = visibilityTimer { 488 | visibilityTimer.invalidate() 489 | self.visibilityTimer = nil 490 | } 491 | } 492 | 493 | // Set input type visibility 494 | func setInputTypeVisibility(keyboard: Bool, mouse: Bool) { 495 | // Store visibility preferences in UserDefaults 496 | UserPreferences.setShowKeyboardInput(keyboard) 497 | UserPreferences.setShowMouseInput(mouse) 498 | 499 | // Update published properties 500 | self.showKeyboardInput = keyboard 501 | self.showMouseInput = mouse 502 | 503 | // Update active input types based on visibility settings 504 | if !keyboard { 505 | activeInputTypes.remove(.keyboard) 506 | keyboardEvents = [] 507 | } 508 | 509 | if !mouse { 510 | activeInputTypes.remove(.mouse) 511 | mouseEvents = [] 512 | } 513 | 514 | // Update all events 515 | updateAllEvents() 516 | 517 | // Hide overlay if no input types are visible 518 | if activeInputTypes.isEmpty { 519 | hideOverlay() 520 | } 521 | } 522 | 523 | // Set auto-hide delay 524 | func setAutoHideDelay(_ delay: Double) { 525 | fadeOutDelay = delay 526 | } 527 | 528 | // Add a method to check monitoring status 529 | func checkMonitoringStatus() -> Bool { 530 | return keyboardMonitor.isMonitoring && mouseMonitor.isMonitoring 531 | } 532 | 533 | // Add a method to restart monitoring 534 | func restartMonitoring() { 535 | Logger.info("Restarting all input monitors", log: Logger.app) 536 | stopMonitoring() 537 | startMonitoring() 538 | } 539 | 540 | // Clear all events 541 | func clearAllEvents() { 542 | Logger.info("Clearing all events", log: Logger.app) 543 | keyboardEvents = [] 544 | mouseEvents = [] 545 | updateAllEvents() 546 | } 547 | 548 | // Show demo inputs 549 | func showDemoInputs() { 550 | Logger.info("Showing demo inputs", log: Logger.app) 551 | showDemoMode() 552 | } 553 | 554 | // Show only READY text for first launch 555 | func showReadyDemo() { 556 | Logger.info("Showing simple READY demo", log: Logger.app) 557 | 558 | // Stop monitoring to prevent real events from interfering 559 | stopMonitoring() 560 | 561 | // Clear any existing events 562 | keyboardEvents = [] 563 | mouseEvents = [] 564 | 565 | // Create welcome message using keyboard events - just "READY" 566 | let rKey = KeyboardEvent( 567 | key: "R", 568 | keyCode: 15, 569 | isDown: true, 570 | modifiers: [], 571 | characters: "R", 572 | isRepeat: false 573 | ) 574 | 575 | let eKey = KeyboardEvent( 576 | key: "E", 577 | keyCode: 14, 578 | isDown: true, 579 | modifiers: [], 580 | characters: "E", 581 | isRepeat: false 582 | ) 583 | 584 | let aKey = KeyboardEvent( 585 | key: "A", 586 | keyCode: 0, 587 | isDown: true, 588 | modifiers: [], 589 | characters: "A", 590 | isRepeat: false 591 | ) 592 | 593 | let dKey = KeyboardEvent( 594 | key: "D", 595 | keyCode: 2, 596 | isDown: true, 597 | modifiers: [], 598 | characters: "D", 599 | isRepeat: false 600 | ) 601 | 602 | let yKey = KeyboardEvent( 603 | key: "Y", 604 | keyCode: 16, 605 | isDown: true, 606 | modifiers: [], 607 | characters: "Y", 608 | isRepeat: false 609 | ) 610 | 611 | // Immediately ensure overlay is visible 612 | isOverlayVisible = true 613 | showOverlay() 614 | 615 | // Show the READY message 616 | keyboardEvents = [ 617 | InputEvent.keyboardEvent(event: rKey), 618 | InputEvent.keyboardEvent(event: eKey), 619 | InputEvent.keyboardEvent(event: aKey), 620 | InputEvent.keyboardEvent(event: dKey), 621 | InputEvent.keyboardEvent(event: yKey) 622 | ] 623 | 624 | // Update active input types 625 | activeInputTypes.insert(.keyboard) 626 | 627 | // Update the all events array 628 | updateAllEvents() 629 | 630 | // Cancel all timers first to ensure demo stays visible 631 | cancelAllEventTimers() 632 | 633 | // Schedule hiding after a delay 634 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { 635 | self.keyboardEvents = [] 636 | self.updateActiveInputTypes(adding: .keyboard, removing: true) 637 | self.updateAllEvents() 638 | self.hideOverlay() 639 | 640 | // Restart monitoring after demo is done 641 | self.startMonitoring() 642 | } 643 | } 644 | 645 | // Set the opacity preference 646 | func setOpacityPreference(_ opacity: Double) { 647 | Logger.info("Setting opacity preference to \(opacity)", log: Logger.app) 648 | userOpacityPreference = opacity 649 | updateOpacity(opacity) 650 | } 651 | 652 | private func shouldKeepEvent(_ event: InputEvent) -> Bool { 653 | // Keep recent events from the last fadeOutDelay seconds 654 | let currentTime = Date() 655 | let eventAge = currentTime.timeIntervalSince(event.timestamp) 656 | 657 | if eventAge <= fadeOutDelay { 658 | // For key events, apply smart filtering 659 | if event.type == .keyboard { 660 | // Keep modifiers and special keys 661 | if let keyEvent = event.keyboardEvent, keyEvent.isModifierKey { 662 | return true 663 | } 664 | } 665 | 666 | // Apply time-based filtering for normal keys during fast typing 667 | return true 668 | } 669 | 670 | // Keep special keys like modifiers always 671 | if let keyEvent = event.keyboardEvent, keyEvent.isModifierKey { 672 | return true 673 | } 674 | 675 | // Apply time-based filtering for normal keys during fast typing 676 | return true 677 | } 678 | } -------------------------------------------------------------------------------- /Klic/KlicApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | // Class for handling AppKit/Objective-C related tasks that can't be in a struct 5 | final class AppDelegate: NSObject { 6 | static let shared: AppDelegate = { 7 | let instance = AppDelegate() 8 | Logger.info("AppDelegate singleton initialized", log: Logger.app) 9 | return instance 10 | }() 11 | 12 | // Use this flag to control whether to use status bar or not 13 | private let useStatusBar = true 14 | 15 | // Status bar item reference to keep it from being deallocated 16 | private var _statusItem: NSStatusItem? 17 | 18 | // Public accessor for statusItem 19 | var statusItem: NSStatusItem? { 20 | return _statusItem 21 | } 22 | 23 | // Flag to track if this is the first launch 24 | internal var isFirstLaunch = true 25 | 26 | // Notification observers 27 | private var becomeKeyObserver: NSObjectProtocol? 28 | private var resizeObserver: NSObjectProtocol? 29 | private var positionObserver: NSObjectProtocol? 30 | private var appFinishedLaunchingObserver: NSObjectProtocol? 31 | 32 | override init() { 33 | super.init() 34 | 35 | // Register default values 36 | UserPreferences.registerDefaults() 37 | 38 | // Listen for when the window becomes key to reset its appearance if needed 39 | becomeKeyObserver = NotificationCenter.default.addObserver( 40 | forName: NSWindow.didBecomeKeyNotification, 41 | object: nil, 42 | queue: .main 43 | ) { [weak self] _ in 44 | self?.configureWindowAppearance() 45 | } 46 | 47 | // Listen for window resize to ensure proper appearance 48 | resizeObserver = NotificationCenter.default.addObserver( 49 | forName: NSWindow.didResizeNotification, 50 | object: nil, 51 | queue: .main 52 | ) { [weak self] _ in 53 | self?.configureWindowAppearance() 54 | } 55 | 56 | // Listen for position change notifications 57 | positionObserver = NotificationCenter.default.addObserver( 58 | forName: NSNotification.Name("ReconfigureOverlayPosition"), 59 | object: nil, 60 | queue: .main 61 | ) { [weak self] _ in 62 | // Reconfigure window appearance when position changes 63 | self?.configureWindowAppearance() 64 | } 65 | 66 | // Add observer for app finished launching 67 | appFinishedLaunchingObserver = NotificationCenter.default.addObserver( 68 | forName: NSApplication.didFinishLaunchingNotification, 69 | object: nil, 70 | queue: .main 71 | ) { [weak self] _ in 72 | // Setup app to run in the background without dock icon 73 | NSApp.setActivationPolicy(.accessory) 74 | 75 | // Setup menu bar immediately 76 | self?.setupMenuBar() 77 | 78 | // And try again after short delays to ensure it's set up 79 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 80 | self?.setupMenuBar() 81 | } 82 | 83 | // And once more after a longer delay 84 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { 85 | if self?._statusItem == nil { 86 | Logger.warning("Status bar not set up after initial attempts, trying again", log: Logger.app) 87 | self?.setupMenuBar() 88 | } 89 | 90 | // Show welcome demo after a short delay to help users get started 91 | if self?.isFirstLaunch == true { 92 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 93 | self?.showOverlayFromMenu() 94 | self?.checkPermissions() 95 | self?.isFirstLaunch = false 96 | } 97 | } 98 | } 99 | } 100 | 101 | // Also try to set up the menu bar here, which might help in some cases 102 | DispatchQueue.main.async { [weak self] in 103 | self?.setupMenuBar() 104 | } 105 | 106 | Logger.info("AppDelegate initialized successfully", log: Logger.app) 107 | } 108 | 109 | deinit { 110 | // Remove all observers 111 | [becomeKeyObserver, resizeObserver, positionObserver, appFinishedLaunchingObserver].forEach { observer in 112 | if let observer = observer { 113 | NotificationCenter.default.removeObserver(observer) 114 | } 115 | } 116 | } 117 | 118 | func setupMenuBar() { 119 | // If we don't want to use status bar, just return 120 | if !useStatusBar { 121 | Logger.info("Status bar disabled, skipping setup", log: Logger.app) 122 | return 123 | } 124 | 125 | // Make sure we're on the main thread 126 | if !Thread.isMainThread { 127 | DispatchQueue.main.async { [weak self] in 128 | self?.setupMenuBar() 129 | } 130 | return 131 | } 132 | 133 | // Check if app is active - remove the NSApp.isRunning check as it might be unreliable 134 | guard NSApp.windows.count > 0 else { 135 | Logger.warning("NSApplication is not fully initialized, will retry later", log: Logger.app) 136 | // Schedule a retry after a short delay 137 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in 138 | self?.setupMenuBar() 139 | } 140 | return 141 | } 142 | 143 | // Already set up? 144 | if _statusItem != nil { 145 | Logger.debug("Menu bar already set up", log: Logger.app) 146 | return 147 | } 148 | 149 | Logger.debug("Setting up menu bar", log: Logger.app) 150 | 151 | // Create status item in the menu bar 152 | let newStatusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) 153 | 154 | if let button = newStatusItem.button { 155 | // Create a more visually appealing template image 156 | if let image = NSImage(systemSymbolName: "keyboard.fill", accessibilityDescription: "Klic") { 157 | image.isTemplate = true // Make it a template image so it adapts to the menu bar 158 | button.image = image 159 | button.toolTip = "Klic Input Visualizer" 160 | 161 | // Force update the button's image 162 | button.needsDisplay = true 163 | } else { 164 | Logger.warning("Failed to create menu bar icon image", log: Logger.app) 165 | // Fallback to a text representation if image fails 166 | button.title = "⌨️" 167 | } 168 | 169 | // Create the menu 170 | let menu = NSMenu() 171 | 172 | // Create menu items with explicit target setting 173 | let showOverlayItem = NSMenuItem(title: "Show Overlay", action: #selector(menuShowOverlay), keyEquivalent: "o") 174 | showOverlayItem.target = self 175 | menu.addItem(showOverlayItem) 176 | 177 | let showDemoItem = NSMenuItem(title: "Show Demo", action: #selector(menuShowOverlayDemo), keyEquivalent: "d") 178 | showDemoItem.target = self 179 | menu.addItem(showDemoItem) 180 | 181 | menu.addItem(NSMenuItem.separator()) 182 | 183 | let preferencesItem = NSMenuItem(title: "Preferences...", action: #selector(menuShowPreferences), keyEquivalent: ",") 184 | preferencesItem.target = self 185 | menu.addItem(preferencesItem) 186 | 187 | let permissionsItem = NSMenuItem(title: "Check Permissions...", action: #selector(checkPermissions), keyEquivalent: "p") 188 | permissionsItem.target = self 189 | menu.addItem(permissionsItem) 190 | 191 | menu.addItem(NSMenuItem.separator()) 192 | 193 | let aboutItem = NSMenuItem(title: "About Klic", action: #selector(showAbout), keyEquivalent: "") 194 | aboutItem.target = self 195 | menu.addItem(aboutItem) 196 | 197 | menu.addItem(NSMenuItem.separator()) 198 | 199 | let quitItem = NSMenuItem(title: "Quit Klic", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") 200 | quitItem.target = NSApp 201 | menu.addItem(quitItem) 202 | 203 | // Assign the menu to the status item 204 | newStatusItem.menu = menu 205 | 206 | // Store the status item 207 | self._statusItem = newStatusItem 208 | 209 | Logger.info("Menu bar setup successful", log: Logger.app) 210 | } else { 211 | Logger.warning("Status item button is nil", log: Logger.app) 212 | } 213 | } 214 | 215 | @objc func menuShowOverlay() { 216 | showOverlayFromMenu() 217 | } 218 | 219 | @objc func menuShowOverlayDemo() { 220 | // Show a demo of various inputs to showcase the app 221 | NotificationCenter.default.post(name: NSNotification.Name("ShowOverlayDemo"), object: nil) 222 | } 223 | 224 | @objc func menuShowPreferences() { 225 | NotificationCenter.default.post(name: NSNotification.Name("ShowPreferences"), object: nil) 226 | } 227 | 228 | @objc func showAbout() { 229 | // Create an about panel with app info 230 | let aboutWindow = NSWindow( 231 | contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), 232 | styleMask: [.titled, .closable], 233 | backing: .buffered, 234 | defer: false 235 | ) 236 | aboutWindow.title = "About Klic" 237 | 238 | // Create about content 239 | let hostingController = NSHostingController(rootView: AboutView()) 240 | aboutWindow.contentView = hostingController.view 241 | aboutWindow.center() 242 | aboutWindow.makeKeyAndOrderFront(nil) 243 | NSApp.activate(ignoringOtherApps: true) 244 | } 245 | 246 | @objc func checkPermissions() { 247 | // Check if we can monitor keyboard events - this will prompt for accessibility permissions if needed 248 | let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true] 249 | let accessEnabled = AXIsProcessTrustedWithOptions(options as CFDictionary) 250 | 251 | if !accessEnabled { 252 | // Show a dialog with instructions 253 | let alert = NSAlert() 254 | alert.messageText = "Accessibility Permissions Required" 255 | alert.informativeText = "Klic needs accessibility permissions to monitor keyboard and mouse inputs. Please go to System Preferences > Security & Privacy > Privacy > Accessibility and add Klic to the list of allowed apps." 256 | alert.alertStyle = .warning 257 | alert.addButton(withTitle: "Open System Preferences") 258 | alert.addButton(withTitle: "Later") 259 | 260 | let response = alert.runModal() 261 | if response == .alertFirstButtonReturn { 262 | // Open System Preferences to the Accessibility section 263 | NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) 264 | } 265 | } else { 266 | // Show success message 267 | let alert = NSAlert() 268 | alert.messageText = "Permissions Granted" 269 | alert.informativeText = "Klic has the necessary permissions to monitor keyboard and mouse inputs." 270 | alert.alertStyle = .informational 271 | alert.addButton(withTitle: "OK") 272 | alert.runModal() 273 | } 274 | } 275 | 276 | @objc func toggleKeyboardInput() { 277 | // Toggle keyboard input visibility 278 | let current = UserDefaults.standard.bool(forKey: "showKeyboardInput") 279 | UserDefaults.standard.set(!current, forKey: "showKeyboardInput") 280 | 281 | // Update the menu item state 282 | if let menu = statusItem?.menu { 283 | if let inputTypesItem = menu.items.first(where: { $0.title == "Input Types" }), 284 | let submenu = inputTypesItem.submenu, 285 | let keyboardItem = submenu.items.first(where: { $0.title == "Keyboard" }) { 286 | keyboardItem.state = !current ? .on : .off 287 | } 288 | } 289 | 290 | // Notify of input type change 291 | NotificationCenter.default.post(name: NSNotification.Name("InputTypesChanged"), object: nil) 292 | } 293 | 294 | @objc func toggleMouseInput() { 295 | // Toggle mouse input visibility 296 | let current = UserDefaults.standard.bool(forKey: "showMouseInput") 297 | UserDefaults.standard.set(!current, forKey: "showMouseInput") 298 | 299 | // Update the menu item state 300 | if let menu = statusItem?.menu { 301 | if let inputTypesItem = menu.items.first(where: { $0.title == "Input Types" }), 302 | let submenu = inputTypesItem.submenu, 303 | let mouseItem = submenu.items.first(where: { $0.title == "Mouse" }) { 304 | mouseItem.state = !current ? .on : .off 305 | } 306 | } 307 | 308 | // Notify of input type change 309 | NotificationCenter.default.post(name: NSNotification.Name("InputTypesChanged"), object: nil) 310 | } 311 | 312 | func configureWindowAppearance() { 313 | if let window = NSApplication.shared.windows.first { 314 | // Make window float above other windows 315 | window.level = .floating 316 | 317 | // Make window transparent but not completely invisible initially 318 | window.isOpaque = false 319 | window.backgroundColor = NSColor.clear.withAlphaComponent(0) 320 | window.hasShadow = false 321 | 322 | // Always ignore mouse events to prevent overlay interfering with user interactions 323 | window.ignoresMouseEvents = true 324 | 325 | // Position at bottom center (fixed position) 326 | if let screen = NSScreen.main { 327 | let defaultWindowSize = CGSize(width: 650, height: 350) 328 | 329 | // Calculate bottom center position 330 | let origin = CGPoint( 331 | x: screen.frame.midX - defaultWindowSize.width / 2, 332 | y: screen.frame.minY + 120 // Fixed position from bottom 333 | ) 334 | 335 | // Set window frame 336 | window.setFrame(CGRect(origin: origin, size: defaultWindowSize), display: true) 337 | 338 | // Reset corner radius 339 | if let contentView = window.contentView?.superview { 340 | contentView.layer?.cornerRadius = 0 341 | } 342 | } 343 | 344 | // Make window visible 345 | window.orderFront(nil) 346 | 347 | // Completely hide the title bar and window controls 348 | window.styleMask = [.borderless, .fullSizeContentView] 349 | window.titlebarAppearsTransparent = true 350 | window.titleVisibility = .hidden 351 | 352 | // More aggressively hide window controls 353 | hideWindowControls(window) 354 | 355 | // Ensure window content is properly setup 356 | if let contentView = window.contentView { 357 | contentView.wantsLayer = true 358 | window.titlebarSeparatorStyle = .none 359 | } 360 | 361 | // Ensure it stays above other windows and across spaces 362 | window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] 363 | 364 | // Additional settings to fully hide from app management 365 | window.isExcludedFromWindowsMenu = true 366 | window.animationBehavior = .none 367 | 368 | Logger.debug("Window configured for transparent overlay at bottom center", log: Logger.app) 369 | } else { 370 | Logger.error("Could not find main window to configure", log: Logger.app) 371 | } 372 | } 373 | 374 | private func hideWindowControls(_ window: NSWindow) { 375 | // Hide standard window buttons 376 | window.standardWindowButton(.closeButton)?.isHidden = true 377 | window.standardWindowButton(.miniaturizeButton)?.isHidden = true 378 | window.standardWindowButton(.zoomButton)?.isHidden = true 379 | 380 | // Hide the entire titlebar view and traffic light buttons 381 | if let titlebarView = window.standardWindowButton(.closeButton)?.superview?.superview { 382 | titlebarView.isHidden = true 383 | titlebarView.alphaValue = 0 384 | } 385 | 386 | // Additional step to hide the titlebar container 387 | if let titlebarContainerView = findTitlebarContainerView(in: window) { 388 | titlebarContainerView.isHidden = true 389 | titlebarContainerView.alphaValue = 0 390 | } 391 | 392 | // Hide any window toolbar if present 393 | window.toolbar?.isVisible = false 394 | 395 | // Ensure no title bar is shown by setting zero height 396 | window.setContentBorderThickness(0, for: .minY) 397 | window.setAutorecalculatesContentBorderThickness(false, for: .minY) 398 | 399 | // Apply additional steps for complete hiding 400 | if let contentView = window.contentView, 401 | let superview = contentView.superview { 402 | // Find and hide all subviews that might be related to window controls 403 | for subview in superview.subviews { 404 | if String(describing: type(of: subview)).contains("NSTitlebar") || 405 | String(describing: type(of: subview)).contains("NSButton") { 406 | subview.isHidden = true 407 | subview.alphaValue = 0 408 | } 409 | } 410 | } 411 | } 412 | 413 | private func findTitlebarContainerView(in window: NSWindow) -> NSView? { 414 | // Find the title bar container view to completely hide it 415 | if let contentView = window.contentView { 416 | return findSubview(named: "NSTitlebarContainerView", in: contentView.superview) 417 | } 418 | return nil 419 | } 420 | 421 | private func findSubview(named className: String, in view: NSView?) -> NSView? { 422 | guard let view = view else { return nil } 423 | 424 | if String(describing: type(of: view)) == className { 425 | return view 426 | } 427 | 428 | for subview in view.subviews { 429 | if let found = findSubview(named: className, in: subview) { 430 | return found 431 | } 432 | } 433 | 434 | return nil 435 | } 436 | 437 | func showOverlayFromMenu() { 438 | // Ensure window is visible and properly configured 439 | DispatchQueue.main.async { 440 | if let window = NSApplication.shared.windows.first { 441 | window.orderFront(nil) 442 | self.configureWindowAppearance() 443 | 444 | // Ensure window ignores mouse events 445 | window.ignoresMouseEvents = true 446 | 447 | // Ensure all input monitors are running 448 | if !InputManager.shared.checkMonitoringStatus() { 449 | InputManager.shared.restartMonitoring() 450 | } 451 | 452 | // Make sure window is properly configured for overlay 453 | NSApp.activate(ignoringOtherApps: true) 454 | 455 | Logger.debug("Show overlay triggered from menu", log: Logger.app) 456 | 457 | // Clear any existing events before demo 458 | InputManager.shared.clearAllEvents() 459 | 460 | // Create test events for demonstration - but skip if it's a first launch 461 | if !self.isFirstLaunch { 462 | InputManager.shared.showDemoInputs() 463 | } else { 464 | // For first launch, only show READY 465 | InputManager.shared.showReadyDemo() 466 | } 467 | } 468 | } 469 | } 470 | } 471 | 472 | // Extension to check if the app is running 473 | extension NSApplication { 474 | var isRunning: Bool { 475 | // The old implementation was causing crashes: 476 | // return NSApp.windows.count > 0 && NSApp.isActive 477 | 478 | // New safer implementation: 479 | // Only check if the app exists and is active, which is safer 480 | let windowCount = NSApp.windows.count 481 | return windowCount > 0 && NSApplication.shared.isActive 482 | } 483 | } 484 | 485 | @main 486 | struct KlicApp: App { 487 | @StateObject private var inputManager = InputManager.shared 488 | @State private var isShowingPreferences = false 489 | @AppStorage("overlayOpacity") private var overlayOpacity: Double = 0.85 490 | 491 | // Hold a reference to our AppDelegate to prevent it from being deallocated 492 | @State private var appDelegate: AppDelegate? = nil 493 | 494 | init() { 495 | // Safely initialize the AppDelegate 496 | if NSApplication.shared.isRunning { 497 | self.appDelegate = AppDelegate.shared 498 | } else { 499 | // If NSApp isn't ready yet, we'll initialize it in onAppear 500 | Logger.info("NSApp not ready during init, will initialize AppDelegate later", log: Logger.app) 501 | } 502 | } 503 | 504 | var body: some Scene { 505 | WindowGroup { 506 | ContentView() 507 | .environmentObject(inputManager) 508 | .background(Color.clear) 509 | .onAppear { 510 | // Initialize AppDelegate if needed and setup the app 511 | if appDelegate == nil { 512 | self.appDelegate = AppDelegate.shared 513 | } 514 | setupApp() 515 | } 516 | .sheet(isPresented: $isShowingPreferences) { 517 | ConfigurationView(opacity: $overlayOpacity) 518 | .onChange(of: overlayOpacity) { oldValue, newValue in 519 | // Save the new opacity preference 520 | inputManager.setOpacityPreference(newValue) 521 | } 522 | } 523 | .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowPreferences"))) { _ in 524 | isShowingPreferences = true 525 | } 526 | .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowOverlayDemo"))) { _ in 527 | // Show demo inputs when requested from menu 528 | inputManager.showDemoInputs() 529 | } 530 | } 531 | .windowStyle(.hiddenTitleBar) 532 | .commands { 533 | CommandGroup(after: .appInfo) { 534 | Button("Show Overlay") { 535 | showOverlayFromMenu() 536 | } 537 | .keyboardShortcut("o", modifiers: [.command, .shift]) 538 | 539 | Divider() 540 | 541 | Button("Preferences...") { 542 | isShowingPreferences = true 543 | } 544 | .keyboardShortcut(",", modifiers: .command) 545 | } 546 | } 547 | } 548 | 549 | private func setupApp() { 550 | // Configure window appearance with a delay to ensure it's ready 551 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 552 | appDelegate?.configureWindowAppearance() 553 | 554 | // Try to setup menu bar again if needed 555 | appDelegate?.setupMenuBar() 556 | 557 | // Show overlay and check permissions on first launch 558 | if appDelegate?.isFirstLaunch == true { 559 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 560 | appDelegate?.showOverlayFromMenu() 561 | // Only check permissions if not already granted 562 | let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: false] 563 | let accessEnabled = AXIsProcessTrustedWithOptions(options as CFDictionary) 564 | if !accessEnabled { 565 | appDelegate?.checkPermissions() 566 | } 567 | appDelegate?.isFirstLaunch = false 568 | } 569 | } 570 | } 571 | 572 | // Make another attempt after a longer delay 573 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { 574 | if appDelegate?.statusItem == nil { 575 | Logger.warning("Menu bar still not set up after initial delay, making additional attempt", log: Logger.app) 576 | // Force the status bar setup 577 | appDelegate?.setupMenuBar() 578 | 579 | // Make sure window is properly configured 580 | appDelegate?.configureWindowAppearance() 581 | } 582 | } 583 | } 584 | 585 | private func showOverlayFromMenu() { 586 | appDelegate?.showOverlayFromMenu() 587 | } 588 | } 589 | --------------------------------------------------------------------------------