term.py 7.5 KB

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