diff --git a/mmgen/addr.py b/mmgen/addr.py index 75e33fff..fb06bc95 100755 --- a/mmgen/addr.py +++ b/mmgen/addr.py @@ -146,6 +146,7 @@ class MMGenID(HiliteStr, InitErrors, MMGenObject): me = str.__new__(cls, id_str) idx, ext = idx.split('-', 1) me.acct_idx, me.addr_idx = [MoneroIdx(e) for e in ext.split('/', 1)] + me.acct_id = f'{sid}:{mmtype}:{idx}:{me.acct_idx}' else: ext = None me = str.__new__(cls, f'{sid}:{mmtype}:{idx}') @@ -154,14 +155,16 @@ class MMGenID(HiliteStr, InitErrors, MMGenObject): me.idx = AddrIdx(idx) me.al_id = str.__new__(AddrListID, me.sid + ':' + me.mmtype) # checks already done if ext: - me.sort_key = '{}:{}:{:0{w1}}:{:0{w2}}:{:0{w2}}'.format( + me.acct_sort_key = '{}:{}:{:0{w1}}:{:0{w2}}'.format( me.sid, me.mmtype, me.idx, me.acct_idx, - me.addr_idx, w1 = me.idx.max_digits, w2 = MoneroIdx.max_digits) + me.sort_key = me.acct_sort_key + ':{:0{w2}}'.format( + me.addr_idx, + w2 = MoneroIdx.max_digits) else: me.sort_key = '{}:{}:{:0{w}}'.format(me.sid, me.mmtype, me.idx, w=me.idx.max_digits) me.proto = proto diff --git a/mmgen/data/version b/mmgen/data/version index 5c5c5fa3..238282e6 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -16.1.dev15 +16.1.dev16 diff --git a/mmgen/proto/xmr/tw/addresses.py b/mmgen/proto/xmr/tw/addresses.py index 363b065b..b453d115 100755 --- a/mmgen/proto/xmr/tw/addresses.py +++ b/mmgen/proto/xmr/tw/addresses.py @@ -20,3 +20,12 @@ class MoneroTwAddresses(MoneroTwView, TwAddresses): include_empty = True has_used = True + + prompt_fs_repl = {'XMR': ( + (1, 'Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels'), + (3, 'Actions: [q]uit menu, add [l]abel, [R]efresh balances:'))} + extra_key_mappings = { + 'u': 'd_showused', + 'R': 'a_sync_wallets'} + removed_key_mappings = { + 'D': 'i_addr_delete'} diff --git a/mmgen/proto/xmr/tw/unspent.py b/mmgen/proto/xmr/tw/unspent.py index 5ca6a634..394e084d 100755 --- a/mmgen/proto/xmr/tw/unspent.py +++ b/mmgen/proto/xmr/tw/unspent.py @@ -12,12 +12,122 @@ proto.xmr.tw.unspent: Monero protocol tracking wallet unspent outputs class """ -from ....tw.addresses import TwAddresses +from collections import namedtuple + +from ....tw.unspent import TwUnspentOutputs +from ....addr import MMGenID, MoneroIdx +from ....color import red, green from .view import MoneroTwView -class MoneroTwUnspentOutputs(MoneroTwView, TwAddresses): +class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs): hdr_lbl = 'spendable accounts' - desc = 'address balances' + desc = 'spendable accounts' + item_desc = 'account' + account_based = True include_empty = False + total = None + nice_addr_w = {'addr': 20} + + prompt_fs_repl = {'XMR': ( + (1, 'Display options: r[e]draw screen'), + (3, 'Actions: [q]uit menu, add [l]abel, [R]efresh balances:'))} + extra_key_mappings = { + 'R': 'a_sync_wallets'} + + sort_disp = { + 'addr': 'Addr', + 'age': 'Age', + 'amt': 'Amt', + 'twmmid': 'MMGenID'} + + sort_funcs = { + 'addr': lambda i: '{}:{}'.format(i.twmmid.obj.acct_sort_key, i.addr), + 'age': lambda i: i.twmmid.sort_key, # dummy (age sort not supported) + 'amt': lambda i: '{}:{:050}'.format(i.twmmid.obj.acct_sort_key, i.amt.to_unit('atomic')), + 'twmmid': lambda i: i.twmmid.sort_key} + + def gen_data(self, rpc_data, lbl_id): + return ( + self.MMGenTwUnspentOutput( + self.proto, + twmmid = twmmid, + addr = data['addr'], + confs = data['confs'], + comment = data['lbl'].comment, + amt = data['amt']) + for twmmid, data in rpc_data.items()) + + def get_disp_data(self): + ad = namedtuple('accts_data', ['total', 'data']) + bd = namedtuple('accts_data_data', ['disp_data_idx', 'data']) + def gen_accts_data(): + acct_id_save, total, d_acc = (None, 0, {}) + for n, d in enumerate(self.data): + m = d.twmmid.obj + if acct_id_save != m.acct_id: + if acct_id_save: + yield (acct_id_save, ad(total, d_acc)) + acct_id_save = m.acct_id + total = d.amt + d_acc = {m.addr_idx: bd(n, d)} + else: + total += d.amt + d_acc[m.addr_idx] = bd(n, d) + if acct_id_save: + yield (acct_id_save, ad(total, d_acc)) + self.accts_data = dict(gen_accts_data()) + return super().get_disp_data() + + class display_type(TwUnspentOutputs.display_type): + + class squeezed(TwUnspentOutputs.display_type.squeezed): + cols = ('addr_idx', 'addr', 'comment', 'amt') + colhdr_fmt_method = None + fmt_method = 'gen_display' + + class detail(TwUnspentOutputs.display_type.detail): + cols = ('addr_idx', 'addr', 'amt', 'comment') + colhdr_fmt_method = None + fmt_method = 'gen_display' + line_fmt_method = 'squeezed_format_line' + + def get_column_widths(self, data, *, wide, interactive): + return self.compute_column_widths( + widths = { # fixed cols + 'addr_idx': MoneroIdx.max_digits, + 'amt': self.amt_widths['amt'], + 'spc': 4}, # 1 leading space plus 3 spaces between 4 cols + maxws = { # expandable cols + 'addr': max(len(d.addr) for d in data), + 'comment': max(d.comment.screen_width for d in data)}, + minws = { + 'addr': 16, + 'comment': len('Comment')}, + maxws_nice = self.nice_addr_w, + wide = wide, + interactive = interactive) + + def gen_display(self, data, cw, fs, color, fmt_method): + yes, no = (red('Yes '), green('No ')) if color else ('Yes ', 'No ') + fs_acct = '{:>4} {} {:%s} {}' % MoneroIdx.max_digits + wallet_wid = 15 + yield ' Wallet Account Balance'.ljust(self.term_width) + for n, (k, v) in enumerate(self.accts_data.items()): + mmid, acct_idx = k.rsplit(':', 1) + m = list(v.data.values())[0].data.twmmid.obj + yield fs_acct.format( + str(n + 1) + ')', + MMGenID.hlc(mmid.ljust(wallet_wid), color=color), + m.acct_idx.fmt(MoneroIdx.max_digits, color=color), + v.total.hl(color=color)).ljust(self.term_width) + for v in v.data.values(): + yield fmt_method(None, v.data, cw, fs, color, yes, no) + + def squeezed_format_line(self, n, d, cw, fs, color, yes, no): + return fs.format( + I = d.twmmid.obj.addr_idx.fmt(cw.addr_idx, color=color), + a = d.addr.fmt(self.addr_view_pref, cw.addr, color=color), + c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'), + A = d.amt.fmt(cw.iwidth, color=color, prec=self.disp_prec)) diff --git a/mmgen/proto/xmr/tw/view.py b/mmgen/proto/xmr/tw/view.py index 90c3bd52..975c3298 100755 --- a/mmgen/proto/xmr/tw/view.py +++ b/mmgen/proto/xmr/tw/view.py @@ -21,15 +21,6 @@ class MoneroTwView: caps = () is_remote = False - prompt_fs_repl = {'XMR': ( - (1, 'Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels'), - (3, 'Actions: [q]uit menu, add [l]abel, [R]efresh balances:'))} - extra_key_mappings = { - 'u': 'd_showused', - 'R': 'a_sync_wallets'} - removed_key_mappings = { - 'D': 'i_addr_delete'} - async def get_rpc_data(self): from mmgen.tw.shared import TwMMGenID, TwLabel diff --git a/mmgen/tw/unspent.py b/mmgen/tw/unspent.py index 296082b5..3b30d1a0 100755 --- a/mmgen/tw/unspent.py +++ b/mmgen/tw/unspent.py @@ -28,7 +28,7 @@ from ..obj import ( TwComment, CoinTxID, NonNegativeInt) -from ..addr import CoinAddr +from ..addr import CoinAddr, MoneroIdx from ..amt import CoinAmtChk from .shared import TwMMGenID, TwLabel, get_tw_label from .view import TwView @@ -166,6 +166,8 @@ class TwUnspentOutputs(TwView): 'amt2': self.amt_widths.get('amt2', 0), 'block': self.age_col_params['block'][0] if wide else 0, 'date_time': self.age_col_params['date_time'][0] if wide else 0, + 'addr_idx': MoneroIdx.max_digits, + 'acct_idx': MoneroIdx.max_digits, 'date': self.age_w, 'spc': self.disp_spc + (2 * show_mmid) + self.has_amt2}, maxws = { # expandable cols diff --git a/mmgen/tw/view.py b/mmgen/tw/view.py index 26906acd..81e2122a 100755 --- a/mmgen/tw/view.py +++ b/mmgen/tw/view.py @@ -27,7 +27,7 @@ from ..cfg import gv from ..objmethods import MMGenObject from ..obj import get_obj, MMGenIdx, MMGenList from ..color import nocolor, yellow, orange, green, red, blue -from ..util import msg, msg_r, fmt, die, capfirst, suf, make_timestr, isAsync +from ..util import msg, msg_r, fmt, die, capfirst, suf, make_timestr, isAsync, is_int from ..rpc import rpc_init from ..base_obj import AsyncInit @@ -80,6 +80,7 @@ class TwView(MMGenObject, metaclass=AsyncInit): def do(method, data, cw, fs, color, fmt_method): return [l.rstrip() for l in method(data, cw, fs, color, fmt_method)] + account_based = False has_wallet = True has_amt2 = False dates_set = False @@ -119,6 +120,7 @@ class TwView(MMGenObject, metaclass=AsyncInit): 'comment': fp('c', True, False, ' {c:%s}', ' {c}'), 'amt': fp('A', True, False, ' {A:%s}', ' {A}'), 'amt2': fp('B', True, False, ' {B:%s}', ' {B}'), + 'addr_idx': fp('I', True, False, ' {I:%s}', ' {I}'), 'date': fp('d', True, True, ' {d:%s}', ' {d:<%s}'), 'date_time': fp('D', True, True, ' {D:%s}', ' {D:%s}'), 'block': fp('b', True, True, ' {b:%s}', ' {b:<%s}'), @@ -700,18 +702,19 @@ class TwView(MMGenObject, metaclass=AsyncInit): if not parent.disp_data: return - async def do_error_msg(data): + async def do_error_msg(data, is_addr_idx): msg_r( 'Choice must be a single number between {n} and {m} inclusive{s}'.format( - n = 1, - m = len(data), + n = list(data.keys())[0] if is_addr_idx else 1, + m = list(data.keys())[-1] if is_addr_idx else len(data), s = ' ' if parent.scroll else '')) if parent.scroll: await asyncio.sleep(1.5) msg_r(CUR_UP(1) + '\r' + ERASE_ALL) - async def get_idx(desc, data): + async def get_idx(desc, data, *, is_addr_idx=False): from ..ui import line_input + ur = namedtuple('usr_idx_data', ['idx', 'addr_idx']) while True: msg_r(parent.blank_prompt if parent.scroll else '\n') usr_ret = line_input( @@ -721,13 +724,24 @@ class TwView(MMGenObject, metaclass=AsyncInit): if parent.scroll: msg_r(CUR_UP(1) + '\r' + ''.ljust(parent.term_width)) return None - idx = get_obj(MMGenIdx, n=usr_ret, silent=True) - if idx and idx <= len(data): - return idx - await do_error_msg(data) + if is_addr_idx: + if is_int(usr_ret) and int(usr_ret) in data: + return ur(MMGenIdx(data[int(usr_ret)].disp_data_idx + 1), int(usr_ret)) + else: + idx = get_obj(MMGenIdx, n=usr_ret, silent=True) + if idx and idx <= len(data): + return ur(idx, None) + await do_error_msg(data, is_addr_idx) async def get_idx_from_user(): - return await get_idx(f'{parent.item_desc} number', parent.disp_data) + if parent.account_based: + if res := await get_idx(f'{parent.item_desc} number', parent.accts_data): + return await get_idx( + 'address index', + list(parent.accts_data.values())[res.idx - 1].data, + is_addr_idx = True) + else: + return await get_idx(f'{parent.item_desc} number', parent.disp_data) while True: # action_method return values: @@ -736,8 +750,8 @@ class TwView(MMGenObject, metaclass=AsyncInit): # None: action aborted by user or no action performed # 'redo': user will be re-prompted for item number # 'redraw': action successfully performed, screen will be redrawn - if idx := await get_idx_from_user(): - ret = await action_method(parent, idx) + if usr_ret := await get_idx_from_user(): + ret = await action_method(parent, usr_ret.idx, usr_ret.addr_idx) else: ret = None if ret != 'redo': @@ -750,7 +764,7 @@ class TwView(MMGenObject, metaclass=AsyncInit): CUR_HOME + ERASE_ALL + await parent.format(display_type='squeezed', interactive=True, scroll=True)) - async def i_balance_refresh(self, parent, idx): + async def i_balance_refresh(self, parent, idx, addr_idx=None): if not parent.keypress_confirm( f'Refreshing tracking wallet {parent.item_desc} #{idx}. OK?'): return 'redo' @@ -767,7 +781,7 @@ class TwView(MMGenObject, metaclass=AsyncInit): if res == 0: return 'redraw' # zeroing balance may mess up display - async def i_addr_delete(self, parent, idx): + async def i_addr_delete(self, parent, idx, addr_idx=None): if not parent.keypress_confirm( 'Removing {} {} from tracking wallet. OK?'.format( parent.item_desc, red(f'#{idx}'))): @@ -781,7 +795,7 @@ class TwView(MMGenObject, metaclass=AsyncInit): parent.oneshot_msg = red('Address could not be removed') return False - async def i_comment_add(self, parent, idx): + async def i_comment_add(self, parent, idx, addr_idx=None): async def do_comment_add(comment_in): from ..obj import TwComment @@ -810,8 +824,12 @@ class TwView(MMGenObject, metaclass=AsyncInit): return False entry = parent.disp_data[idx-1] - desc = f'{parent.item_desc} #{idx}' - color_desc = f'{parent.item_desc} {red("#" + str(idx))}' + if addr_idx is None: + desc = f'{parent.item_desc} #{idx}' + color_desc = f'{parent.item_desc} {red("#" + str(idx))}' + else: + desc = f'address #{addr_idx}' + color_desc = f'address {red("#" + str(addr_idx))}' cur_comment = parent.disp_data[idx-1].comment msg('Current label: {}'.format(cur_comment.hl() if cur_comment else '(none)')) diff --git a/test/cmdtest_d/xmr_autosign.py b/test/cmdtest_d/xmr_autosign.py index f3f1283f..c040ff80 100755 --- a/test/cmdtest_d/xmr_autosign.py +++ b/test/cmdtest_d/xmr_autosign.py @@ -508,6 +508,8 @@ class CmdTestXMRCompat(CmdTestXMRAutosign): ('gen_kafile_miner', 'generating key-address file for Miner'), ('create_wallet_miner', 'creating Monero wallet for Miner'), ('mine_initial_coins', 'mining initial coins'), + ('fund_alice2', 'sending funds to Alice (wallet #2)'), + ('check_bal_alice2', 'mining, checking balance (wallet #2)'), ('fund_alice1', 'sending funds to Alice (wallet #1)'), ('mine_blocks', 'mining some blocks'), ('alice_listaddresses', 'performing operations on Alice’s tracking wallets (listaddresses)'), @@ -537,13 +539,15 @@ class CmdTestXMRCompat(CmdTestXMRAutosign): return self._alice_twops('listaddresses', 2, 'y', r'Primary account.*1\.234567891234') def alice_twview(self): - return self._alice_twops('twview', 1, 'n', r'New Label.*2\.469135782468') + return self._alice_twops('twview', 1, 'n', r'New Label.*2\.469135782468', addr_idx_num=0) - def _alice_twops(self, op, addr_num, add_timestr_resp, expect_str): + def _alice_twops(self, op, addr_num, add_timestr_resp, expect_str, *, addr_idx_num=None): self.insert_device_online() t = self.spawn('mmgen-tool', self.alice_opts + self.autosign_opts + [op, 'interactive=1']) t.expect(self.menu_prompt, 'l') t.expect('main menu): ', str(addr_num)) + if addr_idx_num is not None: + t.expect('main menu): ', str(addr_idx_num)) t.expect(': ', 'New Label\n') t.expect('(y/N): ', add_timestr_resp) t.expect(self.menu_prompt, 'R')