From e6a5b5ae40c61f54a3ddc933af902c272808ba06 Mon Sep 17 00:00:00 2001 From: Saul Pwanson Date: Sat, 4 Nov 2023 23:17:36 -0700 Subject: [PATCH] [input] polish palette and use for jointype #1027 --- visidata/_input.py | 16 +++---- visidata/aggregators.py | 21 +++++---- visidata/features/cmdpalette.py | 76 ++++++++++++++++++++++----------- visidata/features/describe.py | 2 +- visidata/features/join.py | 21 +++++++-- visidata/fuzzymatch.py | 22 ++++++---- 6 files changed, 99 insertions(+), 59 deletions(-) diff --git a/visidata/_input.py b/visidata/_input.py index d68920cc1..64caa65c3 100644 --- a/visidata/_input.py +++ b/visidata/_input.py @@ -15,7 +15,7 @@ vd.theme_option('mouse_interval', 1, 'max time between press/release for click (ms)', sheettype=None) vd.option('input_history', '', 'basename of file to store persistent input history') -vd.disp_help = None # current level of help shown (up to vd.options.disp_help as maximum) +vd.disp_help = 1 # current level of help shown (up to vd.options.disp_help as maximum) class AcceptInput(Exception): '*args[0]* is the input to be accepted' @@ -93,7 +93,6 @@ def splice(v:str, i:int, s:str): # vd.options.disp_help is the effective maximum disp_help. The user can cycle through the various levels of help. class HelpCycler: def __init__(self, scr=None, help=''): - self.saved = False self.help = help self.scr = scr @@ -101,16 +100,10 @@ def __enter__(self): if self.scr: vd.drawInputHelp(self.scr, self.help) - if vd.disp_help is None: - vd.disp_help = vd.options.disp_help - self.saved = True - # otherwise some other HelpCycler will unset it - return self def __exit__(self, *args): - if self.saved: - vd.disp_help = None + pass def cycle(self): vd.disp_help = (vd.disp_help-1)%(vd.options.disp_help+1) @@ -134,7 +127,7 @@ def drawInputHelp(vd, scr, help:str=''): elif vd.disp_help == 1: curhelp = help sheet.drawSidebarText(scr, curhelp) - elif vd.disp_help == 2: + elif vd.disp_help >= 2: curhelp = vd.getHelpPane('input', module='visidata') sheet.drawSidebarText(scr, curhelp, title='Input Keystrokes Help') @@ -224,7 +217,7 @@ def editline(vd, scr, y, x, w, i=0, display=True, updater=lambda val: None, bindings={}, - help='', + help='', # str|HelpPane clear=True): '''A better curses line editing widget. If *clear* is True, clear whole editing area before displaying. @@ -485,6 +478,7 @@ def input(self, prompt, type=None, defaultLast=False, history=[], dy=0, attr=Non - *bindings*: dict of keystroke to func(v, i) that returns updated (v, i) - *dy*: number of lines from bottom of pane - *attr*: curses attribute for prompt + - *help*: string to include in help ''' if attr is None: diff --git a/visidata/aggregators.py b/visidata/aggregators.py index e363a863c..03409b0ed 100644 --- a/visidata/aggregators.py +++ b/visidata/aggregators.py @@ -7,7 +7,7 @@ from visidata import Progress, Sheet, Column, ColumnsSheet, VisiData from visidata import vd, anytype, vlen, asyncthread, wrapply, AttrDict -vd.help_aggrs = 'HELPTODO' +vd.help_aggregators = 'HELPTODO' vd.option('null_value', None, 'a value to be counted as null', replay=True) @@ -200,23 +200,17 @@ def aggregator_choices(vd): ] - -def _format_match(s, positions): - out = list(s) - for p in positions: - out[p] = f'[:red]{out[p]}[/]' - return "".join(out) - @VisiData.api def chooseAggregators(vd): prompt = 'choose aggregators: ' def _fmt_aggr_summary(match, row, trigger_key): - formatted_aggrname = _format_match(row.key, match.positions.get('key', [])) + formatted_aggrname = match.formatted.get('key', row.key) if match else row.key r = ' '*(len(prompt)-3) r += f'[:keystrokes]{trigger_key}[/] ' r += formatted_aggrname if row.desc: - r += ' - ' + _format_match(row.desc, match.positions.get('desc', [])) + r += ' - ' + r += match.formatted.get('desc', row.desc) if match else row.desc return r r = vd.activeSheet.inputPalette(prompt, @@ -224,8 +218,13 @@ def _fmt_aggr_summary(match, row, trigger_key): value_key='key', formatter=_fmt_aggr_summary, type='aggregators', + help=vd.help_aggregators, multiple=True) - return r.split() + + aggrs = r.split() + for aggr in aggrs: + vd.usedInputs[aggr] += 1 + return aggrs Sheet.addCommand('+', 'aggregate-col', 'addAggregators([cursorCol], chooseAggregators())', 'Add aggregator to current column') Sheet.addCommand('z+', 'memo-aggregate', 'for agg in chooseAggregators(): cursorCol.memo_aggregate(aggregators[agg], selectedRows or rows)', 'memo result of aggregator over values in selected rows for current column') diff --git a/visidata/features/cmdpalette.py b/visidata/features/cmdpalette.py index ba5885a35..d86e64d8e 100644 --- a/visidata/features/cmdpalette.py +++ b/visidata/features/cmdpalette.py @@ -1,23 +1,17 @@ import collections -from visidata import BaseSheet, vd, CompleteKey, clipdraw, HelpSheet, colors, AcceptInput, AttrDict +from visidata import DrawablePane, BaseSheet, vd, VisiData, CompleteKey, clipdraw, HelpSheet, colors, AcceptInput, AttrDict vd.theme_option('color_cmdpalette', 'black on 72', 'base color of command palette') -vd.theme_option('disp_cmdpal_max', 5, 'max number of suggestions for command palette') +vd.theme_option('disp_cmdpal_max', 10, 'max number of suggestions for command palette') -def _format_match(s, positions): - out = list(s) - for p in positions: - out[p] = f'[:red]{out[p]}[/]' - return "".join(out) - def make_acceptor(value, multiple=False): def _acceptor(v, i): if multiple: items = list(v.split()) - if v.endswith(' '): + if not v or v.endswith(' '): items.append(value) else: items[-1] = value @@ -28,6 +22,14 @@ def _acceptor(v, i): return _acceptor +@VisiData.lazy_property +def usedInputs(vd): + return collections.defaultdict(int) + +@DrawablePane.after +def execCommand2(sheet, cmd, *args, **kwargs): + vd.usedInputs[cmd.longname] += 1 + @BaseSheet.api def inputPalette(sheet, prompt, items, value_key='key', @@ -39,27 +41,51 @@ def inputPalette(sheet, prompt, items, def _draw_palette(value): words = value.lower().split() - matches = vd.fuzzymatch(items, [words[-1]] if multiple and words else words) + if multiple and words: + if value.endswith(' '): + finished_words = words + unfinished_words = [] + else: + finished_words = words[:-1] + unfinished_words = [words[-1]] + else: + unfinished_words = words + finished_words = [] + + unuseditems = [item for item in items if item[value_key] not in finished_words] + + matches = vd.fuzzymatch(unuseditems, unfinished_words) - # do the drawing - h, w = sheet._scr.getmaxyx() - cmdpal_h = min(h-2, sheet.options.disp_cmdpal_max) - m_max = min(len(matches), cmdpal_h) + h = sheet.windowHeight + w = min(100, sheet.windowWidth) + nitems = min(h-1, sheet.options.disp_cmdpal_max) - for i, m in enumerate(matches[:m_max]): + useditems = [] + palrows = [] + + for m in matches[:nitems]: + useditems.append(m.match) + palrows.append((m, m.match)) + + favitems = sorted([item for item in unuseditems if item not in useditems], + key=lambda item: -vd.usedInputs.get(item[value_key], 0)) + + for item in favitems[:nitems-len(palrows)]: + palrows.append((None, item)) + + for i in range(nitems-len(palrows)): + palrows.append((None, None)) + + for i, (m, item) in enumerate(palrows): trigger_key = ' ' - if i < 9: + if i < 9 and item: trigger_key = f'{i+1}' - bindings[trigger_key] = make_acceptor(m.match[value_key], multiple=multiple) - - match_summary = formatter(m, m.match, trigger_key) + bindings[trigger_key] = make_acceptor(item[value_key], multiple=multiple) - clipdraw(sheet._scr, h-(m_max+1)+i, 0, match_summary, colors.color_cmdpalette, w=120) + match_summary = formatter(m, item, trigger_key) if item else ' ' - # add some empty rows for visual appeal and dropping previous (not-anymore-)matches - for i in range(cmdpal_h - m_max): - clipdraw(sheet._scr, h-(cmdpal_h+1)+i, 0, ' ', colors.color_cmdpalette, w=120) + clipdraw(sheet._scr, h-nitems-1+i, 0, match_summary, colors.color_cmdpalette, w=w) return None @@ -86,12 +112,12 @@ def inputLongname(sheet): def _fmt_cmdpal_summary(match, row, trigger_key): keystrokes = this_sheets_help.revbinds.get(row.longname, [None])[0] or ' ' - formatted_longname = _format_match(row.longname, match.positions.get('longname', [])) + formatted_longname = match.formatted.get('longname', row.longname) if match else row.longname formatted_name = f'[:onclick {row.longname}]{formatted_longname}[/]' r = f' [:keystrokes]{keystrokes.rjust(len(prompt)-5)}[/] ' r += f'[:keystrokes]{trigger_key}[/] {formatted_name}' if row.description: - formatted_desc = _format_match(row.description, match.positions.get('description', [])) + formatted_desc = match.formatted.get('description', row.description) if match else row.description r += f' - {formatted_desc}' if vd.options.debug: debug_info = f'[{m.score}]' diff --git a/visidata/features/describe.py b/visidata/features/describe.py index e80782d4d..b54a0ba11 100644 --- a/visidata/features/describe.py +++ b/visidata/features/describe.py @@ -5,7 +5,7 @@ from visidata import BaseSheet, TableSheet, ColumnsSheet, SheetsSheet -vd.option('describe_aggrs', 'mean stdev', 'numeric aggregators to calculate on Describe sheet', help=vd.help_aggrs) +vd.option('describe_aggrs', 'mean stdev', 'numeric aggregators to calculate on Describe sheet', help=vd.help_aggregators) @Column.api diff --git a/visidata/features/join.py b/visidata/features/join.py index 8ad645642..ba23f6c9b 100644 --- a/visidata/features/join.py +++ b/visidata/features/join.py @@ -3,7 +3,7 @@ import functools from copy import copy -from visidata import vd, VisiData, asyncthread, Sheet, Progress, IndexSheet, Column, CellColorizer, ColumnItem, SubColumnItem, TypedWrapper, ColumnsSheet +from visidata import vd, VisiData, asyncthread, Sheet, Progress, IndexSheet, Column, CellColorizer, ColumnItem, SubColumnItem, TypedWrapper, ColumnsSheet, AttrDict @VisiData.api def ensureLoaded(vd, sheets): @@ -84,7 +84,7 @@ def openJoin(sheet, others, jointype=''): sheetKeyCols={s:s.keyCols for s in sheets}) -vd.jointypes = [{'key': k, 'desc': v} for k, v in { +vd.jointypes = [AttrDict(key=k, desc=v) for k, v in { 'inner': 'only rows with matching keys on all sheets', 'outer': 'only rows with matching keys on first selected sheet', 'full': 'all rows from all sheets (union)', @@ -349,7 +349,22 @@ def iterload(self): @VisiData.api def chooseJointype(vd): - return vd.chooseOne(vd.jointypes, type="jointype") + prompt = 'choose jointype: ' + def _fmt_aggr_summary(match, row, trigger_key): + formatted_jointype = match.formatted.get('key', row.key) if match else row.key + r = ' '*(len(prompt)-3) + r += f'[:keystrokes]{trigger_key}[/] ' + r += formatted_jointype + if row.desc: + r += ' - ' + r += match.formatted.get('desc', row.desc) if match else row.desc + return r + + return vd.activeSheet.inputPalette(prompt, + vd.jointypes, + value_key='key', + formatter=_fmt_aggr_summary, + type='jointype') IndexSheet.addCommand('&', 'join-selected', 'left, rights = someSelectedRows[0], someSelectedRows[1:]; vd.push(left.openJoin(rights, jointype=chooseJointype()))', 'merge selected sheets with visible columns from all, keeping rows according to jointype') diff --git a/visidata/fuzzymatch.py b/visidata/fuzzymatch.py index fd8c93bf0..73abd7563 100644 --- a/visidata/fuzzymatch.py +++ b/visidata/fuzzymatch.py @@ -354,7 +354,13 @@ def _fuzzymatch(target: str, pattern: str) -> MatchResult: return MatchResult(j, maxScorePos + 1, int(maxScore), pos) -CombinedMatch = collections.namedtuple('CombinedMatch', 'score positions match') +def _format_match(s, positions): + out = list(s) + for p in positions: + out[p] = f'[:red]{out[p]}[/]' + return "".join(out) + +CombinedMatch = collections.namedtuple('CombinedMatch', 'score formatted match') @VisiData.api @@ -364,18 +370,18 @@ def fuzzymatch(vd, haystack:list[dict[str, str]], needles:list[str]) -> list[Com matches = [] for h in haystack: match = {} + formatted_hay = {} for k, v in h.items(): for p in needles: - m = _fuzzymatch(v, p) - if m.score > 0: - match[k] = m + mr = _fuzzymatch(v, p) + if mr.score > 0: + match[k] = mr + formatted_hay[k] = _format_match(v, mr.positions) if match: - m = CombinedMatch(score=sum(mr.score**2 for mr in match.values()), - positions={k:mr.positions for k, mr in match.items()}, - match=h) # square to prefer larger scores in a single haystack - matches.append(m) + score = sum(mr.score**2 for mr in match.values()) + matches.append(CombinedMatch(score=score, formatted=formatted_hay, match=h)) return sorted(matches, key=lambda m: -m.score)