1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
4
5
6import os
7import re
8import sys
9from typing import (
10    Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Sequence, Tuple,
11    Union
12)
13
14import kitty.fast_data_types as defines
15from kitty.conf.utils import (
16    KeyAction, key_func, positive_float, positive_int, python_string, to_bool,
17    to_cmdline, to_color, uniq, unit_float
18)
19from kitty.constants import config_dir, is_macos
20from kitty.fast_data_types import CURSOR_BEAM, CURSOR_BLOCK, CURSOR_UNDERLINE
21from kitty.fonts import FontFeature
22from kitty.key_names import (
23    character_key_name_aliases, functional_key_name_aliases,
24    get_key_name_lookup
25)
26from kitty.rgb import Color, color_as_int
27from kitty.types import FloatEdges, MouseEvent, SingleKey
28from kitty.utils import expandvars, log_error
29
30KeyMap = Dict[SingleKey, KeyAction]
31MouseMap = Dict[MouseEvent, KeyAction]
32KeySequence = Tuple[SingleKey, ...]
33SubSequenceMap = Dict[KeySequence, KeyAction]
34SequenceMap = Dict[SingleKey, SubSequenceMap]
35MINIMUM_FONT_SIZE = 4
36default_tab_separator = ' ┇'
37mod_map = {'CTRL': 'CONTROL', 'CMD': 'SUPER', '⌘': 'SUPER',
38           '⌥': 'ALT', 'OPTION': 'ALT', 'KITTY_MOD': 'KITTY'}
39character_key_name_aliases_with_ascii_lowercase: Dict[str, str] = character_key_name_aliases.copy()
40for x in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
41    character_key_name_aliases_with_ascii_lowercase[x] = x.lower()
42sequence_sep = '>'
43func_with_args, args_funcs = key_func()
44FuncArgsType = Tuple[str, Sequence[Any]]
45
46
47class InvalidMods(ValueError):
48    pass
49
50
51# Actions {{{
52@func_with_args(
53    'pass_selection_to_program', 'new_window', 'new_tab', 'new_os_window',
54    'new_window_with_cwd', 'new_tab_with_cwd', 'new_os_window_with_cwd',
55    'launch'
56    )
57def shlex_parse(func: str, rest: str) -> FuncArgsType:
58    return func, to_cmdline(rest)
59
60
61@func_with_args('combine')
62def combine_parse(func: str, rest: str) -> FuncArgsType:
63    sep, rest = rest.split(maxsplit=1)
64    parts = re.split(r'\s*' + re.escape(sep) + r'\s*', rest)
65    args = tuple(map(parse_key_action, filter(None, parts)))
66    return func, args
67
68
69def parse_send_text_bytes(text: str) -> bytes:
70    return python_string(text).encode('utf-8')
71
72
73@func_with_args('send_text')
74def send_text_parse(func: str, rest: str) -> FuncArgsType:
75    args = rest.split(maxsplit=1)
76    mode = ''
77    data = b''
78    if len(args) > 1:
79        mode = args[0]
80        try:
81            data = parse_send_text_bytes(args[1])
82        except Exception:
83            log_error('Ignoring invalid send_text string: ' + args[1])
84    return func, [mode, data]
85
86
87@func_with_args('run_kitten', 'run_simple_kitten', 'kitten')
88def kitten_parse(func: str, rest: str) -> FuncArgsType:
89    if func == 'kitten':
90        args = rest.split(maxsplit=1)
91    else:
92        args = rest.split(maxsplit=2)[1:]
93        func = 'kitten'
94    return func, args
95
96
97@func_with_args('goto_tab')
98def goto_tab_parse(func: str, rest: str) -> FuncArgsType:
99    args = (max(0, int(rest)), )
100    return func, args
101
102
103@func_with_args('detach_window')
104def detach_window_parse(func: str, rest: str) -> FuncArgsType:
105    if rest not in ('new', 'new-tab', 'ask', 'tab-prev', 'tab-left', 'tab-right'):
106        log_error('Ignoring invalid detach_window argument: {}'.format(rest))
107        rest = 'new'
108    return func, (rest,)
109
110
111@func_with_args('detach_tab')
112def detach_tab_parse(func: str, rest: str) -> FuncArgsType:
113    if rest not in ('new', 'ask'):
114        log_error('Ignoring invalid detach_tab argument: {}'.format(rest))
115        rest = 'new'
116    return func, (rest,)
117
118
119@func_with_args('set_background_opacity', 'goto_layout', 'toggle_layout', 'kitty_shell')
120def simple_parse(func: str, rest: str) -> FuncArgsType:
121    return func, [rest]
122
123
124@func_with_args('set_font_size')
125def float_parse(func: str, rest: str) -> FuncArgsType:
126    return func, (float(rest),)
127
128
129@func_with_args('signal_child')
130def signal_child_parse(func: str, rest: str) -> FuncArgsType:
131    import signal
132    signals = []
133    for q in rest.split():
134        try:
135            signum = getattr(signal, q.upper())
136        except AttributeError:
137            log_error(f'Unknown signal: {rest} ignoring')
138        else:
139            signals.append(signum)
140    return func, tuple(signals)
141
142
143@func_with_args('change_font_size')
144def parse_change_font_size(func: str, rest: str) -> Tuple[str, Tuple[bool, Optional[str], float]]:
145    vals = rest.strip().split(maxsplit=1)
146    if len(vals) != 2:
147        log_error('Invalid change_font_size specification: {}, treating it as default'.format(rest))
148        return func, (True, None, 0)
149    c_all = vals[0].lower() == 'all'
150    sign: Optional[str] = None
151    amt = vals[1]
152    if amt[0] in '+-':
153        sign = amt[0]
154        amt = amt[1:]
155    return func, (c_all, sign, float(amt.strip()))
156
157
158@func_with_args('clear_terminal')
159def clear_terminal(func: str, rest: str) -> FuncArgsType:
160    vals = rest.strip().split(maxsplit=1)
161    if len(vals) != 2:
162        log_error('clear_terminal needs two arguments, using defaults')
163        args: List[Union[str, bool]] = ['reset', 'active']
164    else:
165        args = [vals[0].lower(), vals[1].lower() == 'active']
166    return func, args
167
168
169@func_with_args('copy_to_buffer')
170def copy_to_buffer(func: str, rest: str) -> FuncArgsType:
171    return func, [rest]
172
173
174@func_with_args('paste_from_buffer')
175def paste_from_buffer(func: str, rest: str) -> FuncArgsType:
176    return func, [rest]
177
178
179@func_with_args('neighboring_window')
180def neighboring_window(func: str, rest: str) -> FuncArgsType:
181    rest = rest.lower()
182    rest = {'up': 'top', 'down': 'bottom'}.get(rest, rest)
183    if rest not in ('left', 'right', 'top', 'bottom'):
184        log_error('Invalid neighbor specification: {}'.format(rest))
185        rest = 'right'
186    return func, [rest]
187
188
189@func_with_args('resize_window')
190def resize_window(func: str, rest: str) -> FuncArgsType:
191    vals = rest.strip().split(maxsplit=1)
192    if len(vals) > 2:
193        log_error('resize_window needs one or two arguments, using defaults')
194        args = ['wider', 1]
195    else:
196        quality = vals[0].lower()
197        if quality not in ('taller', 'shorter', 'wider', 'narrower'):
198            log_error('Invalid quality specification: {}'.format(quality))
199            quality = 'wider'
200        increment = 1
201        if len(vals) == 2:
202            try:
203                increment = int(vals[1])
204            except Exception:
205                log_error('Invalid increment specification: {}'.format(vals[1]))
206        args = [quality, increment]
207    return func, args
208
209
210@func_with_args('move_window')
211def move_window(func: str, rest: str) -> FuncArgsType:
212    rest = rest.lower()
213    rest = {'up': 'top', 'down': 'bottom'}.get(rest, rest)
214    prest: Union[int, str] = rest
215    try:
216        prest = int(prest)
217    except Exception:
218        if prest not in ('left', 'right', 'top', 'bottom'):
219            log_error('Invalid move_window specification: {}'.format(rest))
220            prest = 0
221    return func, [prest]
222
223
224@func_with_args('pipe')
225def pipe(func: str, rest: str) -> FuncArgsType:
226    import shlex
227    r = shlex.split(rest)
228    if len(r) < 3:
229        log_error('Too few arguments to pipe function')
230        r = ['none', 'none', 'true']
231    return func, r
232
233
234@func_with_args('set_colors')
235def set_colors(func: str, rest: str) -> FuncArgsType:
236    import shlex
237    r = shlex.split(rest)
238    if len(r) < 1:
239        log_error('Too few arguments to set_colors function')
240    return func, r
241
242
243@func_with_args('remote_control')
244def remote_control(func: str, rest: str) -> FuncArgsType:
245    import shlex
246    r = shlex.split(rest)
247    if len(r) < 1:
248        log_error('Too few arguments to remote_control function')
249    return func, r
250
251
252@func_with_args('nth_window')
253def nth_window(func: str, rest: str) -> FuncArgsType:
254    try:
255        num = int(rest)
256    except Exception:
257        log_error('Invalid nth_window number: {}'.format(rest))
258        num = 1
259    return func, [num]
260
261
262@func_with_args('disable_ligatures_in')
263def disable_ligatures_in(func: str, rest: str) -> FuncArgsType:
264    parts = rest.split(maxsplit=1)
265    if len(parts) == 1:
266        where, strategy = 'active', parts[0]
267    else:
268        where, strategy = parts
269    if where not in ('active', 'all', 'tab'):
270        raise ValueError('{} is not a valid set of windows to disable ligatures in'.format(where))
271    if strategy not in ('never', 'always', 'cursor'):
272        raise ValueError('{} is not a valid disable ligatures strategy'.format(strategy))
273    return func, [where, strategy]
274
275
276@func_with_args('layout_action')
277def layout_action(func: str, rest: str) -> FuncArgsType:
278    parts = rest.split(maxsplit=1)
279    if not parts:
280        raise ValueError('layout_action must have at least one argument')
281    return func, [parts[0], tuple(parts[1:])]
282
283
284def parse_marker_spec(ftype: str, parts: Sequence[str]) -> Tuple[str, Union[str, Tuple[Tuple[int, str], ...]], int]:
285    flags = re.UNICODE
286    if ftype in ('text', 'itext', 'regex', 'iregex'):
287        if ftype.startswith('i'):
288            flags |= re.IGNORECASE
289        if not parts or len(parts) % 2 != 0:
290            raise ValueError('No color specified in marker: {}'.format(' '.join(parts)))
291        ans = []
292        for i in range(0, len(parts), 2):
293            try:
294                color = max(1, min(int(parts[i]), 3))
295            except Exception:
296                raise ValueError('color {} in marker specification is not an integer'.format(parts[i]))
297            sspec = parts[i + 1]
298            if 'regex' not in ftype:
299                sspec = re.escape(sspec)
300            ans.append((color, sspec))
301        ftype = 'regex'
302        spec: Union[str, Tuple[Tuple[int, str], ...]] = tuple(ans)
303    elif ftype == 'function':
304        spec = ' '.join(parts)
305    else:
306        raise ValueError('Unknown marker type: {}'.format(ftype))
307    return ftype, spec, flags
308
309
310@func_with_args('toggle_marker')
311def toggle_marker(func: str, rest: str) -> FuncArgsType:
312    import shlex
313    parts = rest.split(maxsplit=1)
314    if len(parts) != 2:
315        raise ValueError('{} is not a valid marker specification'.format(rest))
316    ftype, spec = parts
317    parts = shlex.split(spec)
318    return func, list(parse_marker_spec(ftype, parts))
319
320
321@func_with_args('scroll_to_mark')
322def scroll_to_mark(func: str, rest: str) -> FuncArgsType:
323    parts = rest.split()
324    if not parts or not rest:
325        return func, [True, 0]
326    if len(parts) == 1:
327        q = parts[0].lower()
328        if q in ('prev', 'previous', 'next'):
329            return func, [q != 'next', 0]
330        try:
331            return func, [True, max(0, min(int(q), 3))]
332        except Exception:
333            raise ValueError('{} is not a valid scroll_to_mark destination'.format(rest))
334    return func, [parts[0] != 'next', max(0, min(int(parts[1]), 3))]
335
336
337@func_with_args('mouse_selection')
338def mouse_selection(func: str, rest: str) -> FuncArgsType:
339    cmap = getattr(mouse_selection, 'code_map', None)
340    if cmap is None:
341        cmap = {
342            'normal': defines.MOUSE_SELECTION_NORMAL,
343            'extend': defines.MOUSE_SELECTION_EXTEND,
344            'move-end': defines.MOUSE_SELECTION_MOVE_END,
345            'rectangle': defines.MOUSE_SELECTION_RECTANGLE,
346            'word': defines.MOUSE_SELECTION_WORD,
347            'line': defines.MOUSE_SELECTION_LINE,
348            'line_from_point': defines.MOUSE_SELECTION_LINE_FROM_POINT,
349        }
350        setattr(mouse_selection, 'code_map', cmap)
351    return func, [cmap[rest]]
352
353
354@func_with_args('load_config_file')
355def load_config_file(func: str, rest: str) -> FuncArgsType:
356    import shlex
357    return func, shlex.split(rest)
358# }}}
359
360
361def parse_mods(parts: Iterable[str], sc: str) -> Optional[int]:
362
363    def map_mod(m: str) -> str:
364        return mod_map.get(m, m)
365
366    mods = 0
367    for m in parts:
368        try:
369            mods |= getattr(defines, 'GLFW_MOD_' + map_mod(m.upper()))
370        except AttributeError:
371            if m.upper() != 'NONE':
372                log_error('Shortcut: {} has unknown modifier, ignoring'.format(sc))
373            return None
374
375    return mods
376
377
378def to_modifiers(val: str) -> int:
379    return parse_mods(val.split('+'), val) or 0
380
381
382def parse_shortcut(sc: str) -> SingleKey:
383    if sc.endswith('+') and len(sc) > 1:
384        sc = sc[:-1] + 'plus'
385    parts = sc.split('+')
386    mods = 0
387    if len(parts) > 1:
388        mods = parse_mods(parts[:-1], sc) or 0
389        if not mods:
390            raise InvalidMods('Invalid shortcut')
391    q = parts[-1]
392    q = character_key_name_aliases_with_ascii_lowercase.get(q.upper(), q)
393    is_native = False
394    if q.startswith('0x'):
395        try:
396            key = int(q, 16)
397        except Exception:
398            key = 0
399        else:
400            is_native = True
401    else:
402        try:
403            key = ord(q)
404        except Exception:
405            uq = q.upper()
406            uq = functional_key_name_aliases.get(uq, uq)
407            x: Optional[int] = getattr(defines, f'GLFW_FKEY_{uq}', None)
408            if x is None:
409                lf = get_key_name_lookup()
410                key = lf(q, False) or 0
411                is_native = key > 0
412            else:
413                key = x
414
415    return SingleKey(mods, is_native, key or 0)
416
417
418def adjust_line_height(x: str) -> Union[int, float]:
419    if x.endswith('%'):
420        ans = float(x[:-1].strip()) / 100.0
421        if ans < 0:
422            log_error('Percentage adjustments of cell sizes must be positive numbers')
423            return 0
424        return ans
425    return int(x)
426
427
428def adjust_baseline(x: str) -> Union[int, float]:
429    if x.endswith('%'):
430        ans = float(x[:-1].strip()) / 100.0
431        if abs(ans) > 1:
432            log_error('Percentage adjustments of the baseline cannot exceed 100%')
433            return 0
434        return ans
435    return int(x)
436
437
438def to_font_size(x: str) -> float:
439    return max(MINIMUM_FONT_SIZE, float(x))
440
441
442def disable_ligatures(x: str) -> int:
443    cmap = {'never': 0, 'cursor': 1, 'always': 2}
444    return cmap.get(x.lower(), 0)
445
446
447def box_drawing_scale(x: str) -> Tuple[float, float, float, float]:
448    ans = tuple(float(q.strip()) for q in x.split(','))
449    if len(ans) != 4:
450        raise ValueError('Invalid box_drawing scale, must have four entries')
451    return ans[0], ans[1], ans[2], ans[3]
452
453
454def cursor_text_color(x: str) -> Optional[Color]:
455    if x.lower() == 'background':
456        return None
457    return to_color(x)
458
459
460cshapes = {
461    'block': CURSOR_BLOCK,
462    'beam': CURSOR_BEAM,
463    'underline': CURSOR_UNDERLINE
464}
465
466
467def to_cursor_shape(x: str) -> int:
468    try:
469        return cshapes[x.lower()]
470    except KeyError:
471        raise ValueError(
472            'Invalid cursor shape: {} allowed values are {}'.format(
473                x, ', '.join(cshapes)
474            )
475        )
476
477
478def scrollback_lines(x: str) -> int:
479    ans = int(x)
480    if ans < 0:
481        ans = 2 ** 32 - 1
482    return ans
483
484
485def scrollback_pager_history_size(x: str) -> int:
486    ans = int(max(0, float(x)) * 1024 * 1024)
487    return min(ans, 4096 * 1024 * 1024 - 1)
488
489
490def url_style(x: str) -> int:
491    return url_style_map.get(x, url_style_map['curly'])
492
493
494url_style_map = dict(
495    ((v, i) for i, v in enumerate('none single double curly'.split()))
496)
497
498
499def url_prefixes(x: str) -> Tuple[str, ...]:
500    return tuple(a.lower() for a in x.replace(',', ' ').split())
501
502
503def copy_on_select(raw: str) -> str:
504    q = raw.lower()
505    # boolean values special cased for backwards compat
506    if q in ('y', 'yes', 'true', 'clipboard'):
507        return 'clipboard'
508    if q in ('n', 'no', 'false', ''):
509        return ''
510    return raw
511
512
513def window_size(val: str) -> Tuple[int, str]:
514    val = val.lower()
515    unit = 'cells' if val.endswith('c') else 'px'
516    return positive_int(val.rstrip('c')), unit
517
518
519def to_layout_names(raw: str) -> List[str]:
520    from kitty.layout.interface import all_layouts
521    parts = [x.strip().lower() for x in raw.split(',')]
522    ans: List[str] = []
523    for p in parts:
524        if p in ('*', 'all'):
525            ans.extend(sorted(all_layouts))
526            continue
527        name = p.partition(':')[0]
528        if name not in all_layouts:
529            raise ValueError('The window layout {} is unknown'.format(p))
530        ans.append(p)
531    return uniq(ans)
532
533
534def window_border_width(x: Union[str, int, float]) -> Tuple[float, str]:
535    unit = 'pt'
536    if isinstance(x, str):
537        trailer = x[-2:]
538        if trailer in ('px', 'pt'):
539            unit = trailer
540            val = float(x[:-2])
541        else:
542            val = float(x)
543    else:
544        val = float(x)
545    return max(0, val), unit
546
547
548def edge_width(x: str, converter: Callable[[str], float] = positive_float) -> FloatEdges:
549    parts = str(x).split()
550    num = len(parts)
551    if num == 1:
552        val = converter(parts[0])
553        return FloatEdges(val, val, val, val)
554    if num == 2:
555        v = converter(parts[0])
556        h = converter(parts[1])
557        return FloatEdges(h, v, h, v)
558    if num == 3:
559        top, h, bottom = map(converter, parts)
560        return FloatEdges(h, top, h, bottom)
561    top, right, bottom, left = map(converter, parts)
562    return FloatEdges(left, top, right, bottom)
563
564
565def optional_edge_width(x: str) -> FloatEdges:
566    return edge_width(x, float)
567
568
569def hide_window_decorations(x: str) -> int:
570    if x == 'titlebar-only':
571        return 0b10
572    if to_bool(x):
573        return 0b01
574    return 0b00
575
576
577def resize_draw_strategy(x: str) -> int:
578    cmap = {'static': 0, 'scale': 1, 'blank': 2, 'size': 3}
579    return cmap.get(x.lower(), 0)
580
581
582def tab_separator(x: str) -> str:
583    for q in '\'"':
584        if x.startswith(q) and x.endswith(q):
585            x = x[1:-1]
586            if not x:
587                return ''
588            break
589    if not x.strip():
590        x = ('\xa0' * len(x)) if x else default_tab_separator
591    return x
592
593
594def tab_bar_edge(x: str) -> int:
595    return {'top': 1, 'bottom': 3}.get(x.lower(), 3)
596
597
598def tab_font_style(x: str) -> Tuple[bool, bool]:
599    return {
600        'bold-italic': (True, True),
601        'bold': (True, False),
602        'italic': (False, True)
603    }.get(x.lower().replace('_', '-'), (False, False))
604
605
606def tab_bar_min_tabs(x: str) -> int:
607    return max(1, positive_int(x))
608
609
610def tab_fade(x: str) -> Tuple[float, ...]:
611    return tuple(map(unit_float, x.split()))
612
613
614def tab_activity_symbol(x: str) -> Optional[str]:
615    if x == 'none':
616        return None
617    return tab_title_template(x) or None
618
619
620def tab_title_template(x: str) -> str:
621    if x:
622        for q in '\'"':
623            if x.startswith(q) and x.endswith(q):
624                x = x[1:-1]
625                break
626    return x
627
628
629def active_tab_title_template(x: str) -> Optional[str]:
630    x = tab_title_template(x)
631    return None if x == 'none' else x
632
633
634def config_or_absolute_path(x: str) -> Optional[str]:
635    if x.lower() == 'none':
636        return None
637    x = os.path.expanduser(x)
638    x = os.path.expandvars(x)
639    if not os.path.isabs(x):
640        x = os.path.join(config_dir, x)
641    return x
642
643
644def allow_remote_control(x: str) -> str:
645    if x != 'socket-only':
646        x = 'y' if to_bool(x) else 'n'
647    return x
648
649
650def clipboard_control(x: str) -> Tuple[str, ...]:
651    return tuple(x.lower().split())
652
653
654def allow_hyperlinks(x: str) -> int:
655    if x == 'ask':
656        return 0b11
657    return 1 if to_bool(x) else 0
658
659
660def macos_titlebar_color(x: str) -> int:
661    x = x.strip('"')
662    if x == 'system':
663        return 0
664    if x == 'background':
665        return 1
666    return (color_as_int(to_color(x)) << 8) | 2
667
668
669def macos_option_as_alt(x: str) -> int:
670    x = x.lower()
671    if x == 'both':
672        return 0b11
673    if x == 'left':
674        return 0b10
675    if x == 'right':
676        return 0b01
677    if to_bool(x):
678        return 0b11
679    return 0
680
681
682class TabBarMarginHeight(NamedTuple):
683    outer: float = 0
684    inner: float = 0
685
686    def __bool__(self) -> bool:
687        return (self.outer + self.inner) > 0
688
689
690def tab_bar_margin_height(x: str) -> TabBarMarginHeight:
691    parts = x.split(maxsplit=1)
692    if len(parts) != 2:
693        log_error(f'Invalid tab_bar_margin_height: {tab_bar_margin_height}, ignoring')
694        return TabBarMarginHeight()
695    ans = map(positive_float, parts)
696    return TabBarMarginHeight(next(ans), next(ans))
697
698
699def clear_all_mouse_actions(val: str, dict_with_parse_results: Optional[Dict[str, Any]] = None) -> bool:
700    ans = to_bool(val)
701    if ans and dict_with_parse_results is not None:
702        dict_with_parse_results['mouse_map'] = [None]
703    return ans
704
705
706def clear_all_shortcuts(val: str, dict_with_parse_results: Optional[Dict[str, Any]] = None) -> bool:
707    ans = to_bool(val)
708    if ans and dict_with_parse_results is not None:
709        dict_with_parse_results['map'] = [None]
710    return ans
711
712
713def font_features(val: str) -> Iterable[Tuple[str, Tuple[FontFeature, ...]]]:
714    if val == 'none':
715        return
716    parts = val.split()
717    if len(parts) < 2:
718        log_error("Ignoring invalid font_features {}".format(val))
719        return
720    if parts[0]:
721        features = []
722        for feat in parts[1:]:
723            try:
724                parsed = defines.parse_font_feature(feat)
725            except ValueError:
726                log_error('Ignoring invalid font feature: {}'.format(feat))
727            else:
728                features.append(FontFeature(feat, parsed))
729        yield parts[0], tuple(features)
730
731
732def env(val: str, current_val: Dict[str, str]) -> Iterable[Tuple[str, str]]:
733    key, val = val.partition('=')[::2]
734    key, val = key.strip(), val.strip()
735    if key:
736        yield key, expandvars(val, current_val)
737
738
739def kitten_alias(val: str) -> Iterable[Tuple[str, List[str]]]:
740    parts = val.split(maxsplit=2)
741    if len(parts) >= 2:
742        name = parts.pop(0)
743        yield name, parts
744
745
746def symbol_map(val: str) -> Iterable[Tuple[Tuple[int, int], str]]:
747    parts = val.split()
748
749    def abort() -> Dict[Tuple[int, int], str]:
750        log_error(f'Symbol map: {val} is invalid, ignoring')
751
752    if len(parts) < 2:
753        return abort()
754    family = ' '.join(parts[1:])
755
756    def to_chr(x: str) -> int:
757        if not x.startswith('U+'):
758            raise ValueError()
759        return int(x[2:], 16)
760
761    for x in parts[0].split(','):
762        a_, b_ = x.partition('-')[::2]
763        b_ = b_ or a_
764        try:
765            a, b = map(to_chr, (a_, b_))
766        except Exception:
767            return abort()
768        if b < a or max(a, b) > sys.maxunicode or min(a, b) < 1:
769            return abort()
770        yield (a, b), family
771
772
773def parse_key_action(action: str, action_type: str = 'map') -> Optional[KeyAction]:
774    parts = action.strip().split(maxsplit=1)
775    func = parts[0]
776    if len(parts) == 1:
777        return KeyAction(func, ())
778    rest = parts[1]
779    parser = args_funcs.get(func)
780    if parser is not None:
781        try:
782            func, args = parser(func, rest)
783        except Exception as err:
784            log_error(f'Ignoring invalid {action_type} action: {action} with err: {err}')
785        else:
786            return KeyAction(func, tuple(args))
787    else:
788        log_error(f'Ignoring unknown {action_type} action: {action}')
789    return None
790
791
792class BaseDefinition:
793    action: KeyAction
794
795    def resolve_kitten_aliases(self, aliases: Dict[str, List[str]]) -> KeyAction:
796        if not self.action.args or not aliases:
797            return self.action
798        kitten = self.action.args[0]
799        rest = str(self.action.args[1] if len(self.action.args) > 1 else '')
800        changed = False
801        for key, expanded in aliases.items():
802            if key == kitten:
803                changed = True
804                kitten = expanded[0]
805                if len(expanded) > 1:
806                    rest = expanded[1] + ' ' + rest
807        return self.action._replace(args=(kitten, rest.rstrip())) if changed else self.action
808
809
810class MouseMapping(BaseDefinition):
811
812    def __init__(self, button: int, mods: int, repeat_count: int, grabbed: bool, action: KeyAction):
813        self.button = button
814        self.mods = mods
815        self.repeat_count = repeat_count
816        self.grabbed = grabbed
817        self.action = action
818
819    def __repr__(self) -> str:
820        return f'MouseMapping({self.button}, {self.mods}, {self.repeat_count}, {self.grabbed}, {self.action})'
821
822    def resolve_and_copy(self, kitty_mod: int, aliases: Dict[str, List[str]]) -> 'MouseMapping':
823        return MouseMapping(self.button, defines.resolve_key_mods(kitty_mod, self.mods), self.repeat_count, self.grabbed, self.resolve_kitten_aliases(aliases))
824
825    @property
826    def trigger(self) -> MouseEvent:
827        return MouseEvent(self.button, self.mods, self.repeat_count, self.grabbed)
828
829
830class KeyDefinition(BaseDefinition):
831
832    def __init__(self, is_sequence: bool, action: KeyAction, mods: int, is_native: bool, key: int, rest: Tuple[SingleKey, ...] = ()):
833        self.is_sequence = is_sequence
834        self.action = action
835        self.trigger = SingleKey(mods, is_native, key)
836        self.rest = rest
837
838    def __repr__(self) -> str:
839        return f'KeyDefinition({self.is_sequence}, {self.action}, {self.trigger.mods}, {self.trigger.is_native}, {self.trigger.key}, {self.rest})'
840
841    def resolve_and_copy(self, kitty_mod: int, aliases: Dict[str, List[str]]) -> 'KeyDefinition':
842        def r(k: SingleKey) -> SingleKey:
843            mods = defines.resolve_key_mods(kitty_mod, k.mods)
844            return k._replace(mods=mods)
845        return KeyDefinition(
846            self.is_sequence, self.resolve_kitten_aliases(aliases),
847            defines.resolve_key_mods(kitty_mod, self.trigger.mods),
848            self.trigger.is_native, self.trigger.key, tuple(map(r, self.rest)))
849
850
851def parse_map(val: str) -> Iterable[KeyDefinition]:
852    parts = val.split(maxsplit=1)
853    if len(parts) != 2:
854        return
855    sc, action = parts
856    sc, action = sc.strip().strip(sequence_sep), action.strip()
857    if not sc or not action:
858        return
859    is_sequence = sequence_sep in sc
860    if is_sequence:
861        trigger: Optional[SingleKey] = None
862        restl: List[SingleKey] = []
863        for part in sc.split(sequence_sep):
864            try:
865                mods, is_native, key = parse_shortcut(part)
866            except InvalidMods:
867                return
868            if key == 0:
869                if mods is not None:
870                    log_error('Shortcut: {} has unknown key, ignoring'.format(sc))
871                return
872            if trigger is None:
873                trigger = SingleKey(mods, is_native, key)
874            else:
875                restl.append(SingleKey(mods, is_native, key))
876        rest = tuple(restl)
877    else:
878        try:
879            mods, is_native, key = parse_shortcut(sc)
880        except InvalidMods:
881            return
882        if key == 0:
883            if mods is not None:
884                log_error('Shortcut: {} has unknown key, ignoring'.format(sc))
885            return
886    try:
887        paction = parse_key_action(action)
888    except Exception:
889        log_error('Invalid shortcut action: {}. Ignoring.'.format(
890            action))
891    else:
892        if paction is not None:
893            if is_sequence:
894                if trigger is not None:
895                    yield KeyDefinition(True, paction, trigger[0], trigger[1], trigger[2], rest)
896            else:
897                assert key is not None
898                yield KeyDefinition(False, paction, mods, is_native, key)
899
900
901def parse_mouse_map(val: str) -> Iterable[MouseMapping]:
902    parts = val.split(maxsplit=3)
903    if len(parts) != 4:
904        log_error(f'Ignoring invalid mouse action: {val}')
905        return
906    xbutton, event, modes, action = parts
907    kparts = xbutton.split('+')
908    if len(kparts) > 1:
909        mparts, obutton = kparts[:-1], kparts[-1].lower()
910        mods = parse_mods(mparts, obutton)
911        if mods is None:
912            return
913    else:
914        obutton = parts[0].lower()
915        mods = 0
916    try:
917        b = {'left': 'b1', 'middle': 'b3', 'right': 'b2'}.get(obutton, obutton)[1:]
918        button = getattr(defines, f'GLFW_MOUSE_BUTTON_{b}')
919    except Exception:
920        log_error(f'Mouse button: {xbutton} not recognized, ignoring')
921        return
922    try:
923        count = {'doubleclick': -3, 'click': -2, 'release': -1, 'press': 1, 'doublepress': 2, 'triplepress': 3}[event.lower()]
924    except KeyError:
925        log_error(f'Mouse event type: {event} not recognized, ignoring')
926        return
927    specified_modes = frozenset(modes.lower().split(','))
928    if specified_modes - {'grabbed', 'ungrabbed'}:
929        log_error(f'Mouse modes: {modes} not recognized, ignoring')
930        return
931    try:
932        paction = parse_key_action(action, 'mouse_map')
933    except Exception:
934        log_error(f'Invalid mouse action: {action}. Ignoring.')
935        return
936    if paction is None:
937        return
938    for mode in sorted(specified_modes):
939        yield MouseMapping(button, mods, count, mode == 'grabbed', paction)
940
941
942def deprecated_hide_window_decorations_aliases(key: str, val: str, ans: Dict[str, Any]) -> None:
943    if not hasattr(deprecated_hide_window_decorations_aliases, key):
944        setattr(deprecated_hide_window_decorations_aliases, key, True)
945        log_error('The option {} is deprecated. Use hide_window_decorations instead.'.format(key))
946    if to_bool(val):
947        if is_macos and key == 'macos_hide_titlebar' or (not is_macos and key == 'x11_hide_window_decorations'):
948            ans['hide_window_decorations'] = True
949
950
951def deprecated_macos_show_window_title_in_menubar_alias(key: str, val: str, ans: Dict[str, Any]) -> None:
952    if not hasattr(deprecated_macos_show_window_title_in_menubar_alias, key):
953        setattr(deprecated_macos_show_window_title_in_menubar_alias, 'key', True)
954        log_error('The option {} is deprecated. Use macos_show_window_title_in menubar instead.'.format(key))
955    macos_show_window_title_in = ans.get('macos_show_window_title_in', 'all')
956    if to_bool(val):
957        if macos_show_window_title_in == 'none':
958            macos_show_window_title_in = 'menubar'
959        elif macos_show_window_title_in == 'window':
960            macos_show_window_title_in = 'all'
961    else:
962        if macos_show_window_title_in == 'all':
963            macos_show_window_title_in = 'window'
964        elif macos_show_window_title_in == 'menubar':
965            macos_show_window_title_in = 'none'
966    ans['macos_show_window_title_in'] = macos_show_window_title_in
967
968
969def deprecated_send_text(key: str, val: str, ans: Dict[str, Any]) -> None:
970    parts = val.split(' ')
971
972    def abort(msg: str) -> None:
973        log_error('Send text: {} is invalid ({}), ignoring'.format(
974            val, msg))
975
976    if len(parts) < 3:
977        return abort('Incomplete')
978    mode, sc = parts[:2]
979    text = ' '.join(parts[2:])
980    key_str = '{} send_text {} {}'.format(sc, mode, text)
981    for k in parse_map(key_str):
982        ans['map'].append(k)
983