Curses-like scrolling UI for tracking wallet views

A stationary header and footer with scrollable area in the middle creates
a much improved user experience compared to the old “dumb terminal” interface,
especially for large tracking wallets.

Scroll with either the Vi keys or up/down/arrow keys.

All tracking wallet views supported: unspent outputs, address list, tx history.

Activate with --scroll on the command line, or by setting the `scroll` option
in the config file.

This feature is entirely opt-in.  The old behavior continues to be supported
without alteration.

Sample invocation:

    $ mmgen-txcreate -qi --scroll

Interactive testing:

    # run the required regtest test dependencies:
    $ test/test.py -d regtest.view

    # test the scrolling UI interface interactively:
    $ PYTHONPATH=. MMGEN_TEST_SUITE=1 cmds/mmgen-tool --bob --scroll listaddresses interactive=1

    # when finished, stop the regtest daemon:
    $ test/stop-coin-daemons.py btc_rt
This commit is contained in:
The MMGen Project 2022-12-07 10:40:59 +00:00
commit b26657fb68
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
12 changed files with 200 additions and 24 deletions

View file

@ -5,6 +5,9 @@
## User options ##
##################
# Uncomment to enable the curses-like scrolling UI for tracking wallet views
# scroll true
# Uncomment to suppress the GPL license prompt:
# no_license true

View file

@ -1 +1 @@
13.3.dev25
13.3.dev26

View file

@ -121,6 +121,7 @@ class GlobalContext(Lockable):
mnemonic_entry_modes = {}
# display:
scroll = False
columns = 0
color = bool(
( sys.stdout.isatty() and not os.getenv('MMGEN_TEST_SUITE_PEXPECT') ) or
@ -183,6 +184,7 @@ class GlobalContext(Lockable):
'rpc_password',
'rpc_port',
'rpc_user',
'scroll',
'testnet',
'token' )
@ -216,6 +218,7 @@ class GlobalContext(Lockable):
'rpc_password',
'rpc_port',
'rpc_user',
'scroll',
'subseeds',
'testnet',
'usr_randchars',

View file

