10 Commits bdd7dd3393 ... 1132d0ff6b

Author SHA1 Message Date
  The MMGen Project 1132d0ff6b proto.xmr.tw.unspent: implement account-based view 1 week ago
  The MMGen Project 46c6710a0b support Reth v1.9.3 1 week ago
  The MMGen Project f8bda10d70 tw.view: cleanups; refactor `item_action.run()` 1 week ago
  The MMGen Project eec4de2a01 Int: implement `fmtc()` with right justification 1 week ago
  The MMGen Project 465770bc93 tw.view: grouping cleanups 1 week ago
  The MMGen Project 3a598d5661 tw.view: variable, method renames 1 week ago
  The MMGen Project 112f2cd539 tw.view: get_data(), sort_data(): remove keyword params 1 week ago
  The MMGen Project 894b5d226b tw.view: `do_sort()` -> `sort_data()` 1 week ago
  The MMGen Project a575a03e1a tw.view: minor cleanups, whitespace 1 week ago
  The MMGen Project 95ef7abd1f README: minor cleanup 1 week ago

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # MMGen Wallet
 
-##### An online/offline cryptocurrency wallet for the command line
+##### A terminal-based online/offline cryptocurrency wallet
 
 ![build](https://github.com/mmgen/mmgen-wallet/workflows/build/badge.svg)
 ![ruff](https://github.com/mmgen/mmgen-wallet/workflows/ruff/badge.svg)

+ 5 - 2
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

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-16.1.dev15
+16.1.dev16

+ 3 - 2
mmgen/obj.py

@@ -263,6 +263,7 @@ class Int(int, Hilite, InitErrors):
 	min_val = None
 	max_val = None
 	max_digits = None
+	trunc_ok = False
 	color = 'red'
 
 	def __new__(cls, n, *, base=10):
@@ -282,10 +283,10 @@ class Int(int, Hilite, InitErrors):
 
 	@classmethod
 	def fmtc(cls, s, width, /, *, color=False):
-		return super().fmtc(str(s), width, color=color)
+		return cls.colorize(s.rjust(width), color=color)
 
 	def fmt(self, width, /, *, color=False):
-		return super().fmtc(str(self), width, color=color)
+		return self.fmtc(str(self), width, color=color)
 
 	def hl(self, **kwargs):
 		return super().colorize(str(self), **kwargs)

+ 4 - 1
mmgen/proto/btc/tw/unspent.py

@@ -45,7 +45,10 @@ class BitcoinTwUnspentOutputs(BitcoinTwView, TwUnspentOutputs):
 		scriptPubKey = ImmutableAttr(HexStr)
 
 	has_age = True
-	can_group = True
+	groupable = {
+		'addr':   'addr',
+		'twmmid': 'addr',
+		'txid':   'txid'}
 	disp_spc = 5
 	vout_w = 4
 	hdr_lbl = 'unspent outputs'

+ 1 - 1
mmgen/proto/eth/daemon.py

@@ -130,7 +130,7 @@ class geth_daemon(ethereum_daemon):
 		)
 
 class reth_daemon(geth_daemon):
-	daemon_data = _dd('Reth', 1009002, '1.9.2')
+	daemon_data = _dd('Reth', 1009003, '1.9.3')
 	version_pat = r'reth/v(\d+)\.(\d+)\.(\d+)'
 	exec_fn = 'reth'
 	version_info_arg = '--version'

+ 2 - 2
mmgen/proto/eth/tw/unspent.py

@@ -34,7 +34,7 @@ class EthereumTokenTwUnspentOutputs(EthereumTwUnspentOutputs):
 
 	has_amt2 = True
 
-	async def get_data(self, *args, **kwargs):
-		await super().get_data(*args, **kwargs)
+	async def get_data(self):
+		await super().get_data()
 		for e in self.data:
 			e.amt2 = await self.twctl.get_eth_balance(e.addr)

+ 9 - 0
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'}

+ 113 - 3
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))

+ 0 - 9
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
 

+ 2 - 1
mmgen/tool/rpc.py

