Skip to content

Commit b0e77e6

Browse files
committed
Fix stuck modifiers, safe mouse buttons, code cleanup, BTT preset
- Fix modifier shortcuts (Cmd+W, Ctrl+O, etc.) leaving modifiers stuck by changing from type "key" to "tap" — tap explicitly presses/releases modifier keys, preventing stuck Command/Control after use - Cap mouse button numbers at 31 to prevent WindowServer crash — layers now share buttons 10-23 with modifier flags (Option/Control) instead of using sequential numbers 10-51 - Add BindingType enum replacing string-based type matching - Extract HTML template to readme_template.html - Add configPath constant, tapKey comment, physics constants section - Group state variables, remove inline magic numbers - Bundle BTT preset (codecontroller_triggers.bttpreset) with Space switching on LT+D-Left/Right - Update README: voice transcription requirement, BTT setup with High Level Mouse Event Recognition, modifier-based mouse button docs - Update AGENTS.md with new mouse button scheme
1 parent bbe1947 commit b0e77e6

6 files changed

Lines changed: 234 additions & 161 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Everything lives in `CodeController.swift` (~560 lines):
3434
- Modifier layers: prefix with `lt+` or `lb+` (e.g., `lt+a`, `lb+dpad_left`)
3535
- `ls`/`rs` not `l3`/`r3` — migration in `loadBindings()` handles old configs
3636
- Canonical button order: A, B, X, Y, RT, RB, LS, RS, D-pad, Back, Menu
37-
- Mouse button numbering: base 10-23, +LT 24-37, +LB 38-51
37+
- Mouse button numbering: all layers use 10-23, distinguished by modifier keys (none for base, +Option for LT, +Control for LB). Hard cap at button 31 — higher numbers crash WindowServer
3838

3939
## Gotchas
4040

CodeController.swift

Lines changed: 92 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ import CoreGraphics
66

77
// MARK: - Binding Configuration
88

