Fixing this involved adding a new mode to kitty where it handles ctrl-c/z/q by sending signals to the tty foreground process group instead of delegating to the kernel to do that. Since the pipe may be full we have no way of knowing when the kernel will get around to reading the signal byte. So send the signal ourselves. Fixes #5271
480 lines
13 KiB
Python
480 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
|
|
|
|
import sys
|
|
from contextlib import contextmanager
|
|
from enum import Enum, auto
|
|
from functools import wraps
|
|
from typing import IO, Any, Callable, Dict, Generator, Optional, TypeVar, Union
|
|
|
|
from kitty.fast_data_types import Color
|
|
from kitty.rgb import color_as_sharp, to_color
|
|
from kitty.typing import (
|
|
GraphicsCommandType, HandlerType, ScreenSize, UnderlineLiteral
|
|
)
|
|
|
|
from .operations_stub import CMD
|
|
|
|
GraphicsCommandType, ScreenSize # needed for stub generation
|
|
S7C1T = '\033 F'
|
|
SAVE_CURSOR = '\0337'
|
|
RESTORE_CURSOR = '\0338'
|
|
SAVE_PRIVATE_MODE_VALUES = '\033[?s'
|
|
RESTORE_PRIVATE_MODE_VALUES = '\033[?r'
|
|
SAVE_COLORS = '\033[#P'
|
|
RESTORE_COLORS = '\033[#Q'
|
|
F = TypeVar('F')
|
|
all_cmds: Dict[str, Callable[..., Any]] = {}
|
|
|
|
|
|
class Mode(Enum):
|
|
LNM = 20, ''
|
|
IRM = 4, ''
|
|
DECKM = 1, '?'
|
|
DECSCNM = 5, '?'
|
|
DECOM = 6, '?'
|
|
DECAWM = 7, '?'
|
|
DECARM = 8, '?'
|
|
DECTCEM = 25, '?'
|
|
MOUSE_BUTTON_TRACKING = 1000, '?'
|
|
MOUSE_MOTION_TRACKING = 1002, '?'
|
|
MOUSE_MOVE_TRACKING = 1003, '?'
|
|
FOCUS_TRACKING = 1004, '?'
|
|
MOUSE_UTF8_MODE = 1005, '?'
|
|
MOUSE_SGR_MODE = 1006, '?'
|
|
MOUSE_URXVT_MODE = 1015, '?'
|
|
MOUSE_SGR_PIXEL_MODE = 1016, '?'
|
|
ALTERNATE_SCREEN = 1049, '?'
|
|
BRACKETED_PASTE = 2004, '?'
|
|
PENDING_UPDATE = 2026, '?'
|
|
HANDLE_TERMIOS_SIGNALS = 19997, '?'
|
|
|
|
|
|
def cmd(f: F) -> F:
|
|
all_cmds[f.__name__] = f # type: ignore
|
|
return f
|
|
|
|
|
|
@cmd
|
|
def set_mode(which: Mode) -> str:
|
|
num, private = which.value
|
|
return f'\033[{private}{num}h'
|
|
|
|
|
|
@cmd
|
|
def reset_mode(which: Mode) -> str:
|
|
num, private = which.value
|
|
return f'\033[{private}{num}l'
|
|
|
|
|
|
@cmd
|
|
def clear_screen() -> str:
|
|
return '\033[H\033[2J'
|
|
|
|
|
|
@cmd
|
|
def clear_to_end_of_screen() -> str:
|
|
return '\033[J'
|
|
|
|
|
|
@cmd
|
|
def clear_to_eol() -> str:
|
|
return '\033[K'
|
|
|
|
|
|
@cmd
|
|
def reset_terminal() -> str:
|
|
return '\033]\033\\\033c'
|
|
|
|
|
|
@cmd
|
|
def bell() -> str:
|
|
return '\a'
|
|
|
|
|
|
@cmd
|
|
def beep() -> str:
|
|
return '\a'
|
|
|
|
|
|
@cmd
|
|
def set_window_title(value: str) -> str:
|
|
return '\033]2;' + value.replace('\033', '').replace('\x9c', '') + '\033\\'
|
|
|
|
|
|
@cmd
|
|
def set_line_wrapping(yes_or_no: bool) -> str:
|
|
return set_mode(Mode.DECAWM) if yes_or_no else reset_mode(Mode.DECAWM)
|
|
|
|
|
|
@contextmanager
|
|
def without_line_wrap(write: Callable[[str], None]) -> Generator[None, None, None]:
|
|
write(set_line_wrapping(False))
|
|
try:
|
|
yield
|
|
finally:
|
|
write(set_line_wrapping(True))
|
|
|
|
|
|
@cmd
|
|
def repeat(char: str, count: int) -> str:
|
|
if count > 5:
|
|
return f'{char}\x1b[{count-1}b'
|
|
return char * count
|
|
|
|
|
|
@cmd
|
|
def set_cursor_visible(yes_or_no: bool) -> str:
|
|
return set_mode(Mode.DECTCEM) if yes_or_no else reset_mode(Mode.DECTCEM)
|
|
|
|
|
|
@cmd
|
|
def set_cursor_position(x: int = 0, y: int = 0) -> str: # (0, 0) is top left
|
|
return f'\033[{y + 1};{x + 1}H'
|
|
|
|
|
|
@cmd
|
|
def move_cursor_by(amt: int, direction: str) -> str:
|
|
suffix = {'up': 'A', 'down': 'B', 'right': 'C', 'left': 'D'}[direction]
|
|
return f'\033[{amt}{suffix}'
|
|
|
|
|
|
@cmd
|
|
def set_cursor_shape(shape: str = 'block', blink: bool = True) -> str:
|
|
val = {'block': 1, 'underline': 3, 'bar': 5}.get(shape, 1)
|
|
if not blink:
|
|
val += 1
|
|
return f'\033[{val} q'
|
|
|
|
|
|
@cmd
|
|
def set_scrolling_region(screen_size: Optional['ScreenSize'] = None, top: Optional[int] = None, bottom: Optional[int] = None) -> str:
|
|
if screen_size is None:
|
|
return '\033[r'
|
|
if top is None:
|
|
top = 0
|
|
if bottom is None:
|
|
bottom = screen_size.rows - 1
|
|
if bottom < 0:
|
|
bottom = screen_size.rows - 1 + bottom
|
|
else:
|
|
bottom += 1
|
|
return f'\033[{top + 1};{bottom + 1}r'
|
|
|
|
|
|
@cmd
|
|
def scroll_screen(amt: int = 1) -> str:
|
|
return f'\033[{abs(amt)}{"T" if amt < 0 else "S"}'
|
|
|
|
|
|
STANDARD_COLORS = {'black': 0, 'red': 1, 'green': 2, 'yellow': 3, 'blue': 4, 'magenta': 5, 'cyan': 6, 'gray': 7, 'white': 7}
|
|
UNDERLINE_STYLES = {'straight': 1, 'double': 2, 'curly': 3, 'dotted': 4, 'dashed': 5}
|
|
|
|
|
|
ColorSpec = Union[int, str, Color]
|
|
|
|
|
|
def color_code(color: ColorSpec, intense: bool = False, base: int = 30) -> str:
|
|
if isinstance(color, str):
|
|
e = str((base + 60 if intense else base) + STANDARD_COLORS[color])
|
|
elif isinstance(color, int):
|
|
e = f'{base + 8}:5:{max(0, min(color, 255))}'
|
|
else:
|
|
e = f'{base + 8}{color.as_sgr}'
|
|
return e
|
|
|
|
|
|
@cmd
|
|
def sgr(*parts: str) -> str:
|
|
return '\033[{}m'.format(';'.join(parts))
|
|
|
|
|
|
@cmd
|
|
def colored(
|
|
text: str,
|
|
color: ColorSpec,
|
|
intense: bool = False,
|
|
reset_to: Optional[ColorSpec] = None,
|
|
reset_to_intense: bool = False
|
|
) -> str:
|
|
e = color_code(color, intense)
|
|
return f'\033[{e}m{text}\033[{39 if reset_to is None else color_code(reset_to, reset_to_intense)}m'
|
|
|
|
|
|
@cmd
|
|
def faint(text: str) -> str:
|
|
return colored(text, 'black', True)
|
|
|
|
|
|
@cmd
|
|
def styled(
|
|
text: str,
|
|
fg: Optional[ColorSpec] = None,
|
|
bg: Optional[ColorSpec] = None,
|
|
fg_intense: bool = False,
|
|
bg_intense: bool = False,
|
|
italic: Optional[bool] = None,
|
|
bold: Optional[bool] = None,
|
|
underline: Optional[UnderlineLiteral] = None,
|
|
underline_color: Optional[ColorSpec] = None,
|
|
reverse: Optional[bool] = None,
|
|
dim: Optional[bool] = None,
|
|
) -> str:
|
|
start, end = [], []
|
|
if fg is not None:
|
|
start.append(color_code(fg, fg_intense))
|
|
end.append('39')
|
|
if bg is not None:
|
|
start.append(color_code(bg, bg_intense, 40))
|
|
end.append('49')
|
|
if underline_color is not None:
|
|
if isinstance(underline_color, str):
|
|
underline_color = STANDARD_COLORS[underline_color]
|
|
start.append(color_code(underline_color, base=50))
|
|
end.append('59')
|
|
if underline is not None:
|
|
start.append(f'4:{UNDERLINE_STYLES[underline]}')
|
|
end.append('4:0')
|
|
if italic is not None:
|
|
s, e = (start, end) if italic else (end, start)
|
|
s.append('3')
|
|
e.append('23')
|
|
if bold is not None:
|
|
s, e = (start, end) if bold else (end, start)
|
|
s.append('1')
|
|
e.append('22')
|
|
if dim is not None:
|
|
s, e = (start, end) if dim else (end, start)
|
|
s.append('2')
|
|
e.append('22')
|
|
if reverse is not None:
|
|
s, e = (start, end) if reverse else (end, start)
|
|
s.append('7')
|
|
e.append('27')
|
|
if not start:
|
|
return text
|
|
return '\033[{}m{}\033[{}m'.format(';'.join(start), text, ';'.join(end))
|
|
|
|
|
|
def serialize_gr_command(cmd: Dict[str, Union[int, str]], payload: Optional[bytes] = None) -> bytes:
|
|
from .images import GraphicsCommand
|
|
gc = GraphicsCommand()
|
|
for k, v in cmd.items():
|
|
setattr(gc, k, v)
|
|
return gc.serialize(payload or b'')
|
|
|
|
|
|
@cmd
|
|
def gr_command(cmd: Union[Dict[str, Union[int, str]], 'GraphicsCommandType'], payload: Optional[bytes] = None) -> str:
|
|
if isinstance(cmd, dict):
|
|
raw = serialize_gr_command(cmd, payload)
|
|
else:
|
|
raw = cmd.serialize(payload or b'')
|
|
return raw.decode('ascii')
|
|
|
|
|
|
@cmd
|
|
def clear_images_on_screen(delete_data: bool = False) -> str:
|
|
from .images import GraphicsCommand
|
|
gc = GraphicsCommand()
|
|
gc.a = 'd'
|
|
gc.d = 'A' if delete_data else 'a'
|
|
return gc.serialize().decode('ascii')
|
|
|
|
|
|
class MouseTracking(Enum):
|
|
none = auto()
|
|
buttons_only = auto()
|
|
buttons_and_drag = auto()
|
|
full = auto()
|
|
|
|
|
|
def init_state(alternate_screen: bool = True, mouse_tracking: MouseTracking = MouseTracking.none, kitty_keyboard_mode: bool = True) -> str:
|
|
sc = SAVE_CURSOR if alternate_screen else ''
|
|
ans = (
|
|
S7C1T + sc + SAVE_PRIVATE_MODE_VALUES + reset_mode(Mode.LNM) +
|
|
reset_mode(Mode.IRM) + reset_mode(Mode.DECKM) + reset_mode(Mode.DECSCNM) +
|
|
set_mode(Mode.DECARM) + set_mode(Mode.DECAWM) +
|
|
set_mode(Mode.DECTCEM) + reset_mode(Mode.MOUSE_BUTTON_TRACKING) +
|
|
reset_mode(Mode.MOUSE_MOTION_TRACKING) + reset_mode(Mode.MOUSE_MOVE_TRACKING) +
|
|
reset_mode(Mode.FOCUS_TRACKING) + reset_mode(Mode.MOUSE_UTF8_MODE) +
|
|
reset_mode(Mode.MOUSE_SGR_MODE) + set_mode(Mode.BRACKETED_PASTE) + SAVE_COLORS +
|
|
'\033[*x' # reset DECSACE to default region select
|
|
)
|
|
if alternate_screen:
|
|
ans += set_mode(Mode.ALTERNATE_SCREEN) + reset_mode(Mode.DECOM)
|
|
ans += clear_screen()
|
|
if mouse_tracking is not MouseTracking.none:
|
|
ans += set_mode(Mode.MOUSE_SGR_PIXEL_MODE)
|
|
if mouse_tracking is MouseTracking.buttons_only:
|
|
ans += set_mode(Mode.MOUSE_BUTTON_TRACKING)
|
|
elif mouse_tracking is MouseTracking.buttons_and_drag:
|
|
ans += set_mode(Mode.MOUSE_MOTION_TRACKING)
|
|
elif mouse_tracking is MouseTracking.full:
|
|
ans += set_mode(Mode.MOUSE_MOVE_TRACKING)
|
|
if kitty_keyboard_mode:
|
|
ans += '\033[>31u' # extended keyboard mode
|
|
else:
|
|
ans += '\033[>u' # legacy keyboard mode
|
|
return ans
|
|
|
|
|
|
def reset_state(normal_screen: bool = True) -> str:
|
|
ans = '\033[<u' # restore keyboard mode
|
|
if normal_screen:
|
|
ans += reset_mode(Mode.ALTERNATE_SCREEN)
|
|
else:
|
|
ans += SAVE_CURSOR
|
|
ans += RESTORE_PRIVATE_MODE_VALUES
|
|
ans += RESTORE_CURSOR
|
|
ans += RESTORE_COLORS
|
|
return ans
|
|
|
|
|
|
@contextmanager
|
|
def pending_update(write: Callable[[str], None]) -> Generator[None, None, None]:
|
|
write(set_mode(Mode.PENDING_UPDATE))
|
|
try:
|
|
yield
|
|
finally:
|
|
write(reset_mode(Mode.PENDING_UPDATE))
|
|
|
|
|
|
@contextmanager
|
|
def cursor(write: Callable[[str], None]) -> Generator[None, None, None]:
|
|
write(SAVE_CURSOR)
|
|
try:
|
|
yield
|
|
finally:
|
|
write(RESTORE_CURSOR)
|
|
|
|
|
|
@contextmanager
|
|
def alternate_screen(f: Optional[IO[str]] = None) -> Generator[None, None, None]:
|
|
f = f or sys.stdout
|
|
print(set_mode(Mode.ALTERNATE_SCREEN), end='', file=f)
|
|
try:
|
|
yield
|
|
finally:
|
|
print(reset_mode(Mode.ALTERNATE_SCREEN), end='', file=f)
|
|
|
|
|
|
@contextmanager
|
|
def raw_mode(fd: Optional[int] = None) -> Generator[None, None, None]:
|
|
import termios
|
|
import tty
|
|
if fd is None:
|
|
fd = sys.stdin.fileno()
|
|
old = termios.tcgetattr(fd)
|
|
try:
|
|
tty.setraw(fd)
|
|
yield
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
|
|
|
|
@cmd
|
|
def set_default_colors(
|
|
fg: Optional[Union[Color, str]] = None,
|
|
bg: Optional[Union[Color, str]] = None,
|
|
cursor: Optional[Union[Color, str]] = None,
|
|
select_bg: Optional[Union[Color, str]] = None,
|
|
select_fg: Optional[Union[Color, str]] = None
|
|
) -> str:
|
|
ans = ''
|
|
|
|
def item(which: Optional[Union[Color, str]], num: int) -> None:
|
|
nonlocal ans
|
|
if which is None:
|
|
ans += f'\x1b]1{num}\x1b\\'
|
|
else:
|
|
if isinstance(which, Color):
|
|
q = color_as_sharp(which)
|
|
else:
|
|
x = to_color(which)
|
|
assert x is not None
|
|
q = color_as_sharp(x)
|
|
ans += f'\x1b]{num};{q}\x1b\\'
|
|
|
|
item(fg, 10)
|
|
item(bg, 11)
|
|
item(cursor, 12)
|
|
item(select_bg, 17)
|
|
item(select_fg, 19)
|
|
return ans
|
|
|
|
|
|
@cmd
|
|
def save_colors() -> str:
|
|
return '\x1b[#P'
|
|
|
|
|
|
@cmd
|
|
def restore_colors() -> str:
|
|
return '\x1b[#Q'
|
|
|
|
|
|
@cmd
|
|
def overlay_ready() -> str:
|
|
return '\x1bP@kitty-overlay-ready|\x1b\\'
|
|
|
|
|
|
@cmd
|
|
def write_to_clipboard(data: Union[str, bytes], use_primary: bool = False) -> str:
|
|
from base64 import standard_b64encode
|
|
fmt = 'p' if use_primary else 'c'
|
|
if isinstance(data, str):
|
|
data = data.encode('utf-8')
|
|
payload = standard_b64encode(data).decode('ascii')
|
|
return f'\x1b]52;{fmt};{payload}\a'
|
|
|
|
|
|
@cmd
|
|
def request_from_clipboard(use_primary: bool = False) -> str:
|
|
return '\x1b]52;{};?\a'.format('p' if use_primary else 'c')
|
|
|
|
|
|
# Boilerplate to make operations available via Handler.cmd {{{
|
|
|
|
|
|
def writer(handler: HandlerType, func: Callable[..., Union[bytes, str]]) -> Callable[..., None]:
|
|
@wraps(func)
|
|
def f(*a: Any, **kw: Any) -> None:
|
|
handler.write(func(*a, **kw))
|
|
return f
|
|
|
|
|
|
def commander(handler: HandlerType) -> CMD:
|
|
ans = CMD()
|
|
for name, func in all_cmds.items():
|
|
setattr(ans, name, writer(handler, func))
|
|
return ans
|
|
|
|
|
|
def func_sig(func: Callable[..., Any]) -> Generator[str, None, None]:
|
|
import inspect
|
|
import re
|
|
s = inspect.signature(func)
|
|
for val in s.parameters.values():
|
|
yield re.sub(r'ForwardRef\([\'"](\w+?)[\'"]\)', r'\1', str(val).replace('NoneType', 'None'))
|
|
|
|
|
|
def as_type_stub() -> str:
|
|
ans = [
|
|
'from typing import * # noqa',
|
|
'from kitty.typing import GraphicsCommandType, ScreenSize',
|
|
'from kitty.fast_data_types import Color',
|
|
'import kitty.rgb',
|
|
'import kittens.tui.operations',
|
|
]
|
|
methods = []
|
|
for name, func in all_cmds.items():
|
|
args = ', '.join(func_sig(func))
|
|
if args:
|
|
args = f', {args}'
|
|
methods.append(f' def {name}(self{args}) -> str: pass')
|
|
ans += ['', '', 'class CMD:'] + methods
|
|
|
|
return '\n'.join(ans) + '\n\n\n'
|
|
# }}}
|