diff --git a/visidata/basesheet.py b/visidata/basesheet.py index e2552c87f..7d3c9424b 100644 --- a/visidata/basesheet.py +++ b/visidata/basesheet.py @@ -337,7 +337,7 @@ def getSheet(vd, sheetname): try: sheetidx = int(sheetname) return vd.sheets[sheetidx] - except ValueError: + except (ValueError, IndexError): pass if sheetname == 'options': diff --git a/visidata/cmdlog.py b/visidata/cmdlog.py index 2b2bf9a17..35b9491a8 100644 --- a/visidata/cmdlog.py +++ b/visidata/cmdlog.py @@ -87,7 +87,7 @@ def isLoggableSheet(sheet): def moveToRow(vs, rowstr): 'Move cursor to row given by *rowstr*, which can be either the row number or keystr.' rowidx = vs.getRowIndexFromStr(rowstr) - if rowidx is None: + if rowidx is None or rowidx >= vs.nRows: return False vs.cursorRowIndex = rowidx diff --git a/visidata/features/slide.py b/visidata/features/slide.py index a7cbb27b2..69b24bd79 100644 --- a/visidata/features/slide.py +++ b/visidata/features/slide.py @@ -116,20 +116,20 @@ def t(vdx, golden): def test_slide_keycol_1(vd): t = make_tester(f''' open-file {sample_path} - +::OrderDate key-col - +::Region key-col - +::Rep key-col + +:OrderDate: key-col + +:Region: key-col + +:Rep: key-col ''') t('', 'OrderDate Region Rep Item Units Unit_Cost Total') - t('+::Rep slide-leftmost', 'Rep OrderDate Region Item Units Unit_Cost Total') - t('+::OrderDate slide-rightmost', 'Region Rep OrderDate Item Units Unit_Cost Total') - t('+::Rep slide-left', 'OrderDate Rep Region Item Units Unit_Cost Total') - t('+::OrderDate slide-right', 'Region OrderDate Rep Item Units Unit_Cost Total') + t('+:Rep: slide-leftmost', 'Rep OrderDate Region Item Units Unit_Cost Total') + t('+:OrderDate: slide-rightmost', 'Region Rep OrderDate Item Units Unit_Cost Total') + t('+:Rep: slide-left', 'OrderDate Rep Region Item Units Unit_Cost Total') + t('+:OrderDate: slide-right', 'Region OrderDate Rep Item Units Unit_Cost Total') t(''' - +::Item key-col - +::Item slide-left + +:Item: key-col + +:Item: slide-left slide-left slide-right slide-right @@ -141,17 +141,17 @@ def test_slide_keycol_1(vd): def test_slide_leftmost(vd): t = make_tester(f'''open-file {benchmark_path}''') - t('+::Paid slide-leftmost', 'Paid Date Customer SKU Item Quantity Unit') + t('+:Paid: slide-leftmost', 'Paid Date Customer SKU Item Quantity Unit') t = make_tester(f''' open-file {benchmark_path} - +::Date key-col + +:Date: key-col ''') t('', 'Date Customer SKU Item Quantity Unit Paid') - t('''+::Item slide-leftmost''', 'Date Item Customer SKU Quantity Unit Paid') - t('''+::SKU key-col - +::Quantity slide-leftmost''', 'Date SKU Quantity Customer Item Unit Paid') - t('''+::Date slide-leftmost''', 'Date Customer SKU Item Quantity Unit Paid') - t('''+::Item slide-leftmost - +::SKU slide-leftmost''', 'Date SKU Item Customer Quantity Unit Paid') + t('''+:Item: slide-leftmost''', 'Date Item Customer SKU Quantity Unit Paid') + t('''+:SKU: key-col + +:Quantity: slide-leftmost''', 'Date SKU Quantity Customer Item Unit Paid') + t('''+:Date: slide-leftmost''', 'Date Customer SKU Item Quantity Unit Paid') + t('''+:Item: slide-leftmost + +:SKU: slide-leftmost''', 'Date SKU Item Customer Quantity Unit Paid') diff --git a/visidata/main.py b/visidata/main.py index 19e003321..b92cd3135 100755 --- a/visidata/main.py +++ b/visidata/main.py @@ -15,9 +15,10 @@ import signal import warnings import builtins # to override print +import time from visidata import vd, options, run, BaseSheet, AttrDict -from visidata import Path +from visidata import Path, asyncthread from visidata.settings import _get_config_file import visidata @@ -90,28 +91,43 @@ def duptty(): @visidata.VisiData.api -def parsePos(vd, arg:str, inputs=None): - 'Return (startsheets:list, startrow:str, startcol:str) from *arg* like "+sheet:subsheet:col:row". Empty sheetstr in startsheets means the starting pos applies to all sheets.' - startsheets, startrow, startcol = [], None, None +def parsePos(vd, arg:str, inputs:'list[tuple[str, dict]]'=None): + '''Return (startsheets:list, startcol:str, startrow:str) from *arg* like "+sheet:subsheet:col:row". + The elements of *startsheets* are identifiers that pick out a sheet, either + a) a string that is the name of a sheet or subsheet + b) integers (which are indices of a row or column, or a sheet number). + For example [1, 'sales', 3]. + Returns an empty list for *startsheets* when the starting pos applies to all sheets. + Returns None for *startsheets* when the position expression did not specify a sheet. + *inputs* is a list of (path, options) tuples. + ''' + if arg == '': return None + startsheets, startcol, startrow = None, None, None + + pos = [] + # convert any numeric index strings to ints + for idx in arg.split(':'): + if idx: + if idx.isdigit() or (idx[0] == '-' and idx[1:].isdigit()): + idx = int(idx) + pos.append(idx) - if ':' not in arg: - return (None, arg, None) - - pos = arg.split(':') if len(pos) == 1: - startsheet = [Path(inputs[-1]).base_stem] if inputs else None - start_pos = (startsheet, pos[0], None) + # -1 means the last sheet in the list of open sheets + startsheets = [-1] if inputs else None + startrow = arg elif len(pos) == 2: - startsheet = [Path(inputs[-1]).base_stem] if inputs else None - startrow, startcol = pos - start_pos = (None, startrow, startcol) - else: # if len(pos) >= 3: + startsheets = [-1] if inputs else None + startcol, startrow = pos + else: + # the first element of pos is the startsheet, + # the later elements (if present) describe the branch to a subsheet startsheets = pos[:-2] - startrow, startcol = pos[-2:] - start_pos = (startsheets, startrow, startcol) - - # index subsheets need to be loaded *after* the cursor indexing - vd.options.set('load_lazy', True, obj=start_pos[0]) + if startsheets == ['']: startsheets = [] + startcol, startrow = pos[-2:] + if startcol == '': startcol = None + if startrow == '': startrow = None + start_pos = (startsheets, startcol, startrow) return start_pos @@ -133,45 +149,113 @@ def outputProgressEvery(vd, sheet, seconds:float=0.5): time.sleep(seconds) @visidata.VisiData.api -def moveToPos(vd, sources, startsheets, startrow, startcol): - sheets = [] # sheets to apply startrow:startcol to - if not startsheets: - sheets = sources # apply row/col to all sheets +def moveToPos(vd, sources, sheet_desc, startcol, startrow): + if sheet_desc == [] or sheet_desc[0] == '': + ## the list moves must have each of its elements refer only 1 + # sheet, so expand the "all sheets" sheet descriptor into individual sheets + sheet_descs = [[i] + sheet_desc[1:] for i, sheet in enumerate(sources)] else: - startsheet = startsheets[0] or sources[-1] - vs = vd.getSheet(startsheet) - if not vs: - vd.warning(f'no sheet "{startsheet}"') - return - - vd.sync(vs.ensureLoaded()) - vd.clearCaches() - for startsheet in startsheets[1:]: - rowidx = vs.getRowIndexFromStr(vd.options.rowkey_prefix + startsheet) - if rowidx is None: - vd.warning(f'{vs.name} has no subsheet "{startsheet}"') - vs = None - break - vs = vs.rows[rowidx] + sheet_descs = [sheet_desc] + # for each sheet, attempt column moves first, then rows + if startcol is not None or startrow is not None: + moves = [(d, startcol, None) for d in sheet_descs] + \ + [(d, None, startrow) for d in sheet_descs] + else: + moves = [(d, None, None) for d in sheet_descs] + # start a thread to keep attempting the moves till they all succeed, for sheets that are slow to load + retry_move_to_pos(vd, sources, moves) + +def sheet_from_description(vd, sources, sheet_desc): + '''Return a Sheet to apply col/row to, given a list *sheet_desc* that refers to one specific sheet. + The *sheet_desc* is either a Sheet, or a list of strings/ints similar to the return value of parsePos(), + with the difference that *sheet_desc* will not ever be the empty list that denotes "all sheets". + Return None if no matching sheet was found; if sheets are loading, a subsequent call may return + a matching sheet. + Raise ValueError to indicate that a move failed, and should not be retried.''' + if isinstance(sheet_desc, BaseSheet): + vd.push(sheet_desc) + return sheet_desc + + # descend the tree of subsheets + for desc_lvl, subsheet in enumerate(sheet_desc): + if desc_lvl == 0: + vs = None + #try subsheets as numbers first, then as names + if isinstance(subsheet, int): + try: + vs = sources[subsheet] + except IndexError: + pass + else: + vs = vd.getSheet(subsheet) + if not vs: + raise ValueError(f'no sheet "{subsheet}"') + else: + if isinstance(subsheet, int): + rowidx = subsheet + else: + rowidx = vs.getRowIndexFromStr(vd.options.rowkey_prefix + subsheet) + try: + if rowidx is None: raise IndexError + vs_subsheet = vs.rows[rowidx] + except IndexError: + vd.warning(f'sheet {vs.name} has no subsheet "{subsheet}"') + return None + if not isinstance(vs_subsheet, BaseSheet): + raise ValueError(f'row "{subsheet}" is not a sheet in {vs.name}') + vs = vs_subsheet + # if we have any more levels of subsheets to look at, load the current sheet fully + if desc_lvl < len(sheet_desc) - 1: + # Prevent the sheet from doing automatic ensureLoaded() on its subsheets when it + # loads, so that we can call ensureLoaded() ourselves and sync() on it. + vd.options.set('load_lazy', True, obj=vs) vd.sync(vs.ensureLoaded()) vd.clearCaches() - if vs: - vd.push(vs) - sheets = [vs] - - if startrow: - for vs in sheets: - if vs: - vs.moveToRow(startrow) or vd.warning(f'{vs} has no row "{startrow}"') - - if startcol: - for vs in sheets: - if vs: - if not vs.moveToCol(startcol): - if startcol.isdigit(): - vs.moveToCol(int(startcol)) # handle indexing by column number - else: - vd.warning(f'{vs} has no column "{startcol}"') + vd.push(vs) + return vs + +@asyncthread +def retry_move_to_pos(vd, sources, moves, retry_interval=0.1): + while moves: + unmoved = [] + for move in moves: + try: + move_succeeded = attempt_move_to_pos(vd, sources, *move) + except ValueError as e: + # skip failed moves if they can't ever succeed + vd.warning(e) + continue + if not move_succeeded: + unmoved.append(move) + if unmoved: + time.sleep(retry_interval) + moves = unmoved + +def attempt_move_to_pos(vd, sources, sheet_desc, startcol, startrow): + '''Return True if the move succeeded in moving to the row and column, on the described sheet. + Raise ValueError to indicate that a move failed, and should not be retried.''' + vs = sheet_from_description(vd, sources, sheet_desc) + if not vs: + return False + # switch the active sheet, for command line args like +s:: + if vs and startrow is None and startcol is None: + vd.push(vs) + return True + + # try cursor moves + success = True + if startrow is not None: + if not vs.moveToRow(startrow): + if vs.nRows > 0: # avoid uninformative warnings early in startup + vd.warning(f'{vs} has no row {startrow}: nRows={len(vs.rows)}"') + success = False + + if startcol is not None: + if not vs.moveToCol(startcol): + if vs.nRows > 0: + vd.warning(f'{vs} has no column {startcol}') + success = False + return success def main_vd(): 'Open the given sources using the VisiData interface.' @@ -259,7 +343,9 @@ def main_vd(): if flGlobal: global_args[optname] = optval elif arg.startswith('+'): # position cursor at start - after_config.append((vd.moveToPos, *vd.parsePos(arg[1:], inputs=inputs))) + parsed_pos = vd.parsePos(arg[1:], inputs=inputs) + if parsed_pos: + after_config.append((vd.moveToPos, *parsed_pos)) elif current_args.get('play', None) and '=' in arg: # parse 'key=value' pairs for formatting cmdlog template in replay mode k, v = arg.split('=', maxsplit=1) diff --git a/visidata/man/vd.inc b/visidata/man/vd.inc index 3feabe280..b9f55ccf0 100644 --- a/visidata/man/vd.inc +++ b/visidata/man/vd.inc @@ -37,7 +37,7 @@ .Nm vd .Op Ar options .Op Ar input No ... -.Cm + Ns Ar toplevel Ns : Ns Ar subsheet Ns : Ns Ar row Ns : Ns Ar col +.Cm + Ns Ar toplevel Ns : Ns Ar subsheet Ns : Ns Ar col Ns : Ns Ar row . .Sh DESCRIPTION .Nm VisiData No is an easy-to-use multipurpose tool to explore, clean, edit, and restructure data. @@ -880,8 +880,8 @@ show/hide methods and hidden properties .Bl -tag -width XXXXXXXXXXXXXXXXXXXXXXXXXXX -compact .It Cm -P Ns = Ns Ar longname .No preplay Ar longname No before replay or regular launch; limited to Sy Base Sheet No bound commands -.It Cm + Ns Ar toplevel Ns : Ns Ar subsheet Ns : Ns Ar row Ns : Ns Ar col -.No launch vd with Ar subsheet No of Ar toplevel No at top-of-stack, and cursor at Ar row No and Ar col Ns ; all arguments are optional +.It Cm + Ns Ar toplevel Ns : Ns Ar subsheet Ns : Ns Ar col Ns : Ns Ar row +.No launch vd with Ar subsheet No of Ar toplevel No at top-of-stack, and cursor at Ar col No and Ar row Ns ; all arguments are optional .It Cm --overwrite Ns = Ns Ar c .No Overwrite with confirmation .It Cm --guides @@ -959,11 +959,36 @@ disable loading .visidatarc and plugin addons .Dl Ic vd newfile.tsv .No open a blank sheet named Ar newfile No if file does not exist .Pp -.Dl Ic vd sample.xlsx +:sheet1:2:3 -.No launch with Sy sheet1 No at top-of-stack, and cursor at column Sy 2 No and row Sy 3 -.Pp .Dl Ic vd -P open-plugins .No preplay longname Sy open-plugins No before starting the session +.Pp +.Dl Ic vd a.xlsx b.tsv c.tsv +0:3:1:2 +.No launch with cursor for sheet Sy 0 No (a.xlsx) at subsheet Sy 3 No in sheet Sy 0 Ns , col Sy 1 Ns , row Sy 2 +.Dl Ic vd a.xlsx b.tsv c.tsv +0:1:2 +.No launch with cursor for sheet Sy 0 No (a.xlsx) at col Sy 1 Ns , row Sy 2 +.Dl Ic vd a.tsv b.tsv c.tsv +1:2 +.No launch with cursor for last sheet (c.tsv) at col Sy 1 Ns , row Sy 2 +.Dl Ic vd a.tsv b.tsv c.tsv +1: +.No launch with cursor for last sheet (c.tsv) at col Sy 1 +.Dl Ic vd a.tsv b.tsv c.tsv +:2 +.No launch with cursor for last sheet (c.tsv) at row Sy 2 +.Pp +.Dl Ic vd a.xlsx b.xlsx c.xlsx +0:annual:1:2 +1:sales:monthly:3:4 +.No launch with cursor for sheet Sy 0 No (a.xlsx) in subsheet Sy annual No at col Sy 1 Ns , row Sy 2 Ns , and cursor for sheet Sy 1 No (b.xlsx), subsheet Sy sales No in subsheet monthly; col Sy 3 Ns , row Sy 4 +.Pp +.Dl Ic vd a.tsv b.tsv c.tsv +1:: +.No launch in sheet Sy 1 No (b.tsv), with cursor at col 0, row 0 +.Dl Ic vd a.tsv b.tsv c.tsv +-2:: +.No launch in Sy 2nd No from last sheet (b.tsv), with cursor at col 0, row 0 +.Pp +.Dl Ic vd a.tsv b.tsv c.tsv +:1: +.No launch with cursors for all sheets at col Sy 1 +.Dl Ic vd a.tsv b.tsv c.tsv +::2 +.No launch with cursors for all sheets at row Sy 2 +.Dl Ic vd a.tsv b.tsv c.tsv +:1:2 +.No launch with cursors for all sheets at col Sy 1 Ns , row Sy 2 +.Dl Ic vd a.xlsx b.xslx c.xlsx +:a:1:2 +.No launch with cursors for all sheets at subsheet Sy a Ns , col Sy 1 Ns , row Sy 2 .Sh FILES At the start of every session, .Sy VisiData No looks for Pa $HOME/.visidatarc Ns , and calls Python exec() on its contents if it exists. diff --git a/visidata/man/vd.txt b/visidata/man/vd.txt index a21307b38..07180dcc0 100644 --- a/visidata/man/vd.txt +++ b/visidata/man/vd.txt @@ -566,8 +566,8 @@ COMMANDLINE OPTIONS -P=longname preplay longname before replay or regular launch; limited to Base Sheet bound commands - +toplevel:subsheet:row:col launch vd with subsheet of toplevel at - top-of-stack, and cursor at row and col; all + +toplevel:subsheet:col:row launch vd with subsheet of toplevel at + top-of-stack, and cursor at col and row; all arguments are optional --overwrite=c Overwrite with confirmation --guides open Guide Index