Files
kitty-mirror/kitty/options/utils.py
2025-08-07 09:15:12 +05:30

1753 lines
56 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
import enum
import re
import sys
from collections import defaultdict
from collections.abc import Callable, Container, Iterable, Iterator, Sequence
from contextlib import suppress
from dataclasses import dataclass, fields
from functools import lru_cache
from typing import (
Any,
Generic,
Literal,
NamedTuple,
TypeVar,
cast,
get_args,
)
import kitty.fast_data_types as defines
from kitty.conf.utils import (
CurrentlyParsing,
KeyAction,
KeyFuncWrapper,
currently_parsing,
number_with_unit,
percent,
positive_float,
positive_int,
python_string,
to_bool,
to_cmdline,
to_color,
uniq,
unit_float,
)
from kitty.constants import is_macos
from kitty.fast_data_types import CURSOR_BEAM, CURSOR_BLOCK, CURSOR_HOLLOW, CURSOR_UNDERLINE, NO_CURSOR_SHAPE, Color, Shlex, SingleKey
from kitty.fonts import FontModification, FontSpec, ModificationType, ModificationUnit, ModificationValue
from kitty.key_names import character_key_name_aliases, functional_key_name_aliases, get_key_name_lookup
from kitty.rgb import color_as_int
from kitty.types import FloatEdges, MouseEvent
from kitty.utils import expandvars, log_error, resolve_abs_or_config_path, shlex_split
KeyMap = dict[SingleKey, list['KeyDefinition']]
MouseMap = dict[MouseEvent, str]
KeySequence = tuple[SingleKey, ...]
MINIMUM_FONT_SIZE = 4
default_tab_separator = ''
mod_map = {'': 'CONTROL', 'CTRL': 'CONTROL', '': 'SHIFT', '': 'ALT', 'OPTION': 'ALT', 'OPT': 'ALT',
'': 'SUPER', 'COMMAND': 'SUPER', 'CMD': 'SUPER', 'KITTY_MOD': 'KITTY'}
character_key_name_aliases_with_ascii_lowercase: dict[str, str] = character_key_name_aliases.copy()
for x in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
character_key_name_aliases_with_ascii_lowercase[x] = x.lower()
sequence_sep = '>'
mouse_button_map = {'left': 'b1', 'middle': 'b3', 'right': 'b2'}
mouse_trigger_count_map = {'doubleclick': -3, 'click': -2, 'release': -1, 'press': 1, 'doublepress': 2, 'triplepress': 3}
FuncArgsType = tuple[str, Sequence[Any]]
func_with_args = KeyFuncWrapper[FuncArgsType]()
DELETE_ENV_VAR = '_delete_this_env_var_'
class MapType(enum.Enum):
MAP = 'map'
MOUSE_MAP = 'mouse_map'
OPEN_ACTION = 'open_action'
class InvalidMods(ValueError):
pass
# Actions {{{
@func_with_args(
'pass_selection_to_program', 'new_window', 'new_tab', 'new_os_window',
'new_window_with_cwd', 'new_tab_with_cwd', 'new_os_window_with_cwd',
'launch', 'mouse_handle_click', 'show_error',
)
def shlex_parse(func: str, rest: str) -> FuncArgsType:
return func, to_cmdline(rest)
def parse_send_text_bytes(text: str) -> bytes:
return defines.expand_ansi_c_escapes(text).encode('utf-8')
@func_with_args('scroll_prompt_to_top')
def scroll_prompt_to_top(func: str, rest: str) -> FuncArgsType:
return func, [to_bool(rest) if rest else False]
@func_with_args('send_text')
def send_text_parse(func: str, rest: str) -> FuncArgsType:
args = rest.split(maxsplit=1)
mode = ''
data = b''
if len(args) > 1:
mode = args[0]
try:
data = parse_send_text_bytes(args[1])
except Exception:
log_error('Ignoring invalid send_text string: ' + args[1])
return func, [mode, data]
@func_with_args('send_key')
def send_key(func: str, rest: str) -> FuncArgsType:
return func, rest.split()
@func_with_args('run_kitten', 'run_simple_kitten', 'kitten')
def kitten_parse(func: str, rest: str) -> FuncArgsType:
parts = to_cmdline(rest)
if func == 'kitten':
return func, parts
return 'kitten', parts[1:]
@func_with_args('open_url')
def open_url_parse(func: str, rest: str) -> FuncArgsType:
from urllib.parse import urlparse
url = ''
try:
url = python_string(rest)
tokens = urlparse(url)
if not all((tokens.scheme, tokens.netloc,)):
raise ValueError('Invalid URL')
except Exception:
log_error('Ignoring invalid URL string: ' + rest)
return func, (url,)
@func_with_args('goto_tab')
def goto_tab_parse(func: str, rest: str) -> FuncArgsType:
n = int(rest)
if n < 0:
n += 1 # goto_tab subtracts 1 from its argument, this maps both zero and -1 to previous tab for backwards compat.
return func, (n,)
@func_with_args('detach_window')
def detach_window_parse(func: str, rest: str) -> FuncArgsType:
if rest not in ('new', 'new-tab', 'new-tab-left', 'new-tab-right', 'ask', 'tab-prev', 'tab-left', 'tab-right'):
log_error(f'Ignoring invalid detach_window argument: {rest}')
rest = 'new'
return func, (rest,)
@func_with_args('close_window_with_confirmation')
def close_window_with_confirmation(func: str, rest: str) -> FuncArgsType:
ignore_shell = rest == 'ignore-shell'
return func, (ignore_shell,)
@func_with_args('detach_tab')
def detach_tab_parse(func: str, rest: str) -> FuncArgsType:
if rest not in ('new', 'ask'):
log_error(f'Ignoring invalid detach_tab argument: {rest}')
rest = 'new'
return func, (rest,)
@func_with_args(
'set_background_opacity', 'goto_layout', 'toggle_layout', 'toggle_tab', 'kitty_shell', 'show_kitty_doc',
'set_tab_title', 'push_keyboard_mode', 'dump_lines_with_attrs', 'set_window_title', 'simulate_color_scheme_preference_change',
)
def simple_parse(func: str, rest: str) -> FuncArgsType:
return func, (rest,)
@func_with_args('set_font_size')
def float_parse(func: str, rest: str) -> FuncArgsType:
return func, (float(rest),)
@func_with_args('signal_child')
def signal_child_parse(func: str, rest: str) -> FuncArgsType:
import signal
signals = []
for q in rest.split():
try:
signum = getattr(signal, q.upper())
except AttributeError:
log_error(f'Unknown signal: {rest} ignoring')
else:
signals.append(signum)
return func, tuple(signals)
@func_with_args('change_font_size')
def parse_change_font_size(func: str, rest: str) -> tuple[str, tuple[bool, str | None, float]]:
vals = rest.strip().split(maxsplit=1)
if len(vals) != 2:
log_error(f'Invalid change_font_size specification: {rest}, treating it as default')
return func, (True, None, 0)
c_all = vals[0].lower() == 'all'
sign: str | None = None
amt = vals[1]
if amt[0] in '+-*/':
sign = amt[0]
amt = amt[1:]
return func, (c_all, sign, float(amt.strip()))
@func_with_args('clear_terminal')
def clear_terminal(func: str, rest: str) -> FuncArgsType:
vals = rest.strip().split(maxsplit=1)
if len(vals) != 2:
log_error('clear_terminal needs two arguments, using defaults')
args = ['reset', True]
else:
action = vals[0].lower()
if action not in ('reset', 'scroll', 'scrollback', 'clear', 'to_cursor', 'to_cursor_scroll', 'last_command'):
log_error(f'{action} is unknown for clear_terminal, using reset')
action = 'reset'
args = [action, vals[1].lower() == 'active']
return func, args
@func_with_args('copy_to_buffer')
def copy_to_buffer(func: str, rest: str) -> FuncArgsType:
return func, [rest]
@func_with_args('paste_from_buffer')
def paste_from_buffer(func: str, rest: str) -> FuncArgsType:
return func, [rest]
@func_with_args('paste')
def paste_parse(func: str, rest: str) -> FuncArgsType:
text = ''
try:
text = defines.expand_ansi_c_escapes(rest)
except Exception:
log_error('Ignoring invalid paste string: ' + rest)
return func, [text]
@func_with_args('neighboring_window')
def neighboring_window(func: str, rest: str) -> FuncArgsType:
rest = rest.lower()
rest = {'up': 'top', 'down': 'bottom'}.get(rest, rest)
if rest not in ('left', 'right', 'top', 'bottom'):
log_error(f'Invalid neighbor specification: {rest}')
rest = 'right'
return func, [rest]
@func_with_args('resize_window')
def resize_window(func: str, rest: str) -> FuncArgsType:
vals = rest.strip().split(maxsplit=1)
if len(vals) > 2:
log_error('resize_window needs one or two arguments, using defaults')
args = ['wider', 1]
else:
quality = vals[0].lower()
if quality not in ('reset', 'taller', 'shorter', 'wider', 'narrower'):
log_error(f'Invalid quality specification: {quality}')
quality = 'wider'
increment = 1
if len(vals) == 2:
try:
increment = int(vals[1])
except Exception:
log_error(f'Invalid increment specification: {vals[1]}')
args = [quality, increment]
return func, args
@func_with_args('move_window')
def move_window(func: str, rest: str) -> FuncArgsType:
rest = rest.lower()
rest = {'up': 'top', 'down': 'bottom'}.get(rest, rest)
prest: int | str = rest
try:
prest = int(prest)
except Exception:
if prest not in ('left', 'right', 'top', 'bottom'):
log_error(f'Invalid move_window specification: {rest}')
prest = 0
return func, [prest]
@func_with_args('pipe')
def pipe(func: str, rest: str) -> FuncArgsType:
r = list(shlex_split(rest))
if len(r) < 3:
log_error('Too few arguments to pipe function')
r = ['none', 'none', 'true']
return func, r
@func_with_args('set_colors')
def set_colors(func: str, rest: str) -> FuncArgsType:
r = list(shlex_split(rest))
if len(r) < 1:
log_error('Too few arguments to set_colors function')
return func, r
@func_with_args('remote_control')
def remote_control(func: str, rest: str) -> FuncArgsType:
func, args = shlex_parse(func, rest)
if len(args) < 1:
log_error('Too few arguments to remote_control function')
return func, args
@func_with_args('remote_control_script')
def remote_control_script(func: str, rest: str) -> FuncArgsType:
func, args = shlex_parse(func, rest)
if len(args) < 1:
log_error('Too few arguments to remote_control_script function')
return func, args
@func_with_args('nth_os_window', 'nth_window', 'visual_window_select_action_trigger', 'next_layout')
def single_integer_arg(func: str, rest: str) -> FuncArgsType:
try:
num = int(rest)
except Exception:
if rest:
log_error(f'Invalid number for {func}: {rest}')
num = 1
return func, [num]
@func_with_args('scroll_to_prompt')
def scroll_to_prompt(func: str, rest: str) -> FuncArgsType:
vals = rest.strip().split()
args = [-1, 0]
if len(vals) > 2:
log_error('scroll_to_prompt needs one or two arguments, using defaults')
else:
try:
args[0] = int(vals[0])
except Exception:
log_error(f'{vals[0]} is not a valid number of prompts to jump for scroll_to_prompt')
if len(vals) == 2:
try:
args[1] = int(vals[1])
except Exception:
log_error(f'{vals[1]} is not a valid scroll offset for scroll_to_prompt')
return func, args
@func_with_args('sleep')
def sleep(func: str, sleep_time: str) -> FuncArgsType:
mult = 1
sleep_time = sleep_time or '1'
if sleep_time[-1] in 'shmd':
mult = {'s': 1, 'm': 60, 'h': 3600, 'd': 24 * 3600}[sleep_time[-1]]
sleep_time = sleep_time[:-1]
return func, [abs(float(sleep_time)) * mult]
@func_with_args('disable_ligatures_in')
def disable_ligatures_in(func: str, rest: str) -> FuncArgsType:
parts = rest.split(maxsplit=1)
if len(parts) == 1:
where, strategy = 'active', parts[0]
else:
where, strategy = parts
if where not in ('active', 'all', 'tab'):
raise ValueError(f'{where} is not a valid set of windows to disable ligatures in')
if strategy not in ('never', 'always', 'cursor'):
raise ValueError(f'{strategy} is not a valid disable ligatures strategy')
return func, [where, strategy]
@func_with_args('layout_action')
def layout_action(func: str, rest: str) -> FuncArgsType:
parts = rest.split(maxsplit=1)
if not parts:
raise ValueError('layout_action must have at least one argument')
return func, [parts[0], tuple(parts[1:])]
def parse_marker_spec(ftype: str, parts: Sequence[str]) -> tuple[str, str | tuple[tuple[int, str], ...], int]:
flags = re.UNICODE
if ftype in ('text', 'itext', 'regex', 'iregex'):
if ftype.startswith('i'):
flags |= re.IGNORECASE
if not parts or len(parts) % 2 != 0:
raise ValueError('Mark group number and text/regex are not specified in pairs: {}'.format(' '.join(parts)))
ans = []
for i in range(0, len(parts), 2):
try:
color = max(1, min(int(parts[i]), 3))
except Exception:
raise ValueError(f'Mark group in marker specification is not an integer: {parts[i]}')
sspec = parts[i + 1]
if 'regex' not in ftype:
sspec = re.escape(sspec)
ans.append((color, sspec))
ftype = 'regex'
spec: str | tuple[tuple[int, str], ...] = tuple(ans)
elif ftype == 'function':
spec = ' '.join(parts)
else:
raise ValueError(f'Unknown marker type: {ftype}')
return ftype, spec, flags
@func_with_args('toggle_marker')
def toggle_marker(func: str, rest: str) -> FuncArgsType:
parts = rest.split(maxsplit=1)
if len(parts) != 2:
raise ValueError(f'{rest} is not a valid marker specification')
ftype, spec = parts
parts = list(shlex_split(spec))
return func, list(parse_marker_spec(ftype, parts))
@func_with_args('scroll_to_mark')
def scroll_to_mark(func: str, rest: str) -> FuncArgsType:
parts = rest.split()
if not parts or not rest:
return func, [True, 0]
if len(parts) == 1:
q = parts[0].lower()
if q in ('prev', 'previous', 'next'):
return func, [q != 'next', 0]
try:
return func, [True, max(0, min(int(q), 3))]
except Exception:
raise ValueError(f'{rest} is not a valid scroll_to_mark destination')
return func, [parts[0] != 'next', max(0, min(int(parts[1]), 3))]
@func_with_args('mouse_selection')
def mouse_selection(func: str, rest: str) -> FuncArgsType:
cmap = getattr(mouse_selection, 'code_map', None)
if cmap is None:
cmap = {
'normal': defines.MOUSE_SELECTION_NORMAL,
'extend': defines.MOUSE_SELECTION_EXTEND,
'move-end': defines.MOUSE_SELECTION_MOVE_END,
'rectangle': defines.MOUSE_SELECTION_RECTANGLE,
'word': defines.MOUSE_SELECTION_WORD,
'line': defines.MOUSE_SELECTION_LINE,
'line_from_point': defines.MOUSE_SELECTION_LINE_FROM_POINT,
'word_and_line_from_point': defines.MOUSE_SELECTION_WORD_AND_LINE_FROM_POINT,
'upto_surrounding_whitespace': defines.MOUSE_SELECTION_UPTO_SURROUNDING_WHITESPACE,
}
setattr(mouse_selection, 'code_map', cmap)
return func, [cmap[rest]]
@func_with_args('load_config_file')
def load_config_file(func: str, rest: str) -> FuncArgsType:
return func, list(shlex_split(rest))
# }}}
def parse_mods(parts: Iterable[str], sc: str) -> int | None:
def map_mod(m: str) -> str:
return mod_map.get(m, m)
mods = 0
for m in parts:
try:
mods |= getattr(defines, f'GLFW_MOD_{map_mod(m.upper())}')
except AttributeError:
if m.upper() != 'NONE':
log_error(f'Shortcut: {sc} has unknown modifier, ignoring')
return None
return mods
def to_modifiers(val: str) -> int:
return parse_mods(val.split('+'), val) or 0
def parse_shortcut(sc: str) -> SingleKey:
if sc.endswith('+') and len(sc) > 1:
sc = f'{sc[:-1]}plus'
parts = sc.split('+')
mods = 0
if len(parts) > 1:
mods = parse_mods(parts[:-1], sc) or 0
if not mods:
raise InvalidMods('Invalid shortcut')
q = parts[-1]
q = character_key_name_aliases_with_ascii_lowercase.get(q.upper(), q)
is_native = False
if q.startswith('0x'):
try:
key = int(q, 16)
except Exception:
key = 0
else:
is_native = True
else:
try:
key = ord(q)
except Exception:
uq = q.upper()
uq = functional_key_name_aliases.get(uq, uq)
x: int | None = getattr(defines, f'GLFW_FKEY_{uq}', None)
if x is None:
lf = get_key_name_lookup()
key = lf(q, False) or 0
is_native = key > 0
else:
key = x
return SingleKey(mods, is_native, key or 0)
def to_font_size(x: str) -> float:
return max(MINIMUM_FONT_SIZE, float(x))
def disable_ligatures(x: str) -> int:
cmap = {'never': 0, 'cursor': 1, 'always': 2}
return cmap.get(x.lower(), 0)
def box_drawing_scale(x: str) -> tuple[float, float, float, float]:
ans = tuple(float(q.strip()) for q in x.split(','))
if len(ans) != 4:
raise ValueError('Invalid box_drawing scale, must have four entries')
return ans[0], ans[1], ans[2], ans[3]
def cursor_text_color(x: str) -> Color | None:
if x.lower() == 'background':
return None
return to_color(x)
cshapes = {
'block': CURSOR_BLOCK,
'beam': CURSOR_BEAM,
'underline': CURSOR_UNDERLINE
}
cshapes_unfocused = {
'block': CURSOR_BLOCK,
'beam': CURSOR_BEAM,
'underline': CURSOR_UNDERLINE,
'hollow': CURSOR_HOLLOW,
'unchanged': NO_CURSOR_SHAPE,
}
def to_cursor_shape(x: str) -> int:
try:
return cshapes[x.lower()]
except KeyError:
raise ValueError(
'Invalid cursor shape: {} allowed values are {}'.format(
x, ', '.join(cshapes)
)
)
def to_cursor_unfocused_shape(x: str) -> int:
try:
return cshapes_unfocused[x.lower()]
except KeyError:
raise ValueError(
'Invalid unfocused cursor shape: {} allowed values are {}'.format(
x, ', '.join(cshapes_unfocused)
)
)
def cursor_trail_decay(x: str) -> tuple[float, float]:
fast, slow = map(positive_float, x.split())
slow = max(slow, fast)
return fast, slow
def scrollback_lines(x: str) -> int:
ans = int(x)
if ans < 0:
ans = 2 ** 32 - 1
return ans
def scrollback_pager_history_size(x: str) -> int:
ans = int(max(0, float(x)) * 1024 * 1024)
return min(ans, 4096 * 1024 * 1024 - 1)
# "single" for backwards compat
url_style_map = {'none': 0, 'single': 1, 'straight': 1, 'double': 2, 'curly': 3, 'dotted': 4, 'dashed': 5}
def url_style(x: str) -> int:
return url_style_map.get(x, url_style_map['curly'])
def url_prefixes(x: str) -> tuple[str, ...]:
return tuple(a.lower() for a in x.replace(',', ' ').split())
def copy_on_select(raw: str) -> str:
q = raw.lower()
# boolean values special cased for backwards compat
if q in ('y', 'yes', 'true', 'clipboard'):
return 'clipboard'
if q in ('n', 'no', 'false', ''):
return ''
return raw
def window_size(val: str) -> tuple[int, str]:
val = val.lower()
unit = 'cells' if val.endswith('c') else 'px'
return positive_int(val.rstrip('c')), unit
def parse_layout_names(parts: Iterable[str]) -> list[str]:
from kitty.layout.interface import all_layouts
ans = []
for p in parts:
p = p.lower()
if p in ('*', 'all'):
ans.extend(sorted(all_layouts))
continue
name = p.partition(':')[0]
if name not in all_layouts:
raise ValueError(f'The window layout {p} is unknown')
ans.append(p)
return uniq(ans)
def to_layout_names(raw: str) -> list[str]:
return parse_layout_names(x.strip() for x in raw.split(','))
def window_border_width(x: str | int | float) -> tuple[float, str]:
unit = 'pt'
if isinstance(x, str):
trailer = x[-2:]
if trailer in ('px', 'pt'):
unit = trailer
val = float(x[:-2])
else:
val = float(x)
else:
val = float(x)
return max(0, val), unit
def edge_width(x: str, converter: Callable[[str], float] = positive_float) -> FloatEdges:
parts = str(x).split()
num = len(parts)
if num == 1:
val = converter(parts[0])
return FloatEdges(val, val, val, val)
if num == 2:
v = converter(parts[0])
h = converter(parts[1])
return FloatEdges(h, v, h, v)
if num == 3:
top, h, bottom = map(converter, parts)
return FloatEdges(h, top, h, bottom)
top, right, bottom, left = map(converter, parts)
return FloatEdges(left, top, right, bottom)
def optional_edge_width(x: str) -> FloatEdges:
return edge_width(x, float)
def hide_window_decorations(x: str) -> int:
if x == 'titlebar-only':
return 0b10
if x == 'titlebar-and-corners':
return 0b100
if to_bool(x):
return 0b01
return 0b00
def resize_draw_strategy(x: str) -> int:
cmap = {'static': 0, 'scale': 1, 'blank': 2, 'size': 3}
return cmap.get(x.lower(), 0)
def window_logo_scale(x: str) -> tuple[float, float]:
parts = x.split(maxsplit=1)
if len(parts) == 1:
return positive_float(parts[0]), -1.0
return positive_float(parts[0]), positive_float(parts[1])
def resize_debounce_time(x: str) -> tuple[float, float]:
parts = x.split(maxsplit=1)
if len(parts) == 1:
return positive_float(parts[0]), 0.5
return positive_float(parts[0]), positive_float(parts[1])
def visual_window_select_characters(x: str) -> str:
import string
valid_characters = string.digits + string.ascii_uppercase + "-=[]\\;',./`"
ans = x.upper()
ans_chars = set(ans)
if not ans_chars.issubset(set(valid_characters)):
raise ValueError(f'Invalid characters in visual_window_select_characters: {x} Only numbers (0-9) and alphabets (a-z,A-Z) are allowed. Ignoring.')
if len(ans_chars) < len(x):
raise ValueError(f'Invalid characters in visual_window_select_characters: {x} Contains identical numbers or alphabets, case insensitive. Ignoring.')
return ans
def tab_separator(x: str) -> str:
for q in '\'"':
if x.startswith(q) and x.endswith(q):
x = x[1:-1]
if not x:
return ''
break
if not x.strip():
x = ('\xa0' * len(x)) if x else default_tab_separator
return x
def tab_bar_edge(x: str) -> int:
return {'top': defines.TOP_EDGE, 'bottom': defines.BOTTOM_EDGE}.get(x.lower(), defines.BOTTOM_EDGE)
def tab_font_style(x: str) -> tuple[bool, bool]:
return {
'bold-italic': (True, True),
'bold': (True, False),
'italic': (False, True)
}.get(x.lower().replace('_', '-'), (False, False))
def tab_bar_min_tabs(x: str) -> int:
return max(1, positive_int(x))
def tab_fade(x: str) -> tuple[float, ...]:
return tuple(map(unit_float, x.split()))
def tab_activity_symbol(x: str) -> str:
if x == 'none':
return ''
return tab_title_template(x)
def bell_on_tab(x: str) -> str:
xl = x.lower()
if xl in ('yes', 'y', 'true'):
return '🔔 '
if xl in ('no', 'n', 'false', 'none'):
return ''
return tab_title_template(x)
def tab_title_template(x: str) -> str:
if x:
for q in '\'"':
if x.startswith(q) and x.endswith(q):
x = x[1:-1]
break
return x
def active_tab_title_template(x: str) -> str | None:
x = tab_title_template(x)
return None if x == 'none' else x
def text_fg_override_threshold(x: str) -> tuple[float, Literal['%', 'ratio']]:
val, unit = number_with_unit(x, '%', 'ratio')
return val, cast(Literal['%', 'ratio'], unit)
ClearOn = Literal['next', 'focus']
default_clear_on: tuple[ClearOn, ...] = 'focus', 'next'
all_clear_on = get_args(ClearOn)
class NotifyOnCmdFinish(NamedTuple):
when: str = 'never'
duration: float = 5.0
action: str = 'notify'
cmdline: tuple[str, ...] = ()
clear_on: tuple[ClearOn, ...] = default_clear_on
def notify_on_cmd_finish(x: str) -> NotifyOnCmdFinish:
parts = x.split(maxsplit=3)
if parts[0] not in ('never', 'unfocused', 'invisible', 'always'):
raise ValueError(f'Unknown notify_on_cmd_finish value: {parts[0]}')
when = parts[0]
duration = 5.0
if len(parts) > 1:
duration = float(parts[1])
action = 'notify'
cmdline: tuple[str, ...] = ()
clear_on = default_clear_on
if len(parts) > 2:
if parts[2] not in ('notify', 'bell', 'command'):
raise ValueError(f'Unknown notify_on_cmd_finish action: {parts[2]}')
action = parts[2]
if action == 'notify':
if len(parts) > 3:
con: list[ClearOn] = []
for x in parts[3].split():
if x not in all_clear_on:
raise ValueError(
f'notify_on_cmd_finish: notify clear_on value "{x}" is invalid. Valid values are: {", ".join(all_clear_on)}')
con.append(cast(ClearOn, x))
clear_on = tuple(con)
elif action == 'command':
if len(parts) > 3:
cmdline = tuple(to_cmdline(parts[3]))
else:
raise ValueError('notify_on_cmd_finish `command` action needs a command line')
return NotifyOnCmdFinish(when, duration, action, cmdline, clear_on)
def config_or_absolute_path(x: str, env: dict[str, str] | None = None) -> str | None:
if not x or x.lower() == 'none':
return None
return resolve_abs_or_config_path(x, env)
def filter_notification(val: str, current_val: dict[str, str]) -> Iterable[tuple[str, str]]:
yield val, ''
def remote_control_password(val: str, current_val: dict[str, str]) -> Iterable[tuple[str, Sequence[str]]]:
val = val.strip()
if val:
parts = to_cmdline(val, expand=False)
if parts[0].startswith('-'):
# this is done so in the future we can add --options to the cmd
# line of remote_control_password
raise ValueError('Passwords are not allowed to start with hyphens, ignoring this password')
if len(parts) == 1:
yield parts[0], ()
else:
yield parts[0], tuple(parts[1:])
def clipboard_control(x: str) -> tuple[str, ...]:
return tuple(x.lower().split())
def allow_hyperlinks(x: str) -> int:
if x == 'ask':
return 0b11
return 1 if to_bool(x) else 0
def titlebar_color(x: str) -> int:
x = x.strip('"')
if x == 'system':
return 0
if x == 'background':
return 1
try:
return (color_as_int(to_color(x)) << 8) | 2
except ValueError:
log_error(f'Ignoring invalid title bar color: {x}')
return 0
def macos_titlebar_color(x: str) -> int:
x = x.strip('"')
if x == 'light':
return -1
if x == 'dark':
return -2
return titlebar_color(x)
def macos_option_as_alt(x: str) -> int:
x = x.lower()
if x == 'both':
return 0b11
if x == 'left':
return 0b10
if x == 'right':
return 0b01
if to_bool(x):
return 0b11
return 0
class TabBarMarginHeight(NamedTuple):
outer: float = 0
inner: float = 0
def __bool__(self) -> bool:
return (self.outer + self.inner) > 0
def tab_bar_margin_height(x: str) -> TabBarMarginHeight:
parts = x.split(maxsplit=1)
if len(parts) != 2:
log_error(f'Invalid tab_bar_margin_height: {x}, ignoring')
return TabBarMarginHeight()
ans = map(positive_float, parts)
return TabBarMarginHeight(next(ans), next(ans))
def clone_source_strategies(x: str) -> frozenset[str]:
return frozenset({'venv', 'conda', 'path', 'env_var'} & set(x.lower().split(',')))
def clear_all_mouse_actions(val: str, dict_with_parse_results: dict[str, Any] | None = None) -> bool:
ans = to_bool(val)
if ans and dict_with_parse_results is not None:
dict_with_parse_results['mouse_map'] = [None]
return ans
def clear_all_shortcuts(val: str, dict_with_parse_results: dict[str, Any] | None = None) -> bool:
ans = to_bool(val)
if ans and dict_with_parse_results is not None:
dict_with_parse_results['map'] = [None]
return ans
def font_features(val: str) -> Iterable[tuple[str, tuple[defines.ParsedFontFeature, ...]]]:
if val == 'none':
return
parts = val.split()
if len(parts) < 2:
log_error(f"Ignoring invalid font_features {val}")
return
if parts[0]:
features = []
for feat in parts[1:]:
try:
features.append(defines.ParsedFontFeature(feat))
except ValueError:
log_error(f'Ignoring invalid font feature: {feat}')
yield parts[0], tuple(features)
def modify_font(val: str) -> Iterable[tuple[str, FontModification]]:
parts = val.split()
pos, plen = 0, len(parts)
if plen < 2:
log_error(f"Ignoring invalid modify_font: {val}")
return
mtype: ModificationType | None = getattr(ModificationType, parts[pos], None)
if mtype is None:
log_error(f"Ignoring invalid modify_font with unknown modification type: {parts[pos]}")
return
pos += 1
font_name = ''
if mtype is ModificationType.size:
font_name = parts[pos]
pos += 1
if plen - pos < 1:
log_error(f"Ignoring invalid modify_font: {val}")
return
sz = parts[pos]
pos += 1
munit = ModificationUnit.pt
if sz.endswith('%'):
munit = ModificationUnit.percent
sz = sz[:-1]
elif sz.endswith('px'):
munit = ModificationUnit.pixel
sz = sz[:-2]
try:
mvalue = float(sz)
except Exception:
log_error(f'Ignoring modify_font with invalid size: {sz}')
return
key = mtype.name
if font_name:
key += f':{font_name}'
yield key, FontModification(mtype, ModificationValue(mvalue, munit), font_name)
def env(val: str, current_val: dict[str, str]) -> Iterable[tuple[str, str]]:
val = val.strip()
if val:
if '=' in val:
key, v = val.split('=', 1)
key, v = key.strip(), v.strip()
if key:
if v:
v = expandvars(v, current_val)
yield key, v
else:
yield val, DELETE_ENV_VAR
def store_multiple(val: str, current_val: Container[str]) -> Iterable[tuple[str, str]]:
val = val.strip()
if val not in current_val:
yield val, val
def menu_map(val: str, current_val: Container[str]) -> Iterable[tuple[tuple[str, ...], str]]:
parts = val.split(maxsplit=1)
if len(parts) != 2:
raise ValueError(f'Ignoring invalid menu action: {val}')
if parts[0] != 'global':
raise ValueError(f'Unknown menu type: {parts[0]}. Known types: global')
start = 0
if parts[1].startswith('"'):
start = 1
idx = parts[1].find('"', 1)
if idx == -1:
raise ValueError(f'The menu entry name in {val} must end with a double quote')
else:
idx = parts[1].find(' ')
if idx == -1:
raise ValueError(f'The menu entry {val} must have an action')
location = ('global',) + tuple(parts[1][start:idx].split('::'))
yield location, parts[1][idx+1:].lstrip()
allowed_shell_integration_values = frozenset({'enabled', 'disabled', 'no-rc', 'no-cursor', 'no-title', 'no-prompt-mark', 'no-complete', 'no-cwd', 'no-sudo'})
def shell_integration(x: str) -> frozenset[str]:
q = frozenset(x.lower().split())
if not q.issubset(allowed_shell_integration_values):
log_error(f'Invalid shell integration options: {q - allowed_shell_integration_values}, ignoring')
return q & allowed_shell_integration_values or frozenset({'invalid'})
return q
def confirm_close(x: str) -> tuple[int, bool]:
parts = x.split(maxsplit=1)
num = int(parts[0])
allow_background = len(parts) > 1 and parts[1] == 'count-background'
return num, allow_background
def underline_exclusion(x: str) -> tuple[float, Literal['', 'px', 'pt']]:
try:
return float(x), ''
except Exception:
unit: Literal['pt', 'px'] = x[-2:] # type: ignore
if unit not in ('px', 'pt'):
raise ValueError(f'Invalid underline_exclusion with unrecognized unit: {x}')
try:
val = float(x[:-2])
except Exception:
raise ValueError(f'Invalid underline_exclusion with non numeric value: {x}')
return val, unit
def paste_actions(x: str) -> frozenset[str]:
s = frozenset({'quote-urls-at-prompt', 'confirm', 'filter', 'confirm-if-large', 'replace-dangerous-control-codes', 'replace-newline', 'no-op'})
q = frozenset(x.lower().split(','))
if not q.issubset(s):
raise ValueError(f'Invalid paste actions: {q - s}, ignoring')
return q
def action_alias(val: str) -> Iterable[tuple[str, str]]:
parts = val.split(maxsplit=1)
if len(parts) > 1:
alias_name, rest = parts
yield alias_name, rest
kitten_alias = action_alias
def symbol_map_parser(val: str, min_size: int = 2) -> Iterable[tuple[tuple[int, int], str]]:
parts = val.split()
if len(parts) < min_size:
raise ValueError('must have codepoints AND font name')
family = ' '.join(parts[1:])
def to_chr(x: str) -> int:
if not x.startswith('U+'):
raise ValueError(f'{x} is not a unicode codepoint of the form U+number')
return int(x[2:], 16)
for x in parts[0].split(','):
a_, b_ = x.replace('', '-').partition('-')[::2]
b_ = b_ or a_
a, b = map(to_chr, (a_, b_))
if b < a or max(a, b) > sys.maxunicode or min(a, b) < 1:
raise ValueError(f'Invalid range: {a:x} - {b:x}')
yield (a, b), family
def symbol_map(val: str) -> Iterable[tuple[tuple[int, int], str]]:
yield from symbol_map_parser(val)
def narrow_symbols(val: str) -> Iterable[tuple[tuple[int, int], int]]:
for x, y in symbol_map_parser(val, min_size=1):
yield x, int(y or 1)
def parse_key_action(action: str, action_type: MapType = MapType.MAP) -> KeyAction:
parts = action.strip().split(maxsplit=1)
func = parts[0]
if len(parts) == 1:
return KeyAction(func, ())
rest = parts[1]
parser = func_with_args.get(func)
if parser is None:
raise KeyError(f'Unknown action: {func}')
func, args = parser(func, rest)
return KeyAction(func, tuple(args))
class ActionAlias(NamedTuple):
name: str
value: str
replace_second_arg: bool = False
class AliasMap:
def __init__(self) -> None:
self.aliases: dict[str, list[ActionAlias]] = {}
def append(self, name: str, aa: ActionAlias) -> None:
self.aliases.setdefault(name, []).append(aa)
def update(self, aa: 'AliasMap') -> None:
self.aliases.update(aa.aliases)
@lru_cache(maxsize=256)
def resolve_aliases(self, definition: str, map_type: MapType = MapType.MAP) -> tuple[KeyAction, ...]:
return tuple(resolve_aliases_and_parse_actions(definition, self.aliases, map_type))
def build_action_aliases(raw: dict[str, str], first_arg_replacement: str = '') -> AliasMap:
ans = AliasMap()
if first_arg_replacement:
for alias_name, rest in raw.items():
ans.append(first_arg_replacement, ActionAlias(alias_name, rest, True))
else:
for alias_name, rest in raw.items():
ans.append(alias_name, ActionAlias(alias_name, rest))
return ans
def resolve_aliases_and_parse_actions(
defn: str, aliases: dict[str, list[ActionAlias]], map_type: MapType
) -> Iterator[KeyAction]:
parts = defn.split(maxsplit=1)
if len(parts) == 1:
possible_alias = defn
rest = ''
else:
possible_alias = parts[0]
rest = parts[1]
for alias in aliases.get(possible_alias, ()):
if alias.replace_second_arg: # kitten_alias
if not rest:
continue
parts = rest.split(maxsplit=1)
if parts[0] != alias.name:
continue
new_defn = f'{possible_alias} {alias.value}{f" {parts[1]}" if len(parts) > 1 else ""}'
new_aliases = aliases.copy()
new_aliases[possible_alias] = [a for a in aliases[possible_alias] if a is not alias]
yield from resolve_aliases_and_parse_actions(new_defn, new_aliases, map_type)
return
else: # action_alias
new_defn = f'{alias.value} {rest}' if rest else alias.value
new_aliases = aliases.copy()
new_aliases.pop(possible_alias)
yield from resolve_aliases_and_parse_actions(new_defn, new_aliases, map_type)
return
if possible_alias == 'combine':
sep, rest = rest.split(maxsplit=1)
parts = re.split(fr'\s*{re.escape(sep)}\s*', rest)
for x in parts:
if x:
yield from resolve_aliases_and_parse_actions(x, aliases, map_type)
else:
yield parse_key_action(defn, map_type)
class BaseDefinition:
no_op_actions = frozenset(('noop', 'no-op', 'no_op'))
map_type: MapType = MapType.MAP
definition_location: CurrentlyParsing
def __init__(self, definition: str = '') -> None:
if definition in BaseDefinition.no_op_actions:
definition = ''
self.definition = definition
self.definition_location = currently_parsing.__copy__()
def pretty_repr(self, *fields: str) -> str:
kwds = []
defaults = self.__class__()
for f in fields:
val = getattr(self, f)
if val != getattr(defaults, f):
kwds.append(f'{f}={val!r}')
if self.definition:
kwds.append(f'definition={self.definition!r}')
return f'{self.__class__.__name__}({", ".join(kwds)})'
def resolve_key_mods(kitty_mod: int, mods: int) -> int:
return SingleKey(mods=mods).resolve_kitty_mod(kitty_mod).mods
class MouseMapping(BaseDefinition):
map_type: MapType = MapType.MOUSE_MAP
def __init__(
self, button: int = 0, mods: int = 0, repeat_count: int = 1, grabbed: bool = False,
definition: str = ''
):
super().__init__(definition)
self.button = button
self.mods = mods
self.repeat_count = repeat_count
self.grabbed = grabbed
def __repr__(self) -> str:
return self.pretty_repr('button', 'mods', 'repeat_count', 'grabbed')
def resolve_and_copy(self, kitty_mod: int) -> 'MouseMapping':
ans = MouseMapping(
self.button, resolve_key_mods(kitty_mod, self.mods), self.repeat_count, self.grabbed,
self.definition)
ans.definition_location = self.definition_location
return ans
@property
def trigger(self) -> MouseEvent:
return MouseEvent(self.button, self.mods, self.repeat_count, self.grabbed)
T = TypeVar('T')
class LiteralField(Generic[T]):
def __init__(self, vals: tuple[T, ...]):
self._vals = vals
def __set_name__(self, owner: object, name: str) -> None:
self._name = "_" + name
def __get__(self, obj: object, type: type | None = None) -> T:
if obj is None:
return self._vals[0]
return getattr(obj, self._name, self._vals[0])
def __set__(self, obj: object, value: str) -> None:
if value not in self._vals:
raise KeyError(f'Invalid value for {self._name[1:]}: {value!r}')
object.__setattr__(obj, self._name, value)
OnUnknown = Literal['beep', 'end', 'ignore', 'passthrough']
OnAction = Literal['keep', 'end']
@dataclass(init=False, frozen=True)
class KeyMapOptions:
when_focus_on: str = ''
new_mode: str = ''
mode: str = ''
on_unknown: LiteralField[OnUnknown] = LiteralField[OnUnknown](get_args(OnUnknown))
on_action: LiteralField[OnAction] = LiteralField[OnAction](get_args(OnAction))
default_key_map_options = KeyMapOptions()
allowed_key_map_options = frozenset(f.name for f in fields(KeyMapOptions))
class KeyDefinition(BaseDefinition):
def __init__(
self, is_sequence: bool = False, trigger: SingleKey = SingleKey(),
rest: tuple[SingleKey, ...] = (), definition: str = '',
options: KeyMapOptions = default_key_map_options
):
super().__init__(definition)
self.is_sequence = is_sequence
self.trigger = trigger
self.rest = rest
self.options = options
@property
def is_suitable_for_global_shortcut(self) -> bool:
return not self.options.when_focus_on and not self.options.mode and not self.options.new_mode and not self.is_sequence
@property
def full_key_sequence_to_trigger(self) -> tuple[SingleKey, ...]:
return (self.trigger,) + self.rest
@property
def unique_identity_within_keymap(self) -> tuple[tuple[SingleKey, ...], str]:
return self.full_key_sequence_to_trigger, self.options.when_focus_on
def __repr__(self) -> str:
return self.pretty_repr('is_sequence', 'trigger', 'rest', 'options')
def human_repr(self) -> str:
ans = self.definition or 'no-op'
if self.options.when_focus_on:
ans = f'[--when-focus-on={self.options.when_focus_on}]{ans}'
return ans
def shift_sequence_and_copy(self) -> 'KeyDefinition':
return KeyDefinition(self.is_sequence, self.trigger, self.rest[1:], self.definition, self.options)
def resolve_and_copy(self, kitty_mod: int) -> 'KeyDefinition':
def r(k: SingleKey) -> SingleKey:
return k.resolve_kitty_mod(kitty_mod)
ans = KeyDefinition(
self.is_sequence, r(self.trigger), tuple(map(r, self.rest)),
self.definition, self.options
)
ans.definition_location = self.definition_location
return ans
class KeyboardMode:
on_unknown: OnUnknown = get_args(OnUnknown)[0]
on_action : OnAction = get_args(OnAction)[0]
sequence_keys: list[defines.KeyEvent] | None = None
def __init__(self, name: str = '') -> None:
self.name = name
self.keymap: KeyMap = defaultdict(list)
KeyboardModeMap = dict[str, KeyboardMode]
def parse_options_for_map(val: str) -> tuple[KeyMapOptions, str]:
expecting_arg = ''
ans = KeyMapOptions()
s = Shlex(val)
while (tok := s.next_word())[0] > -1:
x = tok[1]
if expecting_arg:
object.__setattr__(ans, expecting_arg, x)
expecting_arg = ''
elif x.startswith('--'):
expecting_arg = x[2:]
k, sep, v = expecting_arg.partition('=')
k = k.replace('-', '_')
expecting_arg = k
if expecting_arg not in allowed_key_map_options:
raise KeyError(f'The map option {x} is unknown. Allowed options: {", ".join(allowed_key_map_options)}')
if sep == '=':
object.__setattr__(ans, k, v)
expecting_arg = ''
else:
return ans, val[tok[0]:]
return ans, ''
def parse_map(val: str) -> Iterable[KeyDefinition]:
parts = val.split(maxsplit=1)
options = default_key_map_options
if len(parts) == 2:
sc, action = parts
if sc.startswith('--'):
options, leftover = parse_options_for_map(val)
parts = leftover.split(maxsplit=1)
if len(parts) == 1:
sc, action = parts[0], ''
else:
sc = parts[0]
action = ' '.join(parts[1:])
else:
sc, action = val, ''
sc, action = sc.strip().strip(sequence_sep), action.strip()
if not sc:
return
is_sequence = sequence_sep in sc
if is_sequence:
trigger: SingleKey | None = None
restl: list[SingleKey] = []
for part in sc.split(sequence_sep):
try:
mods, is_native, key = parse_shortcut(part)
except InvalidMods:
return
if key == 0:
if mods is not None:
log_error(f'Shortcut: {sc} has unknown key, ignoring')
return
if trigger is None:
trigger = SingleKey(mods, is_native, key)
else:
restl.append(SingleKey(mods, is_native, key))
rest = tuple(restl)
else:
try:
mods, is_native, key = parse_shortcut(sc)
except InvalidMods:
return
if key == 0:
if mods is not None:
log_error(f'Shortcut: {sc} has unknown key, ignoring')
return
if is_sequence:
if trigger is not None:
yield KeyDefinition(True, trigger, rest, definition=action, options=options)
else:
assert key is not None
yield KeyDefinition(False, SingleKey(mods, is_native, key), definition=action, options=options)
def parse_mouse_map(val: str) -> Iterable[MouseMapping]:
parts = val.split(maxsplit=3)
if len(parts) == 4:
xbutton, event, modes, action = parts
elif len(parts) > 2:
xbutton, event, modes = parts
action = ''
else:
log_error(f'Ignoring invalid mouse action: {val}')
return
kparts = xbutton.split('+')
if len(kparts) > 1:
mparts, obutton = kparts[:-1], kparts[-1].lower()
mods = parse_mods(mparts, obutton)
if mods is None:
return
else:
obutton = parts[0].lower()
mods = 0
try:
b = mouse_button_map.get(obutton, obutton)[1:]
button = getattr(defines, f'GLFW_MOUSE_BUTTON_{b}')
except Exception:
log_error(f'Mouse button: {xbutton} not recognized, ignoring')
return
try:
count = mouse_trigger_count_map[event.lower()]
except KeyError:
log_error(f'Mouse event type: {event} not recognized, ignoring')
return
specified_modes = frozenset(modes.lower().split(','))
if specified_modes - {'grabbed', 'ungrabbed'}:
log_error(f'Mouse modes: {modes} not recognized, ignoring')
return
for mode in sorted(specified_modes):
yield MouseMapping(button, mods, count, mode == 'grabbed', definition=action)
def parse_font_spec(spec: str) -> FontSpec:
return FontSpec.from_setting(spec)
JumpTypes = Literal['start', 'end', 'none', 'both']
class EasingFunction(NamedTuple):
type: Literal['steps', 'linear', 'cubic-bezier', ''] = ''
num_steps: int = 0
jump_type: JumpTypes = 'end'
linear_x: tuple[float, ...] = ()
linear_y: tuple[float, ...] = ()
cubic_bezier_points: tuple[float, ...] = ()
def __repr__(self) -> str:
fields = ', '.join(f'{f}={getattr(self, f)!r}' for f in self._fields if getattr(self, f) != self._field_defaults[f])
return f'kitty.options.utils.EasingFunction({fields})'
def __bool__(self) -> bool:
return bool(self.type)
@classmethod
def cubic_bezier(cls, params: str) -> 'EasingFunction':
parts = params.replace(',', ' ').split()
if len(parts) != 4:
raise ValueError('cubic-bezier easing function must have four points')
return cls(type='cubic-bezier', cubic_bezier_points=(
unit_float(parts[0]), float(parts[1]), unit_float(parts[2]), float(parts[3])))
@classmethod
def linear(cls, params: str) -> 'EasingFunction':
parts = params.split(',')
if len(parts) < 2:
raise ValueError('Must specify at least two points for the linear easing function')
xaxis: list[float] = []
yaxis: list[float] = []
def balance(end: float) -> None:
extra = len(yaxis) - len(xaxis)
if extra <= 0:
return
start = xaxis[-1] if xaxis else 0.
delta = (end - start) / max(1, extra - 1)
if delta <= 0.:
raise ValueError(f'Linear easing curve must have strictly increasing points: {params} does not')
if xaxis:
for i in range(extra):
xaxis.append(start + (i+1) * delta)
else:
for i in range(extra):
xaxis.append(i * delta)
def add_point(y: float, x: float | None = None) -> None:
if x is None:
yaxis.append(y)
else:
x = unit_float(x)
balance(x)
xaxis.append(x)
yaxis.append(y)
for r in parts:
points = r.strip().split()
y = unit_float(points[0])
if len(points) == 1:
add_point(y)
elif len(points) == 2:
add_point(y, percent(points[1]))
elif len(points) == 3:
add_point(y, percent(points[1]))
add_point(y, percent(points[2]))
else:
raise ValueError(f'{r} has too many points for a linear easing curve parameter')
balance(1)
return cls(type='linear', linear_x=tuple(xaxis), linear_y=tuple(yaxis))
@classmethod
def steps(cls, params: str) -> 'EasingFunction':
parts = params.replace(',', ' ').split()
jump_type: JumpTypes = 'end'
if len(parts) == 2:
n = int(parts[0])
jt = parts[1]
mapping: dict[str, JumpTypes] = {
'jump-start': 'start', 'start': 'start', 'end': 'end', 'jump-end': 'end', 'jump-none': 'none', 'jump-both': 'both'
}
try:
jump_type = mapping[jt.lower()]
except KeyError:
raise KeyError(f'{jt} is not a valid jump type for a linear easing function')
if jump_type == 'none':
n = max(2, n)
else:
n = max(1, n)
else:
n = max(1, int(parts[0]))
return cls(type='steps', jump_type=jump_type, num_steps=n)
def parse_animation(spec: str, interval: float = -1.) -> tuple[float, EasingFunction, EasingFunction]:
with suppress(Exception):
interval = float(spec)
return interval, EasingFunction(), EasingFunction()
m = [EasingFunction(), EasingFunction()]
def parse_func(func_name: str, params: str) -> None:
idx = 1 if m[0] else 0
if m[idx]:
raise ValueError(f'{spec} specified more than two easing functions')
if func_name == 'cubic-bezier':
m[idx] = EasingFunction.cubic_bezier(params)
elif func_name == 'linear':
m[idx] = EasingFunction.linear(params)
elif func_name == 'steps':
m[idx] = EasingFunction.steps(params)
else:
raise KeyError(f'{func_name} is not a valid easing function')
for match in re.finditer(r'([-+.0-9a-zA-Z]+)(?:\(([^)]*)\)){0,1}', spec):
func_name, params = match.group(1, 2)
if params:
parse_func(func_name, params)
continue
with suppress(Exception):
interval = float(func_name)
continue
if func_name == 'ease-in-out':
parse_func('cubic-bezier', '0.42, 0, 0.58, 1')
elif func_name == 'linear':
parse_func('cubic-bezier', '0, 0, 1, 1')
elif func_name == 'ease':
parse_func('cubic-bezier', '0.25, 0.1, 0.25, 1')
elif func_name == 'ease-out':
parse_func('cubic-bezier', '0, 0, 0.58, 1')
elif func_name == 'ease-in':
parse_func('cubic-bezier', '0.42, 0, 1, 1')
elif func_name == 'step-start':
parse_func('steps', '1, start')
elif func_name == 'step-end':
parse_func('steps', '1, end')
else:
raise KeyError(f'{func_name} is not a valid easing function')
return interval, m[0], m[1]
def cursor_blink_interval(spec: str) -> tuple[float, EasingFunction, EasingFunction]:
return parse_animation(spec)
class MouseHideWait(NamedTuple):
hide_wait: float
show_wait: float
show_threshold: int
scroll_show: bool
def mouse_hide_wait(x: str) -> MouseHideWait:
parts = x.split(maxsplit=3)
if len(parts) != 1 and len(parts) != 4:
log_error(f'Invalid mouse_hide_wait: {x}, ignoring')
return MouseHideWait(3.0, 0.0, 40, True)
if len(parts) == 1:
return MouseHideWait(float(parts[0]), 0.0, 40, True)
else:
return MouseHideWait(float(parts[0]), float(parts[1]), int(parts[2]), to_bool(parts[3]))
def visual_bell_duration(spec: str) -> tuple[float, EasingFunction, EasingFunction]:
return parse_animation(spec, interval=0.)
pointer_shape_names = (
# start pointer shape names (auto generated by gen-key-constants.py do not edit)
'arrow',
'beam',
'text',
'pointer',
'hand',
'help',
'wait',
'progress',
'crosshair',
'cell',
'vertical-text',
'move',
'e-resize',
'ne-resize',
'nw-resize',
'n-resize',
'se-resize',
'sw-resize',
's-resize',
'w-resize',
'ew-resize',
'ns-resize',
'nesw-resize',
'nwse-resize',
'zoom-in',
'zoom-out',
'alias',
'copy',
'not-allowed',
'no-drop',
'grab',
'grabbing',
# end pointer shape names
)
def pointer_shape_when_dragging(spec: str) -> tuple[str, str]:
parts = spec.split(maxsplit=1)
first = parts[0]
if first not in pointer_shape_names:
raise ValueError(f'{first} is not a valid pointer shape name')
second = parts[1] if len(parts) > 1 else first
if second not in pointer_shape_names:
raise ValueError(f'{second} is not a valid pointer shape name')
return first, second
def transparent_background_colors(spec: str) -> tuple[tuple[Color, float], ...]:
if not spec:
return ()
ans: list[tuple[Color, float]] = []
seen: dict[Color, int] = {}
for part in spec.split():
col, sep, alpha = part.partition('@')
c = to_color(col)
o = max(-1, min(float(alpha) if alpha else -1, 1))
if (idx := seen.get(c)) is not None:
ans[idx] = c, o
continue
seen[c] = len(ans)
ans.append((c, o))
return tuple(ans[:7])
def deprecated_hide_window_decorations_aliases(key: str, val: str, ans: dict[str, Any]) -> None:
if not hasattr(deprecated_hide_window_decorations_aliases, key):
setattr(deprecated_hide_window_decorations_aliases, key, True)
log_error(f'The option {key} is deprecated. Use hide_window_decorations instead.')
if to_bool(val):
if is_macos and key == 'macos_hide_titlebar' or (not is_macos and key == 'x11_hide_window_decorations'):
ans['hide_window_decorations'] = True
def deprecated_macos_show_window_title_in_menubar_alias(key: str, val: str, ans: dict[str, Any]) -> None:
if not hasattr(deprecated_macos_show_window_title_in_menubar_alias, key):
setattr(deprecated_macos_show_window_title_in_menubar_alias, 'key', True)
log_error(f'The option {key} is deprecated. Use macos_show_window_title_in menubar instead.')
macos_show_window_title_in = ans.get('macos_show_window_title_in', 'all')
if to_bool(val):
if macos_show_window_title_in == 'none':
macos_show_window_title_in = 'menubar'
elif macos_show_window_title_in == 'window':
macos_show_window_title_in = 'all'
else:
if macos_show_window_title_in == 'all':
macos_show_window_title_in = 'window'
elif macos_show_window_title_in == 'menubar':
macos_show_window_title_in = 'none'
ans['macos_show_window_title_in'] = macos_show_window_title_in
def deprecated_send_text(key: str, val: str, ans: dict[str, Any]) -> None:
parts = val.split(' ')
def abort(msg: str) -> None:
log_error(f'Send text: {val} is invalid ({msg}), ignoring')
if len(parts) < 3:
return abort('Incomplete')
mode, sc = parts[:2]
text = ' '.join(parts[2:])
key_str = f'{sc} send_text {mode} {text}'
for k in parse_map(key_str):
ans['map'].append(k)
def deprecated_adjust_line_height(key: str, x: str, opts_dict: dict[str, Any]) -> None:
fm = {'adjust_line_height': 'cell_height', 'adjust_baseline': 'baseline', 'adjust_column_width': 'cell_width'}[key]
mtype = getattr(ModificationType, fm)
if x.endswith('%'):
ans = float(x[:-1].strip())
if ans < 0:
log_error(f'Percentage adjustments of {key} must be positive numbers')
return
opts_dict['modify_font'][fm] = FontModification(mtype, ModificationValue(ans, ModificationUnit.percent))
else:
opts_dict['modify_font'][fm] = FontModification(mtype, ModificationValue(int(x), ModificationUnit.pixel))