├── .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 | 
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 | 
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 |
--------------------------------------------------------------------------------