From 58828dd893ec1e3a9676bbe86b5312896f1dad55 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 11 Aug 2026 17:16:57 +0530 Subject: [PATCH] Simplify remap_modifier making it a single option --- kitty/options/definition.py | 31 ++++++++---------- kitty/options/parse.py | 8 ++--- kitty/options/to-c-generated.h | 12 +++---- kitty/options/to-c.h | 2 +- kitty/options/types.py | 5 ++- kitty/options/utils.py | 59 ++++++++++++++++++---------------- kitty_tests/options.py | 38 +++++++++++----------- 7 files changed, 75 insertions(+), 80 deletions(-) diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 115e22e3c..1bd22bdff 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -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 + remap_modifiers : : ... -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 diff --git a/kitty/options/parse.py b/kitty/options/parse.py index e37dafcb6..11dbf1d23 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -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': {}, diff --git a/kitty/options/to-c-generated.h b/kitty/options/to-c-generated.h index a5b5dbacb..e0a75bfa8 100644 --- a/kitty/options/to-c-generated.h +++ b/kitty/options/to-c-generated.h @@ -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; } diff --git a/kitty/options/to-c.h b/kitty/options/to-c.h index 19e0ba957..38125ebf8 100644 --- a/kitty/options/to-c.h +++ b/kitty/options/to-c.h @@ -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)) { diff --git a/kitty/options/types.py b/kitty/options/types.py index fd00da163..1ad499eb8 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -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 = {} diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 6851252cc..82c23e54b 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -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: diff --git a/kitty_tests/options.py b/kitty_tests/options.py index 89d31a72b..b27a144ad 100644 --- a/kitty_tests/options.py +++ b/kitty_tests/options.py @@ -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}')