@ -239,6 +239,8 @@ common_opts_data = {
--, --token=t Specify an ERC20 token by address or symbol
--, --color=0|1 Disable or enable color output (enabled by default)
--, --columns=N Force N columns of output with certain commands
--, --scroll Use the curses-like scrolling interface for
tracking wallet views
--, --force-256-color Force 256-color output when color is enabled
--, --pager Pipe output of certain commands to pager (WIP)
--, --data-dir=path Specify {pnm} data directory location

View file

@ -28,6 +28,13 @@ Column options: toggle [D]ays/date/confs/block
Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels
View/Print: pager [v]iew, [w]ide view, [p]rint
Actions: [q]uit view, r[e]draw, add [l]abel:
"""
prompt_scroll = """
Sort options: [a]mt, [A]ge, [M]mgen addr, [r]everse
Column options: toggle [D]ays/date/confs/block
Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels
Scrolling: k=up, j=down, b=pgup, f=pgdown, g=top, G=bottom
Actions: [w]ide view, [q]uit, [p]rint, r[e]draw, add [l]abel:
"""
key_mappings = {
'a':'s_amt',

View file

@ -235,6 +235,13 @@ Sorting: [t]xid, [a]mt, total a[m]t, [A]ge, block[n]um, [r]everse
Column opts: toggle [D]ays/date/confs/block, tx[i]d, [T]otal amt
View/Print: pager [v]iew, full [V]iew, screen [p]rint, full [P]rint
Filters/Actions: show [u]nconfirmed, [q]uit view, r[e]draw:
"""
prompt_scroll = """
Sorting: [t]xid, [a]mt, total a[m]t, [A]ge, block[n]um, [r]everse
Column opts: toggle [D]ays/date/confs/block, tx[i]d, [T]otal amt
View/Print: full [V]iew, screen [p]rint, full [P]rint
Scrolling: k=up, j=down, b=pgup, f=pgdown, g=top, G=bottom
Filters/Actions: show [u]nconfirmed, [q]uit view, r[e]draw:
"""
key_mappings = {
'A':'s_age',

View file

@ -35,6 +35,12 @@ Sort options: [t]xid, [a]mount, a[d]dr, [A]ge, [r]everse, [M]mid
Display options: toggle [D]ays/date, show gr[o]up, show [m]mid
View options: pager [v]iew, [w]ide view
Actions: [q]uit view, [p]rint, r[e]draw, add [l]abel:
"""
prompt_scroll = """
Sort options: [t]xid, [a]mount, a[d]dr, [A]ge, [r]everse, [M]mid
Display options: toggle [D]ays/date, show gr[o]up, show [m]mid
Scrolling: k=up, j=down, b=pgup, f=pgdown, g=top, G=bottom
View/Actions: [w]ide view, [q]uit, [p]rint, r[e]draw, add [l]abel:
"""
key_mappings = {
't':'s_txid',

View file

@ -26,6 +26,13 @@ Sort options: [a]mt, [M]mgen addr, [r]everse
Filters: show [E]mpty addrs, all [L]abels
View/Print: pager [v]iew, [w]ide view, [p]rint
Actions: [q]uit view, r[e]draw, [D]elete addr, add [l]abel:
"""
prompt_scroll = """
Sort options: [a]mt, [M]mgen addr, [r]everse
Filters: show [E]mpty addrs, all [L]abels
View/Print: [w]ide view, [p]rint
Scrolling: k=up, j=down, b=pgup, f=pgdown, g=top, G=bottom
Actions: [q]uit view, r[e]draw, [D]elete addr, add [l]abel:
"""
key_mappings = {
'a':'s_amt',

View file

@ -50,6 +50,13 @@ Sort options: [a]mount, a[d]dress, [r]everse, [M]mgen addr
Display options: show [m]mgen addr, r[e]draw screen
View options: [q]uit view, [p]rint to file, [v]iew, [w]ide view
Actions: [D]elete addr, add [l]abel, [R]efresh balance:
"""
prompt_scroll = """
Sort options: [a]mount, a[d]dress, [r]everse, [M]mgen addr
Display options: show [m]mgen addr, r[e]draw screen
View options: [q]uit view, [p]rint to file, [w]ide view
Scrolling: k=up, j=down, b=pgup, f=pgdown, g=top, G=bottom
Actions: [D]elete addr, add [l]abel, [R]efresh balance:
"""
key_mappings = {
'a':'s_amt',

View file

@ -207,7 +207,7 @@ class TwAddresses(TwView):
for n,d in enumerate(data,1):
if id_save != d.al_id:
id_save = d.al_id
yield ''
yield ''.ljust(self.term_width)
yield fmt_method(n,d,cw,fs,color,yes,no)
async def set_dates(self,addrs):

View file

@ -37,7 +37,7 @@ class TwAddressesPrune(TwAddresses):
for n,d in enumerate(data,1):
if id_save != d.al_id:
id_save = d.al_id
yield ''
yield ''.ljust(self.term_width)
yield (
gray(fmt_method(n,d,cw,fs,False,'Yes ','No ')) if d.tag else
fmt_method(n,d,cw,fs,True,yes,no) )

View file

@ -33,6 +33,8 @@ from ..rpc import rpc_init
from ..base_obj import AsyncInit
CUR_HOME = '\033[H'
CUR_UP = lambda n: f'\033[{n}A'
CUR_DOWN = lambda n: f'\033[{n}B'
CUR_RIGHT = lambda n: f'\033[{n}C'
ERASE_ALL = '\033[0J'
@ -81,6 +83,9 @@ class TwView(MMGenObject,metaclass=AsyncInit):
cols = 0
term_height = 0
term_width = 0
scrollable_height = 0
min_scrollable_height = 5
pos = 0
filters = ()
fp = namedtuple('fs_params',['fs_key','hdr_fs_repl','fs_repl','hdr_fs','fs'])
@ -131,10 +136,41 @@ class TwView(MMGenObject,metaclass=AsyncInit):
Screen is too narrow to display the {}
Please resize your screen to at least {} characters and hit any key:
"""
theight_errmsg = """
Terminal window is too small to display the {}
Please resize it to at least {} lines and hit any key:
"""
squeezed_format_line = None
detail_format_line = None
scroll_keys = {
'vi': {
'k': 'm_cursor_up',
'j': 'm_cursor_down',
'b': 'm_pg_up',
'f': 'm_pg_down',
'g': 'm_top',
'G': 'm_bot',
},
'linux': {
'\x1b[A': 'm_cursor_up',
'\x1b[B': 'm_cursor_down',
'\x1b[5~': 'm_pg_up',
'\x1b[6~': 'm_pg_down',
'\x1b[7~': 'm_top',
'\x1b[8~': 'm_bot',
},
'win': {
'\xe0H': 'm_cursor_up',
'\xe0P': 'm_cursor_down',
'\xe0I': 'm_pg_up',
'\xe0Q': 'm_pg_down',
'\xe0G': 'm_top',
'\xe0O': 'm_bot',
}
}
def __new__(cls,proto,*args,**kwargs):
return MMGenObject.__new__(proto.base_proto_subclass(cls,cls.mod_subpath))
@ -204,7 +240,10 @@ class TwView(MMGenObject,metaclass=AsyncInit):
die(1,f'{key!r}: invalid sort key. Valid options: {" ".join(self.sort_funcs)}')
self.sort_key = key
assert type(reverse) == bool
save = self.data.copy()
self.data.sort(key=self.sort_funcs[key],reverse=reverse or self.reverse)
if self.data != save:
self.pos = 0
async def get_data(self,sort_key=None,reverse_sort=False):
@ -227,21 +266,22 @@ class TwView(MMGenObject,metaclass=AsyncInit):
def filter_data(self):
return self.data.copy()
def get_term_dimensions(self,min_cols):
def get_term_dimensions(self,min_cols,min_lines=None):
from ..term import get_terminal_size,get_char_raw,_term_dimensions
user_resized = False
while True:
ts = get_terminal_size()
cols = g.columns or ts.width
if cols >= min_cols:
lines = ts.height
if cols >= min_cols and (min_lines is None or lines >= min_lines):
if user_resized:
msg_r(CUR_HOME + ERASE_ALL)
return _term_dimensions(cols,ts.height)
if sys.stdout.isatty():
if g.columns:
if g.columns and cols < min_cols:
die(1,'\n'+fmt(self.twidth_diemsg.format(g.columns,self.desc,min_cols),indent=' '))
else:
m,dim = (self.twidth_errmsg,min_cols)
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:
@ -323,7 +363,7 @@ class TwView(MMGenObject,metaclass=AsyncInit):
min(7,max(len(str(getattr(d,k).to_integral_value())) for d in data)) + 1 + self.disp_prec
for k in self.amt_keys}
async def format(self,display_type,color=True,interactive=False,line_processing=None):
async def format(self,display_type,color=True,interactive=False,line_processing=None,scroll=False):
def make_display():
@ -375,10 +415,11 @@ class TwView(MMGenObject,metaclass=AsyncInit):
cw = self.get_column_widths(data,wide=dt.detail,interactive=interactive)
cwh = cw._asdict()
fp = self.fs_params
rfill = ' ' * (self.term_width - self.cols) if scroll else ''
hdr_fs = ''.join(fp[name].hdr_fs % ((),cwh[name])[fp[name].hdr_fs_repl]
for name in dt.cols if cwh[name])
for name in dt.cols if cwh[name]) + rfill
fs = ''.join(fp[name].fs % ((),cwh[name])[fp[name].fs_repl]
for name in dt.cols if cwh[name])
for name in dt.cols if cwh[name]) + rfill
else:
cw = hdr_fs = fs = None
@ -407,12 +448,35 @@ class TwView(MMGenObject,metaclass=AsyncInit):
if data != dsave:
self.pos = 0
display_hdr,display_body = make_display()
if scroll:
display_hdr,display_body = make_display()
fixed_height = len(display_hdr) + self.prompt_height + 1
if self.term_height - fixed_height < self.min_scrollable_height:
td = self.get_term_dimensions(
self.min_term_width,
min_lines = self.min_scrollable_height + fixed_height )
self.term_height = td.height
self.term_width = td.width
display_hdr,display_body = make_display()
self.scrollable_height = self.term_height - fixed_height
self.max_pos = max(0, len(display_body) - self.scrollable_height)
self.pos = min(self.pos,self.max_pos)
else:
display_hdr,display_body = make_display()
if not dt.detail:
self.display_hdr = display_hdr
self.display_body = display_body
if scroll:
top = self.pos
bot = self.pos + self.scrollable_height
fill = ('\n' + ''.ljust(self.term_width)) * (self.scrollable_height - len(display_body))
else:
top,bot,fill = (None,None,'')
if interactive:
footer = ''
else:
@ -421,15 +485,26 @@ class TwView(MMGenObject,metaclass=AsyncInit):
return (
'\n'.join(display_hdr) + '\n'
+ dt.item_separator.join(display_body)
+ dt.item_separator.join(display_body[top:bot])
+ fill
+ footer
)
async def view_filter_and_sort(self):
from ..term import get_char
from ..term import get_term,get_char,get_char_raw
prompt = self.prompt.strip()
scroll = self.scroll = g.scroll
if scroll:
del self.key_mappings['v']
for k in self.scroll_keys['vi']:
assert k not in self.key_mappings, f'{k!r} is in key_mappings'
self.key_mappings.update(self.scroll_keys['vi'])
self.key_mappings.update(self.scroll_keys[g.platform])
prompt = self.prompt_scroll.strip()
else:
prompt = self.prompt.strip()
self.prompt_width = max(len(l) for l in prompt.split('\n'))
self.prompt_height = len(prompt.split('\n'))
@ -438,18 +513,32 @@ class TwView(MMGenObject,metaclass=AsyncInit):
prompt += '\b'
self.cursor_to_end_of_prompt = CUR_RIGHT( len(prompt.split('\n')[-1]) - 2 )
clear_screen = '\n\n' if (opt.no_blank or g.test_suite) else CUR_HOME + ERASE_ALL
clear_screen = (
'\n\n' if (opt.no_blank or g.test_suite) else
CUR_HOME + ('' if scroll else ERASE_ALL) )
if scroll:
term = get_term()
term.register_cleanup()
term.set('noecho')
get_char = get_char_raw
if not (opt.no_blank or g.test_suite):
msg_r(CUR_HOME + ERASE_ALL)
while True:
if self.oneshot_msg and scroll:
msg_r(self.blank_prompt + self.oneshot_msg + ' ') # oneshot_msg must be a one-liner
await asyncio.sleep(2)
msg_r('\r' + ''.ljust(self.term_width))
reply = get_char(
'' if self.no_output else (
clear_screen
+ await self.format('squeezed',interactive=True)
+ await self.format('squeezed',interactive=True,scroll=scroll)
+ '\n\n'
+ (self.oneshot_msg + '\n\n' if self.oneshot_msg else '')
+ (self.oneshot_msg + '\n\n' if self.oneshot_msg and not scroll else '')
+ prompt
),
immed_chars = self.key_mappings )
@ -458,27 +547,40 @@ class TwView(MMGenObject,metaclass=AsyncInit):
self.oneshot_msg = '' if self.oneshot_msg else None # tristate, saves previous state
if reply not in self.key_mappings:
msg_r('\ninvalid keypress ')
await asyncio.sleep(0.3)
if not scroll:
msg_r('\ninvalid keypress ')
await asyncio.sleep(0.3)
continue
action = self.key_mappings[reply]
if hasattr(self.action,action):
if action.startswith('m_'): # scrolling actions
self.use_cached = True
await self.action().run(self,action)
elif action.startswith('s_'): # put here to allow overriding by action method
self.do_sort(action[2:])
elif hasattr(self.item_action,action):
if scroll:
term.set('echo') # item actions may require user input
await self.item_action().run(self,action)
if scroll:
term.set('noecho')
elif action == 'a_quit':
msg('')
return self.disp_data
@property
def blank_prompt(self):
return CUR_HOME + CUR_DOWN(self.term_height - self.prompt_height) + ERASE_ALL
def keypress_confirm(self,*args,**kwargs):
from ..ui import keypress_confirm
if keypress_confirm(*args,**kwargs):
if keypress_confirm(*args,no_nl=self.scroll,**kwargs):
return True
else:
if self.scroll:
msg_r('\r'+''.ljust(self.term_width)+'\r'+yellow('Canceling! '))
return False
class action:
@ -523,7 +625,7 @@ class TwView(MMGenObject,metaclass=AsyncInit):
from ..exception import UserNonConfirmation
print_hdr = getattr(parent.display_type,output_type).print_header.format(parent.cols)
msg('')
msg_r(parent.blank_prompt if parent.scroll else '\n')
try:
write_data_to_file(
@ -554,6 +656,24 @@ class TwView(MMGenObject,metaclass=AsyncInit):
msg_r(parent.cursor_to_end_of_prompt)
parent.no_output = True
async def m_cursor_up(self,parent):
parent.pos -= min( parent.pos - 0, 1 )
async def m_cursor_down(self,parent):
parent.pos += min( parent.max_pos - parent.pos, 1 )
async def m_pg_up(self,parent):
parent.pos -= min( parent.scrollable_height, parent.pos - 0 )
async def m_pg_down(self,parent):
parent.pos += min( parent.scrollable_height, parent.max_pos - parent.pos )
async def m_top(self,parent):
parent.pos = 0
async def m_bot(self,parent):
parent.pos = parent.max_pos
class item_action:
async def run(self,parent,action):
@ -563,13 +683,21 @@ class TwView(MMGenObject,metaclass=AsyncInit):
from ..ui import line_input
while True:
msg_r('\n')
msg_r(parent.blank_prompt if parent.scroll else '\n')
ret = line_input(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(f'Choice must be a single number between 1 and {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) + ERASE_ALL + '\r')
else:
# action return values:
# True: action successfully performed
@ -581,6 +709,12 @@ class TwView(MMGenObject,metaclass=AsyncInit):
break
await asyncio.sleep(0.5)
if parent.scroll and ret == False:
# error messages could leave screen in messy state, so do complete redraw:
msg_r(
CUR_HOME + ERASE_ALL +
await parent.format(display_type='squeezed',interactive=True,scroll=True) )
async def a_balance_refresh(self,parent,idx):
if not parent.keypress_confirm(
f'Refreshing tracking wallet {parent.item_desc} #{idx}. Is this what you want?'):
@ -607,7 +741,7 @@ class TwView(MMGenObject,metaclass=AsyncInit):
async def do_comment_add(comment):
if await parent.twctl.set_comment( entry.twmmid, comment, entry.addr, silent=True ):
if await parent.twctl.set_comment( entry.twmmid, comment, entry.addr, silent=parent.scroll ):
entry.comment = comment
edited = cur_comment and comment
parent.oneshot_msg = (green if comment else yellow)('Label {a} {b}{c}'.format(