@@ -64,12 +64,13 @@ class tool_cmd(tool_cmd_base):
 			**kwargs):
 
 		obj.reverse = reverse
+		obj.sort_key = sort or obj.sort_key
 		obj.age_fmt = age_fmt
 
 		for k, v in kwargs.items():
 			setattr(obj, k, v)
 
-		await obj.get_data(sort_key=sort, reverse_sort=reverse)
+		await obj.get_data()
 
 		if interactive:
 			await obj.view_filter_and_sort()

+ 13 - 13
mmgen/tw/addresses.py

@@ -79,8 +79,7 @@ class TwAddresses(TwView):
 			'amt',
 			'recvd',
 			'is_used',
-			'date',
-			'skip'}
+			'date'}
 		invalid_attrs = {'proto'}
 
 		twmmid  = ImmutableAttr(TwMMGenID, include_proto=True) # contains confs,txid(unused),date(unused),al_id
@@ -92,7 +91,6 @@ class TwAddresses(TwView):
 		recvd   = ImmutableAttr(CoinAmtChk, include_proto=True)
 		is_used = ImmutableAttr(bool)
 		date    = ListItemAttr(int, typeconv=False, reassign_ok=True)
-		skip    = ListItemAttr(str, typeconv=False, reassign_ok=True)
 
 		def __init__(self, proto, **kwargs):
 			self.__dict__['proto'] = proto
@@ -153,7 +151,7 @@ class TwAddresses(TwView):
 
 		return addrs
 
-	async def gen_data(self, rpc_data, lbl_id):
+	def gen_data(self, rpc_data, lbl_id):
 		return (
 			self.TwAddress(
 					self.proto,
@@ -165,20 +163,17 @@ class TwAddresses(TwView):
 					amt     = data['amt'],
 					recvd   = data['recvd'],
 					is_used = data['is_used'],
-					date    = 0,
-					skip    = '')
-				for twmmid, data in rpc_data.items()
-		)
+					date    = 0)
+				for twmmid, data in rpc_data.items())
 
-	def filter_data(self):
+	def get_disp_data(self):
 		if self.usr_addr_list:
 			return (d for d in self.data if d.twmmid.obj in self.usr_addr_list)
 		else:
 			return (d for d in self.data if
 				(self.all_labels and d.comment) or
 				(self.showused == 2 and d.is_used) or
-				(not (d.is_used and not self.showused) and (d.amt or self.showempty))
-			)
+				(not (d.is_used and not self.showused) and (d.amt or self.showempty)))
 
 	def get_column_widths(self, data, *, wide, interactive):
 
@@ -260,8 +255,13 @@ class TwAddresses(TwView):
 		if not self.dates_set:
 			bc = self.rpc.blockcount + 1
 			caddrs = [addr for addr in addrs if addr.confs]
-			hashes = await self.rpc.gathered_call('getblockhash', [(n,) for n in [bc - a.confs for a in caddrs]])
-			dates = [d['time'] for d in await self.rpc.gathered_call('getblockheader', [(h,) for h in hashes])]
+			hashes = await self.rpc.gathered_call(
+				'getblockhash',
+				[(n,) for n in [bc - a.confs for a in caddrs]])
+			dates = [d['time']
+				for d in await self.rpc.gathered_call(
+					'getblockheader',
+					[(h,) for h in hashes])]
 			for idx, addr in enumerate(caddrs):
 				addr.date = dates[idx]
 			self.dates_set = True

+ 1 - 1
mmgen/tw/prune.py

@@ -58,7 +58,7 @@ class TwAddressesPrune(TwAddresses):
 
 		pruned = []
 		self.reverse = False
-		self.do_sort('twmmid')
+		self.sort_data('twmmid')
 		self.data = list(gen())
 
 		return pruned

+ 17 - 12
mmgen/tw/txhistory.py

@@ -43,14 +43,15 @@ class TwTxHistory(TwView):
 
 	async def __init__(self, cfg, proto, *, sinceblock=0):
 		await super().__init__(cfg, proto)
