view.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871
  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. tw.view: base class for tracking wallet view classes
  20. """
  21. import sys, time, asyncio
  22. from collections import namedtuple
  23. from ..cfg import gv
  24. from ..objmethods import MMGenObject
  25. from ..obj import get_obj, MMGenIdx, MMGenList
  26. from ..color import nocolor, yellow, orange, green, red, blue
  27. from ..util import msg, msg_r, fmt, die, capfirst, suf, make_timestr, isAsync
  28. from ..rpc import rpc_init
  29. from ..base_obj import AsyncInit
  30. # these are replaced by fake versions in overlay:
  31. CUR_HOME = '\033[H'
  32. CUR_UP = lambda n: f'\033[{n}A'
  33. CUR_DOWN = lambda n: f'\033[{n}B'
  34. ERASE_ALL = '\033[0J'
  35. # decorator for action.run():
  36. def enable_echo(orig_func):
  37. async def f(self, parent, action_method):
  38. if parent.scroll:
  39. parent.term.set('echo')
  40. ret = await orig_func(self, parent, action_method)
  41. if parent.scroll:
  42. parent.term.set('noecho')
  43. return ret
  44. return f
  45. # base class for TwUnspentOutputs, TwAddresses, TwTxHistory:
  46. class TwView(MMGenObject, metaclass=AsyncInit):
  47. class display_type:
  48. class squeezed:
  49. detail = False
  50. fmt_method = 'gen_squeezed_display'
  51. line_fmt_method = 'squeezed_format_line'
  52. subhdr_fmt_method = 'gen_subheader'
  53. colhdr_fmt_method = 'squeezed_col_hdr'
  54. need_column_widths = True
  55. item_separator = '\n'
  56. print_header = '[screen print truncated to width {}]\n'
  57. class detail:
  58. detail = True
  59. fmt_method = 'gen_detail_display'
  60. line_fmt_method = 'detail_format_line'
  61. subhdr_fmt_method = 'gen_subheader'
  62. colhdr_fmt_method = 'detail_col_hdr' # set to None to disable
  63. need_column_widths = True
  64. item_separator = '\n'
  65. print_header = ''
  66. class line_processing:
  67. class print:
  68. @staticmethod
  69. def do(method, data, cw, fs, color, fmt_method):
  70. return [l.rstrip() for l in method(data, cw, fs, color, fmt_method)]
  71. has_wallet = True
  72. has_amt2 = False
  73. dates_set = False
  74. reverse = False
  75. group = False
  76. use_cached = False
  77. minconf = 1
  78. txid_w = 0
  79. txid_max_w = {}
  80. txid_min_w = {}
  81. txid_nice_w = {}
  82. nice_addr_w = {'addr': 14}
  83. sort_key = 'age'
  84. display_hdr = ()
  85. display_body = ()
  86. prompt_fs_repl = {}
  87. removed_key_mappings = {}
  88. nodata_msg = '[no data for requested parameters]'
  89. cols = 0
  90. term_height = 0
  91. term_width = 0
  92. scrollable_height = 0
  93. min_scrollable_height = 5
  94. addr_view_pref = 0
  95. pos = 0
  96. filters = ()
  97. fp = namedtuple('fs_params', ['fs_key', 'hdr_fs_repl', 'fs_repl', 'hdr_fs', 'fs'])
  98. fs_params = {
  99. 'num': fp('n', True, True, ' {n:>%s}', ' {n:>%s}'),
  100. 'txid': fp('t', True, False, ' {t:%s}', ' {t}'),
  101. 'vout': fp('v', True, False, '{v:%s}', '{v}'),
  102. 'used': fp('u', True, False, ' {u:%s}', ' {u}'),
  103. 'addr': fp('a', True, False, ' {a:%s}', ' {a}'),
  104. 'mmid': fp('m', True, False, ' {m:%s}', ' {m}'),
  105. 'comment': fp('c', True, False, ' {c:%s}', ' {c}'),
  106. 'amt': fp('A', True, False, ' {A:%s}', ' {A}'),
  107. 'amt2': fp('B', True, False, ' {B:%s}', ' {B}'),
  108. 'date': fp('d', True, True, ' {d:%s}', ' {d:<%s}'),
  109. 'date_time': fp('D', True, True, ' {D:%s}', ' {D:%s}'),
  110. 'block': fp('b', True, True, ' {b:%s}', ' {b:<%s}'),
  111. 'inputs': fp('i', True, False, ' {i:%s}', ' {i}'),
  112. 'outputs': fp('o', True, False, ' {o:%s}', ' {o}')}
  113. age_fmts = ('confs', 'block', 'days', 'date', 'date_time')
  114. age_fmts_date_dependent = ('days', 'date', 'date_time')
  115. _age_fmt = 'confs'
  116. bch_addr_fmts = ('cashaddr', 'legacy')
  117. age_col_params = {
  118. 'confs': (0, 'Confs'),
  119. 'block': (0, 'Block'),
  120. 'days': (0, 'Age(d)'),
  121. 'date': (0, 'Date'),
  122. 'date_time': (0, 'Date/Time')}
  123. date_formatter = {
  124. 'days': lambda rpc, secs: (rpc.cur_date - secs) // 86400 if secs else 0,
  125. 'date': (
  126. lambda rpc, secs: '{}-{:02}-{:02}'.format(*time.gmtime(secs)[:3])[2:]
  127. if secs else '- '),
  128. 'date_time': (
  129. lambda rpc, secs: '{}-{:02}-{:02} {:02}:{:02}'.format(*time.gmtime(secs)[:5])
  130. if secs else '- ')}
  131. twidth_diemsg = """
  132. --columns or MMGEN_COLUMNS value ({}) is too small to display the {}
  133. Minimum value for this configuration: {}
  134. """
  135. twidth_errmsg = """
  136. Screen is too narrow to display the {} with current configuration
  137. Please resize your screen to at least {} characters and hit any key:
  138. """
  139. theight_errmsg = """
  140. Terminal window is too small to display the {} with current configuration
  141. Please resize it to at least {} lines and hit any key:
  142. """
  143. squeezed_format_line = None
  144. detail_format_line = None
  145. scroll_keys = {
  146. 'vi': {
  147. 'k': 'm_cursor_up',
  148. 'j': 'm_cursor_down',
  149. 'b': 'm_pg_up',
  150. 'f': 'm_pg_down',
  151. 'g': 'm_top',
  152. 'G': 'm_bot'},
  153. 'linux': {
  154. '\x1b[A': 'm_cursor_up',
  155. '\x1b[B': 'm_cursor_down',
  156. '\x1b[5~': 'm_pg_up',
  157. '\x1b[6~': 'm_pg_down',
  158. '\x1b[7~': 'm_top',
  159. '\x1b[8~': 'm_bot'},
  160. 'win32': {
  161. '\xe0H': 'm_cursor_up',
  162. '\xe0P': 'm_cursor_down',
  163. '\xe0I': 'm_pg_up',
  164. '\xe0Q': 'm_pg_down',
  165. '\xe0G': 'm_top',
  166. '\xe0O': 'm_bot'}}
  167. scroll_keys['darwin'] = scroll_keys['linux']
  168. extra_key_mappings = {}
  169. def __new__(cls, cfg, proto, *args, **kwargs):
  170. return MMGenObject.__new__(proto.base_proto_subclass(cls, cls.mod_subpath))
  171. async def __init__(self, cfg, proto):
  172. self.cfg = cfg
  173. self.proto = proto
  174. self.rpc = await rpc_init(cfg, proto)
  175. if self.has_wallet:
  176. from .ctl import TwCtl
  177. self.twctl = await TwCtl(cfg, proto, mode='w')
  178. self.amt_keys = {'amt':'iwidth', 'amt2':'iwidth2'} if self.has_amt2 else {'amt':'iwidth'}
  179. if repl_data := self.prompt_fs_repl.get(self.proto.coin):
  180. for repl in [repl_data] if isinstance(repl_data[0], int) else repl_data:
  181. self.prompt_fs_in[repl[0]] = repl[1]
  182. self.prompt_fs = '\n'.join(self.prompt_fs_in)
  183. for k, v in self.removed_key_mappings.items():
  184. if k in self.key_mappings and self.key_mappings[k] == v:
  185. del self.key_mappings[k]
  186. else:
  187. raise ValueError(f'{k}: {v}: unrecognized or invalid key mapping')
  188. self.key_mappings.update(self.extra_key_mappings)
  189. if self.proto.coin == 'BCH':
  190. self.key_mappings.update({'h': 'd_addr_view_pref'})
  191. self.addr_view_pref = 1 if not self.cfg.cashaddr else not self.proto.cashaddr
  192. @property
  193. def age_w(self):
  194. return self.age_col_params[self.age_fmt][0]
  195. @property
  196. def age_hdr(self):
  197. return self.age_col_params[self.age_fmt][1]
  198. @property
  199. def age_fmt(self):
  200. return self._age_fmt
  201. @age_fmt.setter
  202. def age_fmt(self, val):
  203. if val not in self.age_fmts:
  204. die('BadAgeFormat', f'{val!r}: invalid age format (must be one of {self.age_fmts!r})')
  205. self._age_fmt = val
  206. def age_disp(self, o, age_fmt):
  207. if self.has_age:
  208. match age_fmt:
  209. case 'confs':
  210. return o.confs or '-'
  211. case 'block':
  212. return self.rpc.blockcount + 1 - o.confs if o.confs else '-'
  213. case _:
  214. return self.date_formatter[age_fmt](self.rpc, o.date)
  215. def get_disp_prec(self, wide):
  216. return self.proto.coin_amt.max_prec
  217. sort_disp = {
  218. 'addr': 'Addr',
  219. 'age': 'Age',
  220. 'amt': 'Amt',
  221. 'txid': 'TxID',
  222. 'twmmid': 'MMGenID'}
  223. sort_funcs = {
  224. 'addr': lambda i: i.addr,
  225. 'age': lambda i: 0 - i.confs,
  226. 'amt': lambda i: i.amt,
  227. 'txid': lambda i: f'{i.txid} {i.vout:04}',
  228. 'twmmid': lambda i: i.twmmid.sort_key}
  229. def sort_info(self, *, include_group=True):
  230. ret = ([], ['Reverse'])[self.reverse]
  231. ret.append(self.sort_disp[self.sort_key])
  232. if include_group and self.group and (self.sort_key in ('addr', 'txid', 'twmmid')):
  233. ret.append('Grouped')
  234. return ret
  235. def do_sort(self, key=None, *, reverse=False):
  236. if key == 'txid' and not self.txid_w:
  237. return
  238. key = key or self.sort_key
  239. if key not in self.sort_funcs:
  240. die(1, f'{key!r}: invalid sort key. Valid options: {" ".join(self.sort_funcs)}')
  241. self.sort_key = key
  242. assert isinstance(reverse, bool)
  243. save = self.data.copy()
  244. self.data.sort(key=self.sort_funcs[key], reverse=reverse or self.reverse)
  245. if self.data != save:
  246. self.pos = 0
  247. async def get_data(self, *, sort_key=None, reverse_sort=False):
  248. rpc_data = await self.get_rpc_data()
  249. if not rpc_data:
  250. die(1, fmt(self.no_rpcdata_errmsg).strip())
  251. lbl_id = ('account', 'label')['label_api' in self.rpc.caps]
  252. self.data = MMGenList(
  253. await self.gen_data(rpc_data, lbl_id) if isAsync(self.gen_data) else
  254. self.gen_data(rpc_data, lbl_id))
  255. self.disp_data = list(self.filter_data())
  256. if not self.data:
  257. die(1, f'No {self.item_desc_pl} in tracking wallet!')
  258. self.do_sort(key=sort_key, reverse=reverse_sort)
  259. # get_data() is immediately followed by display header, and get_rpc_data() produces output,
  260. # so add NL here (' ' required because CUR_HOME erases preceding blank lines)
  261. msg(' ')
  262. def get_term_dimensions(self, min_cols, *, min_lines=None):
  263. from ..term import get_terminal_size, get_char_raw, _term_dimensions
  264. user_resized = False
  265. while True:
  266. ts = get_terminal_size()
  267. cols = self.cfg.columns or ts.width
  268. lines = ts.height
  269. if cols >= min_cols and (min_lines is None or lines >= min_lines):
  270. if user_resized:
  271. msg_r(CUR_HOME + ERASE_ALL)
  272. return _term_dimensions(cols, ts.height)
  273. if sys.stdout.isatty():
  274. if self.cfg.columns and cols < min_cols:
  275. die(1, '\n'+fmt(self.twidth_diemsg.format(self.cfg.columns, self.desc, min_cols), indent=' '))
  276. else:
  277. m, dim = (self.twidth_errmsg, min_cols) if cols < min_cols else (self.theight_errmsg, min_lines)
  278. get_char_raw(CUR_HOME + ERASE_ALL + fmt(m.format(self.desc, dim), append=''))
  279. user_resized = True
  280. else:
  281. return _term_dimensions(min_cols, ts.height)
  282. def compute_column_widths(self, widths, maxws, minws, maxws_nice, *, wide, interactive):
  283. def do_ret(freews):
  284. widths.update({k: minws[k] + freews.get(k, 0) for k in minws})
  285. widths.update({ikey: widths[key] - self.disp_prec - 1 for key, ikey in self.amt_keys.items()})
  286. return namedtuple('column_widths', widths.keys())(*widths.values())
  287. def do_ret_max():
  288. widths.update({k: max(minws[k], maxws[k]) for k in minws})
  289. widths.update({ikey: widths[key] - self.disp_prec - 1 for key, ikey in self.amt_keys.items()})
  290. return namedtuple('column_widths', widths.keys())(*widths.values())
  291. def get_freews(cols, varws, varw, minw):
  292. freew = cols - minw
  293. if freew and varw:
  294. x = freew / varw
  295. freews = {k: int(varws[k] * x) for k in varws}
  296. remainder = freew - sum(freews.values())
  297. for k in varws:
  298. if not remainder:
  299. break
  300. if freews[k] < varws[k]:
  301. freews[k] += 1
  302. remainder -= 1
  303. return freews
  304. else:
  305. return {k:0 for k in varws}
  306. varws = {k: maxws[k] - minws[k] for k in maxws if maxws[k] > minws[k]}
  307. minw = sum(widths.values()) + sum(minws.values())
  308. varw = sum(varws.values())
  309. self.min_term_width = 40 if wide else max(self.prompt_width, minw) if interactive else minw
  310. td = self.get_term_dimensions(self.min_term_width)
  311. self.term_height = td.height
  312. self.term_width = td.width
  313. self.cols = min(self.term_width, minw + varw)
  314. if wide or self.cols == minw + varw:
  315. return do_ret_max()
  316. if maxws_nice:
  317. # compute high-priority widths:
  318. varws_hp = {k: maxws_nice[k] - minws[k] if k in maxws_nice else varws[k] for k in varws}
  319. varw_hp = sum(varws_hp.values())
  320. widths_hp = get_freews(
  321. min(self.term_width, minw + varw_hp),
  322. varws_hp,
  323. varw_hp,
  324. minw)
  325. # compute low-priority (nice) widths:
  326. varws_lp = {k: varws[k] - varws_hp[k] for k in maxws_nice if k in varws}
  327. widths_lp = get_freews(
  328. self.cols,
  329. varws_lp,
  330. sum(varws_lp.values()),
  331. minw + sum(widths_hp.values()))
  332. # sum the two for each field:
  333. return do_ret({k: widths_hp[k] + widths_lp.get(k, 0) for k in varws})
  334. else:
  335. return do_ret(get_freews(self.cols, varws, varw, minw))
  336. def gen_subheader(self, cw, color):
  337. c_orange = (nocolor, orange)[color]
  338. c_yellow = (nocolor, yellow)[color]
  339. if self.rpc.is_remote:
  340. yield (
  341. c_orange(f'WARNING: Connecting to public {self.rpc.server_proto} node at ') +
  342. self.rpc.server_domain.hl(color=color))
  343. yield c_orange(' To improve anonymity, proxy requests via Tor or I2P')
  344. if self.twctl.use_cached_balances:
  345. yield c_yellow('Using cached balances. These may be out of date!')
  346. else:
  347. yield f'Displaying balances with {self.minconf} confirmation{suf(self.minconf)}'
  348. def gen_footer(self, color):
  349. if hasattr(self, 'total'):
  350. yield 'TOTAL: {} {}'.format(self.total.hl(color=color), self.proto.dcoin)
  351. def set_amt_widths(self, data):
  352. # width of amts column: min(7, width of integer part) + len('.') + width of fractional part
  353. self.amt_widths = {
  354. k: min(7, max(len(str(getattr(d, k).to_integral_value()))
  355. for d in data)) + 1 + self.disp_prec
  356. for k in self.amt_keys}
  357. async def format(
  358. self,
  359. display_type,
  360. *,
  361. color = True,
  362. interactive = False,
  363. line_processing = None,
  364. scroll = False):
  365. def make_display():
  366. def gen_hdr(spc):
  367. Blue, Green = (blue, green) if color else (nocolor, nocolor)
  368. Yes, No, All = (green('yes'), red('no'), yellow('all')) if color else ('yes', 'no', 'all')
  369. sort_info = ' '.join(self.sort_info())
  370. def fmt_filter(k):
  371. return '{}:{}'.format(k, {0: No, 1: Yes, 2: All}[getattr(self, k)])
  372. yield '{} (sort order: {}){}'.format(
  373. self.hdr_lbl.upper(),
  374. Blue(sort_info),
  375. spc * (self.cols - len(f'{self.hdr_lbl} (sort order: {sort_info})')))
  376. if self.filters:
  377. yield 'Filters: {}{}'.format(
  378. ' '.join(map(fmt_filter, self.filters)),
  379. spc * len(self.filters))
  380. yield 'Network: {}'.format(Green(
  381. self.proto.coin + ' ' + self.proto.chain_name.upper()))
  382. if not self.rpc.is_remote:
  383. yield 'Block {} [{}]'.format(
  384. self.rpc.blockcount.hl(color=color),
  385. make_timestr(self.rpc.cur_date))
  386. if hasattr(self, 'total'):
  387. yield 'Total {}: {}'.format(self.proto.dcoin, self.total.hl(color=color))
  388. yield from getattr(self, dt.subhdr_fmt_method)(cw, color)
  389. yield spc * self.term_width
  390. if data and dt.colhdr_fmt_method:
  391. col_hdr = getattr(self, dt.colhdr_fmt_method)(cw, hdr_fs, color)
  392. yield col_hdr.rstrip() if line_processing == 'print' else col_hdr
  393. def get_body(method):
  394. if line_processing:
  395. return getattr(self.line_processing, line_processing).do(
  396. method, data, cw, fs, color, getattr(self, dt.line_fmt_method))
  397. else:
  398. return method(data, cw, fs, color, getattr(self, dt.line_fmt_method))
  399. if data and dt.need_column_widths:
  400. self.set_amt_widths(data)
  401. cw = self.get_column_widths(data, wide=dt.detail, interactive=interactive)
  402. cwh = cw._asdict()
  403. fp = self.fs_params
  404. rfill = ' ' * (self.term_width - self.cols) if scroll else ''
  405. hdr_fs = ''.join(fp[name].hdr_fs % ((), cwh[name])[fp[name].hdr_fs_repl]
  406. for name in dt.cols if cwh[name]) + rfill
  407. fs = ''.join(fp[name].fs % ((), cwh[name])[fp[name].fs_repl]
  408. for name in dt.cols if cwh[name]) + rfill
  409. else:
  410. cw = hdr_fs = fs = None
  411. return (
  412. tuple(gen_hdr(spc='' if line_processing == 'print' else ' ')),
  413. tuple(
  414. get_body(getattr(self, dt.fmt_method)) if data else
  415. [(nocolor, yellow)[color](self.nodata_msg.ljust(self.term_width))]))
  416. if not gv.stdout.isatty():
  417. line_processing = 'print'
  418. dt = getattr(self.display_type, display_type)
  419. if self.use_cached:
  420. self.use_cached = False
  421. display_hdr = self.display_hdr
  422. display_body = self.display_body
  423. else:
  424. self.disp_prec = self.get_disp_prec(wide=dt.detail)
  425. if self.has_age and (self.age_fmt in self.age_fmts_date_dependent or dt.detail):
  426. await self.set_dates(self.data)
  427. dsave = self.disp_data
  428. data = self.disp_data = list(self.filter_data()) # method could be a generator
  429. if data != dsave:
  430. self.pos = 0
  431. display_hdr, display_body = make_display()
  432. if scroll:
  433. fixed_height = len(display_hdr) + self.prompt_height + 1
  434. if self.term_height - fixed_height < self.min_scrollable_height:
  435. td = self.get_term_dimensions(
  436. self.min_term_width,
  437. min_lines = self.min_scrollable_height + fixed_height)
  438. self.term_height = td.height
  439. self.term_width = td.width
  440. display_hdr, display_body = make_display()
  441. self.scrollable_height = self.term_height - fixed_height
  442. self.max_pos = max(0, len(display_body) - self.scrollable_height)
  443. self.pos = min(self.pos, self.max_pos)
  444. if not dt.detail:
  445. self.display_hdr = display_hdr
  446. self.display_body = display_body
  447. if scroll:
  448. top = self.pos
  449. bot = self.pos + self.scrollable_height
  450. fill = ('\n' + ''.ljust(self.term_width)) * (self.scrollable_height - len(display_body))
  451. else:
  452. top, bot, fill = (None, None, '')
  453. if interactive:
  454. footer = ''
  455. else:
  456. footer = '\n'.join(self.gen_footer(color))
  457. footer = ('\n\n' + footer if footer else '') + '\n'
  458. return (
  459. '\n'.join(display_hdr) + '\n'
  460. + dt.item_separator.join(display_body[top:bot])
  461. + fill
  462. + footer)
  463. async def view_filter_and_sort(self):
  464. action_map = {
  465. 'a_': 'action',
  466. 's_': 'sort_action',
  467. 'd_': 'display_action',
  468. 'm_': 'scroll_action',
  469. 'i_': 'item_action'}
  470. def make_key_mappings(scroll):
  471. if scroll:
  472. for k in self.scroll_keys['vi']:
  473. assert k not in self.key_mappings, f'{k!r} is in key_mappings'
  474. self.key_mappings.update(self.scroll_keys['vi'])
  475. self.key_mappings.update(self.scroll_keys[sys.platform])
  476. return self.key_mappings
  477. scroll = self.scroll = self.cfg.scroll
  478. key_mappings = make_key_mappings(scroll)
  479. action_classes = {k: getattr(self, action_map[v[:2]])() for k, v in key_mappings.items()}
  480. action_methods = {k: getattr(v, key_mappings[k]) for k, v in action_classes.items()}
  481. prompt = self.prompt_fs.strip().format(
  482. s='\nScrolling: k=up, j=down, b=pgup, f=pgdown, g=top, G=bottom' if scroll else '')
  483. self.prompt_width = max(len(l) for l in prompt.split('\n'))
  484. self.prompt_height = len(prompt.split('\n'))
  485. self.oneshot_msg = ''
  486. prompt += '\b'
  487. clear_screen = '\n\n' if self.cfg.no_blank else CUR_HOME + ('' if scroll else ERASE_ALL)
  488. from ..term import get_term, get_char, get_char_raw
  489. if scroll:
  490. self.term = get_term()
  491. self.term.register_cleanup()
  492. self.term.set('noecho')
  493. get_char = get_char_raw
  494. msg_r(CUR_HOME + ERASE_ALL)
  495. while True:
  496. if self.oneshot_msg and scroll:
  497. msg_r(self.blank_prompt + self.oneshot_msg + ' ') # oneshot_msg must be a one-liner
  498. await asyncio.sleep(2)
  499. msg_r('\r' + ''.ljust(self.term_width))
  500. reply = get_char(
  501. clear_screen
  502. + await self.format('squeezed', interactive=True, scroll=scroll)
  503. + '\n\n'
  504. + (self.oneshot_msg + '\n\n' if self.oneshot_msg and not scroll else '')
  505. + prompt,
  506. immed_chars = key_mappings)
  507. self.oneshot_msg = ''
  508. match reply:
  509. case ch if ch in key_mappings:
  510. func = action_classes[ch].run
  511. arg = action_methods[ch]
  512. await func(self, arg) if isAsync(func) else func(self, arg)
  513. case 'q':
  514. msg('')
  515. if self.scroll:
  516. self.term.set('echo')
  517. return
  518. case _:
  519. if not scroll:
  520. msg_r('\ninvalid keypress ')
  521. await asyncio.sleep(0.3)
  522. @property
  523. def blank_prompt(self):
  524. return CUR_HOME + CUR_DOWN(self.term_height - self.prompt_height) + ERASE_ALL
  525. def keypress_confirm(self, *args, **kwargs):
  526. from ..ui import keypress_confirm
  527. if keypress_confirm(self.cfg, *args, no_nl=self.scroll, **kwargs):
  528. return True
  529. else:
  530. if self.scroll:
  531. msg_r('\r'+''.ljust(self.term_width)+'\r'+yellow('Canceling! '))
  532. return False
  533. class action:
  534. @enable_echo
  535. async def run(self, parent, action_method):
  536. return await action_method(parent)
  537. async def a_print_detail(self, parent):
  538. return await self._print(parent, output_type='detail')
  539. async def a_print_squeezed(self, parent):
  540. return await self._print(parent, output_type='squeezed')
  541. async def _print(self, parent, output_type):
  542. if not parent.disp_data:
  543. return None
  544. outfile = '{a}{b}-{c}{d}[{e}].out'.format(
  545. a = parent.dump_fn_pfx,
  546. b = f'-{output_type}' if len(parent.print_output_types) > 1 else '',
  547. c = parent.proto.dcoin,
  548. d = ('' if parent.proto.network == 'mainnet' else '-'+parent.proto.network.upper()),
  549. e = ','.join(parent.sort_info(include_group=False)).replace(' ', ''))
  550. print_hdr = getattr(parent.display_type, output_type).print_header.format(parent.cols)
  551. msg_r(parent.blank_prompt if parent.scroll else '\n')
  552. from ..fileutil import write_data_to_file
  553. from ..exception import UserNonConfirmation
  554. try:
  555. write_data_to_file(
  556. cfg = parent.cfg,
  557. outfile = outfile,
  558. data = print_hdr + await parent.format(
  559. display_type = output_type,
  560. line_processing = 'print',
  561. color = False),
  562. desc = f'{parent.desc} listing')
  563. except UserNonConfirmation:
  564. parent.oneshot_msg = yellow(f'File {outfile!r} not overwritten by user request')
  565. else:
  566. parent.oneshot_msg = green(f'Data written to {outfile!r}')
  567. async def a_view(self, parent):
  568. from ..ui import do_pager
  569. parent.use_cached = True
  570. msg_r(CUR_HOME)
  571. do_pager(await parent.format('squeezed', color=True))
  572. async def a_view_detail(self, parent):
  573. from ..ui import do_pager
  574. msg_r(CUR_HOME)
  575. do_pager(await parent.format('detail', color=True))
  576. class item_action:
  577. @enable_echo
  578. async def run(self, parent, action_method):
  579. if not parent.disp_data:
  580. return
  581. from ..ui import line_input
  582. while True:
  583. msg_r(parent.blank_prompt if parent.scroll else '\n')
  584. ret = line_input(
  585. parent.cfg,
  586. f'Enter {parent.item_desc} number (or ENTER to return to main menu): ')
  587. if ret == '':
  588. if parent.scroll:
  589. msg_r(CUR_UP(1) + '\r' + ''.ljust(parent.term_width))
  590. return
  591. idx = get_obj(MMGenIdx, n=ret, silent=True)
  592. if not idx or idx < 1 or idx > len(parent.disp_data):
  593. msg_r(
  594. 'Choice must be a single number between 1 and {n}{s}'.format(
  595. n = len(parent.disp_data),
  596. s = ' ' if parent.scroll else ''))
  597. if parent.scroll:
  598. await asyncio.sleep(1.5)
  599. msg_r(CUR_UP(1) + '\r' + ERASE_ALL)
  600. else:
  601. # action return values:
  602. # True: action successfully performed
  603. # None: action aborted by user or no action performed
  604. # False: an error occurred
  605. # 'redo': user will be re-prompted for item number
  606. ret = await action_method(parent, idx)
  607. if ret != 'redo':
  608. break
  609. await asyncio.sleep(0.5)
  610. if parent.scroll and ret is False:
  611. # error messages could leave screen in messy state, so do complete redraw:
  612. msg_r(
  613. CUR_HOME + ERASE_ALL +
  614. await parent.format(display_type='squeezed', interactive=True, scroll=True))
  615. async def i_balance_refresh(self, parent, idx):
  616. if not parent.keypress_confirm(
  617. f'Refreshing tracking wallet {parent.item_desc} #{idx}. OK?'):
  618. return 'redo'
  619. msg_r('Refreshing balance...')
  620. res = await parent.twctl.get_balance(parent.disp_data[idx-1].addr, force_rpc=True)
  621. if res is None:
  622. parent.oneshot_msg = red(
  623. f'Unable to refresh {parent.proto.dcoin} balance for {parent.item_desc} #{idx}')
  624. return False
  625. else:
  626. await parent.get_data()
  627. parent.oneshot_msg = yellow(
  628. f'{parent.proto.dcoin} balance for {parent.item_desc} #{idx} refreshed')
  629. if res == 0:
  630. return False # zeroing balance may mess up display
  631. async def i_addr_delete(self, parent, idx):
  632. if not parent.keypress_confirm(
  633. 'Removing {} {} from tracking wallet. OK?'.format(
  634. parent.item_desc, red(f'#{idx}'))):
  635. return 'redo'
  636. if await parent.twctl.remove_address(parent.disp_data[idx-1].addr):
  637. await parent.get_data()
  638. parent.oneshot_msg = yellow(f'{capfirst(parent.item_desc)} #{idx} removed')
  639. return True
  640. else:
  641. await asyncio.sleep(3)
  642. parent.oneshot_msg = red('Address could not be removed')
  643. return False
  644. async def i_comment_add(self, parent, idx):
  645. async def do_comment_add(comment):
  646. if await parent.twctl.set_comment(
  647. addrspec = None,
  648. comment = comment,
  649. trusted_pair = (entry.twmmid, entry.addr),
  650. silent = parent.scroll):
  651. entry.comment = comment
  652. edited = cur_comment and comment
  653. parent.oneshot_msg = (green if comment else yellow)('Label {a} {b}{c}'.format(
  654. a = 'for' if edited else 'added to' if comment else 'removed from',
  655. b = desc,
  656. c = ' edited' if edited else ''))
  657. return True
  658. else:
  659. await asyncio.sleep(3)
  660. parent.oneshot_msg = red('Label for {desc} could not be {action}'.format(
  661. desc = desc,
  662. action = 'edited' if cur_comment and comment else 'added' if comment else 'removed'
  663. ))
  664. return False
  665. entry = parent.disp_data[idx-1]
  666. desc = f'{parent.item_desc} #{idx}'
  667. cur_comment = parent.disp_data[idx-1].comment
  668. msg('Current label: {}'.format(cur_comment.hl() if cur_comment else '(none)'))
  669. from ..ui import line_input
  670. res = line_input(
  671. parent.cfg,
  672. 'Enter label text for {} {}: '.format(parent.item_desc, red(f'#{idx}')),
  673. insert_txt = cur_comment)
  674. match res:
  675. case s if s == cur_comment:
  676. parent.oneshot_msg = yellow(f'Label for {desc} unchanged')
  677. return None
  678. case '':
  679. if not parent.keypress_confirm(f'Removing label for {desc}. OK?'):
  680. return 'redo'
  681. return await do_comment_add(res)
  682. class scroll_action:
  683. def run(self, parent, action_method):
  684. self.use_cached = True
  685. return action_method(parent)
  686. def m_cursor_up(self, parent):
  687. parent.pos -= min(parent.pos - 0, 1)
  688. def m_cursor_down(self, parent):
  689. parent.pos += min(parent.max_pos - parent.pos, 1)
  690. def m_pg_up(self, parent):
  691. parent.pos -= min(parent.scrollable_height, parent.pos - 0)
  692. def m_pg_down(self, parent):
  693. parent.pos += min(parent.scrollable_height, parent.max_pos - parent.pos)
  694. def m_top(self, parent):
  695. parent.pos = 0
  696. def m_bot(self, parent):
  697. parent.pos = parent.max_pos
  698. class sort_action:
  699. def run(self, parent, action_method):
  700. return action_method(parent)
  701. def s_addr(self, parent):
  702. parent.do_sort('addr')
  703. def s_age(self, parent):
  704. parent.do_sort('age')
  705. def s_amt(self, parent):
  706. parent.do_sort('amt')
  707. def s_txid(self, parent):
  708. parent.do_sort('txid')
  709. def s_twmmid(self, parent):
  710. parent.do_sort('twmmid')
  711. def s_reverse(self, parent):
  712. parent.data.reverse()
  713. parent.reverse = not parent.reverse
  714. class display_action:
  715. def run(self, parent, action_method):
  716. return action_method(parent)
  717. def d_days(self, parent):
  718. af = parent.age_fmts
  719. parent.age_fmt = af[(af.index(parent.age_fmt) + 1) % len(af)]
  720. if parent.update_widths_on_age_toggle: # TODO
  721. pass
  722. def d_redraw(self, parent):
  723. msg_r(CUR_HOME + ERASE_ALL)
  724. def d_addr_view_pref(self, parent):
  725. parent.addr_view_pref = (parent.addr_view_pref + 1) % len(parent.bch_addr_fmts)