diff --git a/flake.nix b/flake.nix index f5e88be..541a4f5 100644 --- a/flake.nix +++ b/flake.nix @@ -25,6 +25,10 @@ shellHook = '' export QMK_HOME="$PWD/qmk_firmware" ln -sfn "$PWD/keymaps/custom" "$QMK_HOME/keyboards/system76/launch_3/keymaps/custom" + git -C "$QMK_HOME" checkout -- . + for p in "$PWD"/patches/*.patch; do + [ -f "$p" ] && patch -d "$QMK_HOME" -p1 < "$p" + done ''; }; }); @@ -40,6 +44,9 @@ src = ./qmk_firmware; + patches = builtins.map (f: ./patches + "/${f}") + (builtins.attrNames (builtins.readDir ./patches)); + postUnpack = '' cp -r ${./keymaps/custom} $sourceRoot/keyboards/system76/launch_3/keymaps/custom ''; diff --git a/keymaps/custom/app_focus.c b/keymaps/custom/app_focus.c new file mode 100644 index 0000000..42af0cb --- /dev/null +++ b/keymaps/custom/app_focus.c @@ -0,0 +1,51 @@ +#include "raw_hid.h" +#include + +#include "app_focus.h" + +#define CMD_SET_FOCUSED_APP 0x80 +#define APP_NAME_BUF_SIZE 64 + +focused_app_t focused_app = APP_UNKNOWN; + +static char app_name_buf[APP_NAME_BUF_SIZE]; + +typedef struct { + const char *name; + focused_app_t app; +} app_mapping_t; + +static const app_mapping_t app_map[] = { + { "Terminal", APP_TERMINAL }, + { "iTerm2", APP_TERMINAL }, + { "Xcode", APP_XCODE }, + { "Code", APP_VSCODE }, // "Code" is VSCode's localizedName + { "Visual Studio Code", APP_VSCODE }, +}; + +static focused_app_t resolve_app(const char *name) { + for (uint8_t i = 0; i < sizeof(app_map) / sizeof(app_map[0]); i++) { + if (strcmp(name, app_map[i].name) == 0) { + return app_map[i].app; + } + } + return APP_UNKNOWN; +} + +// Called from system76_ec.c's raw_hid_receive (injected via sed at build time). +// Returns true if the command was handled. +bool app_focus_raw_hid_intercept(uint8_t *data, uint8_t length) { + if (data[0] == CMD_SET_FOCUSED_APP) { + uint8_t copy_len = length - 1; + if (copy_len >= APP_NAME_BUF_SIZE) { + copy_len = APP_NAME_BUF_SIZE - 1; + } + memcpy(app_name_buf, &data[1], copy_len); + app_name_buf[copy_len] = '\0'; + focused_app = resolve_app(app_name_buf); + data[1] = 0; // success + raw_hid_send(data, length); + return true; + } + return false; +} diff --git a/keymaps/custom/app_focus.h b/keymaps/custom/app_focus.h new file mode 100644 index 0000000..9bba2d9 --- /dev/null +++ b/keymaps/custom/app_focus.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +typedef enum { + APP_UNKNOWN, + APP_TERMINAL, // Terminal.app, iTerm2 + APP_XCODE, + APP_VSCODE, // Visual Studio Code +} focused_app_t; + +extern focused_app_t focused_app; + +bool app_focus_raw_hid_intercept(uint8_t *data, uint8_t length); diff --git a/keymaps/custom/keymap.c b/keymaps/custom/keymap.c index ecd04cc..3c931dd 100644 --- a/keymaps/custom/keymap.c +++ b/keymaps/custom/keymap.c @@ -2,6 +2,7 @@ #include "keymap_introspection.h" #include "dynamic_keymap.h" #include "os_mode.h" +#include "app_focus.h" // System76 per-layer RGB state (defined in system76_ec.c, declared in launch_3.c) extern rgb_config_t layer_rgb[]; @@ -26,6 +27,7 @@ static const mod_rewrite_t ALT_TO_SUPER = { MOD_MASK_ALT, static const mod_rewrite_t SUPER_TO_SUPER = { MOD_MASK_GUI, MOD_MASK_GUI }; static const mod_rewrite_t SUPER_TO_CTRL = { MOD_MASK_GUI, MOD_MASK_CTRL }; static const mod_rewrite_t NONE_TO_SUPER = { 0, MOD_MASK_GUI }; +static const mod_rewrite_t NONE_TO_CTRL = { 0, MOD_MASK_CTRL }; // Collapse left/right mods into modifier types for comparison. // e.g. either LCTL (0x01) or RCTL (0x10) both become 0x01. @@ -34,9 +36,9 @@ static uint8_t normalize_mods(uint8_t mods) { } // Check if current mods exactly match a rule's `from` mods. -// Returns false if FN layer is active (skip rewrites when FN is held). +// Returns false if FN layer is active (skip rewrites when FN + any modifier is held). static bool mods_match(uint8_t mods, mod_rewrite_t rule) { - if (layer_state_is(1)) return false; + if (layer_state_is(1) && get_mods() != 0) return false; return normalize_mods(mods) == normalize_mods(rule.from); } @@ -223,8 +225,11 @@ bool process_record_user(uint16_t keycode, keyrecord_t *record) { case KC_C: case KC_V: if (os_mode == OS_MODE_MAC && record->event.pressed) { - if (rewrite_mods(keycode, CTRL_TO_SUPER)) return false; if (rewrite_mods(keycode, CTRL_SHIFT_TO_SUPER)) return false; + // Terminal: plain Ctrl+C/V passes through (SIGINT, etc.) + if (focused_app != APP_TERMINAL) { + if (rewrite_mods(keycode, CTRL_TO_SUPER)) return false; + } } break; // Mac mode rewrites: @@ -274,11 +279,19 @@ bool process_record_user(uint16_t keycode, keyrecord_t *record) { // End -> Super+Right (line end) case KC_HOME: if (os_mode == OS_MODE_MAC && record->event.pressed) { + // Terminal: Home -> Ctrl+A (line start) + if (focused_app == APP_TERMINAL) { + if (rewrite_mods_and_key(NONE_TO_CTRL, KC_A)) return false; + } if (rewrite_mods_and_key(NONE_TO_SUPER, KC_LEFT)) return false; } break; case KC_END: if (os_mode == OS_MODE_MAC && record->event.pressed) { + // Terminal: End -> Ctrl+E (line end) + if (focused_app == APP_TERMINAL) { + if (rewrite_mods_and_key(NONE_TO_CTRL, KC_E)) return false; + } if (rewrite_mods_and_key(NONE_TO_SUPER, KC_RGHT)) return false; } break; diff --git a/keymaps/custom/rules.mk b/keymaps/custom/rules.mk index a5f0b67..b297cca 100644 --- a/keymaps/custom/rules.mk +++ b/keymaps/custom/rules.mk @@ -1,2 +1,3 @@ OS_DETECTION_ENABLE = yes SRC += os_detect.c +SRC += app_focus.c diff --git a/mac-agent/com.user.kb-focus.plist b/mac-agent/com.user.kb-focus.plist new file mode 100644 index 0000000..f8ad529 --- /dev/null +++ b/mac-agent/com.user.kb-focus.plist @@ -0,0 +1,21 @@ + + + + + Label + com.user.kb-focus + ProgramArguments + + /usr/bin/python3 + kb-focus.py + + WorkingDirectory + /Users/USER/keyboard/mac-agent + KeepAlive + + StandardOutPath + /tmp/kb-focus.stdout.log + StandardErrorPath + /tmp/kb-focus.stderr.log + + diff --git a/mac-agent/kb-focus.py b/mac-agent/kb-focus.py new file mode 100644 index 0000000..f2bcb74 --- /dev/null +++ b/mac-agent/kb-focus.py @@ -0,0 +1,67 @@ +import signal, sys +from AppKit import NSWorkspace +from Foundation import NSRunLoop, NSDate +import hid + +VID, PID = 0x3384, 0x0009 +USAGE_PAGE, USAGE = 0xFF60, 0x61 +CMD_SET_FOCUSED_APP = 0x80 + +def find_keyboard_path(): + for info in hid.enumerate(VID, PID): + if info["usage_page"] == USAGE_PAGE and info["usage"] == USAGE: + return info["path"] + return None + +def send_app_name(dev, name): + payload = name.encode("utf-8", errors="ignore")[:31] + payload = payload.decode("utf-8", errors="ignore").encode("utf-8") + data = bytes([0x00, CMD_SET_FOCUSED_APP]) + payload + b"\x00" * (32 - 1 - len(payload)) + dev.write(data) + dev.read(32, timeout=1000) + +def main(): + signal.signal(signal.SIGINT, lambda *_: sys.exit(0)) + ws = NSWorkspace.sharedWorkspace() + dev = None + last_app = None + + while True: + connected = find_keyboard_path() is not None + + # Detect disconnect + if dev is not None and not connected: + print("Keyboard disconnected") + dev = None + last_app = None + + # Connect and send current app + if dev is None and connected: + try: + dev = hid.Device(path=find_keyboard_path()) + print("Keyboard connected") + last_app = ws.frontmostApplication().localizedName() + print(f"App: {last_app}") + send_app_name(dev, last_app) + except Exception: + dev = None + + # App change + if dev is not None: + app = ws.frontmostApplication().localizedName() + if app != last_app: + print(f"App: {app}") + last_app = app + try: + send_app_name(dev, app) + except Exception: + print("Keyboard disconnected") + dev = None + last_app = None + + NSRunLoop.currentRunLoop().runUntilDate_( + NSDate.dateWithTimeIntervalSinceNow_(0.25) + ) + +if __name__ == "__main__": + main() diff --git a/mac-agent/requirements.txt b/mac-agent/requirements.txt new file mode 100644 index 0000000..0b0c047 --- /dev/null +++ b/mac-agent/requirements.txt @@ -0,0 +1,2 @@ +pyobjc-framework-Cocoa +hid diff --git a/patches/app_focus_intercept.patch b/patches/app_focus_intercept.patch new file mode 100644 index 0000000..50f476c --- /dev/null +++ b/patches/app_focus_intercept.patch @@ -0,0 +1,15 @@ +--- a/keyboards/system76/system76_ec.c ++++ b/keyboards/system76/system76_ec.c +@@ -1,3 +1,4 @@ ++#include "app_focus.h" + /* + * Copyright (C) 2023 System76 + * Copyright (C) 2021 Jimmy Cassis +@@ -278,6 +279,7 @@ + } + + void raw_hid_receive(uint8_t *data, uint8_t length) { ++ if (app_focus_raw_hid_intercept(data, length)) return; + // Error response by default, set to success by commands + data[1] = 1; +