#!/usr/bin/env python3 # License: GPL v3 Copyright: 2018, Kovid Goyal 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 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, '?' 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 '\033[' + str(abs(amt)) + ('T' if amt < 0 else 'S') STANDARD_COLORS = {name: i for i, name in enumerate( 'black red green yellow blue magenta cyan gray'.split())} STANDARD_COLORS['white'] = STANDARD_COLORS['gray'] UNDERLINE_STYLES = {name: i + 1 for i, name in enumerate( 'straight double curly'.split())} 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[str] = 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[ 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 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 = ', ' + args methods.append(f' def {name}(self{args}) -> str: pass') ans += ['', '', 'class CMD:'] + methods return '\n'.join(ans) + '\n\n\n' # }}}