-		self.sinceblock = NonNegativeInt(sinceblock if sinceblock >= 0 else self.rpc.blockcount + sinceblock)
+		self.sinceblock = NonNegativeInt(
+			sinceblock if sinceblock >= 0 else self.rpc.blockcount + sinceblock)
 
 	@property
 	def no_rpcdata_errmsg(self):
 		return 'No transaction history {}found!'.format(
 			f'from block {self.sinceblock} ' if self.sinceblock else '')
 
-	def filter_data(self):
+	def get_disp_data(self):
 		return (d for d in self.data if d.confirmations > 0 or self.show_unconfirmed)
 
 	def set_amt_widths(self, data):
@@ -131,9 +132,11 @@ class TwTxHistory(TwView):
 				n = str(n) + ')',
 				t = d.txid_disp(width=cw.txid, color=color) if hasattr(cw, 'txid') else None,
 				d = d.age_disp(self.age_fmt, width=self.age_w, color=color),
-				i = d.vouts_disp('inputs', width=cw.inputs, color=color, addr_view_pref=self.addr_view_pref),
+				i = d.vouts_disp(
+					'inputs', width=cw.inputs, color=color, addr_view_pref=self.addr_view_pref),
 				A = d.amt_disp(self.show_total_amt).fmt(cw.iwidth, prec=self.disp_prec, color=color),
-				o = d.vouts_disp('outputs', width=cw.outputs, color=color, addr_view_pref=self.addr_view_pref),
+				o = d.vouts_disp(
+					'outputs', width=cw.outputs, color=color, addr_view_pref=self.addr_view_pref),
 				c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'))
 
 	def gen_detail_display(self, data, cw, fs, color, fmt_method):
@@ -161,10 +164,11 @@ class TwTxHistory(TwView):
 				A = d.amt_disp(show_total_amt=True).hl(color=color),
 				B = d.amt_disp(show_total_amt=False).hl(color=color),
 				f = d.fee_disp(color=color),
-				i = d.vouts_list_disp('inputs', color=color, indent=' '*8, addr_view_pref=self.addr_view_pref),
+				i = d.vouts_list_disp(
+					'inputs', color=color, indent=' '*8, addr_view_pref=self.addr_view_pref),
 				N = d.nOutputs,
-				o = d.vouts_list_disp('outputs', color=color, indent=' '*8, addr_view_pref=self.addr_view_pref),
-			)
+				o = d.vouts_list_disp(
+					'outputs', color=color, indent=' '*8, addr_view_pref=self.addr_view_pref))
 
 	sort_disp = {
 		'age':         'Age',
@@ -174,8 +178,9 @@ class TwTxHistory(TwView):
 		'txid':        'TxID'}
 
 	sort_funcs = {
-		'age':         lambda i: '{:010}.{:010}'.format(0xffffffff - abs(i.confirmations), i.time_received or 0),
-		'blockheight': lambda i: 0 - abs(i.confirmations), # old/altcoin daemons return no 'blockheight' field
+		'age':         lambda i: '{:010}.{:010}'.format(
+			0xffffffff - abs(i.confirmations), i.time_received or 0),
+		'blockheight': lambda i: 0 - abs(i.confirmations), # old/altcoin daemons lack 'blockheight' field
 		'amt':         lambda i: i.wallet_outputs_total,
 		'total_amt':   lambda i: i.outputs_total,
 		'txid':        lambda i: i.txid}
@@ -190,14 +195,14 @@ class TwTxHistory(TwView):
 	class sort_action(TwView.sort_action):
 
 		def s_blockheight(self, parent):
-			parent.do_sort('blockheight')
+			parent.sort_data('blockheight')
 
 		def s_amt(self, parent):
-			parent.do_sort('amt')
+			parent.sort_data('amt')
 			parent.show_total_amt = False
 
 		def s_total_amt(self, parent):
-			parent.do_sort('total_amt')
+			parent.sort_data('total_amt')
 			parent.show_total_amt = True
 
 	class display_action(TwView.display_action):

