view.py 24 KB

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