Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion MiddleDrag.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@
Models/GestureModels.swift,
Models/TouchModels.swift,
UI/AlertHelper.swift,
UI/HotKeyRecorderView.swift,
UI/MenuBarController.swift,
Utilities/AnalyticsManager.swift,
Utilities/GlobalHotKeyManager.swift,
Utilities/LaunchAtLoginManager.swift,
Utilities/PreferencesManager.swift,
Utilities/ScreenHelper.swift,
Expand Down Expand Up @@ -109,7 +111,6 @@
membershipExceptions = (
Debug.xcconfig,
Release.xcconfig,
Secrets.xcconfig,
);
target = 1A0000011 /* MiddleDrag */;
};
Expand Down Expand Up @@ -145,8 +146,10 @@
Models/GestureModels.swift,
Models/TouchModels.swift,
UI/AlertHelper.swift,
UI/HotKeyRecorderView.swift,
UI/MenuBarController.swift,
Utilities/AnalyticsManager.swift,
Utilities/GlobalHotKeyManager.swift,
Utilities/LaunchAtLoginManager.swift,
Utilities/PreferencesManager.swift,
Utilities/ScreenHelper.swift,
Expand Down
39 changes: 39 additions & 0 deletions MiddleDrag/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Cocoa
import Carbon.HIToolbox
import MiddleDragCore

/// Main application delegate
Expand All @@ -18,6 +19,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private var preferences: UserPreferences!

private var accessibilityMonitor: AccessibilityMonitor?

private var toggleHotKeyID: UInt32 = 0
private var menuBarHotKeyID: UInt32 = 0

// MARK: - Application Lifecycle

Expand Down Expand Up @@ -111,6 +115,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
)
Log.info("Menu bar controller initialized", category: .app)

// Register global hotkeys
registerHotKeys()

// Set up notification observers
setupNotifications()

Expand Down Expand Up @@ -160,6 +167,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return true
}

func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
menuBarController?.toggleMenuBarVisibility()
return false
}

// MARK: - Setup

private func setupNotifications() {
Expand All @@ -177,6 +189,32 @@ class AppDelegate: NSObject, NSApplicationDelegate {
object: nil
)
}

/// Register (or re-register) global hotkeys from current preferences
private func registerHotKeys() {
// Unregister existing
if toggleHotKeyID != 0 {
GlobalHotKeyManager.shared.unregister(id: toggleHotKeyID)
}
if menuBarHotKeyID != 0 {
GlobalHotKeyManager.shared.unregister(id: menuBarHotKeyID)
}

// Register from preferences
toggleHotKeyID = GlobalHotKeyManager.shared.register(
keyCode: preferences.toggleHotKey.keyCode,
modifiers: preferences.toggleHotKey.carbonModifiers
) { [weak self] in
self?.menuBarController?.toggleEnabled()
}

menuBarHotKeyID = GlobalHotKeyManager.shared.register(
keyCode: preferences.menuBarHotKey.keyCode,
modifiers: preferences.menuBarHotKey.carbonModifiers
) { [weak self] in
self?.menuBarController?.toggleMenuBarVisibility()
}
}

// MARK: - Notification Handlers

Expand All @@ -185,6 +223,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
preferences = newPreferences
PreferencesManager.shared.savePreferences(preferences)
multitouchManager.updateConfiguration(preferences.gestureConfig)
registerHotKeys()
Log.info("Preferences updated", category: .app)
}
}
Expand Down
104 changes: 104 additions & 0 deletions MiddleDrag/Models/GestureModels.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Foundation
import AppKit
import Carbon.HIToolbox

// MARK: - Gesture State

Expand Down Expand Up @@ -107,6 +109,98 @@ enum ModifierKeyType: String, Codable, CaseIterable, Sendable {
}
}

// MARK: - Hot Key Binding