+ 21 - 21
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
@@ -36,7 +36,6 @@ from .view import TwView
 class TwUnspentOutputs(TwView):
 
 	has_age = False
-	can_group = False
 	show_mmid = True
 	hdr_lbl = 'tracked addresses'
 	desc    = 'address balances'
@@ -120,7 +119,8 @@ class TwUnspentOutputs(TwView):
 					'twmmid':  l.mmid,
 					'comment': l.comment or '',
 					'addr':    CoinAddr(self.proto, o['address']),
-					'confs':   o['confirmations']})
+					'confs':   o['confirmations'],
+					'skip':    ''})
 				yield self.MMGenTwUnspentOutput(
 					self.proto,
 					**{k: v for k, v in o.items() if k in self.MMGenTwUnspentOutput.valid_attrs})
@@ -138,21 +138,19 @@ class TwUnspentOutputs(TwView):
 				'confirmations': minconf,
 				} for d in wl]
 
-	def filter_data(self):
+	def get_disp_data(self):
 
-		data = self.data.copy()
-
-		for d in data:
+		for d in self.data:
 			d.skip = ''
 
-		gkeys = {'addr': 'addr', 'twmmid': 'addr', 'txid': 'txid'}
-		if self.group and self.sort_key in gkeys:
-			for a, b in [(data[i], data[i+1]) for i in range(len(data)-1)]:
-				for k in gkeys:
-					if self.sort_key == k and getattr(a, k) == getattr(b, k):
-						b.skip = gkeys[k]
+		if self.group and (e := self.sort_key) in self.groupable:
+			data = self.data
+			skip = self.groupable[e]
+			for i in range(len(data) - 1):
+				if getattr(data[i], e) == getattr(data[i + 1], e):
+					data[i + 1].skip = skip
 
-		return data
+		return self.data
 
 	def get_column_widths(self, data, *, wide, interactive):
 
@@ -168,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
@@ -182,8 +182,7 @@ class TwUnspentOutputs(TwView):
 				self.nice_addr_w if show_mmid else {}
 			) | self.txid_nice_w,
 			wide = wide,
-			interactive = interactive,
-		)
+			interactive = interactive)
 
 	def squeezed_col_hdr(self, cw, fs, color):
 		return fs.format(
@@ -225,8 +224,7 @@ class TwUnspentOutputs(TwView):
 				c = d.comment.fmt2(cw.comment, color=color, nullrepl='-') if cw.comment else None,
 				A = d.amt.fmt(cw.iwidth, color=color, prec=self.disp_prec),
 				B = d.amt2.fmt(cw.iwidth2, color=color, prec=self.disp_prec) if cw.amt2 else None,
-				d = self.age_disp(d, self.age_fmt),
-			)
+				d = self.age_disp(d, self.age_fmt))
 
 	def gen_detail_display(self, data, cw, fs, color, fmt_method):
 
@@ -255,7 +253,9 @@ class TwUnspentOutputs(TwView):
 		if not self.dates_set:
 			# 'blocktime' differs from 'time', is same as getblockheader['time']
 			dates = [o.get('blocktime', 0)
-				for o in await self.rpc.gathered_icall('gettransaction', [(o.txid, True, False) for o in us])]
+				for o in await self.rpc.gathered_icall(
+					'gettransaction',
+					[(o.txid, True, False) for o in us])]
 			for idx, o in enumerate(us):
 				o.date = dates[idx]
 			self.dates_set = True
@@ -263,7 +263,7 @@ class TwUnspentOutputs(TwView):
 	class sort_action(TwView.sort_action):
 
 		def s_twmmid(self, parent):
-			parent.do_sort('twmmid')
+			parent.sort_data('twmmid')
 			parent.show_mmid = True
 
 	class display_action(TwView.display_action):
@@ -272,5 +272,5 @@ class TwUnspentOutputs(TwView):
 			parent.show_mmid = not parent.show_mmid
 
 		def d_group(self, parent):
-			if parent.can_group:
+			if parent.groupable:
 				parent.group = not parent.group

+ 113 - 75
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,11 +80,13 @@ 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
 	reverse     = False
 	group       = False
