operations.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. #!/usr/bin/env python
  2. # License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
  3. import os
  4. import sys
  5. from contextlib import contextmanager
  6. from enum import Enum, auto
  7. from functools import wraps
  8. from typing import Any, Callable, Dict, Generator, Optional, TypeVar, Union
  9. from kitty.fast_data_types import Color
  10. from kitty.rgb import color_as_sharp, to_color
  11. from kitty.typing import GraphicsCommandType, HandlerType, ScreenSize, UnderlineLiteral
  12. from .operations_stub import CMD
  13. GraphicsCommandType, ScreenSize # needed for stub generation
  14. SAVE_CURSOR = '\0337'
  15. RESTORE_CURSOR = '\0338'
  16. SAVE_PRIVATE_MODE_VALUES = '\033[?s'
  17. RESTORE_PRIVATE_MODE_VALUES = '\033[?r'
  18. SAVE_COLORS = '\033[#P'
  19. RESTORE_COLORS = '\033[#Q'
  20. F = TypeVar('F')
  21. all_cmds: Dict[str, Callable[..., Any]] = {}
  22. class Mode(Enum):
  23. LNM = 20, ''
  24. IRM = 4, ''
  25. DECKM = 1, '?'
  26. DECSCNM = 5, '?'
  27. DECOM = 6, '?'
  28. DECAWM = 7, '?'
  29. DECARM = 8, '?'
  30. DECTCEM = 25, '?'
  31. MOUSE_BUTTON_TRACKING = 1000, '?'
  32. MOUSE_MOTION_TRACKING = 1002, '?'
  33. MOUSE_MOVE_TRACKING = 1003, '?'
  34. FOCUS_TRACKING = 1004, '?'
  35. MOUSE_UTF8_MODE = 1005, '?'
  36. MOUSE_SGR_MODE = 1006, '?'
  37. MOUSE_URXVT_MODE = 1015, '?'
  38. MOUSE_SGR_PIXEL_MODE = 1016, '?'
  39. ALTERNATE_SCREEN = 1049, '?'
  40. BRACKETED_PASTE = 2004, '?'
  41. PENDING_UPDATE = 2026, '?'
  42. HANDLE_TERMIOS_SIGNALS = 19997, '?'
  43. def cmd(f: F) -> F:
  44. all_cmds[f.__name__] = f # type: ignore
  45. return f
  46. @cmd
  47. def set_mode(which: Mode) -> str:
  48. num, private = which.value
  49. return f'\033[{private}{num}h'
  50. @cmd
  51. def reset_mode(which: Mode) -> str:
  52. num, private = which.value
  53. return f'\033[{private}{num}l'
  54. @cmd
  55. def clear_screen() -> str:
  56. return '\033[H\033[2J'
  57. @cmd
  58. def clear_to_end_of_screen() -> str:
  59. return '\033[J'
  60. @cmd
  61. def clear_to_eol() -> str:
  62. return '\033[K'
  63. @cmd
  64. def reset_terminal() -> str:
  65. return '\033]\033\\\033c'
  66. @cmd
  67. def bell() -> str:
  68. return '\a'
  69. @cmd
  70. def beep() -> str:
  71. return '\a'
  72. @cmd
  73. def set_window_title(value: str) -> str:
  74. return '\033]2;' + value.replace('\033', '').replace('\x9c', '') + '\033\\'
  75. @cmd
  76. def set_line_wrapping(yes_or_no: bool) -> str:
  77. return set_mode(Mode.DECAWM) if yes_or_no else reset_mode(Mode.DECAWM)
  78. @contextmanager
  79. def without_line_wrap(write: Callable[[str], None]) -> Generator[None, None, None]:
  80. write(set_line_wrapping(False))
  81. try:
  82. yield
  83. finally:
  84. write(set_line_wrapping(True))
  85. @cmd
  86. def repeat(char: str, count: int) -> str:
  87. if count > 5:
  88. return f'{char}\x1b[{count-1}b'
  89. return char * count
  90. @cmd
  91. def set_cursor_visible(yes_or_no: bool) -> str:
  92. return set_mode(Mode.DECTCEM) if yes_or_no else reset_mode(Mode.DECTCEM)
  93. @cmd
  94. def set_cursor_position(x: int = 0, y: int = 0) -> str: # (0, 0) is top left
  95. return f'\033[{y + 1};{x + 1}H'
  96. @cmd
  97. def move_cursor_by(amt: int, direction: str) -> str:
  98. suffix = {'up': 'A', 'down': 'B', 'right': 'C', 'left': 'D'}[direction]
  99. return f'\033[{amt}{suffix}'
  100. @cmd
  101. def set_cursor_shape(shape: str = 'block', blink: bool = True) -> str:
  102. val = {'block': 1, 'underline': 3, 'beam': 5}.get(shape, 1)
  103. if not blink:
  104. val += 1
  105. return f'\033[{val} q'
  106. @cmd
  107. def set_scrolling_region(screen_size: Optional['ScreenSize'] = None, top: Optional[int] = None, bottom: Optional[int] = None) -> str:
  108. if screen_size is None:
  109. return '\033[r'
  110. if top is None:
  111. top = 0
  112. if bottom is None:
  113. bottom = screen_size.rows - 1
  114. if bottom < 0:
  115. bottom = screen_size.rows - 1 + bottom
  116. else:
  117. bottom += 1
  118. return f'\033[{top + 1};{bottom + 1}r'
  119. @cmd
  120. def scroll_screen(amt: int = 1) -> str:
  121. return f'\033[{abs(amt)}{"T" if amt < 0 else "S"}'
  122. STANDARD_COLORS = {'black': 0, 'red': 1, 'green': 2, 'yellow': 3, 'blue': 4, 'magenta': 5, 'cyan': 6, 'gray': 7, 'white': 7}
  123. UNDERLINE_STYLES = {'straight': 1, 'double': 2, 'curly': 3, 'dotted': 4, 'dashed': 5}
  124. ColorSpec = Union[int, str, Color]
  125. def color_code(color: ColorSpec, intense: bool = False, base: int = 30) -> str:
  126. if isinstance(color, str):
  127. e = str((base + 60 if intense else base) + STANDARD_COLORS[color])
  128. elif isinstance(color, int):
  129. e = f'{base + 8}:5:{max(0, min(color, 255))}'
  130. else:
  131. e = f'{base + 8}{color.as_sgr}'
  132. return e
  133. @cmd
  134. def sgr(*parts: str) -> str:
  135. return '\033[{}m'.format(';'.join(parts))
  136. @cmd
  137. def colored(
  138. text: str,
  139. color: ColorSpec,
  140. intense: bool = False,
  141. reset_to: Optional[ColorSpec] = None,
  142. reset_to_intense: bool = False
  143. ) -> str:
  144. e = color_code(color, intense)
  145. return f'\033[{e}m{text}\033[{39 if reset_to is None else color_code(reset_to, reset_to_intense)}m'
  146. @cmd
  147. def faint(text: str) -> str:
  148. return colored(text, 'black', True)
  149. @cmd
  150. def styled(
  151. text: str,
  152. fg: Optional[ColorSpec] = None,
  153. bg: Optional[ColorSpec] = None,
  154. fg_intense: bool = False,
  155. bg_intense: bool = False,
  156. italic: Optional[bool] = None,
  157. bold: Optional[bool] = None,
  158. underline: Optional[UnderlineLiteral] = None,
  159. underline_color: Optional[ColorSpec] = None,
  160. reverse: Optional[bool] = None,
  161. dim: Optional[bool] = None,
  162. ) -> str:
  163. start, end = [], []
  164. if fg is not None:
  165. start.append(color_code(fg, fg_intense))
  166. end.append('39')
  167. if bg is not None:
  168. start.append(color_code(bg, bg_intense, 40))
  169. end.append('49')
  170. if underline_color is not None:
  171. if isinstance(underline_color, str):
  172. underline_color = STANDARD_COLORS[underline_color]
  173. start.append(color_code(underline_color, base=50))
  174. end.append('59')
  175. if underline is not None:
  176. start.append(f'4:{UNDERLINE_STYLES[underline]}')
  177. end.append('4:0')
  178. if italic is not None:
  179. s, e = (start, end) if italic else (end, start)
  180. s.append('3')
  181. e.append('23')
  182. if bold is not None:
  183. s, e = (start, end) if bold else (end, start)
  184. s.append('1')
  185. e.append('22')
  186. if dim is not None:
  187. s, e = (start, end) if dim else (end, start)
  188. s.append('2')
  189. e.append('22')
  190. if reverse is not None:
  191. s, e = (start, end) if reverse else (end, start)
  192. s.append('7')
  193. e.append('27')
  194. if not start:
  195. return text
  196. return '\033[{}m{}\033[{}m'.format(';'.join(start), text, ';'.join(end))
  197. def serialize_gr_command(cmd: Dict[str, Union[int, str]], payload: Optional[bytes] = None) -> bytes:
  198. from .images import GraphicsCommand
  199. gc = GraphicsCommand()
  200. for k, v in cmd.items():
  201. setattr(gc, k, v)
  202. return gc.serialize(payload or b'')
  203. @cmd
  204. def gr_command(cmd: Union[Dict[str, Union[int, str]], 'GraphicsCommandType'], payload: Optional[bytes] = None) -> str:
  205. if isinstance(cmd, dict):
  206. raw = serialize_gr_command(cmd, payload)
  207. else:
  208. raw = cmd.serialize(payload or b'')
  209. return raw.decode('ascii')
  210. @cmd
  211. def clear_images_on_screen(delete_data: bool = False) -> str:
  212. from .images import GraphicsCommand
  213. gc = GraphicsCommand()
  214. gc.a = 'd'
  215. gc.d = 'A' if delete_data else 'a'
  216. return gc.serialize().decode('ascii')
  217. class MouseTracking(Enum):
  218. none = auto()
  219. buttons_only = auto()
  220. buttons_and_drag = auto()
  221. full = auto()
  222. def init_state(alternate_screen: bool = True, mouse_tracking: MouseTracking = MouseTracking.none, kitty_keyboard_mode: bool = True) -> str:
  223. sc = SAVE_CURSOR if alternate_screen else ''
  224. ans = (
  225. sc + SAVE_PRIVATE_MODE_VALUES + reset_mode(Mode.LNM) +
  226. reset_mode(Mode.IRM) + reset_mode(Mode.DECKM) + reset_mode(Mode.DECSCNM) +
  227. set_mode(Mode.DECARM) + set_mode(Mode.DECAWM) +
  228. set_mode(Mode.DECTCEM) + reset_mode(Mode.MOUSE_BUTTON_TRACKING) +
  229. reset_mode(Mode.MOUSE_MOTION_TRACKING) + reset_mode(Mode.MOUSE_MOVE_TRACKING) +
  230. reset_mode(Mode.FOCUS_TRACKING) + reset_mode(Mode.MOUSE_UTF8_MODE) +
  231. reset_mode(Mode.MOUSE_SGR_MODE) + set_mode(Mode.BRACKETED_PASTE) + SAVE_COLORS +
  232. '\033[*x' # reset DECSACE to default region select
  233. )
  234. if alternate_screen:
  235. ans += set_mode(Mode.ALTERNATE_SCREEN) + reset_mode(Mode.DECOM)
  236. ans += clear_screen()
  237. if mouse_tracking is not MouseTracking.none:
  238. ans += set_mode(Mode.MOUSE_SGR_PIXEL_MODE)
  239. if mouse_tracking is MouseTracking.buttons_only:
  240. ans += set_mode(Mode.MOUSE_BUTTON_TRACKING)
  241. elif mouse_tracking is MouseTracking.buttons_and_drag:
  242. ans += set_mode(Mode.MOUSE_MOTION_TRACKING)
  243. elif mouse_tracking is MouseTracking.full:
  244. ans += set_mode(Mode.MOUSE_MOVE_TRACKING)
  245. if kitty_keyboard_mode:
  246. ans += '\033[>31u' # extended keyboard mode
  247. else:
  248. ans += '\033[>u' # legacy keyboard mode
  249. return ans
  250. def reset_state(normal_screen: bool = True) -> str:
  251. ans = '\033[<u' # restore keyboard mode
  252. if normal_screen:
  253. ans += reset_mode(Mode.ALTERNATE_SCREEN)
  254. else:
  255. ans += SAVE_CURSOR
  256. ans += RESTORE_PRIVATE_MODE_VALUES
  257. ans += RESTORE_CURSOR
  258. ans += RESTORE_COLORS
  259. return ans
  260. @contextmanager
  261. def pending_update(write: Callable[[str], None]) -> Generator[None, None, None]:
  262. write(set_mode(Mode.PENDING_UPDATE))
  263. try:
  264. yield
  265. finally:
  266. write(reset_mode(Mode.PENDING_UPDATE))
  267. @contextmanager
  268. def cursor(write: Callable[[str], None]) -> Generator[None, None, None]:
  269. write(SAVE_CURSOR)
  270. try:
  271. yield
  272. finally:
  273. write(RESTORE_CURSOR)
  274. @contextmanager
  275. def alternate_screen() -> Generator[None, None, None]:
  276. with open(os.ctermid(), 'w') as f:
  277. print(set_mode(Mode.ALTERNATE_SCREEN), end='', file=f, flush=True)
  278. try:
  279. yield
  280. finally:
  281. print(reset_mode(Mode.ALTERNATE_SCREEN), end='', file=f, flush=True)
  282. @contextmanager
  283. def raw_mode(fd: Optional[int] = None) -> Generator[None, None, None]:
  284. import termios
  285. import tty
  286. if fd is None:
  287. fd = sys.stdin.fileno()
  288. old = termios.tcgetattr(fd)
  289. try:
  290. tty.setraw(fd)
  291. yield
  292. finally:
  293. termios.tcsetattr(fd, termios.TCSADRAIN, old)
  294. @cmd
  295. def set_default_colors(
  296. fg: Optional[Union[Color, str]] = None,
  297. bg: Optional[Union[Color, str]] = None,
  298. cursor: Optional[Union[Color, str]] = None,
  299. select_bg: Optional[Union[Color, str]] = None,
  300. select_fg: Optional[Union[Color, str]] = None
  301. ) -> str:
  302. ans = ''
  303. def item(which: Optional[Union[Color, str]], num: int) -> None:
  304. nonlocal ans
  305. if which is None:
  306. ans += f'\x1b]1{num}\x1b\\'
  307. else:
  308. if isinstance(which, Color):
  309. q = color_as_sharp(which)
  310. else:
  311. x = to_color(which)
  312. assert x is not None
  313. q = color_as_sharp(x)
  314. ans += f'\x1b]{num};{q}\x1b\\'
  315. item(fg, 10)
  316. item(bg, 11)
  317. item(cursor, 12)
  318. item(select_bg, 17)
  319. item(select_fg, 19)
  320. return ans
  321. @cmd
  322. def save_colors() -> str:
  323. return '\x1b[#P'
  324. @cmd
  325. def restore_colors() -> str:
  326. return '\x1b[#Q'
  327. @cmd
  328. def overlay_ready() -> str:
  329. return '\x1bP@kitty-overlay-ready|\x1b\\'
  330. @cmd
  331. def write_to_clipboard(data: Union[str, bytes], use_primary: bool = False) -> str:
  332. from base64 import standard_b64encode
  333. fmt = 'p' if use_primary else 'c'
  334. if isinstance(data, str):
  335. data = data.encode('utf-8')
  336. payload = standard_b64encode(data).decode('ascii')
  337. return f'\x1b]52;{fmt};{payload}\a'
  338. @cmd
  339. def request_from_clipboard(use_primary: bool = False) -> str:
  340. return '\x1b]52;{};?\a'.format('p' if use_primary else 'c')
  341. # Boilerplate to make operations available via Handler.cmd {{{
  342. def writer(handler: HandlerType, func: Callable[..., Union[bytes, str]]) -> Callable[..., None]:
  343. @wraps(func)
  344. def f(*a: Any, **kw: Any) -> None:
  345. handler.write(func(*a, **kw))
  346. return f
  347. def commander(handler: HandlerType) -> CMD:
  348. ans = CMD()
  349. for name, func in all_cmds.items():
  350. setattr(ans, name, writer(handler, func))
  351. return ans
  352. def func_sig(func: Callable[..., Any]) -> Generator[str, None, None]:
  353. import inspect
  354. import re
  355. s = inspect.signature(func)
  356. for val in s.parameters.values():
  357. yield re.sub(r'ForwardRef\([\'"](\w+?)[\'"]\)', r'\1', str(val).replace('NoneType', 'None'))
  358. def as_type_stub() -> str:
  359. ans = [
  360. 'from typing import * # noqa',
  361. 'from kitty.typing import GraphicsCommandType, ScreenSize',
  362. 'from kitty.fast_data_types import Color',
  363. 'import kitty.rgb',
  364. 'import kittens.tui.operations',
  365. ]
  366. methods = []
  367. for name, func in all_cmds.items():
  368. args = ', '.join(func_sig(func))
  369. if args:
  370. args = f', {args}'
  371. methods.append(f' def {name}(self{args}) -> str: pass')
  372. ans += ['', '', 'class CMD:'] + methods
  373. return '\n'.join(ans) + '\n\n\n'
  374. # }}}