Simplify remap_modifier making it a single option

This commit is contained in:
Kovid Goyal 2026-08-11 17:16:57 +05:30
parent f5fba5e584
commit 58828dd893
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
7 changed files with 75 additions and 80 deletions

View file

@ -3568,39 +3568,35 @@ this option to alter all default shortcuts that use :opt:`kitty_mod`.
)
opt(
'+remap_modifier',
'ctrl super',
option_type='remap_modifier',
ctype='!remap_modifier',
'remap_modifiers',
'',
option_type='remap_modifiers',
ctype='!remap_modifiers',
add_to_default=False,
long_text="""
Present one modifier key to kitty as if it were another. The syntax is::
Remap modifiers, making one modifier act as another. The syntax is::
remap_modifier <from> <to>
remap_modifiers <from>:<to> <from>:<to> ...
for example :code:`remap_modifier ctrl super` makes every :kbd:`Ctrl+key` press
for example :code:`remap_modifiers 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::
To swap two modifiers, specify remap rules for both, for example, to swap ctrl and super::
remap_modifier ctrl super
remap_modifier super ctrl
remap_modifiers ctrl:super 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.
Ordering among :opt:`remap_modifiers` items matters only in that the last item for
a given source wins.
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`
keycap — including the built-in shortcuts, so :code:`remap_modifiers ctrl super`
moves every default :code:`ctrl+shift+…` binding onto the physical Super key
unless you also change :opt:`kitty_mod`.
@ -3625,8 +3621,7 @@ 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.
nothing to act on there.
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

View file

@ -17,7 +17,7 @@ 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, remap_modifier,
parse_map, parse_mouse_map, paste_actions, pointer_shape_when_dragging, remap_modifiers,
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,
@ -1242,9 +1242,8 @@ 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 remap_modifiers(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['remap_modifiers'] = remap_modifiers(val)
def remember_window_position(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['remember_window_position'] = to_bool(val)
@ -1644,7 +1643,6 @@ def create_result_dict() -> dict[str, typing.Any]:
'menu_map': {},
'modify_font': {},
'narrow_symbols': {},
'remap_modifier': {},
'remote_control_password': {},
'symbol_map': {},
'watcher': {},

View file

@ -1539,15 +1539,15 @@ convert_from_opts_wayland_enable_ime(PyObject *py_opts, Options *opts) {
}
static void
convert_from_python_remap_modifier(PyObject *val, Options *opts) {
remap_modifier(val, opts);
convert_from_python_remap_modifiers(PyObject *val, Options *opts) {
remap_modifiers(val, opts);
}
static void
convert_from_opts_remap_modifier(PyObject *py_opts, Options *opts) {
PyObject *ret = PyObject_GetAttrString(py_opts, "remap_modifier");
convert_from_opts_remap_modifiers(PyObject *py_opts, Options *opts) {
PyObject *ret = PyObject_GetAttrString(py_opts, "remap_modifiers");
if (ret == NULL) return;
convert_from_python_remap_modifier(ret, opts);
convert_from_python_remap_modifiers(ret, opts);
Py_DECREF(ret);
}
@ -1789,7 +1789,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);
convert_from_opts_remap_modifiers(py_opts, opts);
if (PyErr_Occurred()) return false;
return true;
}

View file

@ -555,7 +555,7 @@ box_drawing_scale(PyObject *val, Options *opts) {
}
static inline void
remap_modifier(PyObject *val, Options *opts) {
remap_modifiers(PyObject *val, Options *opts) {
memset(opts->modifier_remap, 0, sizeof(opts->modifier_remap));
opts->modifier_remap_mask = 0;
if (!PyDict_Check(val)) {

View file

@ -426,7 +426,7 @@ option_names = (
'pointer_shape_when_dragging',
'pointer_shape_when_grabbed',
'progress_bar',
'remap_modifier',
'remap_modifiers',
'remember_window_position',
'remember_window_size',
'remote_control_password',
@ -642,6 +642,7 @@ class Options:
pointer_shape_when_dragging: tuple[str, str] = ('beam', 'crosshair')
pointer_shape_when_grabbed: choices_for_pointer_shape_when_grabbed = 'arrow'
progress_bar: choices_for_progress_bar = 'top'
remap_modifiers: dict[int, int] = {}
remember_window_position: bool = False
remember_window_size: bool = True
repaint_delay: int = 10
@ -745,7 +746,6 @@ 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] = {}
@ -875,7 +875,6 @@ 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

@ -524,44 +524,49 @@ def to_modifiers(val: str) -> int:
return parse_mods(val.split('+'), val) or 0
def remap_modifier(val: str) -> Iterable[tuple[int, int]]:
def remap_modifiers(val: str) -> dict[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.
ans = {}
if not val:
return ans
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}')
log_error(f'Ignoring invalid remap_modifiers "{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
for token in val.split():
parts = token.split(':', 1)
if len(parts) != 2:
bad('must be two modifier names separated by a colon')
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 {}
ans[src] = dest
return ans
def parse_shortcut(sc: str) -> SingleKey:

View file

@ -466,32 +466,30 @@ def conf_parsing(self):
# sequential implementation would produce an identical one. That property is
# covered end to end by tests/test-remap-e2e.sh, which asserts that the
# encoded bytes actually exchange.
self.ae(p().remap_modifier, {})
self.ae(p().remap_modifiers, {})
ctrl, hyper, sup = to_modifiers('ctrl'), to_modifiers('hyper'), to_modifiers('super')
self.ae(p('remap_modifier ctrl hyper').remap_modifier, {ctrl: hyper})
self.ae(p('remap_modifiers ctrl:hyper').remap_modifiers, {ctrl: hyper})
# both directions of a swap are recorded
self.ae(p('remap_modifier ctrl hyper', 'remap_modifier hyper ctrl').remap_modifier, {ctrl: hyper, hyper: ctrl})
self.ae(p('remap_modifiers ctrl:hyper hyper:ctrl').remap_modifiers, {ctrl: hyper, hyper: ctrl})
# the last declaration for a given source wins, wherever it appears
self.ae(p('remap_modifier ctrl hyper', 'kitty_mod ctrl', 'remap_modifier ctrl super').remap_modifier, {ctrl: sup})
# unrelated options in between do not disturb it
self.ae(p('remap_modifier ctrl hyper', 'font_size 13', 'remap_modifier super alt').remap_modifier, {ctrl: hyper, sup: to_modifiers('alt')})
self.ae(p('remap_modifiers ctrl:hyper', 'kitty_mod ctrl', 'remap_modifiers ctrl:super').remap_modifiers, {ctrl: sup})
# the destination may name more than one modifier
self.ae(p('remap_modifier ctrl ctrl+shift').remap_modifier, {ctrl: to_modifiers('ctrl+shift')})
self.ae(p('remap_modifiers ctrl:ctrl+shift').remap_modifiers, {ctrl: to_modifiers('ctrl+shift')})
# every rejection must be REPORTED, never silently ignored: wrong arity, unknown
# or non-remappable modifier names, a source naming more than one modifier, and
# a mapping that does nothing
for bad in (
'remap_modifier ctrl',
'remap_modifier ctrl hyper super',
'remap_modifier ctrl nosuchmod',
'remap_modifier nosuchmod ctrl',
'remap_modifier ctrl+shift hyper',
'remap_modifier ctrl none',
'remap_modifier none ctrl',
'remap_modifier ctrl kitty_mod',
'remap_modifier kitty_mod ctrl',
'remap_modifier caps_lock ctrl',
'remap_modifier ctrl num_lock',
'remap_modifier ctrl ctrl',
'remap_modifiers ctrl',
'remap_modifiers ctrl:hyper super',
'remap_modifiers ctrl:nosuchmod',
'remap_modifiers nosuchmod:ctrl',
'remap_modifiers ctrl+shift:hyper',
'remap_modifiers ctrl:none',
'remap_modifiers none:ctrl',
'remap_modifiers ctrl:kitty_mod',
'remap_modifiers kitty_mod:ctrl',
'remap_modifiers caps_lock:ctrl',
'remap_modifiers ctrl:num_lock',
'remap_modifiers ctrl:ctrl',
):
self.ae(p(bad, num_err=1).remap_modifier, {}, f'not rejected: {bad}')
self.ae(p(bad, num_err=1).remap_modifiers, {}, f'not rejected: {bad}')