+	groupable   = {}
 	use_cached  = False
 	minconf     = 1
 	txid_w      = 0
@@ -118,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}'),
@@ -260,24 +263,22 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 	def sort_info(self, *, include_group=True):
 		ret = ([], ['Reverse'])[self.reverse]
 		ret.append(self.sort_disp[self.sort_key])
-		if include_group and self.group and (self.sort_key in ('addr', 'txid', 'twmmid')):
+		if include_group and self.group and self.sort_key in self.groupable:
 			ret.append('Grouped')
 		return ret
 
-	def do_sort(self, key=None, *, reverse=False):
+	def sort_data(self, key):
 		if key == 'txid' and not self.txid_w:
 			return
-		key = key or self.sort_key
 		if key not in self.sort_funcs:
 			die(1, f'{key!r}: invalid sort key.  Valid options: {" ".join(self.sort_funcs)}')
 		self.sort_key = key
-		assert isinstance(reverse, bool)
 		save = self.data.copy()
-		self.data.sort(key=self.sort_funcs[key], reverse=reverse or self.reverse)
+		self.data.sort(key=self.sort_funcs[key], reverse=self.reverse)
 		if self.data != save:
 			self.pos = 0
 
-	async def get_data(self, *, sort_key=None, reverse_sort=False):
+	async def get_data(self):
 
 		rpc_data = await self.get_rpc_data()
 
@@ -290,12 +291,12 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			await self.gen_data(rpc_data, lbl_id) if isAsync(self.gen_data) else
 			self.gen_data(rpc_data, lbl_id))
 
-		self.disp_data = list(self.filter_data())
-
 		if not self.data:
 			die(1, f'No {self.item_desc_pl} in tracking wallet!')
 
-		self.do_sort(key=sort_key, reverse=reverse_sort)
+		self.sort_data(self.sort_key)
+
+		self.disp_data = tuple(self.get_disp_data())
 
 		# get_data() is immediately followed by display header, and get_rpc_data() produces output,
 		# so add NL here (' ' required because CUR_HOME erases preceding blank lines)
@@ -314,9 +315,13 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 				return _term_dimensions(cols, ts.height)
 			if sys.stdout.isatty():
 				if self.cfg.columns and cols < min_cols:
-					die(1, '\n'+fmt(self.twidth_diemsg.format(self.cfg.columns, self.desc, min_cols), indent='  '))
+					die(1, '\n'+fmt(
+						self.twidth_diemsg.format(self.cfg.columns, self.desc, min_cols),
+						indent = '  '))
 				else:
-					m, dim = (self.twidth_errmsg, min_cols) if cols < min_cols else (self.theight_errmsg, min_lines)
+					m, dim = (
+						(self.twidth_errmsg, min_cols) if cols < min_cols else
+						(self.theight_errmsg, min_lines))
 					get_char_raw(CUR_HOME + ERASE_ALL + fmt(m.format(self.desc, dim), append=''))
 					user_resized = True
 			else:
@@ -325,12 +330,10 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 	def compute_column_widths(self, widths, maxws, minws, maxws_nice, *, wide, interactive):
 
 		def do_ret(freews):
-			widths.update({k: minws[k] + freews.get(k, 0) for k in minws})
-			widths.update({ikey: widths[key] - self.disp_prec - 1 for key, ikey in self.amt_keys.items()})
-			return namedtuple('column_widths', widths.keys())(*widths.values())
-
-		def do_ret_max():
-			widths.update({k: max(minws[k], maxws[k]) for k in minws})
+			if freews:
+				widths.update({k: minws[k] + freews.get(k, 0) for k in minws})
+			else:
+				widths.update({k: max(minws[k], maxws[k]) for k in minws})
 			widths.update({ikey: widths[key] - self.disp_prec - 1 for key, ikey in self.amt_keys.items()})
 			return namedtuple('column_widths', widths.keys())(*widths.values())
 
@@ -362,7 +365,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 		self.cols = min(self.term_width, minw + varw)
 
 		if wide or self.cols == minw + varw:
