kitty/kitty_tests/options.py
rudolf-blue 18bddffcca Raise the maximum font size and fix the legacy zoom limit
The ceiling in change_font_size is ten times the configured font_size, so
the largest size you can reach depends on the size you configured. With
font_size 6 zooming stops at 60pt, while font_size 20 reaches 200pt. That
is backwards: a small configured font is if anything a reason to want a
larger temporary zoom, not a smaller one.

Use the larger of that multiple and an absolute 256pt. No existing setup
loses range, since the multiple still wins whenever it is the bigger of
the two. The bounds and the clamp move into options/utils.py so the
numbers are in one place and can be tested directly.

increase_font_size also had its own cap of five times font_size. That was
the old limit, missed when 25d8d9fe6 raised change_font_size to ten, and
because the wrapper clamps before delegating, the stale bound is the one
that wins. Both legacy wrappers can simply drop their bounds: they go
through change_font_size, which already clamps.
2026-09-07 19:41:08 -04:00

580 lines
24 KiB
Python

#!/usr/bin/env python
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import os
import shutil
import subprocess
import tempfile
from kitty.constants import kitty_exe
from kitty.fast_data_types import Color, test_cursor_blink_easing_function
from kitty.options.utils import DELETE_ENV_VAR, EasingFunction, to_color
from kitty.utils import log_error, shlex_split
from .base import BaseTest
class TestConfParsing(BaseTest):
def setUp(self):
super().setUp()
self.error_messages = []
log_error.redirect = self.error_messages.append
self.tdir = tempfile.mkdtemp()
def tearDown(self):
del log_error.redirect
shutil.rmtree(self.tdir)
super().tearDown()
def test_conf_parsing(self):
conf_parsing(self)
def test_launcher(self):
launcher(self)
def test_cli_parsing(self):
cli_parsing(self)
def test_font_size_clamping(self):
font_size_clamping(self)
def test_session_discovery(self):
from unittest.mock import patch
from kitty.config import load_config
from kitty.constants import config_dir
from kitty.fast_data_types import set_options
from kitty.session import get_all_known_sessions, seen_session_paths
def discover(*lines):
bad_lines = []
opts = load_config(overrides=('clear_all_shortcuts yes', *lines), accumulate_bad_lines=bad_lines)
self.assertFalse(bad_lines)
set_options(opts)
return get_all_known_sessions()
with patch.dict(seen_session_paths, {}, clear=True):
for args, name in (
('work.kitty-session', 'work'),
('--sort-by alphabetical work.kitty-session', 'work'),
('--sort-by=alphabetical work.kitty-session', 'work'),
('--active-only=no --sort-by recent work.kitty-session', 'work'),
('work.kitty-session --sort-by alphabetical', 'work'),
('-- "-work project.kitty-session"', '-work project'),
('-- -0', '-0'),
('', ''),
('--sort-by alphabetical', ''),
('--sort-by=alphabetical', ''),
('--active-only', ''),
('-1', ''),
('-- -1', ''),
('--sort-by alphabetical -- -2', ''),
('--sort-by', ''),
('--sort-by invalid work.kitty-session', ''),
('--unknown work.kitty-session', ''),
('--active-only=invalid work.kitty-session', ''),
):
with self.subTest(args=args):
filename = f'{name}.kitty-session' if name != '-0' else name
expected = {name: os.path.join(config_dir, filename)} if name else {}
self.ae(discover(f'map f7 goto_session {args}'), expected)
seen_path = os.path.join(self.tdir, 'work.kitty-session')
seen_session_paths['work'] = seen_path
self.ae(
discover(
'action_alias project goto_session --sort-by alphabetical',
'map f7 project work.kitty-session',
'map f8 combine : goto_session --sort-by : project other.kitty-session',
),
{'work': seen_path, 'other': os.path.join(config_dir, 'other.kitty-session')},
)
def cli_parsing(self):
from kitty.cli import CLIOptions, Options, parse_cmdline, parse_option_spec
seq, disabled = parse_option_spec("""\
--simple-string -s
a simple string
--list -l
type=list
a list
--choice -c
choices=a,b,c
some choices
--bool-set -1
type=bool-set
set a bool
--bool-reset -0
type=bool-reset
unset a bool
--int -i
type=int
a integer
--float -f
type=float
a float
--version -v
type=bool-set
version
""")
oc = Options(seq, usage='xxx', message='yyy', appname='test')
oc.do_print = False
def t(args, leftover=(), fails=False, version_called=False, help_called=False, **expected):
oc.help_called = oc.version_called = False
args = list(shlex_split(args))
ans = CLIOptions()
if fails:
if isinstance(fails, str):
with self.assertRaisesRegex(SystemExit, fails):
parse_cmdline(oc, disabled, ans, args=args)
else:
with self.assertRaises(SystemExit):
parse_cmdline(oc, disabled, ans, args=args)
else:
actual_leftover = parse_cmdline(oc, disabled, ans, args=args)
self.assertEqual(tuple(leftover), tuple(actual_leftover), f'{args}\n{ans}')
for dest, defval in oc.values_map.items():
val = expected.get(dest, defval)
self.assertEqual(val, getattr(ans, dest, BaseTest), f'Failed to parse {dest} correctly for: {args}\n{ans}')
self.assertEqual(version_called, oc.version_called, f'Failed to call version for: {args}\n{ans}')
self.assertEqual(help_called, oc.help_called, f'Failed to call help for: {args}\n{ans}')
t('-1', bool_set=True)
t('-01', bool_reset=False, bool_set=True)
t('-01=y', bool_reset=False, bool_set=True)
t('-01=n', bool_reset=False, bool_set=False)
t('--simple-string xx moo -1', leftover=['moo', '-1'], simple_string='xx')
t('--si -0 -- -1', leftover=['-1'], simple_string='-0')
t('--simple-string=-0 -- -1', leftover=['-1'], simple_string='-0')
t('--simple-string=--help -- -1', leftover=['-1'], simple_string='--help')
t('--simple-string --help -- -1', leftover=['-1'], simple_string='--help')
t('-1l=a --list=b -c b --list c', bool_set=True, choice='b', list=list('abc'))
t('-1s= -l "" --list= x', leftover=['x'], bool_set=True, simple_string='', list=['', ''])
t('--choice moo', fails=r'moo')
t('--bool', fails='mbiguous')
t('-1c moo', fails=r'moo')
t('-10c=moo', fails=r'moo')
t('-1 -h', fails=True, help_called=True)
t('-1 --help', fails=True, help_called=True)
t('-1 -0v', fails=True, version_called=True)
t('-1 -v0', fails=True, version_called=True)
t('-1 --version', fails=True, version_called=True)
t('-f=3.142 --int 17', float=3.142, int=17)
def font_size_clamping(self: 'BaseTest') -> None:
from kitty.options.utils import MAXIMUM_FONT_SIZE, MINIMUM_FONT_SIZE, clamp_font_size
# the floor applies whatever the configured size
self.ae(clamp_font_size(0, 11), MINIMUM_FONT_SIZE)
self.ae(clamp_font_size(-100, 11), MINIMUM_FONT_SIZE)
# sizes inside the range come back untouched
self.ae(clamp_font_size(11, 11), 11)
self.ae(clamp_font_size(64, 11), 64)
# the ceiling is the larger of the multiple and the absolute maximum, so a
# small configured size no longer implies a small maximum zoom
self.ae(clamp_font_size(1e6, 4), MAXIMUM_FONT_SIZE)
self.ae(clamp_font_size(1e6, 11), MAXIMUM_FONT_SIZE)
self.ae(clamp_font_size(200, 4), 200)
# a large configured size keeps its proportional ceiling
self.ae(clamp_font_size(1e6, 40), 400)
self.ae(clamp_font_size(1e6, 100), 1000)
# and the ceiling is never below the absolute maximum
for configured in (1, 4, 11, 25.6, 40, 100):
self.assertGreaterEqual(clamp_font_size(1e6, configured), MAXIMUM_FONT_SIZE)
def launcher(self):
import tempfile
from kitty.constants import is_macos
kexe = kitty_exe()
cfgdir = None
def get_report(cmdline: str, launch_services=False, config_dir: str = ''):
nonlocal cfgdir
args = list(shlex_split(cmdline))
env = dict(os.environ)
if launch_services:
env['KITTY_LAUNCHED_BY_LAUNCH_SERVICES'] = '1'
if config_dir:
env['KITTY_CONFIG_DIRECTORY'] = config_dir
cp = subprocess.run([kexe, '+testing-launcher-code'] + args, env=env, stdout=subprocess.PIPE)
self.assertEqual(cp.returncode, 0)
ans = {}
for line in cp.stdout.decode().split('\n'):
if not line:
continue
try:
key, val = line.split(':')
except ValueError:
raise AssertionError(f'Unexpected output from launcher: {line!r}\n{cp.stdout.decode()}')
if '\x1e' in val:
val = [x for x in val.split('\x1e') if x]
else:
val = val.strip()
ans[key] = val
if key == 'config_dir':
cfgdir = val
return ans, cp.stdout.decode().replace('\x1e', ' ')
def test(cmdline, assertions):
r, output = get_report(cmdline, launch_services=assertions.get('launched_by_launch_services', '0') != '0')
for key, expected in assertions.items():
self.assertEqual(expected, r.get(key), f'Failed for {key} with command line: {cmdline}\nOutput:\n{output}')
return output
def t(cmdline, **assertions):
assertions['is_quick_access_terminal'] = '0'
if cfgdir:
assertions['config_dir'] = cfgdir
assertions.setdefault('launched_by_launch_services', '0')
test(cmdline, assertions)
def si(cmdline, **assertions):
assertions['single_instance'] = '1'
test(cmdline, assertions)
def dt(cmdline, **assertions):
assertions['detach'] = 'true'
test(cmdline, assertions)
def k(cmdline):
assertions = {}
assertions['argv'] = ['kitten'] + cmdline.split()
for prefix in ('+kitten', '+ kitten'):
output = test(prefix + ' ' + cmdline, assertions)
self.assertIn('kitten_exe:', output)
def pn(cmdline, **assertions):
ig = assertions.get('instance_group')
assertions['instance_group'] = f'panel-{ig}' if ig else 'panel'
assertions['single_instance'] = '1'
assertions['session'] = ''
test(cmdline, assertions)
t('', original_argv=[kexe], argv=[])
t(
'--title=xxx --start-as maximized -c=a -c b cat',
title='xxx',
start_as='maximized',
config=['a', 'b'],
original_argv=[kexe, '--title=xxx', '--start-as', 'maximized', '-c=a', '-c', 'b', 'cat'],
argv=['cat'],
)
k('icat abc xyz')
t('+kitten unwrapped xyz', argv=['+kitten', 'unwrapped', 'xyz'])
t('+ kitten unwrapped xyz', original_argv=[kexe, '+', 'kitten', 'unwrapped', 'xyz'])
si('--single-instance --instance-group=g -T 3', argv=[kexe, '--single-instance', '--instance-group=g', '-T', '3'])
t('+open --help', argv=['+open', '--help'])
t('+open -1 --help', argv=['+open', '-1', '--help'])
si('+open -1 moose', argv=[kexe, '+open', '-1', 'moose'], open_urls=['moose'])
si('+open -1 --instance-group=g x y', instance_group='g', open_urls=['x', 'y'])
dt('--detach --session=moose --detached-log=xyz', detached_log='xyz', session='moose')
pn('+kitten panel -1 --edge=left', edge='left')
if is_macos:
with tempfile.TemporaryDirectory() as tdir:
with open(tdir + '/macos-launch-services-cmdline', 'w') as f:
f.write('kitty --title from-file\n')
# File args are used when launched by launch services
r, output = get_report('', launch_services=True, config_dir=tdir)
self.assertEqual(r.get('title'), 'from-file', f'Expected title=from-file in:\n{output}')
# User args passed via open --args are appended after file args (and override them)
r, output = get_report('--title from-args', launch_services=True, config_dir=tdir)
self.assertEqual(r.get('title'), 'from-args', f'Expected title=from-args to override from-file in:\n{output}')
def conf_parsing(self):
from kitty.config import defaults, load_config
from kitty.constants import is_macos
from kitty.fast_data_types import LEFT_EDGE, RIGHT_EDGE
from kitty.fonts import FontModification, ModificationType, ModificationUnit, ModificationValue
from kitty.options.utils import to_modifiers
bad_lines = []
def p(*lines, bad_line_num=0, num_err=None):
del bad_lines[:]
del self.error_messages[:]
ans = load_config(overrides=lines, accumulate_bad_lines=bad_lines)
if bad_line_num:
self.ae(len(bad_lines), bad_line_num)
else:
self.assertFalse(bad_lines)
if num_err is not None:
self.ae(len(self.error_messages), num_err, '\n'.join(self.error_messages))
return ans
def keys_for_func(opts, name):
for key, defns in opts.keyboard_modes[''].keymap.items():
for action in opts.alias_map.resolve_aliases(defns[0].definition):
if action.func == name:
yield key
opts = p('font_size 11.37', 'clear_all_shortcuts y', 'color23 red')
self.ae(opts.font_size, 11.37)
radius_opts = p('window_border_radius 7px')
self.ae(radius_opts.window_border_radius, (7.0, 'px'))
radius_opts = p('window_border_radius -2')
self.ae(radius_opts.window_border_radius, (0.0, 'pt'))
self.ae(opts.mouse_hide_wait[0], 0 if is_macos else 3)
self.ae(opts.mouse_hide_wait[1], 0)
self.ae(opts.mouse_hide_wait[2], 40)
self.ae(opts.mouse_hide_wait[3], True)
self.ae(opts.color23, Color(255, 0, 0))
self.assertFalse(opts.keyboard_modes[''].keymap)
opts = p("url_excluded_characters '''")
self.ae(opts.url_excluded_characters, "'''")
opts = p("url_excluded_characters abc'")
self.ae(opts.url_excluded_characters, "abc'")
opts = p('tab_bar_edge left')
self.ae(opts.tab_bar_edge, LEFT_EDGE)
opts = p('tab_bar_edge right')
self.ae(opts.tab_bar_edge, RIGHT_EDGE)
opts = p('tab_bar_align start')
self.ae(opts.tab_bar_align, 'start')
opts = p('tab_bar_align end')
self.ae(opts.tab_bar_align, 'end')
opts = p('tab_bar_align left')
self.ae(opts.tab_bar_align, 'left')
opts = p('tab_bar_align right')
self.ae(opts.tab_bar_align, 'right')
opts = p('clear_all_shortcuts y', 'map f1 next_window')
self.ae(len(opts.keyboard_modes[''].keymap), 1)
opts = p('clear_all_mouse_actions y', 'mouse_map left click ungrabbed mouse_click_url_or_select')
self.ae(len(opts.mousemap), 1)
opts = p('strip_trailing_spaces always')
self.ae(opts.strip_trailing_spaces, 'always')
self.assertFalse(bad_lines)
opts = p('pointer_shape_when_grabbed XXX', bad_line_num=1)
self.ae(opts.pointer_shape_when_grabbed, defaults.pointer_shape_when_grabbed)
opts = p('modify_font underline_position -2', 'modify_font underline_thickness 150%', 'modify_font size Test -1px')
self.ae(
opts.modify_font,
{
'underline_position': FontModification(ModificationType.underline_position, ModificationValue(-2.0, ModificationUnit.pt)),
'underline_thickness': FontModification(ModificationType.underline_thickness, ModificationValue(150, ModificationUnit.percent)),
'size:Test': FontModification(ModificationType.size, ModificationValue(-1.0, ModificationUnit.pixel), 'Test'),
},
)
# test the aliasing options
opts = p('env A=1', 'env B=x$A', 'env C=', 'env D', 'clear_all_shortcuts y', 'kitten_alias a b --moo', 'map f1 kitten a arg')
self.ae(opts.env, {'A': '1', 'B': 'x1', 'C': '', 'D': DELETE_ENV_VAR})
def ac(which=0):
ka = tuple(opts.keyboard_modes[''].keymap.values())[0][0]
acs = opts.alias_map.resolve_aliases(ka.definition)
return acs[which]
ka = ac()
self.ae(ka.func, 'kitten')
self.ae(ka.args, ('b', '--moo', 'arg'))
opts = p('clear_all_shortcuts y', 'kitten_alias hints hints --hi', 'map f1 kitten hints XXX')
ka = ac()
self.ae(ka.func, 'kitten')
self.ae(ka.args, ('hints', '--hi', 'XXX'))
opts = p('clear_all_shortcuts y', 'action_alias la launch --moo', 'map f1 la XXX')
ka = ac()
self.ae(ka.func, 'launch')
self.ae(ka.args, ('--moo', 'XXX'))
opts = p('clear_all_shortcuts y', 'action_alias one launch --moo', 'action_alias two one recursive', 'map f1 two XXX')
ka = ac()
self.ae(ka.func, 'launch')
self.ae(ka.args, ('--moo', 'recursive', 'XXX'))
opts = p('clear_all_shortcuts y', 'action_alias launch two 1', 'action_alias two launch 2', 'map f1 launch 3')
ka = ac()
self.ae(ka.func, 'launch')
self.ae(ka.args, ('2', '1', '3'))
opts = p('clear_all_shortcuts y', 'action_alias launch launch --moo', 'map f1 launch XXX')
ka = ac()
self.ae(ka.func, 'launch')
self.ae(ka.args, ('--moo', 'XXX'))
opts = p('clear_all_shortcuts y', 'action_alias cfs change_font_size current', 'map f1 cfs +2')
ka = ac()
self.ae(ka.func, 'change_font_size')
self.ae(ka.args, (False, '+', 2.0))
opts = p('clear_all_shortcuts y', 'action_alias la launch --moo', 'map f1 combine : new_window : la ')
self.ae((ac().func, ac(1).func), ('new_window', 'launch'))
opts = p('clear_all_shortcuts y', 'action_alias cc combine : new_window : launch --moo', 'map f1 cc XXX')
self.ae((ac().func, ac(1).func), ('new_window', 'launch'))
self.ae(ac(1).args, ('--moo', 'XXX'))
opts = p('clear_all_shortcuts y', 'action_alias ss kitten "space 1"', 'map f1 ss "space 2"')
self.ae(ac().args, ('space 1', 'space 2'))
opts = p('kitty_mod alt')
self.ae(opts.kitty_mod, to_modifiers('alt'))
self.ae(next(keys_for_func(opts, 'next_layout')).mods, opts.kitty_mod)
opts = p('inactive_text_alpha 0.25')
self.ae(opts.inactive_text_alpha, 0.25)
opts = p('inactive_text_alpha -0.25')
self.ae(opts.inactive_text_alpha, -0.25)
opts = p('inactive_text_alpha 2')
self.ae(opts.inactive_text_alpha, 1.0)
opts = p('inactive_text_alpha -2')
self.ae(opts.inactive_text_alpha, -1.0)
# deprecation handling
opts = p('clear_all_shortcuts y', 'send_text all f1 hello')
self.ae(len(opts.keyboard_modes[''].keymap), 1)
opts = p('macos_hide_titlebar y' if is_macos else 'x11_hide_window_decorations y')
self.assertTrue(opts.hide_window_decorations)
self.ae(len(self.error_messages), 1)
# line breaks
opts = p(' font', ' \t \t \\_size', ' \\ 12', '\\.35', 'col', '\\o', '\t \t\\r', '\\25', ' \\ blue')
self.ae(opts.font_size, 12.35)
self.ae(opts.color25, Color(0, 0, 255))
# cursor_blink_interval
def cb(src, interval=-1, first=EasingFunction(), second=EasingFunction()):
opts = p('cursor_blink_interval ' + src)
self.ae((float(interval), first, second), (float(opts.cursor_blink_interval[0]), opts.cursor_blink_interval[1], opts.cursor_blink_interval[2]))
cb('3', 3)
cb('-2.3', -2.3)
cb('linear', first=EasingFunction('cubic-bezier', cubic_bezier_points=(0, 0.0, 1.0, 1.0)))
cb('linear 19', 19, EasingFunction('cubic-bezier', cubic_bezier_points=(0, 0.0, 1.0, 1.0)))
cb(
'ease-in-out cubic-bezier(0.1, 0.2, 0.3, 0.4) 11',
11,
EasingFunction('cubic-bezier', cubic_bezier_points=(0.42, 0, 0.58, 1)),
EasingFunction('cubic-bezier', cubic_bezier_points=(0.1, 0.2, 0.3, 0.4)),
)
cb('step-start', first=EasingFunction('steps', num_steps=1, jump_type='start'))
cb('steps(7, jump-none)', first=EasingFunction('steps', num_steps=7, jump_type='none'))
cb('linear(0, 0.25, 1)', first=EasingFunction('linear', linear_x=(0.0, 0.5, 1.0), linear_y=(0, 0.25, 1.0)))
cb('linear(0, 0.25 75%, 1)', first=EasingFunction('linear', linear_x=(0.0, 0.75, 1.0), linear_y=(0, 0.25, 1.0)))
cb('linear(0, 0.25 25% 75%, 1)', first=EasingFunction('linear', linear_x=(0.0, 0.25, 0.75, 1.0), linear_y=(0, 0.25, 0.25, 1.0)))
# test that easing functions give expected values
def ef(spec, tests, only_single=True, duration=0.5, accuracy=0):
cfv = p('cursor_blink_interval ' + spec).cursor_blink_interval
self.set_options({'cursor_blink_interval': cfv})
for t, expected in tests.items():
actual = test_cursor_blink_easing_function(t, only_single, duration)
if abs(actual - expected) > accuracy:
self.ae(expected, actual, f'Failed for {spec=} with {t=}: {expected} != {actual}')
ef('linear', {0: 1, 0.25: 0.5, 0.5: 0, 0.75: 0.5, 1: 1}, only_single=False)
ef('linear(0, 0.25 25% 75%, 1)', {0: 0, 0.25: 0.25, 0.3: 0.25, 0.75: 0.25, 1: 1})
linear_vals = {0: 0, 1: 1, 0.1234: 0.1234, 0.6453: 0.6453}
for spec in ('linear', 'linear(0, 1)', 'cubic-bezier(0, 0, 1, 1)', 'cubic-bezier(0.2, 0.2, 0.7, 0.7)'):
ef(spec, linear_vals)
# test an almost linear function to test cubic bezier implementation
ef('cubic-bezier(0.2, 0.2, 0.7, 0.71)', linear_vals, accuracy=0.01)
ef('cubic-bezier(0.23, 0.2, 0.7, 0.71)', linear_vals, accuracy=0.01)
ef('steps(5)', {0: 0, 0.1: 0, 0.3: 0.2, 0.9: 0.8})
ef('steps(5, start)', {0: 0.2, 0.1: 0.2, 0.3: 0.4, 0.9: 1})
ef('steps(4, jump-both)', {0: 0.2, 0.1: 0.2, 0.3: 0.4, 0.9: 1})
ef('steps(6, jump-none)', {0: 0, 0.1: 0.0, 0.3: 0.2, 0.9: 1})
# test various include modes
base = os.path.join(self.tdir, 'glob')
os.mkdir(base)
with open(os.path.join(base, 'fg'), 'w') as f:
print('foreground red', file=f)
opts = p(f'include {f.name}', num_err=0)
self.ae(opts.foreground, to_color('red'))
with open(os.path.join(self.tdir, 'bg'), 'w') as f:
print('background white', file=f)
print('globinclude glob/*', file=f)
print('envinclude ENVINCLUDE', file=f)
print('geninclude g.py', file=f)
print('geninclude g', file=f)
with open(os.path.join(self.tdir, 'g.py'), 'w') as g:
print('print("background_opacity .77")', file=g)
print('print("background_blur 77")', file=g)
with open(os.path.join(self.tdir, 'g'), 'w') as g:
print('#!/bin/sh', file=g)
print('echo background_image_linear y', file=g)
print('echo background_image_layout clamped', file=g)
os.chmod(g.fileno(), 0o700)
os.environ['ENVINCLUDE'] = 'cursor yellow'
opts = p(f'include {f.name}', num_err=0)
os.environ.pop('ENVINCLUDE')
self.ae(opts.foreground, to_color('red'))
self.ae(opts.background, to_color('white'))
self.ae(opts.cursor, to_color('yellow'))
self.ae(opts.background_opacity, 0.77)
self.ae(opts.background_blur, 77)
self.ae(opts.background_image_linear, True)
self.ae(opts.background_image_layout, 'clamped')
with open(os.path.join(self.tdir, 'a'), 'w') as a:
print('background red', file=a)
print('include b', file=a)
with open(os.path.join(self.tdir, 'b'), 'w') as a:
print('foreground red', file=a)
print('include a', file=a)
opts = p(f'include {a.name}', num_err=0, bad_line_num=1)
self.ae(opts.foreground, to_color('red'))
self.ae(opts.background, to_color('red'))
# remap_modifier. NOTE: parsing only. Whether the permutation is applied
# simultaneously rather than sequentially is a property of
# apply_modifier_remap() in C and is NOT observable from this dict - a
# 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_modifiers, {})
ctrl, hyper, sup = to_modifiers('ctrl'), to_modifiers('hyper'), to_modifiers('super')
self.ae(p('remap_modifiers ctrl:hyper').remap_modifiers, {ctrl: hyper})
# both directions of a swap are recorded
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_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_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_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_modifiers, {}, f'not rejected: {bad}')