9+
let configPath = NSString("~/.config/codecontroller.json").expandingTildeInPath
10+
11+
enum BindingType: String, Codable {
12+
case key, tap, mouse, leftClick, rightClick, cycleWindows
13+
}
14+
915
struct Binding: Codable {
10-
let type: String // "key", "tap", "mouse", "leftClick", "rightClick", "cycleWindows"
16+
let type: BindingType
1117
var key: String? // keyboard key name (for "key" and "tap")
1218
var modifiers: [String]? // "command"/"cmd", "shift", "option"/"alt", "control"/"ctrl"
1319
var button: Int? // mouse button number (for "mouse")
@@ -32,7 +38,7 @@ let defaultConfigJSON = """
3238
"b": { "key": "escape", "type": "key" },
3339
"x": { "key": "backspace", "type": "key" },
3440
"y": { "key": "tab", "type": "key" },
35-
"back": { "key": "o", "type": "key", "modifiers": ["control"] },
41+
"back": { "key": "o", "type": "tap", "modifiers": ["control"] },
3642
"menu": { "key": "/", "type": "key" },
3743
"dpad_up": { "key": "up", "type": "key" },
3844
"dpad_down": { "key": "down", "type": "key" },
@@ -42,16 +48,16 @@ let defaultConfigJSON = """
4248
"rs": { "type": "rightClick" },
4349
4450
"lt+a": { "key": "space", "type": "key" },
45-
"lt+y": { "key": "tab", "type": "key", "modifiers": ["shift"] },
46-
"lt+back": { "key": "e", "type": "key", "modifiers": ["control"] },
51+
"lt+y": { "key": "tab", "type": "tap", "modifiers": ["shift"] },
52+
"lt+back": { "key": "e", "type": "tap", "modifiers": ["control"] },
4753
"lt+dpad_up": { "type": "cycleWindows" },
4854
"lt+dpad_down": { "type": "cycleWindows" },
4955
"lt+dpad_left": { "key": "[", "type": "tap", "modifiers": ["command", "shift"] },
5056
"lt+dpad_right": { "key": "]", "type": "tap", "modifiers": ["command", "shift"] },
5157
52-
"lb+b": { "key": "w", "type": "key", "modifiers": ["command"] },
53-
"lb+y": { "key": "t", "type": "key", "modifiers": ["command"] },
54-
"lb+back": { "key": "b", "type": "key", "modifiers": ["control"] }
58+
"lb+b": { "key": "w", "type": "tap", "modifiers": ["command"] },
59+
"lb+y": { "key": "t", "type": "tap", "modifiers": ["command"] },
60+
"lb+back": { "key": "b", "type": "tap", "modifiers": ["control"] }
5561
}
5662
}
5763
"""
@@ -100,22 +106,20 @@ func resolveModifiers(_ names: [String]) -> CGEventFlags {
100106

101107
func describeBinding(_ b: Binding) -> String {
102108
switch b.type {
103-
case "key", "tap":
109+
case .key, .tap:
104110
let mods = (b.modifiers ?? []).map { $0.capitalized }
105111
let key = (b.key ?? "?").capitalized
106112
return (mods + [key]).joined(separator: "+")
107-
case "mouse": return "Mouse \(b.button ?? 0)"
108-
case "leftClick": return "Left Click"
109-
case "rightClick": return "Right Click"
110-
case "cycleWindows": return "Cycle Windows"
111-
default: return b.type
113+
case .mouse: return "Mouse \(b.button ?? 0)"
114+
case .leftClick: return "Left Click"
115+
case .rightClick: return "Right Click"
116+
case .cycleWindows: return "Cycle Windows"
112117
}
113118
}
114119

115120
// Load config from file, or write defaults
116121
func loadConfig() -> Config {
117-
let configDir = NSString("~/.config").expandingTildeInPath
118-
let configPath = "\(configDir)/codecontroller.json"
122+
let configDir = (configPath as NSString).deletingLastPathComponent
119123

120124
if FileManager.default.fileExists(atPath: configPath) {
121125
do {
@@ -151,7 +155,6 @@ func loadConfig() -> Config {
151155
return try! JSONDecoder().decode(Config.self, from: defaultConfigJSON.data(using: .utf8)!)
152156
}
153157

154-
let validBindingTypes = Set(["key", "tap", "mouse", "leftClick", "rightClick", "cycleWindows"])
155158
let validButtonNames = Set(["a", "b", "x", "y", "rt", "rb", "ls", "rs", "dpad_up", "dpad_down", "dpad_left", "dpad_right", "back", "menu"])
156159
let validPrefixes = ["lt+", "lb+"]
157160

@@ -167,24 +170,19 @@ func validateBindings(_ config: [String: Binding]) -> [String] {
167170
warnings.append("\"\(name)\": unknown button name \"\(baseName)\"")
168171
}
169172

170-
// Validate binding type
171-
if !validBindingTypes.contains(binding.type) {
172-
warnings.append("\"\(name)\": unknown type \"\(binding.type)\"")
173-
}
174-
175173
// Validate key/tap have a key
176-
if (binding.type == "key" || binding.type == "tap") {
174+
if binding.type == .key || binding.type == .tap {
177175
if let key = binding.key {
178176
if resolveKeyCode(key) == nil {
179177
warnings.append("\"\(name)\": unknown key name \"\(key)\"")
180178
}
181179
} else {
182-
warnings.append("\"\(name)\": type \"\(binding.type)\" requires a \"key\" field")
180+
warnings.append("\"\(name)\": type \"\(binding.type.rawValue)\" requires a \"key\" field")
183181
}
184182
}
185183

186184
// Validate mouse button number
187-
if binding.type == "mouse" {
185+
if binding.type == .mouse {
188186
if let btn = binding.button {
189187
if btn < 0 {
190188
warnings.append("\"\(name)\": negative mouse button number \(btn)")
@@ -224,29 +222,46 @@ var bindings = config.bindings
224222
var loggingEnabled = config.settings?.logging ?? true
225223
showConfigWarnings(validateBindings(bindings))
226224

227-
// MARK: - State Tracking
225+
// MARK: - Constants
228226

229-
var buttonStates: [String: Bool] = [:]
230-
var activeBindingObjects: [String: Binding] = [:] // button name -> binding that was activated
227+
// Mouse cursor (left stick)
228+
let mouseDeadzone: Float = 0.10
229+
let mouseSpeed: Double = 20.0
230+
let mouseAccel: Double = 0.35
231+
let mouseDecay: Double = 0.7
232+
233+
// Scroll (right stick)
234+
let scrollDeadzone: Float = 0.02
235+
let scrollSpeed: Float = 30.0
236+
let scrollAccel: Float = 0.15
237+
let scrollDecay: Float = 0.92
238+
let scrollFlat: Float = 1.0
239+
let scrollCurve: Float = 5.5
231240

232241
// Universal mouse button fallback: every button combo gets a sequential number (10-51)
233242
let canonicalOrder = ["a", "b", "x", "y", "rt", "rb", "ls", "rs", "dpad_up", "dpad_down", "dpad_left", "dpad_right", "back", "menu"]
234243

235-
func fallbackMouseButton(for name: String, ltHeld: Bool, lbHeld: Bool) -> Int {
244+
func fallbackMouseButton(for name: String) -> Int {
236245
let idx = canonicalOrder.firstIndex(of: name) ?? 0
237-
let layerOffset = lbHeld ? 28 : (ltHeld ? 14 : 0)
238-
return 10 + layerOffset + idx
246+
return 10 + idx
239247
}
240248

241-
// Scroll momentum
249+
// Modifier flags attached to fallback mouse events to distinguish layers
250+
func fallbackModifiers(ltHeld: Bool, lbHeld: Bool) -> CGEventFlags {
251+
if lbHeld { return .maskControl }
252+
if ltHeld { return .maskAlternate }
253+
return []
254+
}
255+
256+
// MARK: - State
257+
258+
var buttonStates: [String: Bool] = [:]
259+
var activeBindingObjects: [String: Binding] = [:] // button name -> binding that was activated
260+
var activeMouseModifiers: [String: CGEventFlags] = [:] // button name -> modifier flags for fallback mouse
242261
var scrollVelocityX: Float = 0
243262
var scrollVelocityY: Float = 0
244-
245-
// Mouse momentum
246263
var mouseVelocityX: Double = 0
247264
var mouseVelocityY: Double = 0
248-
249-
// Mouse click state
250265
var mouseDown = false
251266
var rightMouseDown = false
252267

@@ -260,6 +275,8 @@ func pressKey(_ keyCode: CGKeyCode, down: Bool, modifiers: CGEventFlags = []) {
260275
event?.post(tap: .cgSessionEventTap)
261276
}
262277

278+
// Posts explicit modifier key-down/up events around the main key.
279+
// Required for system shortcuts (e.g. Cmd+Shift+[) that don't register from just setting CGEvent flags.
263280
func tapKey(_ keyCode: CGKeyCode, modifiers: CGEventFlags = []) {
264281
let modifierKeys: [(CGEventFlags, CGKeyCode)] = [
265282
(.maskCommand, 55), (.maskShift, 56), (.maskAlternate, 58), (.maskControl, 59)
@@ -279,11 +296,12 @@ func tapKey(_ keyCode: CGKeyCode, modifiers: CGEventFlags = []) {
279296
}
280297
}
281298

282-
func pressMouseButton(_ button: CGMouseButton, down: Bool) {
299+
func pressMouseButton(_ button: CGMouseButton, down: Bool, modifiers: CGEventFlags = []) {
283300
let location = CGEvent(source: nil)?.location ?? .zero
284301
let eventType: CGEventType = down ? .otherMouseDown : .otherMouseUp
285302
if let event = CGEvent(mouseEventSource: eventSource, mouseType: eventType, mouseCursorPosition: location, mouseButton: button) {
286303
event.setIntegerValueField(.mouseEventButtonNumber, value: Int64(button.rawValue))
304+
if !modifiers.isEmpty { event.flags = modifiers }
287305
event.post(tap: .cgSessionEventTap)
288306
}
289307
}
@@ -340,25 +358,23 @@ func cycleWindows() {
340358

341359
// MARK: - Binding Dispatch
342360

343-
func executeBinding(_ binding: Binding, down: Bool) {
361+
func executeBinding(_ binding: Binding, down: Bool, mouseFlags: CGEventFlags = []) {
344362
switch binding.type {
345-
case "key":
363+
case .key:
346364
guard let name = binding.key, let code = resolveKeyCode(name) else { return }
347365
pressKey(code, down: down, modifiers: resolveModifiers(binding.modifiers ?? []))
348-
case "tap":
366+
case .tap:
349367
guard down, let name = binding.key, let code = resolveKeyCode(name) else { return }
350368
tapKey(code, modifiers: resolveModifiers(binding.modifiers ?? []))
351-
case "mouse":
352-
guard let btn = binding.button, btn >= 0 else { return }
353-
pressMouseButton(CGMouseButton(rawValue: UInt32(btn))!, down: down)
354-
case "leftClick":
369+
case .mouse:
370+
guard let btn = binding.button, btn >= 0, btn <= 31 else { return }
371+
pressMouseButton(CGMouseButton(rawValue: UInt32(btn))!, down: down, modifiers: mouseFlags)
372+
case .leftClick:
355373
mouseClick(down: down)
356-
case "rightClick":
374+
case .rightClick:
357375
rightClick(down: down)
358-
case "cycleWindows":
376+
case .cycleWindows:
359377
if down { cycleWindows() }
360-
default:
361-
break
362378
}
363379
}
364380

@@ -379,20 +395,26 @@ func handleButton(_ name: String, pressed: Bool, ltHeld: Bool, lbHeld: Bool) {
379395

380396
// Use config binding if present, otherwise fall back to mouse button
381397
let binding: Binding
398+
let mouseFlags: CGEventFlags
382399
if let b = bindings[bindingKey] {
383400
binding = b
401+
mouseFlags = []
384402
} else {
385-
let mouseBtn = fallbackMouseButton(for: name, ltHeld: ltHeld, lbHeld: lbHeld)
386-
binding = Binding(type: "mouse", key: nil, modifiers: nil, button: mouseBtn)
403+
let mouseBtn = fallbackMouseButton(for: name)
404+
binding = Binding(type: .mouse, key: nil, modifiers: nil, button: mouseBtn)
405+
mouseFlags = fallbackModifiers(ltHeld: ltHeld, lbHeld: lbHeld)
387406
}
388407

389408
activeBindingObjects[name] = binding
390-
executeBinding(binding, down: true)
391-
print("\(bindingKey) -> \(describeBinding(binding))")
409+
activeMouseModifiers[name] = mouseFlags
410+
executeBinding(binding, down: true, mouseFlags: mouseFlags)
411+
let modDesc = mouseFlags.contains(.maskControl) ? " +Ctrl" : mouseFlags.contains(.maskAlternate) ? " +Opt" : ""
412+
print("\(bindingKey) -> \(describeBinding(binding))\(modDesc)")
392413
} else {
393414
// Release the same binding that was activated (prevents stuck keys)
394415
if let binding = activeBindingObjects.removeValue(forKey: name) {
395-
executeBinding(binding, down: false)
416+
let mouseFlags = activeMouseModifiers.removeValue(forKey: name) ?? []
417+
executeBinding(binding, down: false, mouseFlags: mouseFlags)
396418
}
397419
}
398420
}
@@ -422,10 +444,6 @@ func pollController(_ gamepad: GCExtendedGamepad) {
422444
// --- Left stick: mouse movement with momentum ---
423445
let mouseX = gamepad.leftThumbstick.xAxis.value
424446
let mouseY = gamepad.leftThumbstick.yAxis.value
425-
let mouseDeadzone: Float = 0.10
426-
let mouseSpeed: Double = 20.0
427-
let mouseAccel: Double = 0.35
428-
let mouseDecay: Double = 0.7
429447

430448
if abs(mouseX) > mouseDeadzone || abs(mouseY) > mouseDeadzone {
431449
let curvedX = (mouseX > 0 ? 1.0 : -1.0) * pow(Double(abs(mouseX)), 1.5) * mouseSpeed
@@ -444,25 +462,20 @@ func pollController(_ gamepad: GCExtendedGamepad) {
444462
// --- Right stick: scrolling with momentum ---
445463
let scrollX = gamepad.rightThumbstick.xAxis.value
446464
let scrollY = gamepad.rightThumbstick.yAxis.value
447-
let deadzone: Float = 0.02
448-
let scrollSpeed: Float = 30.0
449-
let accel: Float = 0.15
450-
451-
if abs(scrollX) > deadzone || abs(scrollY) > deadzone {
452-
let curvedX = (scrollX > 0 ? 1.0 : -1.0) * pow(abs(scrollX), 5.5) * scrollSpeed
453-
let curvedY = (scrollY > 0 ? 1.0 : -1.0) * pow(abs(scrollY), 5.5) * scrollSpeed
454-
scrollVelocityX += (curvedX - scrollVelocityX) * accel
455-
scrollVelocityY += (curvedY - scrollVelocityY) * accel
465+
466+
if abs(scrollX) > scrollDeadzone || abs(scrollY) > scrollDeadzone {
467+
let curvedX = (scrollX > 0 ? 1.0 : -1.0) * pow(abs(scrollX), scrollCurve) * scrollSpeed
468+
let curvedY = (scrollY > 0 ? 1.0 : -1.0) * pow(abs(scrollY), scrollCurve) * scrollSpeed
469+
scrollVelocityX += (curvedX - scrollVelocityX) * scrollAccel
470+
scrollVelocityY += (curvedY - scrollVelocityY) * scrollAccel
456471
} else {
457-
let decay: Float = 0.92
458-
let flat: Float = 1.0
459-
scrollVelocityX *= decay
460-
scrollVelocityY *= decay
461-
if scrollVelocityX > flat { scrollVelocityX -= flat }
462-
else if scrollVelocityX < -flat { scrollVelocityX += flat }
472+
scrollVelocityX *= scrollDecay
473+
scrollVelocityY *= scrollDecay
474+
if scrollVelocityX > scrollFlat { scrollVelocityX -= scrollFlat }
475+
else if scrollVelocityX < -scrollFlat { scrollVelocityX += scrollFlat }
463476
else { scrollVelocityX = 0 }
464-
if scrollVelocityY > flat { scrollVelocityY -= flat }
465-
else if scrollVelocityY < -flat { scrollVelocityY += flat }
477+
if scrollVelocityY > scrollFlat { scrollVelocityY -= scrollFlat }
478+
else if scrollVelocityY < -scrollFlat { scrollVelocityY += scrollFlat }
466479
else { scrollVelocityY = 0 }
467480
}
468481

@@ -519,66 +532,18 @@ class AppActions: NSObject {
519532
guard let readmePath = Bundle.main.path(forResource: "README", ofType: "md"),
520533
let md = try? String(contentsOfFile: readmePath, encoding: .utf8),
521534
let markedPath = Bundle.main.path(forResource: "marked.min", ofType: "js"),
522-
let markedJS = try? String(contentsOfFile: markedPath, encoding: .utf8) else { return }
523-
let base64 = Data(md.utf8).base64EncodedString()
524-
let page = ##"""
525-
<!DOCTYPE html>
526-
<html><head><meta charset="utf-8"><title>CodeController</title>
527-
<script>\##(markedJS)</script>
528-
<style>
529-
* { box-sizing: border-box; margin: 0; padding: 0; }
530-
body { font-family: 'SF Mono','Menlo','Monaco',monospace; background: #0a0a0a; color: #b0b0b0; font-size: 13px; line-height: 1.7; }
531-
nav#toc { position: fixed; left: 0; top: 0; width: 200px; height: 100vh; overflow-y: auto; padding: 20px 14px; background: #050505; border-right: 1px solid #1a3a1a; z-index: 10; }
532-
nav#toc .toc-title { color: #00ff41; font-size: 11px; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #1a3a1a; }
533-
nav#toc a { display: block; color: #666; text-decoration: none; font-size: 11px; padding: 3px 0; transition: color 0.2s; }
534-
nav#toc a:hover { color: #00ff41; }
535-
nav#toc a.h3 { padding-left: 12px; font-size: 10px; color: #444; }
536-
main#content { margin-left: 200px; max-width: 800px; padding: 40px 30px; }
537-
h1 { color: #00ff41; border-bottom: 1px solid #1a3a1a; padding-bottom: 10px; font-size: 20px; text-transform: uppercase; letter-spacing: 3px; margin-bottom: 16px; }
538-
h2 { color: #00cc33; border-bottom: 1px solid #1a2a1a; padding-bottom: 6px; margin-top: 36px; margin-bottom: 12px; font-size: 15px; letter-spacing: 1px; }
539-
h3 { color: #00aa2a; margin-top: 24px; margin-bottom: 8px; font-size: 13px; }
540-
p { margin: 8px 0; }
541-
a { color: #00ff41; text-decoration: none; border-bottom: 1px solid #1a3a1a; }
542-
a:hover { border-bottom-color: #00ff41; }
543-
ul, ol { padding-left: 20px; margin: 8px 0; }
544-
li { margin: 6px 0; }
545-
table { margin: 12px 0; border-collapse: collapse; width: 100%; }
546-
th { background: #111; color: #00ff41; text-align: left; padding: 8px 12px; border: 1px solid #1a3a1a; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
547-
td { padding: 6px 12px; border: 1px solid #1a2a1a; color: #999; }
548-
td:empty { background: #070707; }
549-
tr:hover td { background: #0f1a0f; color: #ccc; }
550-
code { background: #151515; color: #00ff41; padding: 2px 6px; border-radius: 3px; border: 1px solid #1a3a1a; font-size: 12px; }
551-
pre { margin: 12px 0; }
552-
pre code { display: block; padding: 14px; background: #0d0d0d; border: 1px solid #1a3a1a; border-radius: 4px; overflow-x: auto; color: #00dd38; border-left: 3px solid #00ff41; }
553-
strong { color: #ddd; }
554-
em { color: #777; font-style: normal; }
555-
@media (max-width: 700px) { nav#toc { display: none; } main#content { margin-left: 0; } }
556-
</style>
557-
</head><body>
558-
<nav id="toc"><div class="toc-title">CodeController</div></nav>
559-
<main id="content"></main>
560-
<script>
561-
const raw = new TextDecoder().decode(Uint8Array.from(atob('\##(base64)'), c => c.charCodeAt(0)));
562-
document.getElementById('content').innerHTML = marked.parse(raw);
563-
document.querySelectorAll('h2, h3').forEach(h => {
564-
const id = h.textContent.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
565-
h.id = id;
566-
const a = document.createElement('a');
567-
a.href = '#' + id;
568-
a.textContent = h.textContent;
569-
a.className = h.tagName.toLowerCase();
570-
document.getElementById('toc').appendChild(a);
571-
});
572-
</script>
573-
</body></html>
574-
"""##
535+
let markedJS = try? String(contentsOfFile: markedPath, encoding: .utf8),
536+
let templatePath = Bundle.main.path(forResource: "readme_template", ofType: "html"),
537+
let template = try? String(contentsOfFile: templatePath, encoding: .utf8) else { return }
538+
let page = template
539+
.replacingOccurrences(of: "{{MARKED_JS}}", with: markedJS)
540+
.replacingOccurrences(of: "{{MARKDOWN_BASE64}}", with: Data(md.utf8).base64EncodedString())
575541
let tmpPath = NSTemporaryDirectory() + "codecontroller_readme.html"
576542
try? page.write(toFile: tmpPath, atomically: true, encoding: .utf8)
577543
NSWorkspace.shared.open(URL(fileURLWithPath: tmpPath))
578544
}
579545

580546
@objc func editBindings() {
581-
let configPath = NSString("~/.config/codecontroller.json").expandingTildeInPath
582547
NSWorkspace.shared.open(URL(fileURLWithPath: configPath))
583548
}
584549

0 commit comments

Comments
 (0)