/// Persistable hotkey binding (virtual key code + modifier flags)
public struct HotKeyBinding: Codable, Equatable, Sendable {
public var keyCode: UInt32
public var carbonModifiers: UInt32

// Human-readable strings
var displayString: String {
var parts: [String] = []
if carbonModifiers & UInt32(cmdKey) != 0 { parts.append("⌘ Command") }
if carbonModifiers & UInt32(optionKey) != 0 { parts.append("⌥ Option") }
if carbonModifiers & UInt32(controlKey) != 0 { parts.append("⌃ Control") }
if carbonModifiers & UInt32(shiftKey) != 0 { parts.append("⇧ Shift") }
parts.append(Self.keyName(for: keyCode))
return parts.joined()
}

private static func keyName(for keyCode: UInt32) -> String {
let source = CGEventSource(stateID: .hidSystemState)
if let cgEvent = CGEvent(keyboardEventSource: source, virtualKey: UInt16(keyCode), keyDown: true),
let nsEvent = NSEvent(cgEvent: cgEvent) {
let chars = nsEvent.charactersIgnoringModifiers?.uppercased() ?? ""
if !chars.isEmpty && chars.rangeOfCharacter(from: .controlCharacters) == nil {
return chars
}
}

// Fallback for non-pritable keys
switch Int(keyCode) {
case kVK_ANSI_A: return "A"
case kVK_ANSI_B: return "B"
case kVK_ANSI_C: return "C"
case kVK_ANSI_D: return "D"
case kVK_ANSI_E: return "E"
case kVK_ANSI_F: return "F"
case kVK_ANSI_G: return "G"
case kVK_ANSI_H: return "H"
case kVK_ANSI_I: return "I"
case kVK_ANSI_J: return "J"
case kVK_ANSI_K: return "K"
case kVK_ANSI_L: return "L"
case kVK_ANSI_M: return "M"
case kVK_ANSI_N: return "N"
case kVK_ANSI_O: return "O"
case kVK_ANSI_P: return "P"
case kVK_ANSI_Q: return "Q"
case kVK_ANSI_R: return "R"
case kVK_ANSI_S: return "S"
case kVK_ANSI_T: return "T"
case kVK_ANSI_U: return "U"
case kVK_ANSI_V: return "V"
case kVK_ANSI_W: return "W"
case kVK_ANSI_X: return "X"
case kVK_ANSI_Y: return "Y"
case kVK_ANSI_Z: return "Z"
case kVK_ANSI_0: return "0"
case kVK_ANSI_1: return "1"
case kVK_ANSI_2: return "2"
case kVK_ANSI_3: return "3"
case kVK_ANSI_4: return "4"
case kVK_ANSI_5: return "5"
case kVK_ANSI_6: return "6"
case kVK_ANSI_7: return "7"
case kVK_ANSI_8: return "8"
case kVK_ANSI_9: return "9"
case kVK_F1: return "F1"
case kVK_F2: return "F2"
case kVK_F3: return "F3"
case kVK_F4: return "F4"
case kVK_F5: return "F5"
case kVK_F6: return "F6"
case kVK_F7: return "F7"
case kVK_F8: return "F8"
case kVK_F9: return "F9"
case kVK_F10: return "F10"
case kVK_F11: return "F11"
case kVK_F12: return "F12"
case kVK_Space: return "Space"
case kVK_Tab: return "Tab"
case kVK_Return: return "Return"
case kVK_Delete: return "Delete"
case kVK_Escape: return "Esc"
case kVK_LeftArrow: return "←"
case kVK_RightArrow: return "→"
case kVK_UpArrow: return "↑"
case kVK_DownArrow: return "↓"
default: return unsafe String(format: "0x%02X", keyCode)
}
}
}

// MARK: - User Preferences

/// User preferences that persist across app launches
Expand Down Expand Up @@ -150,6 +244,16 @@ public struct UserPreferences: Codable, Sendable {
// Title bar passthrough - pass gesture to system when cursor is over window title bar
var passThroughTitleBar: Bool = false
var titleBarHeight: Double = 28 // Height of title bar region in pixels

// Hotkey bindings
public var toggleHotKey: HotKeyBinding = HotKeyBinding(
keyCode: UInt32(kVK_ANSI_E),
carbonModifiers: UInt32(cmdKey) | UInt32(optionKey)
)
public var menuBarHotKey: HotKeyBinding = HotKeyBinding(
keyCode: UInt32(kVK_ANSI_M),
carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)
)

/// Convert to GestureConfiguration
public var gestureConfig: GestureConfiguration {
Expand Down
92 changes: 92 additions & 0 deletions MiddleDrag/UI/HotKeyRecorderView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// HotKeyRecorderView.swift
// MiddleDrag
//

import Cocoa
import Carbon.HIToolbox

/// A button that captures the next key + modifier combo when clicked.
/// Displays the current binding as a human-readable string (e.g. "⌘⇧E").
@MainActor
final class HotKeyRecorderView: NSButton {

var binding: HotKeyBinding {
didSet { updateLabel() }
}

var onBindingChanged: ((HotKeyBinding) -> Void)?

private var isRecording = false
private var localMonitor: Any?

init(binding: HotKeyBinding) {
self.binding = binding
super.init(frame: .zero)
bezelStyle = .recessed
setButtonType(.momentaryPushIn)
isBordered = true
font = .monospacedSystemFont(ofSize: 12, weight: .medium)
updateLabel()
target = self
action = #selector(startRecording)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) not implemented")
}

private func updateLabel() {
title = isRecording ? "Press a key…" : binding.displayString
}

@objc private func startRecording() {
guard !isRecording else { return }
isRecording = true
updateLabel()

localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
self?.handleKeyDown(event)
return nil // swallow the event
}
}

private func stopRecording() {
isRecording = false
if let monitor = localMonitor {
NSEvent.removeMonitor(monitor)
localMonitor = nil
}
updateLabel()
}

private func handleKeyDown(_ event: NSEvent) {
let modifiers = GlobalHotKeyManager.carbonModifiers(from: event.modifierFlags)

// Escape cancels without changing the binding
if event.keyCode == UInt16(kVK_Escape) {
stopRecording()
return
}

// Require at least one modifier (bare keys are too easy to trigger accidentally)
guard modifiers != 0 else { return }

let newBinding = HotKeyBinding(
keyCode: UInt32(event.keyCode),
carbonModifiers: modifiers
)

binding = newBinding
stopRecording()
onBindingChanged?(newBinding)
}

// If the view loses focus while recording, cancel
override func resignFirstResponder() -> Bool {
if isRecording { stopRecording() }
return super.resignFirstResponder()
}
}

Loading
Loading