From 9d678fa9f2f81fd7050ee6066a48ce32f03df103 Mon Sep 17 00:00:00 2001 From: bjohas Date: Thu, 6 Aug 2026 16:22:37 +0100 Subject: [PATCH] Add remap_modifier to present one modifier key to kitty as another remap_modifier Repeatable and off by default. The declarations compose as a single simultaneous permutation applied once per event, so remap_modifier ctrl super remap_modifier super ctrl exchanges the two rather than collapsing to the identity. Only shift, alt, ctrl, super, hyper and meta may be named; anything else is rejected with an error. The last declaration for a given source wins. The remap is applied to the modifier mask BEFORE the platform produces the keysym and text - in glfw_xkb_update_modifiers() before xkb_state_update_mask() on Linux, and in remap_cocoa_flags() before UCKeyTranslate() and interpretKeyEvents: on macOS. Applying it to the finished event instead desynchronises the modifiers from the text for any modifier that participates in text production: shift on Linux, and Option on macOS, which produces text natively unless macos_option_as_alt is set. Because the GLFW modifiers are themselves derived from that state, this is application wide: kitty's own shortcuts, kitty_mod and the bytes sent to the child all see the remapped modifier, and every mapping in kitty.conf is written in terms of the modifier a key becomes. X11 pointer events do not go through xkb, so translateState() - which only ever sees button and DND events - gets the same treatment; Wayland pointer events already carry xkb-derived modifiers. A modifier key's own event has its identity remapped too, so a program using the full keyboard protocol does not see ctrl reported together with LEFT_HYPER. The press/release state fixup on Linux is given the remapped modifier rather than the physical one, so press and release agree. --debug-input reports each remapped event. Notes: on Wayland kitty only detects hyper and meta when KITTY_WAYLAND_DETECT_MODIFIERS is set, so remapping to or from them has nothing to act on there. On macOS hyper and meta have no Cocoa modifier flag, so a remap onto them is left unapplied rather than silently dropping the modifier, and interpretKeyEvents: is only approximately corrected because the event's characters strings were computed by the OS from the physical flags; the UCKeyTranslate() path, normally the one taken, is exact. Generated option files and the glfw wrapper come from their generators. --- glfw/cocoa_window.m | 59 +++++++++++++++++++-- glfw/glfw3.h | 1 + glfw/input.c | 21 ++++++++ glfw/internal.h | 7 +++ glfw/x11_window.c | 5 +- glfw/xkb_glfw.c | 45 ++++++++++++++++ kitty/glfw-wrapper.c | 3 ++ kitty/glfw-wrapper.h | 4 ++ kitty/glfw.c | 96 +++++++++++++++++++++++++++++++--- kitty/options/definition.py | 68 ++++++++++++++++++++++++ kitty/options/parse.py | 22 +++++--- kitty/options/to-c-generated.h | 15 ++++++ kitty/options/to-c.h | 19 +++++++ kitty/options/types.py | 3 ++ kitty/options/utils.py | 41 +++++++++++++++ kitty/state.c | 1 + kitty/state.h | 7 +++ 17 files changed, 397 insertions(+), 20 deletions(-) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index c48a13a4d..d99c63c9d 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -516,6 +516,58 @@ translateFlags(NSUInteger flags) return mods; } +// Apply remap_modifier to Cocoa modifier flags. This has to happen BEFORE the +// flags are used to produce text - UCKeyTranslate() below, and Cocoa's own +// interpretKeyEvents: - for the same reason as on Linux: Option produces text +// natively (see macos_option_as_alt), so remapping only the finished event's +// modifier bitmask would leave the modifiers and the text contradicting each +// other. +// +// Only the flags that GLFW models as remappable modifiers are touched; caps +// lock, function, numeric pad and the device-dependent bits pass through. +// GLFW_MOD_HYPER and GLFW_MOD_META have no Cocoa flag, so a remap onto them +// cannot be represented here and is left alone rather than silently dropped. +static NSUInteger +remap_cocoa_flags(NSUInteger flags) { + if (!_glfw.modifierRemapMask) return flags; + int mods = 0; + if (flags & NSEventModifierFlagShift) mods |= GLFW_MOD_SHIFT; + if (flags & NSEventModifierFlagControl) mods |= GLFW_MOD_CONTROL; + if (flags & NSEventModifierFlagOption) mods |= GLFW_MOD_ALT; + if (flags & NSEventModifierFlagCommand) mods |= GLFW_MOD_SUPER; + const int remapped = _glfwApplyModifierRemap(mods); + if (remapped == mods) return flags; + if (remapped & (GLFW_MOD_HYPER | GLFW_MOD_META)) return flags; + NSUInteger ans = flags & ~(NSUInteger)(NSEventModifierFlagShift | NSEventModifierFlagControl | + NSEventModifierFlagOption | NSEventModifierFlagCommand); + if (remapped & GLFW_MOD_SHIFT) ans |= NSEventModifierFlagShift; + if (remapped & GLFW_MOD_CONTROL) ans |= NSEventModifierFlagControl; + if (remapped & GLFW_MOD_ALT) ans |= NSEventModifierFlagOption; + if (remapped & GLFW_MOD_SUPER) ans |= NSEventModifierFlagCommand; + return ans; +} + +// Rebuild a key event carrying the remapped flags, for handing to +// interpretKeyEvents:. NOTE the characters strings were computed by the OS from +// the PHYSICAL flags and cannot be recomputed here, so this path is only +// approximately corrected; the UCKeyTranslate() path above, which is the one +// normally taken, uses the remapped flags directly and is exact. +static NSEvent* +event_with_remapped_flags(NSEvent *event, NSUInteger flags) { + if (flags == [event modifierFlags]) return event; + NSEvent *ans = [NSEvent keyEventWithType:[event type] + location:[event locationInWindow] + modifierFlags:flags + timestamp:[event timestamp] + windowNumber:[event windowNumber] + context:nil + characters:[event characters] + charactersIgnoringModifiers:[event charactersIgnoringModifiers] + isARepeat:[event isARepeat] + keyCode:[event keyCode]]; + return ans ? ans : event; +} + static const char* format_mods(int mods) { static char buf[128]; @@ -1279,7 +1331,8 @@ is_ascii_control_char(char x) { } const unsigned int keycode = [event keyCode]; - const NSUInteger flags = [event modifierFlags]; + const NSUInteger flags = remap_cocoa_flags([event modifierFlags]); + event = event_with_remapped_flags(event, flags); const int mods = translateFlags(flags); const uint32_t key = translateKey(keycode, true); const bool process_text = !_glfw.ignoreOSKeyboardProcessing && (!window->ns.textInputFilterCallback || window->ns.textInputFilterCallback(key, mods, keycode, flags) != 1); @@ -1383,7 +1436,7 @@ is_modifier_pressed(NSUInteger flags, NSUInteger target_mask, NSUInteger other_m int action = GLFW_RELEASE; const char old_first_char = _glfw.ns.text[0]; _glfw.ns.text[0] = 0; - const NSUInteger modifierFlags = [event modifierFlags] & NSEventModifierFlagDeviceIndependentFlagsMask; + const NSUInteger modifierFlags = remap_cocoa_flags([event modifierFlags]) & NSEventModifierFlagDeviceIndependentFlagsMask; const uint32_t key = vk_code_to_functional_key_code([event keyCode]); const unsigned int keycode = [event keyCode]; const int mods = translateFlags(modifierFlags); @@ -1432,7 +1485,7 @@ is_modifier_pressed(NSUInteger flags, NSUInteger target_mask, NSUInteger other_m { const uint32_t keycode = [event keyCode]; const uint32_t key = translateKey(keycode, true); - const int mods = translateFlags([event modifierFlags]); + const int mods = translateFlags(remap_cocoa_flags([event modifierFlags])); GLFWkeyevent glfw_keyevent = {.key = key, .native_key = keycode, .native_key_id = keycode, .action = GLFW_RELEASE, .mods = mods}; add_alternate_keys(&glfw_keyevent, event); diff --git a/glfw/glfw3.h b/glfw/glfw3.h index bceae97ae..10c1b3d71 100644 --- a/glfw/glfw3.h +++ b/glfw/glfw3.h @@ -4375,6 +4375,7 @@ GLFWAPI void glfwPostEmptyEvent(void); GLFWAPI bool glfwGetIgnoreOSKeyboardProcessing(void); GLFWAPI void glfwSetIgnoreOSKeyboardProcessing(bool enabled); +GLFWAPI void glfwSetModifierRemap(const int *table); GLFWAPI bool glfwGrabKeyboard(int grab); GLFWAPI void glfwGetKeyboardRepeatDelay(monotonic_t *delay, monotonic_t *interval); diff --git a/glfw/input.c b/glfw/input.c index 8eb696c68..b2aa3390e 100644 --- a/glfw/input.c +++ b/glfw/input.c @@ -746,6 +746,27 @@ GLFWAPI void glfwSetIgnoreOSKeyboardProcessing(bool enabled) { _glfw.ignoreOSKeyboardProcessing = enabled; } +// Apply the remap_modifier permutation to a GLFW modifier mask. Every source bit +// is translated in the same pass, so a pair of declarations exchanging two +// modifiers is a swap rather than the identity. +int _glfwApplyModifierRemap(int mods) { + if (!(mods & _glfw.modifierRemapMask)) return mods; + int ans = 0; + for (unsigned i = 0; i < GLFW_MOD_REMAP_SZ; i++) { + const int bit = 1 << i; + if (mods & bit) ans |= _glfw.modifierRemap[i] ? _glfw.modifierRemap[i] : bit; + } + return ans; +} + +GLFWAPI void glfwSetModifierRemap(const int *table) { + _glfw.modifierRemapMask = 0; + for (unsigned i = 0; i < GLFW_MOD_REMAP_SZ; i++) { + _glfw.modifierRemap[i] = table ? table[i] : 0; + if (_glfw.modifierRemap[i]) _glfw.modifierRemapMask |= 1 << i; + } +} + GLFWAPI bool glfwGrabKeyboard(int grab) { if (grab == 0 || grab == 1) { if (_glfwPlatformGrabKeyboard(grab)) _glfw.keyboard_grabbed = grab; diff --git a/glfw/internal.h b/glfw/internal.h index f4917a22d..2e9789279 100644 --- a/glfw/internal.h +++ b/glfw/internal.h @@ -57,6 +57,7 @@ #define EGL_PRESENT_OPAQUE_EXT 0x31df +#define GLFW_MOD_REMAP_SZ 8 #define _GLFW_INSERT_FIRST 0 #define _GLFW_INSERT_LAST 1 @@ -620,6 +621,10 @@ struct _GLFWlibrary _GLFWmutex errorLock; bool ignoreOSKeyboardProcessing, keyboard_grabbed; + // remap_modifier: modifierRemap[i] is the GLFW modifier mask that the + // modifier at bit i is presented as, or 0 to leave it alone. + int modifierRemap[GLFW_MOD_REMAP_SZ]; + int modifierRemapMask; struct { bool available; @@ -926,3 +931,5 @@ void _glfw_free_clipboard_data(_GLFWClipboardData *cd); #define debug_rendering(...) if (_glfw.hints.init.debugRendering) { timed_debug_print(__VA_ARGS__); } #define debug_input(...) if (_glfw.hints.init.debugKeyboard) { timed_debug_print(__VA_ARGS__); } #define safe_close(fd) do { errno = 0; close(fd); } while(errno == EINTR) + +int _glfwApplyModifierRemap(int mods); diff --git a/glfw/x11_window.c b/glfw/x11_window.c index 8fa76f53b..98eff8fe7 100644 --- a/glfw/x11_window.c +++ b/glfw/x11_window.c @@ -240,7 +240,10 @@ static int translateState(int state) if (state & Mod2Mask) mods |= GLFW_MOD_NUM_LOCK; - return mods; + // Only button/DND events reach here; key events get their modifiers from the + // xkb state, which is remapped in glfw_xkb_update_modifiers(). Remap here too + // so mouse_map and map agree on X11. + return _glfwApplyModifierRemap(mods); } // Sends an EWMH or ICCCM event to the window manager diff --git a/glfw/xkb_glfw.c b/glfw/xkb_glfw.c index 2a27e47a7..2f22addb6 100644 --- a/glfw/xkb_glfw.c +++ b/glfw/xkb_glfw.c @@ -697,10 +697,55 @@ glfw_xkb_compile_keymap(_GLFWXKBData *xkb, const char *map_str) { return true; } +// Map a GLFW modifier bit to the xkb modifier index for this keymap. +static xkb_mod_index_t +xkb_idx_for_glfw_mod(_GLFWXKBData *xkb, int glfw_mod) { + switch (glfw_mod) { + case GLFW_MOD_SHIFT: return xkb->shiftIdx; + case GLFW_MOD_CONTROL: return xkb->controlIdx; + case GLFW_MOD_ALT: return xkb->altIdx; + case GLFW_MOD_SUPER: return xkb->superIdx; + case GLFW_MOD_HYPER: return xkb->hyperIdx; + case GLFW_MOD_META: return xkb->metaIdx; + default: return XKB_MOD_INVALID; + } +} + +// Apply remap_modifier to an xkb modifier mask, BEFORE the state is used to +// produce keysyms and text. Doing it here rather than on the finished event is +// what keeps the two consistent: a modifier that participates in text production +// (shift always; others depending on the layout) would otherwise report one +// modifier while carrying text generated by another. +static xkb_mod_mask_t +remap_xkb_mask(_GLFWXKBData *xkb, xkb_mod_mask_t mask) { + if (!_glfw.modifierRemapMask) return mask; + xkb_mod_mask_t consumed = 0, produced = 0; + for (unsigned i = 0; i < GLFW_MOD_REMAP_SZ; i++) { + const int dest = _glfw.modifierRemap[i]; + if (!dest) continue; + const xkb_mod_index_t src_idx = xkb_idx_for_glfw_mod(xkb, 1 << i); + if (src_idx == XKB_MOD_INVALID || !(mask & (1u << src_idx))) continue; + consumed |= 1u << src_idx; + for (unsigned j = 0; j < GLFW_MOD_REMAP_SZ; j++) { + if (!(dest & (1 << j))) continue; + const xkb_mod_index_t dst_idx = xkb_idx_for_glfw_mod(xkb, 1 << j); + if (dst_idx != XKB_MOD_INVALID) produced |= 1u << dst_idx; + } + } + return (mask & ~consumed) | produced; +} + void glfw_xkb_update_modifiers(_GLFWXKBData *xkb, xkb_mod_mask_t depressed, xkb_mod_mask_t latched, xkb_mod_mask_t locked, xkb_layout_index_t base_group, xkb_layout_index_t latched_group, xkb_layout_index_t locked_group) { if (!xkb->keymap) return; xkb->states.modifiers = 0; + if (_glfw.modifierRemapMask) { + const xkb_mod_mask_t before = depressed; + depressed = remap_xkb_mask(xkb, depressed); + latched = remap_xkb_mask(xkb, latched); + locked = remap_xkb_mask(xkb, locked); + if (before != depressed) debug("remap_modifier: xkb depressed mask 0x%x -> 0x%x\n", before, depressed); + } xkb_state_update_mask(xkb->states.state, depressed, latched, locked, base_group, latched_group, locked_group); // We have to update the groups in clean_state, as they change for // different keyboard layouts, see https://github.com/kovidgoyal/kitty/issues/488 diff --git a/kitty/glfw-wrapper.c b/kitty/glfw-wrapper.c index 42ac7c5e9..66f905951 100644 --- a/kitty/glfw-wrapper.c +++ b/kitty/glfw-wrapper.c @@ -293,6 +293,9 @@ load_glfw(const char* path) { *(void **) (&glfwSetIgnoreOSKeyboardProcessing_impl) = dlsym(handle, "glfwSetIgnoreOSKeyboardProcessing"); if (glfwSetIgnoreOSKeyboardProcessing_impl == NULL) fail("Failed to load glfw function glfwSetIgnoreOSKeyboardProcessing with error: %s", dlerror()); + *(void **) (&glfwSetModifierRemap_impl) = dlsym(handle, "glfwSetModifierRemap"); + if (glfwSetModifierRemap_impl == NULL) fail("Failed to load glfw function glfwSetModifierRemap with error: %s", dlerror()); + *(void **) (&glfwGrabKeyboard_impl) = dlsym(handle, "glfwGrabKeyboard"); if (glfwGrabKeyboard_impl == NULL) fail("Failed to load glfw function glfwGrabKeyboard with error: %s", dlerror()); diff --git a/kitty/glfw-wrapper.h b/kitty/glfw-wrapper.h index 8a759cfd7..db216a716 100644 --- a/kitty/glfw-wrapper.h +++ b/kitty/glfw-wrapper.h @@ -2208,6 +2208,10 @@ typedef void (*glfwSetIgnoreOSKeyboardProcessing_func)(bool); GFW_EXTERN glfwSetIgnoreOSKeyboardProcessing_func glfwSetIgnoreOSKeyboardProcessing_impl; #define glfwSetIgnoreOSKeyboardProcessing glfwSetIgnoreOSKeyboardProcessing_impl +typedef void (*glfwSetModifierRemap_func)(const int*); +GFW_EXTERN glfwSetModifierRemap_func glfwSetModifierRemap_impl; +#define glfwSetModifierRemap glfwSetModifierRemap_impl + typedef bool (*glfwGrabKeyboard_func)(int); GFW_EXTERN glfwGrabKeyboard_func glfwGrabKeyboard_impl; #define glfwGrabKeyboard glfwGrabKeyboard_impl diff --git a/kitty/glfw.c b/kitty/glfw.c index 877eee498..a49bc9f89 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -17,6 +17,8 @@ #else #include "freetype_render_ui_text.h" #endif + +static bool glfw_is_loaded = false; #define debug debug_rendering typedef struct mouse_cursor { @@ -462,11 +464,6 @@ refresh_callback(GLFWwindow *w) { request_tick_callback(); } -#ifndef __APPLE__ -typedef struct modifier_key_state { - bool left, right; -} modifier_key_state; - static int key_to_modifier(uint32_t key, bool *is_left) { *is_left = false; @@ -494,6 +491,51 @@ key_to_modifier(uint32_t key, bool *is_left) { } } +// The inverse of key_to_modifier(), used to keep a modifier key's own identity +// consistent with its remapped modifier bit. +static uint32_t +modifier_to_key(int modifier, bool is_left) { + switch (modifier) { + case GLFW_MOD_SHIFT: return is_left ? GLFW_FKEY_LEFT_SHIFT : GLFW_FKEY_RIGHT_SHIFT; + case GLFW_MOD_CONTROL: return is_left ? GLFW_FKEY_LEFT_CONTROL : GLFW_FKEY_RIGHT_CONTROL; + case GLFW_MOD_ALT: return is_left ? GLFW_FKEY_LEFT_ALT : GLFW_FKEY_RIGHT_ALT; + case GLFW_MOD_SUPER: return is_left ? GLFW_FKEY_LEFT_SUPER : GLFW_FKEY_RIGHT_SUPER; + case GLFW_MOD_HYPER: return is_left ? GLFW_FKEY_LEFT_HYPER : GLFW_FKEY_RIGHT_HYPER; + case GLFW_MOD_META: return is_left ? GLFW_FKEY_LEFT_META : GLFW_FKEY_RIGHT_META; + default: return 0; + } +} + +static_assert(GLFW_MOD_LAST < (1 << arraysz(((Options*)NULL)->modifier_remap)), + "Options.modifier_remap is indexed by modifier bit position, it must cover every GLFW modifier"); + +#ifdef __APPLE__ +// Map a modifier named in kitty.conf back to the physical modifier that now +// produces it (for the macOS menu bar). Unlike apply_modifier_remap() this is not +// always possible, so it reports failure rather than guessing: false when more +// than one source maps onto these modifiers, or when a requested modifier has +// been remapped away so nothing physical produces it any more. +static bool +invert_modifier_remap(int mods, int *ans) { + if (!OPT(modifier_remap_mask)) { *ans = mods; return true; } + int consumed = 0, produced = 0; + for (unsigned i = 0; i < arraysz(OPT(modifier_remap)); i++) { + const int dest = OPT(modifier_remap)[i]; + if (!dest || (mods & dest) != dest) continue; + if (consumed & dest) return false; + consumed |= dest; produced |= 1 << i; + } + const int leftover = mods & ~consumed; + if (leftover & OPT(modifier_remap_mask)) return false; + *ans = leftover | produced; + return true; +} +#endif + +#ifndef __APPLE__ +typedef struct modifier_key_state { + bool left, right; +} modifier_key_state; static void update_modifier_state_on_modifier_key_event(GLFWkeyevent *ev, int key_modifier, bool is_left) { @@ -525,11 +567,31 @@ key_callback(GLFWwindow *w, GLFWkeyevent *ev) { #ifdef __APPLE__ cocoa_clear_dock_badge_if_set(); #endif -#ifndef __APPLE__ bool is_left; int key_modifier = key_to_modifier(ev->key, &is_left); + // remap_modifier is applied to the modifier bits inside glfw, before the + // platform produces the keysym and text. What is left to do here is a + // modifier key's own identity, so that a program reading the keyboard + // protocol does not see a self-contradictory event such as ctrl+LEFT_HYPER. + // + // This has to happen BEFORE the state fixup below, and key_modifier has to + // become the remapped modifier: the event's mods already carry the remapped + // bit, so adding the physical one there would report the physical modifier + // on press and the remapped one on release. + if (key_modifier > 0 && (OPT(modifier_remap_mask) & key_modifier)) { + const int dest = OPT(modifier_remap)[__builtin_ctz((unsigned)key_modifier)]; + if (dest && !(dest & (dest - 1))) { // only when the destination is a single modifier + const uint32_t remapped_key = modifier_to_key(dest, is_left); + if (remapped_key) { + debug_input("\x1b[35mremap_modifier\x1b[m: modifier key 0x%x -> 0x%x\n", ev->key, remapped_key); + ev->key = remapped_key; + key_modifier = dest; + } + } + } +#ifndef __APPLE__ if (key_modifier != -1) update_modifier_state_on_modifier_key_event(ev, key_modifier, is_left); - #endif +#endif global_state.mods_at_last_key_or_button_event = ev->mods; global_state.callback_os_window->cursor_blink_zero_time = monotonic(); if (is_window_ready_for_callbacks() && !ev->fake_event_on_focus_change) on_key_input(ev); @@ -2150,6 +2212,7 @@ glfw_init(PyObject UNUSED *self, PyObject *args) { cocoa_set_uncaught_exception_handler(); #endif const char* err = load_glfw(path); + if (!err) { glfw_is_loaded = true; push_modifier_remap_to_glfw(); } if (err) { PyErr_SetString(PyExc_RuntimeError, err); return NULL; } glfwSetErrorCallback(error_callback); glfwInitHint(GLFW_DEBUG_KEYBOARD, debug_keyboard); @@ -2794,7 +2857,17 @@ set_custom_cursor(PyObject *self UNUSED, PyObject *args) { void get_cocoa_key_equivalent(uint32_t key, int mods, char *cocoa_key, size_t key_sz, int *cocoa_mods) { memset(cocoa_key, 0, key_sz); - uint32_t ans = glfwGetCocoaKeyEquivalent(key, mods, cocoa_mods); + // Menu accelerators come from kitty.conf, i.e. they are named in post-remap + // terms. Invert the remap so the menu bar displays and triggers on the + // physical key that now produces that modifier. Leaving cocoa_key empty + // makes the caller skip this accelerator, which is what we want whenever the + // physical combination cannot be named: Cocoa has no flag for hyper or meta, + // so emitting one would silently produce a key equivalent with NO modifiers + // at all - e.g. a bare "q" that quits kitty. + int physical_mods; + if (!invert_modifier_remap(mods, &physical_mods)) return; + if (physical_mods & ~(GLFW_MOD_SHIFT | GLFW_MOD_CONTROL | GLFW_MOD_ALT | GLFW_MOD_SUPER | GLFW_MOD_CAPS_LOCK)) return; + uint32_t ans = glfwGetCocoaKeyEquivalent(key, physical_mods, cocoa_mods); if (ans) encode_utf8(ans, cocoa_key); } @@ -2959,6 +3032,13 @@ set_ignore_os_keyboard_processing(bool enabled) { glfwSetIgnoreOSKeyboardProcessing(enabled); } +// set_options() runs before glfw is loaded at startup, and again on every config +// reload, so push the table from both places and no-op until glfw exists. +void +push_modifier_remap_to_glfw(void) { + if (glfw_is_loaded) glfwSetModifierRemap(OPT(modifier_remap)); +} + static void decref_pyobj(void *x) { Py_XDECREF(x); diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 5155f1eef..240af82cb 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -3532,6 +3532,74 @@ this option to alter all default shortcuts that use :opt:`kitty_mod`. """, ) +opt( + '+remap_modifier', + 'ctrl super', + option_type='remap_modifier', + ctype='!remap_modifier', + add_to_default=False, + long_text=""" +Present one modifier key to kitty as if it were another. The syntax is:: + + remap_modifier + +for example :code:`remap_modifier ctrl super` makes every :kbd:`Ctrl+key` press +arrive as :kbd:`Super+key`. Only :code:`shift`, :code:`alt`, :code:`ctrl`, +:code:`super`, :code:`hyper` and :code:`meta` can be named. The source must be +exactly one modifier; the destination may name more than one, in which case +holding the source is indistinguishable from holding all of them. + +This option can be specified multiple times, and the declarations are applied as +a single simultaneous permutation, once, as each event arrives. So:: + + remap_modifier ctrl super + remap_modifier super ctrl + +exchanges the two rather than collapsing to no change. Ordering among +:opt:`remap_modifier` declarations matters only in that the last declaration for +a given source wins; where they sit relative to other options in +:file:`kitty.conf` makes no difference. + +The remapping happens before anything else looks at the event, so it is +application wide: kitty's own keyboard shortcuts, :opt:`kitty_mod`, +:opt:`mouse_map` and the keys sent to the program running in the terminal all +see the remapped modifier. Every mapping in :file:`kitty.conf` is therefore +written in terms of the modifier a key *becomes*, not the one printed on the +keycap — including the built-in shortcuts, so :code:`remap_modifier ctrl super` +moves every default :code:`ctrl+shift+…` binding onto the physical Super key +unless you also change :opt:`kitty_mod`. + +A modifier key's own press and release are remapped too, so that a program using +the full keyboard protocol does not see, for example, ctrl reported together with +the Hyper key. That is only possible when the destination is a single modifier; +with a multi-modifier destination the key itself keeps its original identity. + +On macOS, a menu bar accelerator is shown on the physical key that now produces +the modifier it was declared with. Where no such key exists — because more than +one modifier maps onto it, or because it maps to hyper or meta, which the macOS +menu bar cannot express — the accelerator is left off the menu rather than shown +incorrectly. The shortcut itself continues to work. + +On macOS the remap is applied before the key event reaches the text input +system, so :kbd:`Option`, which produces text natively unless +:opt:`macos_option_as_alt` is set, behaves consistently with the modifiers it +reports. A destination of :kbd:`hyper` or :kbd:`meta` cannot be expressed in +Cocoa's modifier flags, so such a remap is left unapplied there rather than +silently losing the modifier. + +On Wayland, kitty only detects the :kbd:`hyper` and :kbd:`meta` modifiers when +:envvar:`KITTY_WAYLAND_DETECT_MODIFIERS` is set in the environment. Without it +those two never appear on key events at all, so remapping to or from them has +nothing to act on there. This is pre-existing behaviour, not a limitation of +this option. + +This is useful for keyboard layouts that move Control somewhere more comfortable +— for example placing a Hyper key on Caps Lock to use for readline and TUI +editing, while leaving the physical Control key free for GUI-style shortcuts. +Use :code:`kitty --debug-input` to see the remapping applied to each event. +""", +) + opt( 'clear_all_shortcuts', 'no', diff --git a/kitty/options/parse.py b/kitty/options/parse.py index 670796d08..80593fbfe 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -17,14 +17,15 @@ from kitty.options.utils import ( deprecated_send_text, disable_ligatures, edge_width, env, filter_notification, font_features, hide_window_decorations, macos_option_as_alt, macos_titlebar_color, menu_map, modify_font, mouse_hide_wait, narrow_symbols, notify_on_cmd_finish, optional_edge_width, parse_font_spec, - parse_map, parse_mouse_map, paste_actions, pointer_shape_when_dragging, remote_control_password, - resize_debounce_time, scrollback_lines, scrollback_pager_history_size, scrollbar_color, - shell_integration, show_hyperlink_targets, store_multiple, symbol_map, tab_activity_symbol, - tab_bar_edge, tab_bar_margin_height, tab_bar_min_tabs, tab_fade, tab_font_style, tab_separator, - tab_title_template, tab_title_wrap, text_fg_override_threshold, titlebar_color, to_cursor_shape, - to_cursor_unfocused_shape, to_font_size, to_layout_names, to_modifiers, - transparent_background_colors, underline_exclusion, url_prefixes, url_style, visual_bell_duration, - visual_window_select_characters, window_border_width, window_logo_scale, window_size + parse_map, parse_mouse_map, paste_actions, pointer_shape_when_dragging, remap_modifier, + remote_control_password, resize_debounce_time, scrollback_lines, scrollback_pager_history_size, + scrollbar_color, shell_integration, show_hyperlink_targets, store_multiple, symbol_map, + tab_activity_symbol, tab_bar_edge, tab_bar_margin_height, tab_bar_min_tabs, tab_fade, + tab_font_style, tab_separator, tab_title_template, tab_title_wrap, text_fg_override_threshold, + titlebar_color, to_cursor_shape, to_cursor_unfocused_shape, to_font_size, to_layout_names, + to_modifiers, transparent_background_colors, underline_exclusion, url_prefixes, url_style, + visual_bell_duration, visual_window_select_characters, window_border_width, window_logo_scale, + window_size ) @@ -1230,6 +1231,10 @@ class Parser: choices_for_progress_bar = frozenset(('left', 'right', 'top', 'bottom', 'hidden')) + def remap_modifier(self, val: str, ans: dict[str, typing.Any]) -> None: + for k, v in remap_modifier(val): + ans["remap_modifier"][k] = v + def remember_window_position(self, val: str, ans: dict[str, typing.Any]) -> None: ans['remember_window_position'] = to_bool(val) @@ -1628,6 +1633,7 @@ def create_result_dict() -> dict[str, typing.Any]: 'menu_map': {}, 'modify_font': {}, 'narrow_symbols': {}, + 'remap_modifier': {}, 'remote_control_password': {}, 'symbol_map': {}, 'watcher': {}, diff --git a/kitty/options/to-c-generated.h b/kitty/options/to-c-generated.h index d209fb78d..393e2d67a 100644 --- a/kitty/options/to-c-generated.h +++ b/kitty/options/to-c-generated.h @@ -1526,6 +1526,19 @@ convert_from_opts_wayland_enable_ime(PyObject *py_opts, Options *opts) { Py_DECREF(ret); } +static void +convert_from_python_remap_modifier(PyObject *val, Options *opts) { + remap_modifier(val, opts); +} + +static void +convert_from_opts_remap_modifier(PyObject *py_opts, Options *opts) { + PyObject *ret = PyObject_GetAttrString(py_opts, "remap_modifier"); + if (ret == NULL) return; + convert_from_python_remap_modifier(ret, opts); + Py_DECREF(ret); +} + static bool convert_opts_from_python_opts(PyObject *py_opts, Options *opts) { convert_from_opts_font_size(py_opts, opts); @@ -1762,5 +1775,7 @@ convert_opts_from_python_opts(PyObject *py_opts, Options *opts) { if (PyErr_Occurred()) return false; convert_from_opts_wayland_enable_ime(py_opts, opts); if (PyErr_Occurred()) return false; + convert_from_opts_remap_modifier(py_opts, opts); + if (PyErr_Occurred()) return false; return true; } diff --git a/kitty/options/to-c.h b/kitty/options/to-c.h index 4a43e8d62..45176f366 100644 --- a/kitty/options/to-c.h +++ b/kitty/options/to-c.h @@ -500,6 +500,25 @@ box_drawing_scale(PyObject *val, Options *opts) { } } +static inline void +remap_modifier(PyObject *val, Options *opts) { + memset(opts->modifier_remap, 0, sizeof(opts->modifier_remap)); + opts->modifier_remap_mask = 0; + if (!PyDict_Check(val)) { PyErr_SetString(PyExc_TypeError, "remap_modifier must be a dict"); return; } + PyObject *key, *value; + Py_ssize_t pos = 0; + while (PyDict_Next(val, &pos, &key, &value)) { + const long src = PyLong_AsLong(key), dest = PyLong_AsLong(value); + if (PyErr_Occurred()) { PyErr_Print(); PyErr_Clear(); continue; } + // the parser guarantees a single source bit, but do not trust it in C + if (src <= 0 || (src & (src - 1)) || dest <= 0) continue; + const unsigned idx = (unsigned)__builtin_ctz((unsigned)src); + if (idx >= arraysz(opts->modifier_remap)) continue; + opts->modifier_remap[idx] = (int)dest; + opts->modifier_remap_mask |= (int)src; + } +} + static inline void text_composition_strategy(PyObject *val, Options *opts) { if (!PyUnicode_Check(val)) { PyErr_SetString(PyExc_TypeError, "text_composition_strategy must be a string"); return; } diff --git a/kitty/options/types.py b/kitty/options/types.py index 6cb2b4804..d127b580a 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -423,6 +423,7 @@ option_names = ( 'pointer_shape_when_dragging', 'pointer_shape_when_grabbed', 'progress_bar', + 'remap_modifier', 'remember_window_position', 'remember_window_size', 'remote_control_password', @@ -739,6 +740,7 @@ class Options: menu_map: dict[tuple[str, ...], str] = {} modify_font: dict[str, kitty.fonts.FontModification] = {} narrow_symbols: dict[tuple[int, int], int] = {} + remap_modifier: dict[int, int] = {} remote_control_password: dict[str, collections.abc.Sequence[str]] = {} symbol_map: dict[tuple[int, int], str] = {} watcher: dict[str, str] = {} @@ -868,6 +870,7 @@ defaults.kitten_alias = {} defaults.menu_map = {} defaults.modify_font = {} defaults.narrow_symbols = {} +defaults.remap_modifier = {} defaults.remote_control_password = {} defaults.symbol_map = {} defaults.watcher = {} diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 079aacf6f..80b36ad0a 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -487,6 +487,47 @@ def to_modifiers(val: str) -> int: return parse_mods(val.split('+'), val) or 0 +def remap_modifier(val: str) -> Iterable[tuple[int, int]]: + # Only the real modifiers may be remapped. parse_mods() also accepts + # kitty_mod (a parse-time placeholder resolved out of keymaps before any key + # event exists) and the lock modifiers, neither of which can be meaningfully + # carried on an event, so they are rejected here rather than silently + # mangling events later. + remappable = ( + defines.GLFW_MOD_SHIFT | defines.GLFW_MOD_ALT | defines.GLFW_MOD_CONTROL | + defines.GLFW_MOD_SUPER | defines.GLFW_MOD_HYPER | defines.GLFW_MOD_META + ) + + def bad(msg: str) -> None: + log_error(f'Ignoring invalid remap_modifier "{val}": {msg}') + + parts = val.split() + if len(parts) != 2: + bad('must be two modifier names separated by whitespace') + return + # parse_mods() returns None both for an unknown name (already logged) and for + # "none" (deliberately not logged), so check the names before trusting it + for part in parts: + for name in part.split('+'): + if name.upper() in ('NONE', ''): + bad('none is not a modifier') + return + src = parse_mods(parts[0].split('+'), val) + dest = parse_mods(parts[1].split('+'), val) + if src is None or dest is None: + return # parse_mods() has already logged the unknown modifier name + if src & ~remappable or dest & ~remappable: + bad('only shift, alt, ctrl, super, hyper and meta can be remapped') + return + if src & (src - 1): + bad('the source must name exactly one modifier') + return + if src == dest: + bad('the source and destination are the same') + return + yield src, dest + + def parse_shortcut(sc: str) -> SingleKey: if sc.endswith('+') and len(sc) > 1: sc = f'{sc[:-1]}plus' diff --git a/kitty/state.c b/kitty/state.c index 56e08965a..a00a31f7a 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -957,6 +957,7 @@ PYWRAP1(set_options) { global_state.debug_rendering = debug_rendering ? true : false; global_state.debug_font_fallback = debug_font_fallback ? true : false; if (!convert_opts_from_python_opts(opts, &global_state.opts)) return NULL; + push_modifier_remap_to_glfw(); Py_XDECREF(global_state.options_object); global_state.options_object = Py_NewRef(opts); Py_RETURN_NONE; diff --git a/kitty/state.h b/kitty/state.h index 6858ccaa1..c5190fb02 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -117,6 +117,11 @@ typedef struct Options { bool resize_in_steps; bool sync_to_monitor; bool close_on_child_death; + // remap_modifier: modifier_remap[i] is the mask that the modifier at bit i + // is presented as, or 0 to leave it alone. modifier_remap_mask is the OR of + // all remapped source bits, used as a fast path. + int modifier_remap[8]; + int modifier_remap_mask; bool window_alert_on_bell; bool macos_dock_badge_on_bell; bool debug_keyboard; @@ -626,6 +631,7 @@ bool mouse_select_cmd_output(Window *w); bool move_cursor_to_mouse_if_at_shell_prompt(Window *w); void mouse_selection(Window *w, int code, int button); const char* format_mods(unsigned mods); + void dispatch_pending_clicks(id_type, void*); void send_pending_click_to_window(Window*, int); void get_platform_dependent_config_values(void *glfw_window); @@ -636,6 +642,7 @@ void update_ime_focus(OSWindow* osw, bool focused); void update_ime_position(Window* w, Screen *screen); bool update_ime_position_for_window(id_type window_id, bool force, int update_focus); void set_ignore_os_keyboard_processing(bool enabled); +void push_modifier_remap_to_glfw(void); void update_menu_bar_title(PyObject *title UNUSED); void change_live_resize_state(OSWindow*, bool); bool render_os_window(OSWindow *w, monotonic_t now, bool scan_for_animated_images);