Add remap_modifier to present one modifier key to kitty as another

remap_modifier <from> <to>

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.
This commit is contained in:
bjohas 2026-08-06 16:22:37 +01:00
parent 9f5cc8a484
commit 9d678fa9f2
17 changed files with 397 additions and 20 deletions

View file

@ -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);

1
glfw/glfw3.h vendored
View file

@ -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);

21
glfw/input.c vendored
View file

@ -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;

7
glfw/internal.h vendored
View file

@ -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);

5
glfw/x11_window.c vendored
View file

@ -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

45
glfw/xkb_glfw.c vendored
View file

@ -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

3
kitty/glfw-wrapper.c generated
View file

@ -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());

4
kitty/glfw-wrapper.h generated
View file

@ -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

View file

@ -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);

View file

@ -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 <from> <to>
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',

22
kitty/options/parse.py generated
View file

@ -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': {},

View file

@ -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;
}

View file

@ -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; }

View file

@ -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 = {}

View file

@ -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'

View file

@ -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;

View file

@ -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);