#!/usr/bin/env python # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal import ast import json import os import re import shlex import sys import tempfile from collections import namedtuple from . import fast_data_types as defines from .constants import config_dir from .fast_data_types import CURSOR_BEAM, CURSOR_BLOCK, CURSOR_UNDERLINE from .layout import all_layouts from .rgb import to_color from .utils import safe_print key_pat = re.compile(r'([a-zA-Z][a-zA-Z0-9_-]*)\s+(.+)$') MINIMUM_FONT_SIZE = 4 def to_font_size(x): return max(MINIMUM_FONT_SIZE, float(x)) cshapes = { 'block': CURSOR_BLOCK, 'beam': CURSOR_BEAM, 'underline': CURSOR_UNDERLINE } def to_cursor_shape(x): try: return cshapes[x.lower()] except KeyError: raise ValueError( 'Invalid cursor shape: {} allowed values are {}'.format( x, ', '.join(cshapes) ) ) def to_bool(x): return x.lower() in 'y yes true'.split() def parse_mods(parts): def map_mod(m): return {'CTRL': 'CONTROL', 'CMD': 'CONTROL'}.get(m, m) mods = 0 for m in parts: try: mods |= getattr(defines, 'GLFW_MOD_' + map_mod(m.upper())) except AttributeError: safe_print( 'Shortcut: {} has an unknown modifier, ignoring'.format( parts.join('+') ), file=sys.stderr ) return return mods named_keys = { "'": 'APOSTROPHE', ',': 'COMMA', '-': 'MINUS', '.': 'PERIOD', '/': 'SLASH', ';': 'SEMICOLON', '=': 'EQUAL', '[': 'LEFT_BRACKET', ']': 'RIGHT_BRACKET', '`': 'GRAVE_ACCENT' } def parse_shortcut(sc): parts = sc.split('+') mods = parse_mods(parts[:-1]) key = parts[-1].upper() key = getattr(defines, 'GLFW_KEY_' + named_keys.get(key, key), None) if key is not None: return mods, key return None, None KeyAction = namedtuple('KeyAction', 'func args') shlex_actions = {'pass_selection_to_program', 'new_window', 'new_tab'} def parse_key_action(action): parts = action.split(' ', 1) func = parts[0] if len(parts) == 1: return KeyAction(func, ()) rest = parts[1] if func == 'combine': sep, rest = rest.split(' ', 1) parts = re.split(r'\s*' + re.escape(sep) + r'\s*', rest) args = tuple(map(parse_key_action, filter(None, parts))) elif func == 'send_text': args = rest.split(' ', 1) elif func == 'goto_tab': args = (max(0, int(rest)),) elif func in shlex_actions: args = shlex.split(rest) return KeyAction(func, args) def parse_key(val, keymap): sc, action = val.partition(' ')[::2] sc, action = sc.strip(), action.strip() if not sc or not action: return mods, key = parse_shortcut(sc) if key is None: safe_print( 'Shortcut: {} has an unknown key, ignoring'.format(val), file=sys.stderr ) return try: paction = parse_key_action(action) except Exception: safe_print( 'Invalid shortcut action: {}. Ignoring.'.format(action), file=sys.stderr ) else: if paction is not None: keymap[(mods, key)] = paction def parse_symbol_map(val): parts = val.split(' ') symbol_map = {} def abort(): safe_print( 'Symbol map: {} is invalid, ignoring'.format(val), file=sys.stderr ) return {} if len(parts) < 2: return abort() family = ' '.join(parts[1:]) def to_chr(x): if not x.startswith('U+'): raise ValueError() x = int(x[2:], 16) return x for x in parts[0].split(','): a, b = x.partition('-')[::2] b = b or a try: a, b = map(to_chr, (a, b)) except Exception: return abort() if b < a or max(a, b) > sys.maxunicode or min(a, b) < 1: return abort() symbol_map[(a, b)] = family return symbol_map def parse_send_text_bytes(text): return ast.literal_eval("'''" + text + "'''").encode('utf-8') def parse_send_text(val, keymap): parts = val.split(' ') def abort(msg): safe_print( 'Send text: {} is invalid ({}), ignoring'.format(val, msg), file=sys.stderr ) return {} if len(parts) < 3: return abort('Incomplete') mode, sc = parts[:2] text = ' '.join(parts[2:]) key_str = '{} send_text {} {}'.format(sc, mode, text) return parse_key(key_str, keymap) def to_open_url_modifiers(val): return parse_mods(val.split('+')) def to_layout_names(raw): parts = [x.strip().lower() for x in raw.split(',')] if '*' in parts: return sorted(all_layouts) for p in parts: if p not in all_layouts: raise ValueError('The window layout {} is unknown'.format(p)) return parts def positive_int(x): return max(0, int(x)) def positive_float(x): return max(0, float(x)) def unit_float(x): return max(0, min(float(x), 1)) def adjust_line_height(x): if x.endswith('%'): return float(x[:-1].strip()) / 100.0 return int(x) def box_drawing_scale(x): ans = tuple(float(x.strip()) for x in x.split(',')) if len(ans) != 4: raise ValueError('Invalid box_drawing scale, must have four entries') return ans def tab_separator(x): for q in '\'"': if x.startswith(q) and x.endswith(q): x = x[1:-1] break if not x.strip(): x = defaults.tab_separator return x def tab_font_style(x): return {'bold-italic': (True, True), 'bold': (True, False), 'italic': (False, True)}.get(x.lower().replace('_', '-'), (False, False)) def url_style(x): return url_style.map.get(x, url_style.map['curly']) url_style.map = dict(((v, i) for i, v in enumerate('none single double curly'.split()))) type_map = { 'adjust_line_height': adjust_line_height, 'scrollback_lines': positive_int, 'scrollback_pager': shlex.split, 'scrollback_in_new_tab': to_bool, 'font_size': to_font_size, 'font_size_delta': positive_float, 'focus_follows_mouse': to_bool, 'cursor_shape': to_cursor_shape, 'open_url_modifiers': to_open_url_modifiers, 'repaint_delay': positive_int, 'input_delay': positive_int, 'window_border_width': positive_float, 'window_margin_width': positive_float, 'window_padding_width': positive_float, 'wheel_scroll_multiplier': float, 'visual_bell_duration': positive_float, 'enable_audio_bell': to_bool, 'click_interval': positive_float, 'mouse_hide_wait': positive_float, 'cursor_blink_interval': positive_float, 'cursor_stop_blinking_after': positive_float, 'enabled_layouts': to_layout_names, 'remember_window_size': to_bool, 'initial_window_width': positive_int, 'initial_window_height': positive_int, 'use_system_wcwidth': to_bool, 'macos_hide_titlebar': to_bool, 'macos_option_as_alt': to_bool, 'box_drawing_scale': box_drawing_scale, 'x11_bell_volume': int, 'background_opacity': unit_float, 'tab_separator': tab_separator, 'active_tab_font_style': tab_font_style, 'inactive_tab_font_style': tab_font_style, 'url_style': url_style, } for name in ( 'foreground background cursor active_border_color inactive_border_color' ' selection_foreground selection_background url_color' ).split(): type_map[name] = lambda x: to_color(x, validate=True) for i in range(16): type_map['color%d' % i] = lambda x: to_color(x, validate=True) for a in ('active', 'inactive'): for b in ('foreground', 'background'): type_map['%s_tab_%s' % (a, b)] = lambda x: to_color(x, validate=True) def parse_config(lines, check_keys=True): ans = { 'keymap': {}, 'symbol_map': {}, } if check_keys: all_keys = defaults._asdict() for line in lines: line = line.strip() if not line or line.startswith('#'): continue m = key_pat.match(line) if m is not None: key, val = m.groups() if key == 'map': parse_key(val, ans['keymap']) continue if key == 'symbol_map': ans['symbol_map'].update(parse_symbol_map(val)) continue if key == 'send_text': # For legacy compatibility parse_send_text(val, ans['keymap']) continue if check_keys: if key not in all_keys: safe_print( 'Ignoring unknown config key: {}'.format(key), file=sys.stderr ) continue tm = type_map.get(key) if tm is not None: val = tm(val) ans[key] = val return ans with open( os.path.join(os.path.dirname(os.path.abspath(__file__)), 'kitty.conf') ) as f: defaults = parse_config(f.readlines(), check_keys=False) Options = namedtuple('Defaults', ','.join(defaults.keys())) defaults = Options(**defaults) actions = frozenset(a.func for a in defaults.keymap.values()) | frozenset({'combine', 'send_text', 'goto_tab'}) no_op_actions = frozenset({'noop', 'no-op', 'no_op'}) def merge_keymaps(defaults, newvals): ans = defaults.copy() for k, v in newvals.items(): f = v.func if f in no_op_actions: ans.pop(k, None) continue if f in actions: ans[k] = v return ans def merge_dicts(defaults, newvals): ans = defaults.copy() ans.update(newvals) return ans def merge_configs(defaults, vals): ans = {} for k, v in defaults.items(): if isinstance(v, dict): newvals = vals.get(k, {}) if k == 'keymap': ans['keymap'] = merge_keymaps(v, newvals) else: ans[k] = merge_dicts(v, newvals) else: ans[k] = vals.get(k, v) return ans def load_config(*paths, overrides=None) -> Options: ans = defaults._asdict() for path in paths: if not path: continue try: f = open(path) except FileNotFoundError: continue with f: vals = parse_config(f) ans = merge_configs(ans, vals) if overrides is not None: vals = parse_config(overrides) ans = merge_configs(ans, vals) return Options(**ans) def build_ansi_color_table(opts: Options = defaults): def as_int(x): return (x[0] << 16) | (x[1] << 8) | x[2] def col(i): return as_int(getattr(opts, 'color{}'.format(i))) return list(map(col, range(16))) cached_values = {} cached_path = os.path.join(config_dir, 'cached.json') def load_cached_values(): cached_values.clear() try: with open(cached_path, 'rb') as f: cached_values.update(json.loads(f.read().decode('utf-8'))) except FileNotFoundError: pass except Exception as err: safe_print( 'Failed to load cached values with error: {}'.format(err), file=sys.stderr ) def save_cached_values(): fd, p = tempfile.mkstemp( dir=os.path.dirname(cached_path), suffix='cached.json.tmp' ) try: with os.fdopen(fd, 'wb') as f: f.write(json.dumps(cached_values).encode('utf-8')) os.rename(p, cached_path) except Exception as err: safe_print( 'Failed to save cached values with error: {}'.format(err), file=sys.stderr ) finally: try: os.remove(p) except FileNotFoundError: pass except Exception as err: safe_print( 'Failed to delete temp file for saved cached values with error: {}'. format(err), file=sys.stderr ) def initial_window_size(opts): w, h = opts.initial_window_width, opts.initial_window_height if 'window-size' in cached_values and opts.remember_window_size: ws = cached_values['window-size'] try: w, h = map(int, ws) except Exception: safe_print('Invalid cached window size, ignoring', file=sys.stderr) return w, h