term.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. #!/usr/bin/env python3
  2. #
  3. # MMGen Wallet, a terminal-based cryptocurrency wallet
  4. # Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. term: Terminal classes for the MMGen suite
  20. """
  21. # TODO: reimplement as instance instead of class
  22. import sys, os, time
  23. from collections import namedtuple
  24. from .util import msg, msg_r, die
  25. match sys.platform:
  26. case 'linux' | 'darwin':
  27. import tty, termios
  28. from select import select
  29. hold_protect_timeout = 2 if sys.platform == 'darwin' else 0.3
  30. case 'win32':
  31. try:
  32. import msvcrt
  33. except:
  34. die(2, 'Unable to set terminal mode')
  35. if not sys.stdin.isatty():
  36. msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
  37. case x:
  38. die(2, f'{x!r}: unsupported platform')
  39. _term_dimensions = namedtuple('terminal_dimensions', ['width', 'height'])
  40. class MMGenTerm:
  41. @classmethod
  42. def register_cleanup(cls):
  43. pass
  44. @classmethod
  45. def init(cls, *, noecho=False):
  46. pass
  47. @classmethod
  48. def set(cls, *args, **kwargs):
  49. pass
  50. @classmethod
  51. def reset(cls):
  52. pass
  53. @classmethod
  54. def kb_hold_protect(cls):
  55. return None
  56. class MMGenTermLinux(MMGenTerm):
  57. @classmethod
  58. def register_cleanup(cls):
  59. if not hasattr(cls, 'cleanup_registered'):
  60. import atexit
  61. atexit.register(
  62. lambda: termios.tcsetattr(
  63. cls.stdin_fd,
  64. termios.TCSADRAIN,
  65. cls.orig_term))
  66. cls.cleanup_registered = True
  67. @classmethod
  68. def reset(cls):
  69. termios.tcsetattr(cls.stdin_fd, termios.TCSANOW, cls.orig_term)
  70. cls.cur_term = cls.orig_term
  71. @classmethod
  72. def set(cls, setting):
  73. d = {
  74. 'echo': lambda t: t[:3] + [t[3] | (termios.ECHO | termios.ECHONL)] + t[4:], # echo input chars
  75. 'noecho': lambda t: t[:3] + [t[3] & ~(termios.ECHO | termios.ECHONL)] + t[4:], # don’t echo input chars
  76. }
  77. termios.tcsetattr(cls.stdin_fd, termios.TCSANOW, d[setting](cls.cur_term))
  78. cls.cur_term = termios.tcgetattr(cls.stdin_fd)
  79. @classmethod
  80. def init(cls, *, noecho=False):
  81. cls.stdin_fd = sys.stdin.fileno()
  82. cls.cur_term = termios.tcgetattr(cls.stdin_fd)
  83. if not hasattr(cls, 'orig_term'):
  84. cls.orig_term = cls.cur_term
  85. if noecho:
  86. cls.set('noecho')
  87. @classmethod
  88. def get_terminal_size(cls):
  89. try:
  90. ret = os.get_terminal_size()
  91. except:
  92. try:
  93. ret = (
  94. int(os.environ['COLUMNS']),
  95. int(os.environ['LINES']))
  96. except:
  97. ret = (80, 25)
  98. return _term_dimensions(*ret)
  99. @classmethod
  100. def kb_hold_protect(cls):
  101. if cls.cfg.hold_protect_disable:
  102. return
  103. tty.setcbreak(cls.stdin_fd)
  104. while True:
  105. key = select([sys.stdin], [], [], hold_protect_timeout)[0]
  106. if key:
  107. sys.stdin.read(1)
  108. else:
  109. termios.tcsetattr(cls.stdin_fd, termios.TCSADRAIN, cls.cur_term)
  110. break
  111. @classmethod
  112. def get_char(cls, prompt='', *, immed_chars='', prehold_protect=True, num_bytes=5):
  113. """
  114. Use os.read(), not file.read(), to get a variable number of bytes without blocking.
  115. Request 5 bytes to cover escape sequences generated by F1, F2, .. Fn keys (5 bytes)
  116. as well as UTF8 chars (4 bytes max).
  117. """
  118. timeout = 0.3
  119. tty.setcbreak(cls.stdin_fd)
  120. msg_r(prompt)
  121. if cls.cfg.hold_protect_disable:
  122. prehold_protect = False
  123. while True:
  124. # Protect against held-down key before read()
  125. key = select([sys.stdin], [], [], timeout)[0]
  126. s = os.read(cls.stdin_fd, num_bytes).decode()
  127. if prehold_protect and key:
  128. continue
  129. if s in immed_chars:
  130. break
  131. # Protect against long keypress
  132. key = select([sys.stdin], [], [], timeout)[0]
  133. if not key:
  134. break
  135. termios.tcsetattr(cls.stdin_fd, termios.TCSADRAIN, cls.cur_term)
  136. return s
  137. @classmethod
  138. def get_char_raw(cls, prompt='', num_bytes=5, **kwargs):
  139. tty.setcbreak(cls.stdin_fd)
  140. msg_r(prompt)
  141. s = os.read(cls.stdin_fd, num_bytes).decode()
  142. termios.tcsetattr(cls.stdin_fd, termios.TCSADRAIN, cls.cur_term)
  143. return s
  144. class MMGenTermLinuxStub(MMGenTermLinux):
  145. @classmethod
  146. def register_cleanup(cls):
  147. pass
  148. @classmethod
  149. def init(cls, *, noecho=False):
  150. cls.stdin_fd = sys.stdin.fileno()
  151. @classmethod
  152. def set(cls, *args, **kwargs):
  153. pass
  154. @classmethod
  155. def reset(cls):
  156. pass
  157. @classmethod
  158. def get_char(cls, prompt='', *, immed_chars='', prehold_protect=None, num_bytes=5):
  159. msg_r(prompt)
  160. return os.read(0, num_bytes).decode()
  161. get_char_raw = get_char
  162. @classmethod
  163. def kb_hold_protect(cls):
  164. pass
  165. class MMGenTermMSWin(MMGenTerm):
  166. @classmethod
  167. def get_terminal_size(cls):
  168. import struct
  169. x, y = 0, 0
  170. try:
  171. from ctypes import windll, create_string_buffer
  172. # handles - stdin: -10, stdout: -11, stderr: -12
  173. csbi = create_string_buffer(22)
  174. h = windll.kernel32.GetStdHandle(-12)
  175. res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi)
  176. assert res, 'failed to get console screen buffer info'
  177. left, top, right, bottom = struct.unpack('hhhhHhhhhhh', csbi.raw)[5:9]
  178. x = right - left + 1
  179. y = bottom - top + 1
  180. except:
  181. pass
  182. if x and y:
  183. return _term_dimensions(x, y)
  184. else:
  185. from .color import yellow
  186. msg(yellow('Warning: could not get terminal size. Using fallback dimensions.'))
  187. return _term_dimensions(80, 25)
  188. @classmethod
  189. def kb_hold_protect(cls):
  190. timeout = 0.5
  191. while True:
  192. hit_time = time.time()
  193. while True:
  194. if msvcrt.kbhit():
  195. msvcrt.getch()
  196. break
  197. if time.time() - hit_time > timeout:
  198. return
  199. @classmethod
  200. def get_char(cls, prompt='', *, immed_chars='', prehold_protect=True, num_bytes=None):
  201. """
  202. always return a single character, ignore num_bytes
  203. first character of 2-character sequence returned by F1-F12 keys is discarded
  204. prehold_protect is ignored
  205. """
  206. msg_r(prompt)
  207. timeout = 0.5
  208. while True:
  209. if msvcrt.kbhit():
  210. ch = chr(msvcrt.getch()[0])
  211. if ch == '\x03':
  212. raise KeyboardInterrupt
  213. if ch in immed_chars:
  214. return ch
  215. hit_time = time.time()
  216. while True:
  217. if msvcrt.kbhit():
  218. break
  219. if time.time() - hit_time > timeout:
  220. return ch
  221. @classmethod
  222. def get_char_raw(cls, prompt='', num_bytes=None, **kwargs):
  223. """
  224. return single ASCII char or 2-char escape sequence, ignoring num_bytes
  225. """
  226. msg_r(prompt)
  227. ret = msvcrt.getch()
  228. if ret in (b'\x00', b'\xe0'): # first byte of 2-byte escape sequence
  229. return chr(ret[0]) + chr(msvcrt.getch()[0])
  230. if ret == b'\x03':
  231. raise KeyboardInterrupt
  232. return chr(ret[0])
  233. class MMGenTermMSWinStub(MMGenTermMSWin):
  234. @classmethod
  235. def get_char(cls, prompt='', *, immed_chars='', prehold_protect=None, num_bytes=None):
  236. """
  237. Use stdin to allow UTF-8 and emulate the one-character behavior of MMGenTermMSWin
  238. """
  239. msg_r(prompt)
  240. while True:
  241. try:
  242. return sys.stdin.read(1)
  243. except:
  244. msg('[read error, trying again]')
  245. time.sleep(0.5)
  246. get_char_raw = get_char
  247. def get_term():
  248. return {
  249. 'linux': (MMGenTermLinux if sys.stdin.isatty() else MMGenTermLinuxStub),
  250. 'darwin': (MMGenTermLinux if sys.stdin.isatty() else MMGenTermLinuxStub),
  251. 'win32': (MMGenTermMSWin if sys.stdin.isatty() else MMGenTermMSWinStub),
  252. }[sys.platform]
  253. def init_term(cfg, *, noecho=False):
  254. term = get_term()
  255. term.init(noecho=noecho)
  256. for var in ('get_char', 'get_char_raw', 'kb_hold_protect', 'get_terminal_size'):
  257. globals()[var] = getattr(term, var)
  258. term.cfg = cfg # setting the _class_ attribute
  259. def reset_term():
  260. get_term().reset()