-			return do_ret_max()
+			return do_ret(None)
 
 		if maxws_nice:
 			# compute high-priority widths:
@@ -422,8 +425,13 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 
 			def gen_hdr(spc):
 
-				Blue, Green = (blue, green) if color else (nocolor, nocolor)
-				Yes, No, All = (green('yes'), red('no'), yellow('all')) if color else ('yes', 'no', 'all')
+				if color:
+					Blue, Green = (blue, green)
+					Yes, No, All = (green('yes'), red('no'), yellow('all'))
+				else:
+					Blue, Green = (nocolor, nocolor)
+					Yes, No, All = ('yes', 'no', 'all')
+
 				sort_info = ' '.join(self.sort_info())
 
 				def fmt_filter(k):
@@ -454,20 +462,20 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 
 				yield spc * self.term_width
 
-				if data and dt.colhdr_fmt_method:
+				if self.disp_data and dt.colhdr_fmt_method:
 					col_hdr = getattr(self, dt.colhdr_fmt_method)(cw, hdr_fs, color)
 					yield col_hdr.rstrip() if line_processing == 'print' else col_hdr
 
 			def get_body(method):
 				if line_processing:
 					return getattr(self.line_processing, line_processing).do(
-						method, data, cw, fs, color, getattr(self, dt.line_fmt_method))
+						method, self.disp_data, cw, fs, color, getattr(self, dt.line_fmt_method))
 				else:
-					return method(data, cw, fs, color, getattr(self, dt.line_fmt_method))
+					return method(self.disp_data, cw, fs, color, getattr(self, dt.line_fmt_method))
 
-			if data and dt.need_column_widths:
-				self.set_amt_widths(data)
-				cw = self.get_column_widths(data, wide=dt.detail, interactive=interactive)
+			if self.disp_data and dt.need_column_widths:
+				self.set_amt_widths(self.disp_data)
+				cw = self.get_column_widths(self.disp_data, wide=dt.detail, interactive=interactive)
 				cwh = cw._asdict()
 				fp = self.fs_params
 				rfill = ' ' * (self.term_width - self.cols) if scroll else ''
@@ -481,7 +489,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			return (
 				tuple(gen_hdr(spc='' if line_processing == 'print' else ' ')),
 				tuple(
-					get_body(getattr(self, dt.fmt_method)) if data else
+					get_body(getattr(self, dt.fmt_method)) if self.disp_data else
 					[(nocolor, yellow)[color](self.nodata_msg.ljust(self.term_width))]))
 
 		if not gv.stdout.isatty():
@@ -499,10 +507,10 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			if self.has_age and (self.age_fmt in self.age_fmts_date_dependent or dt.detail):
 				await self.set_dates(self.data)
 
-			dsave = self.disp_data
-			data = self.disp_data = list(self.filter_data()) # method could be a generator
+			disp_data_save = self.disp_data
+			self.disp_data = tuple(self.get_disp_data()) # method could be a generator
 
-			if data != dsave:
+			if self.disp_data != disp_data_save:
 				self.pos = 0
 
 			display_hdr, display_body = make_display()
@@ -694,36 +702,61 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			if not parent.disp_data:
 				return
 
-			from ..ui import line_input
+			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 = 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, *, 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(
+						parent.cfg,
+						f'Enter {desc} (or ENTER to return to main menu): ')
+					if usr_ret == '':
+						if parent.scroll:
+							msg_r(CUR_UP(1) + '\r' + ''.ljust(parent.term_width))
+						return None
+					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():
+				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:
-				msg_r(parent.blank_prompt if parent.scroll else '\n')
-				ret = line_input(
-					parent.cfg,
-					f'Enter {parent.item_desc} number (or ENTER to return to main menu): ')
-				if ret == '':
-					if parent.scroll:
-						msg_r(CUR_UP(1) + '\r' + ''.ljust(parent.term_width))
-					return
-				idx = get_obj(MMGenIdx, n=ret, silent=True)
-				if not idx or idx < 1 or idx > len(parent.disp_data):
-					msg_r(
-						'Choice must be a single number between 1 and {n}{s}'.format(
-							n = len(parent.disp_data),
-							s = ' ' if parent.scroll else ''))
-					if parent.scroll:
-						await asyncio.sleep(1.5)
-						msg_r(CUR_UP(1) + '\r' + ERASE_ALL)
+				# action_method return values:
+				#  True:   action successfully performed
+				#  False:  an error occurred
+				#  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 usr_ret := await get_idx_from_user():
+					ret = await action_method(parent, usr_ret.idx, usr_ret.addr_idx)
 				else:
-					# action return values:
-					#  True:   action successfully performed
-					#  None:   action aborted by user or no action performed
-					#  False:  an error occurred
-					#  'redo': user will be re-prompted for item number
-					#  'redraw': action successfully performed, screen will be redrawn
-					ret = await action_method(parent, idx)
-					if ret != 'redo':
-						break
-					await asyncio.sleep(0.5)
+					ret = None
+				if ret != 'redo':
+					break
+				await asyncio.sleep(0.5)
 
 			if parent.scroll and ret is False or ret == 'redraw':
 				# error messages could leave screen in messy state, so do complete redraw:
@@ -731,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'
@@ -748,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}'))):
@@ -762,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
@@ -784,27 +817,32 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 					await asyncio.sleep(3)
 					parent.oneshot_msg = red('Label for {desc} could not be {action}'.format(
 						desc = desc,
-						action = 'edited' if cur_comment and comment else 'added' if comment else 'removed'
-					))
+						action =
+							'edited' if cur_comment and comment else
+							'added' if comment else
+							'removed'))
 					return False
 
 			entry = parent.disp_data[idx-1]
-			desc = f'{parent.item_desc} #{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)'))
 
 			from ..ui import line_input
-			res = line_input(
-				parent.cfg,
-				'Enter label text for {} {}: '.format(parent.item_desc, red(f'#{idx}')),
-				insert_txt = cur_comment)
+			res = line_input(parent.cfg, f'Enter label text for {color_desc}: ', insert_txt=cur_comment)
 
 			match res:
 				case s if s == cur_comment:
 					parent.oneshot_msg = yellow(f'Label for {desc} unchanged')
 					return None
 				case '':
-					if not parent.keypress_confirm(f'Removing label for {desc}. OK?'):
+					if not parent.keypress_confirm(f'Removing label for {color_desc}. OK?'):
 						return 'redo'
 
 			return await do_comment_add(res)
@@ -839,19 +877,19 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			return action_method(parent)
 
 		def s_addr(self, parent):
-			parent.do_sort('addr')
+			parent.sort_data('addr')
 
 		def s_age(self, parent):
-			parent.do_sort('age')
+			parent.sort_data('age')
 
 		def s_amt(self, parent):
-			parent.do_sort('amt')
+			parent.sort_data('amt')
 
 		def s_txid(self, parent):
-			parent.do_sort('txid')
+			parent.sort_data('txid')
 
 		def s_twmmid(self, parent):
-			parent.do_sort('twmmid')
+			parent.sort_data('twmmid')
 
 		def s_reverse(self, parent):
 			parent.data.reverse()

+ 2 - 2
nix/reth.nix

@@ -17,7 +17,7 @@ in
 
 pkgs.rustPlatform.buildRustPackage rec {
     pname = "reth";
-    version = "1.9.2";
+    version = "1.9.3";
 
     src = fetchGit {
         url = "https://github.com/paradigmxyz/reth";
@@ -26,7 +26,7 @@ pkgs.rustPlatform.buildRustPackage rec {
         shallow = true;
     };
 
-    cargoHash = "sha256-NGVHKoh/coGMkI5tcF+UnylGa1RO8K/rQRpFVTgaw5Y=";
+    cargoHash = "sha256-WDe75Sg7y4GfH3dSfY48aXrIBe89skj1VW0NcgtLEVU=";
 
     nativeBuildInputs = [
         pkgs.clang

+ 6 - 2
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')