visidata 3.0.1__py3-none-any.whl → 3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. visidata/__init__.py +12 -10
  2. visidata/_input.py +208 -199
  3. visidata/_open.py +4 -1
  4. visidata/_types.py +4 -3
  5. visidata/aggregators.py +88 -39
  6. visidata/apps/vdsql/_ibis.py +9 -11
  7. visidata/apps/vdsql/clickhouse.py +2 -2
  8. visidata/apps/vdsql/snowflake.py +1 -1
  9. visidata/apps/vgit/status.py +1 -1
  10. visidata/basesheet.py +11 -4
  11. visidata/canvas.py +66 -24
  12. visidata/clipboard.py +13 -6
  13. visidata/cliptext.py +7 -6
  14. visidata/cmdlog.py +40 -27
  15. visidata/column.py +14 -49
  16. visidata/ddw/regex.ddw +3 -2
  17. visidata/deprecated.py +14 -2
  18. visidata/desktop/visidata.desktop +2 -2
  19. visidata/editor.py +1 -0
  20. visidata/errors.py +1 -1
  21. visidata/experimental/sort_selected.py +54 -0
  22. visidata/expr.py +69 -18
  23. visidata/features/change_precision.py +1 -3
  24. visidata/features/cmdpalette.py +23 -4
  25. visidata/features/colorsheet.py +1 -1
  26. visidata/features/dedupe.py +3 -3
  27. visidata/features/go_col.py +71 -0
  28. visidata/features/graph_seaborn.py +1 -1
  29. visidata/features/join.py +20 -10
  30. visidata/features/layout.py +18 -5
  31. visidata/features/ping.py +16 -12
  32. visidata/features/regex.py +5 -5
  33. visidata/features/slide.py +15 -17
  34. visidata/features/status_source.py +3 -1
  35. visidata/features/sysedit.py +1 -1
  36. visidata/features/transpose.py +2 -1
  37. visidata/features/type_ipaddr.py +2 -4
  38. visidata/features/unfurl.py +1 -0
  39. visidata/form.py +2 -2
  40. visidata/freqtbl.py +16 -11
  41. visidata/fuzzymatch.py +1 -0
  42. visidata/graph.py +173 -12
  43. visidata/guide.py +61 -25
  44. visidata/guides/ClipboardGuide.md +48 -0
  45. visidata/guides/ColumnsGuide.md +52 -0
  46. visidata/guides/CommandsSheet.md +28 -0
  47. visidata/guides/DirSheet.md +34 -0
  48. visidata/guides/ErrorsSheet.md +17 -0
  49. visidata/guides/FrequencyTable.md +42 -0
  50. visidata/guides/GrepSheet.md +28 -0
  51. visidata/guides/JsonSheet.md +38 -0
  52. visidata/guides/MacrosSheet.md +19 -0
  53. visidata/guides/MeltGuide.md +52 -0
  54. visidata/guides/MemorySheet.md +7 -0
  55. visidata/guides/MenuGuide.md +26 -0
  56. visidata/guides/ModifyGuide.md +38 -0
  57. visidata/guides/PivotGuide.md +71 -0
  58. visidata/guides/RegexGuide.md +107 -0
  59. visidata/guides/SelectionGuide.md +44 -0
  60. visidata/guides/SlideGuide.md +26 -0
  61. visidata/guides/SortGuide.md +0 -0
  62. visidata/guides/SplitpaneGuide.md +15 -0
  63. visidata/guides/TypesSheet.md +43 -0
  64. visidata/guides/XsvGuide.md +36 -0
  65. visidata/help.py +6 -6
  66. visidata/hint.py +2 -1
  67. visidata/indexsheet.py +2 -2
  68. visidata/interface.py +13 -14
  69. visidata/keys.py +4 -1
  70. visidata/loaders/api_airtable.py +1 -1
  71. visidata/loaders/archive.py +1 -1
  72. visidata/loaders/csv.py +9 -5
  73. visidata/loaders/eml.py +11 -6
  74. visidata/loaders/f5log.py +1 -0
  75. visidata/loaders/fec.py +18 -42
  76. visidata/loaders/fixed_width.py +19 -3
  77. visidata/loaders/grep.py +121 -0
  78. visidata/loaders/html.py +1 -0
  79. visidata/loaders/http.py +6 -1
  80. visidata/loaders/json.py +22 -4
  81. visidata/loaders/jsonla.py +8 -2
  82. visidata/loaders/mailbox.py +1 -0
  83. visidata/loaders/markdown.py +25 -6
  84. visidata/loaders/msgpack.py +19 -0
  85. visidata/loaders/npy.py +0 -1
  86. visidata/loaders/odf.py +18 -4
  87. visidata/loaders/orgmode.py +1 -1
  88. visidata/loaders/rec.py +6 -4
  89. visidata/loaders/sas.py +11 -4
  90. visidata/loaders/scrape.py +0 -1
  91. visidata/loaders/texttables.py +2 -0
  92. visidata/loaders/tsv.py +24 -7
  93. visidata/loaders/unzip_http.py +127 -3
  94. visidata/loaders/vds.py +4 -0
  95. visidata/loaders/vdx.py +1 -1
  96. visidata/loaders/xlsx.py +5 -0
  97. visidata/loaders/xml.py +2 -1
  98. visidata/macros.py +14 -31
  99. visidata/main.py +20 -15
  100. visidata/mainloop.py +17 -6
  101. visidata/man/vd.1 +74 -39
  102. visidata/man/vd.txt +73 -41
  103. visidata/memory.py +16 -5
  104. visidata/menu.py +14 -3
  105. visidata/metasheets.py +5 -6
  106. visidata/modify.py +4 -4
  107. visidata/mouse.py +2 -0
  108. visidata/movement.py +14 -28
  109. visidata/optionssheet.py +3 -5
  110. visidata/path.py +59 -37
  111. visidata/pivot.py +8 -5
  112. visidata/pyobj.py +63 -9
  113. visidata/rename_col.py +18 -1
  114. visidata/save.py +16 -9
  115. visidata/search.py +4 -4
  116. visidata/selection.py +10 -56
  117. visidata/settings.py +37 -35
  118. visidata/sheets.py +189 -118
  119. visidata/shell.py +23 -14
  120. visidata/sidebar.py +71 -16
  121. visidata/sort.py +21 -6
  122. visidata/statusbar.py +42 -5
  123. visidata/stored_list.py +5 -2
  124. visidata/tests/conftest.py +1 -0
  125. visidata/tests/test_commands.py +9 -1
  126. visidata/tests/test_completer.py +18 -0
  127. visidata/tests/test_edittext.py +3 -2
  128. visidata/text_source.py +7 -4
  129. visidata/textsheet.py +20 -6
  130. visidata/themes/ascii8.py +9 -6
  131. visidata/themes/asciimono.py +14 -4
  132. visidata/threads.py +13 -3
  133. visidata/tuiwin.py +5 -1
  134. visidata/type_currency.py +1 -2
  135. visidata/type_date.py +6 -1
  136. visidata/undo.py +10 -13
  137. visidata/utils.py +9 -3
  138. visidata/vdobj.py +21 -1
  139. visidata/wrappers.py +9 -1
  140. {visidata-3.0.1.data → visidata-3.1.data}/data/share/applications/visidata.desktop +2 -2
  141. {visidata-3.0.1.data → visidata-3.1.data}/data/share/man/man1/vd.1 +74 -39
  142. {visidata-3.0.1.data → visidata-3.1.data}/data/share/man/man1/visidata.1 +74 -39
  143. {visidata-3.0.1.dist-info → visidata-3.1.dist-info}/METADATA +33 -5
  144. visidata-3.1.dist-info/RECORD +284 -0
  145. visidata-3.0.1.dist-info/RECORD +0 -258
  146. {visidata-3.0.1.data → visidata-3.1.data}/scripts/vd +0 -0
  147. {visidata-3.0.1.data → visidata-3.1.data}/scripts/vd2to3.vdx +0 -0
  148. {visidata-3.0.1.dist-info → visidata-3.1.dist-info}/LICENSE.gpl3 +0 -0
  149. {visidata-3.0.1.dist-info → visidata-3.1.dist-info}/WHEEL +0 -0
  150. {visidata-3.0.1.dist-info → visidata-3.1.dist-info}/entry_points.txt +0 -0
  151. {visidata-3.0.1.dist-info → visidata-3.1.dist-info}/top_level.txt +0 -0
visidata/_types.py CHANGED
@@ -53,9 +53,8 @@ def numericFormatter(vd, fmtstr, typedval):
53
53
  def numericType(vd, icon='', fmtstr='', formatter=vd.numericFormatter):
54
54
  '''Decorator for numeric types.'''
55
55
  def _decorator(f):
56
- vd.addType(f, icon=icon, fmtstr=fmtstr, formatter=formatter)
56
+ vd.addType(f, icon=icon, fmtstr=fmtstr, formatter=formatter, name=f.__name__)
57
57
  vd.numericTypes.append(f)
58
- vd.addGlobals({f.__name__: f})
59
58
  return f
60
59
  return _decorator
61
60
 
@@ -82,6 +81,8 @@ def addType(vd, typetype=None, icon=None, fmtstr='', formatter=vd.numericFormatt
82
81
  t = VisiDataType(typetype=typetype, icon=icon, fmtstr=fmtstr, formatter=formatter, key=key, name=name)
83
82
  if typetype:
84
83
  vd.typemap[typetype] = t
84
+ if name:
85
+ vd.addGlobals({name: typetype})
85
86
  return t
86
87
 
87
88
  vdtype = vd.addType
@@ -94,7 +95,7 @@ vd.typemap = {}
94
95
  def getType(vd, typetype):
95
96
  return vd.typemap.get(typetype) or VisiDataType()
96
97
 
97
- vdtype(None, '∅')
98
+ vdtype(None, '∅', name='none')
98
99
  vdtype(anytype, '', formatter=lambda _,v: str(v))
99
100
  vdtype(str, '~', formatter=lambda _,v: v)
100
101
  vdtype(int, '#')
visidata/aggregators.py CHANGED
@@ -5,7 +5,7 @@ import collections
5
5
  import statistics
6
6
 
7
7
  from visidata import Progress, Sheet, Column, ColumnsSheet, VisiData
8
- from visidata import vd, anytype, vlen, asyncthread, wrapply, AttrDict
8
+ from visidata import vd, anytype, vlen, asyncthread, wrapply, AttrDict, date, INPROGRESS
9
9
 
10
10
  vd.help_aggregators = '''# Choose Aggregators
11
11
  Start typing an aggregator name or description.
@@ -48,10 +48,15 @@ def getValues(self, rows):
48
48
  vd.aggregators = collections.OrderedDict() # [aggname] -> annotated func, or list of same
49
49
 
50
50
  Column.init('aggstr', str, copy=True)
51
+ Column.init('_aggregatedTotals', dict) # [aggname] -> agg total over all rows
51
52
 
52
53
  def aggregators_get(col):
53
54
  'A space-separated names of aggregators on this column.'
54
- return list(vd.aggregators[k] for k in (col.aggstr or '').split())
55
+ aggs = []
56
+ for k in (col.aggstr or '').split():
57
+ agg = vd.aggregators[k]
58
+ aggs += agg if isinstance(agg, list) else [agg]
59
+ return aggs
55
60
 
56
61
  def aggregators_set(col, aggs):
57
62
  if isinstance(aggs, str):
@@ -71,32 +76,28 @@ Column.aggregators = property(aggregators_get, aggregators_set)
71
76
 
72
77
 
73
78
  class Aggregator:
74
- def __init__(self, name, type, funcRows, funcValues=None, helpstr='foo'):
75
- 'Define aggregator `name` that calls func(col, rows)'
79
+ def __init__(self, name, type, funcValues=None, helpstr='foo'):
80
+ 'Define aggregator `name` that calls funcValues(values)'
76
81
  self.type = type
77
- self.func = funcRows # funcRows(col, rows)
78
- self.funcValues = funcValues # funcValues(values, *args)
82
+ self.funcValues = funcValues # funcValues(values)
79
83
  self.helpstr = helpstr
80
84
  self.name = name
81
85
 
82
- def __call__(self, *args, **kwargs):
83
- return self.func(*args, **kwargs)
84
-
85
- _defaggr = Aggregator
86
-
87
- @VisiData.api
88
- def aggregator(vd, name, funcValues, helpstr='', *args, type=None):
89
- 'Define simple aggregator *name* that calls ``funcValues(values, *args)`` to aggregate *values*. Use *type* to force the default type of the aggregated column.'
90
- def _funcRows(col, rows): # wrap builtins so they can have a .type
86
+ def aggregate(self, col, rows): # wrap builtins so they can have a .type
91
87
  vals = list(col.getValues(rows))
92
88
  try:
93
- return funcValues(vals, *args)
89
+ return self.funcValues(vals)
94
90
  except Exception as e:
95
91
  if len(vals) == 0:
96
92
  return None
97
- return e
93
+ raise e
98
94
 
99
- vd.aggregators[name] = _defaggr(name, type, _funcRows, funcValues=funcValues, helpstr=helpstr) # accepts a srccol + list of rows
95
+
96
+ @VisiData.api
97
+ def aggregator(vd, name, funcValues, helpstr='', *, type=None):
98
+ '''Define simple aggregator *name* that calls ``funcValues(values)`` to aggregate *values*.
99
+ Use *type* to force type of aggregated column (default to use type of source column).'''
100
+ vd.aggregators[name] = Aggregator(name, type, funcValues=funcValues, helpstr=helpstr)
100
101
 
101
102
  ## specific aggregator implementations
102
103
 
@@ -105,12 +106,9 @@ def mean(vals):
105
106
  if vals:
106
107
  return float(sum(vals))/len(vals)
107
108
 
108
- def _vsum(vals):
109
+ def vsum(vals):
109
110
  return sum(vals, start=type(vals[0] if len(vals) else 0)()) #1996
110
111
 
111
- # start parameter in sum() added in Python 3.8
112
- vsum = _vsum if sys.version_info[:2] >= (3, 8) else sum
113
-
114
112
  # http://code.activestate.com/recipes/511478-finding-the-percentile-of-the-values/
115
113
  def _percentile(N, percent, key=lambda x:x):
116
114
  """
@@ -134,11 +132,18 @@ def _percentile(N, percent, key=lambda x:x):
134
132
  return d0+d1
135
133
 
136
134
  @functools.lru_cache(100)
137
- def percentile(pct, helpstr=''):
138
- return _defaggr('p%s'%pct, None, lambda col,rows,pct=pct: _percentile(sorted(col.getValues(rows)), pct/100), helpstr=helpstr)
135
+ class PercentileAggregator(Aggregator):
136
+ def __init__(self, pct, helpstr=''):
137
+ super().__init__('p%s'%pct, None, helpstr=helpstr)
138
+ self.pct = pct
139
+
140
+ def aggregate(self, col, rows):
141
+ return _percentile(sorted(col.getValues(rows)), self.pct/100, key=float)
142
+
139
143
 
140
144
  def quantiles(q, helpstr):
141
- return [percentile(round(100*i/q), helpstr) for i in range(1, q)]
145
+ return [PercentileAggregator(round(100*i/q), helpstr) for i in range(1, q)]
146
+
142
147
 
143
148
  vd.aggregator('min', min, 'minimum value')
144
149
  vd.aggregator('max', max, 'maximum value')
@@ -149,7 +154,7 @@ vd.aggregator('mode', statistics.mode, 'mode of values')
149
154
  vd.aggregator('sum', vsum, 'sum of values')
150
155
  vd.aggregator('distinct', set, 'distinct values', type=vlen)
151
156
  vd.aggregator('count', lambda values: sum(1 for v in values), 'number of values', type=int)
152
- vd.aggregator('list', list, 'list of values')
157
+ vd.aggregator('list', list, 'list of values', type=anytype)
153
158
  vd.aggregator('stdev', statistics.stdev, 'standard deviation of values', type=float)
154
159
 
155
160
  vd.aggregators['q3'] = quantiles(3, 'tertiles (33/66th pctile)')
@@ -160,10 +165,31 @@ vd.aggregators['q10'] = quantiles(10, 'deciles (10/20/30/40/50/60/70/80/90th pct
160
165
  # since bb29b6e, a record of every aggregator
161
166
  # is needed in vd.aggregators
162
167
  for pct in (10, 20, 25, 30, 33, 40, 50, 60, 67, 70, 75, 80, 90, 95, 99):
163
- vd.aggregators[f'p{pct}'] = percentile(pct, f'{pct}th percentile')
168
+ vd.aggregators[f'p{pct}'] = PercentileAggregator(pct, f'{pct}th percentile')
169
+
170
+ class KeyFindingAggregator(Aggregator):
171
+ '''Return the key of the row that results from applying *aggr_func* to *rows*.
172
+ Return None if *rows* is an empty list.
173
+ *aggr_func* takes a list of (value, row) tuples, one for each row in the column,
174
+ excluding rows where the column holds null and error values.
175
+ *aggr_func* must also take the parameters *default* and *key*, as max() does:
176
+ https://docs.python.org/3/library/functions.html#max'''
164
177
 
165
- # returns keys of the row with the max value
166
- vd.aggregators['keymax'] = _defaggr('keymax', anytype, lambda col, rows: col.sheet.rowkey(max(col.getValueRows(rows))[1]), helpstr='key of the maximum value')
178
+ def __init__(self, aggr_func, *args, **kwargs):
179
+ self.aggr_func = aggr_func
180
+ super().__init__(*args, **kwargs)
181
+
182
+ def aggregate(self, col, rows):
183
+ if not col.sheet.keyCols:
184
+ vd.error('key aggregator function requires one or more key columns')
185
+ return None
186
+ # convert dicts to lists because functions like max() can't compare dicts
187
+ sortkey = lambda t: (t[0], sorted(t[1].items())) if isinstance(t[1], dict) else t
188
+ row = self.aggr_func(col.getValueRows(rows), default=(None, None), key=sortkey)[1]
189
+ return col.sheet.rowkey(row) if row else None
190
+
191
+ vd.aggregators['keymin'] = KeyFindingAggregator(min, 'keymin', anytype, helpstr='key of the minimum value')
192
+ vd.aggregators['keymax'] = KeyFindingAggregator(max, 'keymax', anytype, helpstr='key of the maximum value')
167
193
 
168
194
 
169
195
  ColumnsSheet.columns += [
@@ -175,7 +201,7 @@ ColumnsSheet.columns += [
175
201
 
176
202
  @Sheet.api
177
203
  def addAggregators(sheet, cols, aggrnames):
178
- 'Add each aggregator in list of *aggrnames* to each of *cols*.'
204
+ 'Add each aggregator in list of *aggrnames* to each of *cols*. Ignores names that are not valid.'
179
205
  for aggrname in aggrnames:
180
206
  aggrs = vd.aggregators.get(aggrname)
181
207
  aggrs = aggrs if isinstance(aggrs, list) else [aggrs]
@@ -192,16 +218,35 @@ def aggname(col, agg):
192
218
  'Consistent formatting of the name of given aggregator for this column. e.g. "col1_sum"'
193
219
  return '%s_%s' % (col.name, agg.name)
194
220
 
221
+ @Column.api
222
+ def aggregateTotal(col, agg):
223
+ if agg not in col._aggregatedTotals:
224
+ col._aggregatedTotals[agg] = INPROGRESS
225
+ col._aggregateTotalAsync(agg)
226
+ return col._aggregatedTotals[agg]
227
+
228
+
229
+ @Column.api
230
+ @asyncthread
231
+ def _aggregateTotalAsync(col, agg):
232
+ col._aggregatedTotals[agg] = agg.aggregate(col, col.sheet.rows)
233
+
234
+
195
235
  @Column.api
196
236
  @asyncthread
197
- def memo_aggregate(col, agg, rows):
237
+ def memo_aggregate(col, agg_choices, rows):
198
238
  'Show aggregated value in status, and add to memory.'
199
- aggval = agg(col, rows)
200
- typedval = wrapply(agg.type or col.type, aggval)
201
- dispval = col.format(typedval)
202
- k = col.name+'_'+agg.name
203
- vd.status(f'{k}={dispval}')
204
- vd.memory[k] = typedval
239
+ for agg_choice in agg_choices:
240
+ agg = vd.aggregators.get(agg_choice)
241
+ if not agg: continue
242
+ aggs = agg if isinstance(agg, list) else [agg]
243
+ for agg in aggs:
244
+ aggval = agg.aggregate(col, rows)
245
+ typedval = wrapply(agg.type or col.type, aggval)
246
+ dispval = col.format(typedval)
247
+ k = col.name+'_'+agg.name
248
+ vd.status(f'{k}={dispval}')
249
+ vd.memory[k] = typedval
205
250
 
206
251
 
207
252
  @VisiData.property
@@ -215,6 +260,7 @@ def aggregator_choices(vd):
215
260
 
216
261
  @VisiData.api
217
262
  def chooseAggregators(vd):
263
+ '''Return a list of aggregator name strings chosen or entered by the user. User-entered names may be invalid.'''
218
264
  prompt = 'choose aggregators: '
219
265
  def _fmt_aggr_summary(match, row, trigger_key):
220
266
  formatted_aggrname = match.formatted.get('key', row.key) if match else row.key
@@ -235,12 +281,15 @@ def chooseAggregators(vd):
235
281
  multiple=True)
236
282
 
237
283
  aggrs = r.split()
284
+ valid_choices = vd.aggregators.keys()
238
285
  for aggr in aggrs:
239
286
  vd.usedInputs[aggr] += 1
287
+ if aggr not in valid_choices:
288
+ vd.warning(f'aggregator does not exist: {aggr}')
240
289
  return aggrs
241
290
 
242
- Sheet.addCommand('+', 'aggregate-col', 'addAggregators([cursorCol], chooseAggregators())', 'Add aggregator to current column')
243
- 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')
291
+ Sheet.addCommand('+', 'aggregate-col', 'addAggregators([cursorCol], chooseAggregators())', 'add aggregator to current column')
292
+ Sheet.addCommand('z+', 'memo-aggregate', 'cursorCol.memo_aggregate(chooseAggregators(), selectedRows or rows)', 'memo result of aggregator over values in selected rows for current column')
244
293
  ColumnsSheet.addCommand('g+', 'aggregate-cols', 'addAggregators(selectedRows or source[0].nonKeyVisibleCols, chooseAggregators())', 'add aggregators to selected source columns')
245
294
 
246
295
  vd.addMenuItems('''
@@ -60,6 +60,8 @@ def configure_ibis(vd):
60
60
 
61
61
  @VisiData.api
62
62
  def open_vdsql(vd, p, filetype=None):
63
+ import ibis
64
+
63
65
  vd.configure_ibis()
64
66
 
65
67
  # on-demand aliasing, so we don't need deps for all backends
@@ -70,7 +72,9 @@ def open_vdsql(vd, p, filetype=None):
70
72
  return IbisTableIndexSheet(p.base_stem, source=p, filetype=None, database_name=None,
71
73
  ibis_conpool=IbisConnectionPool(p), sheet_type=IbisTableSheet)
72
74
 
75
+
73
76
  vd.open_ibis = vd.open_vdsql
77
+ vd.openurl_sqlite = vd.open_vdsql
74
78
 
75
79
 
76
80
  class IbisConnectionPool:
@@ -85,14 +89,8 @@ class IbisConnectionPool:
85
89
  @contextmanager
86
90
  def get_conn(self):
87
91
  if not self.pool:
88
- import sqlalchemy
89
- try:
90
- import ibis
91
- r = ibis.connect(str(self.source))
92
- except sqlalchemy.exc.NoSuchModuleError as e:
93
- dialect = str(e).split(':')[-1]
94
- vd.warning(f'{dialect} not installed')
95
- vd.fail(f'pip install ibis-framework[{dialect}]')
92
+ import ibis
93
+ r = ibis.connect(str(self.source))
96
94
  else:
97
95
  r = self.pool.pop(0)
98
96
 
@@ -210,9 +208,9 @@ class IbisTableSheet(Sheet):
210
208
  def curcol_sql(self):
211
209
  expr = self.cursorCol.get_ibis_col(self.ibis_current_expr)
212
210
  if expr is not None:
213
- return self.ibis_to_sql(expr, fragment=True)
211
+ return self.ibis_expr_to_sql(expr, fragment=True)
214
212
 
215
- def ibis_to_sql(self, expr, fragment=False):
213
+ def ibis_expr_to_sql(self, expr, fragment=False):
216
214
  import sqlparse
217
215
  with self.con as con:
218
216
  context = con.compiler.make_context()
@@ -323,7 +321,7 @@ class IbisTableSheet(Sheet):
323
321
  def sqlize(self, expr):
324
322
  if vd.options.debug:
325
323
  expr = self.withRowcount(expr)
326
- return self.ibis_to_sql(expr)
324
+ return self.ibis_expr_to_sql(expr)
327
325
 
328
326
  @property
329
327
  def substrait(self):
@@ -1,6 +1,6 @@
1
1
  import time
2
2
 
3
- from visidata import BaseException, vd, VisiData, Progress
3
+ from visidata import vd, VisiData, Progress
4
4
 
5
5
  from ._ibis import IbisTableSheet, IbisTableIndexSheet, IbisConnectionPool
6
6
 
@@ -45,7 +45,7 @@ class ClickhouseSheet(IbisTableSheet):
45
45
  raise
46
46
  except BaseException:
47
47
  if qid:
48
- con.con.cancel(qid)
48
+ con._client.cancel(qid)
49
49
 
50
50
 
51
51
  ClickhouseSheet.init('total_rows', lambda: None)
@@ -1,6 +1,6 @@
1
1
  import time
2
2
 
3
- from visidata import vd, BaseException, VisiData
3
+ from visidata import vd, VisiData
4
4
 
5
5
  from ._ibis import IbisTableSheet, IbisConnectionPool, IbisTableIndexSheet
6
6
 
@@ -112,7 +112,7 @@ class GitStatus(GitSheet):
112
112
  return self.gitBranchStatuses.get(self.branch, 'no branch')
113
113
 
114
114
  def iterload(self):
115
- files = [GitFile(p, self.source) for p in self.source.iterdir() if p.base_stem not in ('.git')] # files in working dir
115
+ files = [GitFile(p, self.source) for p in self.source.iterdir() if p.stem not in ('.git')] # files in working dir
116
116
 
117
117
  filenames = dict((gf.filename, gf) for gf in files)
118
118
 
visidata/basesheet.py CHANGED
@@ -67,6 +67,10 @@ class DrawablePane(Extensible):
67
67
  'Width of the current sheet window, in single-width characters.'
68
68
  return self._scr.getmaxyx()[1] if self._scr else 80
69
69
 
70
+ @property
71
+ def currow(self):
72
+ return None
73
+
70
74
  def execCommand2(self, cmd, vdglobals=None):
71
75
  "Execute `cmd` with `vdglobals` as globals and this sheet's attributes as locals. Return True if user cancelled."
72
76
 
@@ -112,9 +116,9 @@ class BaseSheet(DrawablePane):
112
116
 
113
117
  def __init__(self, *names, rows=UNLOADED, **kwargs):
114
118
  self._name = None # initial cache value necessary for self.options
119
+ self._names = []
115
120
  self.loading = False
116
121
  self.names = list(names)
117
- self.name = self.options.name_joiner.join(str(x) for x in self.names if x)
118
122
  self.source = None
119
123
  self.rows = rows # list of opaque objects
120
124
  self._scr = None
@@ -194,7 +198,7 @@ class BaseSheet(DrawablePane):
194
198
 
195
199
  cmd = self.getCommand(longname or keystrokes)
196
200
  if not cmd:
197
- vd.warning('no command for %s' % (longname or keystrokes))
201
+ vd.fail('no command for %s' % (longname or keystrokes))
198
202
  return False
199
203
 
200
204
  escaped = False
@@ -238,8 +242,10 @@ class BaseSheet(DrawablePane):
238
242
 
239
243
  @names.setter
240
244
  def names(self, names):
245
+ if self._names:
246
+ vd.addUndo(setattr, self, 'names', self._names)
241
247
  self._names = names
242
- self.name = self.options.name_joiner.join(self.maybeClean(str(x)) for x in self._names)
248
+ self._name = self.options.name_joiner.join(self.maybeClean(str(x)) for x in self._names)
243
249
 
244
250
  @property
245
251
  def name(self):
@@ -250,8 +256,9 @@ class BaseSheet(DrawablePane):
250
256
  def name(self, name):
251
257
  'Set name without spaces.'
252
258
  if self._names:
253
- vd.addUndo(setattr, self, '_names', self._names)
259
+ vd.addUndo(setattr, self, 'names', self._names)
254
260
  self._name = self.maybeClean(str(name))
261
+ self._names = [self._name]
255
262
 
256
263
  def maybeClean(self, s):
257
264
  'stub'
visidata/canvas.py CHANGED
@@ -145,7 +145,7 @@ class Plotter(BaseSheet):
145
145
  self.labels = [] # (x, y, text, attr, row)
146
146
  self.hiddenAttrs = set()
147
147
  self.needsRefresh = False
148
- self.resetCanvasDimensions(self.windowHeight, self.windowWidth)
148
+ self.resetCanvasDimensions(1, 1) #2171
149
149
 
150
150
  @property
151
151
  def nRows(self):
@@ -209,15 +209,23 @@ class Plotter(BaseSheet):
209
209
  self.hiddenAttrs.remove(attr)
210
210
  self.plotlegends()
211
211
 
212
- def rowsWithin(self, plotter_bbox):
212
+ def rowsWithin(self, plotter_bbox, invert_y=False):
213
213
  'return list of deduped rows within plotter_bbox'
214
214
  ret = {}
215
+
215
216
  x_start = max(0, plotter_bbox.xmin)
217
+ if len(self.pixels) == 0: return []
218
+ x_end = min(len(self.pixels[0]), plotter_bbox.xmax)
219
+
216
220
  y_start = max(0, plotter_bbox.ymin)
217
221
  y_end = min(len(self.pixels), plotter_bbox.ymax)
218
- for y in range(y_start, y_end):
219
- x_end = min(len(self.pixels[y]), plotter_bbox.xmax)
220
- for x in range(x_start, x_end):
222
+ if invert_y:
223
+ y_range = range(y_end-1, y_start-1, -1)
224
+ else:
225
+ y_range = range(y_start, y_end)
226
+
227
+ for x in range(x_start, x_end):
228
+ for y in y_range:
221
229
  for attr, rows in self.pixels[y][x].items():
222
230
  if attr not in self.hiddenAttrs:
223
231
  for r in rows:
@@ -226,12 +234,28 @@ class Plotter(BaseSheet):
226
234
 
227
235
  def draw(self, scr):
228
236
  windowHeight, windowWidth = scr.getmaxyx()
229
- disp_canvas_charset = self.options.disp_canvas_charset or ' o'
230
- disp_canvas_charset += (256 - len(disp_canvas_charset)) * disp_canvas_charset[-1]
231
-
232
237
  if self.needsRefresh:
233
238
  self.render(windowHeight, windowWidth)
234
239
 
240
+ self.draw_pixels(scr)
241
+ self.draw_labels(scr)
242
+
243
+ def draw_empty(self, scr):
244
+ # use draw_empty() when calling draw_pixels() with clear_empty_squares=False
245
+ cursorBBox = self.plotterCursorBox
246
+ for char_y in range(0, self.plotheight//4):
247
+ for char_x in range(0, self.plotwidth//2):
248
+ cattr = ColorAttr()
249
+ ch = ' '
250
+ # draw cursor
251
+ if cursorBBox.contains(char_x*2, char_y*4) or \
252
+ cursorBBox.contains(char_x*2+1, char_y*4+3):
253
+ cattr = update_attr(cattr, colors.color_current_row)
254
+ scr.addstr(char_y, char_x, ch, cattr.attr)
255
+
256
+ def draw_pixels(self, scr, clear_empty_squares=True):
257
+ disp_canvas_charset = self.options.disp_canvas_charset or ' o'
258
+ disp_canvas_charset += (256 - len(disp_canvas_charset)) * disp_canvas_charset[-1]
235
259
  if self.pixels:
236
260
  cursorBBox = self.plotterCursorBox
237
261
  getPixelAttr = self.getPixelAttrRandom if self.options.disp_pixel_random else self.getPixelAttrMost
@@ -256,19 +280,26 @@ class Plotter(BaseSheet):
256
280
  braille_num += pow2
257
281
  pow2 *= 2
258
282
 
283
+ ch = disp_canvas_charset[braille_num]
259
284
  if braille_num != 0:
260
285
  color = Counter(c for c in block_attrs if c).most_common(1)[0][0]
261
286
  cattr = colors.get_color(color)
262
287
  else:
263
288
  cattr = ColorAttr()
289
+ # don't erase empty squares, useful for subclasses that draw elements like reflines
290
+ # before pixels are drawn
291
+ if not clear_empty_squares:
292
+ continue
264
293
 
294
+ # draw cursor
265
295
  if cursorBBox.contains(char_x*2, char_y*4) or \
266
296
  cursorBBox.contains(char_x*2+1, char_y*4+3):
267
297
  cattr = update_attr(cattr, colors.color_current_row)
268
298
 
269
299
  if cattr.attr:
270
- scr.addstr(char_y, char_x, disp_canvas_charset[braille_num], cattr.attr)
300
+ scr.addstr(char_y, char_x, ch, cattr.attr)
271
301
 
302
+ def draw_labels(self, scr):
272
303
  def _mark_overlap_text(labels, textobj):
273
304
  def _overlaps(a, b):
274
305
  a_x1, _, a_txt, _, _ = a
@@ -310,7 +341,7 @@ class Plotter(BaseSheet):
310
341
  cursorBBox = self.plotterCursorBox
311
342
  for c in txt:
312
343
  w = dispwidth(c)
313
- # check if the cursor contains the midpoint of the character box
344
+ # draw cursor if the cursor contains the midpoint of the character cell
314
345
  if cursorBBox.contains(char_x*2+1, char_y*4+2):
315
346
  char_attr = update_attr(cattr, colors.color_current_row)
316
347
  clipdraw(scr, char_y, char_x, c, char_attr, w)
@@ -385,6 +416,12 @@ class Canvas(Plotter):
385
416
  if self.cursorBox.xmin == self.visibleBox.xmin and self.cursorBox.ymin == self.calcBottomCursorY():
386
417
  realign_cursor = True
387
418
  super().resetCanvasDimensions(windowHeight, windowWidth)
419
+ # if window is not big enough to contain a particular margin, pretend that margin is 0
420
+ pvbox_x = pvbox_y = 0
421
+ if self.plotwidth > self.left_margin:
422
+ pvbox_x = self.left_margin
423
+ if self.plotheight > self.topMarginPixels:
424
+ pvbox_y = self.topMarginPixels
388
425
  if hasattr(self, 'legendwidth'):
389
426
  # +4 = 1 empty space after the graph + 2 characters for the legend prefixes of "1:", "2:", etc +
390
427
  # 1 character for the empty rightmost column
@@ -395,8 +432,10 @@ class Canvas(Plotter):
395
432
  else:
396
433
  pvbox_xmax = self.plotwidth-self.rightMarginPixels-1
397
434
  self.left_margin = min(self.left_margin, math.ceil(self.plotwidth * 1/3)//2*2)
398
- self.plotviewBox = BoundingBox(self.left_margin, self.topMarginPixels,
399
- pvbox_xmax, self.plotheight-self.bottomMarginPixels-1)
435
+ # enforce a minimum plotview box size of 1x1
436
+ pvbox_xmax = max(pvbox_xmax, 1)
437
+ pvbox_ymax = max(self.plotheight-self.bottomMarginPixels-1, 1)
438
+ self.plotviewBox = BoundingBox(pvbox_x, pvbox_y, pvbox_xmax, pvbox_ymax)
400
439
  if [self.plotheight, self.plotwidth] != old_plotsize:
401
440
  if hasattr(self, 'cursorBox') and self.cursorBox:
402
441
  self.setCursorSizeInPlotterPixels(2, 4)
@@ -513,13 +552,14 @@ class Canvas(Plotter):
513
552
  'adjust visibleBox.xymin so that canvasPoint is plotted at plotterPoint'
514
553
  self.visibleBox.xmin = canvasPoint.x - self.canvasW(plotterPoint.x-self.plotviewBox.xmin)
515
554
  self.visibleBox.ymin = canvasPoint.y - self.canvasH(plotterPoint.y-self.plotviewBox.ymin)
516
- self.refresh()
555
+ self.resetBounds()
517
556
 
518
557
  def zoomTo(self, bbox):
519
558
  'set visible area to bbox, maintaining aspectRatio if applicable'
520
559
  self.fixPoint(self.plotviewBox.xymin, bbox.xymin)
521
560
  self.xzoomlevel=bbox.w/self.canvasBox.w
522
561
  self.yzoomlevel=bbox.h/self.canvasBox.h
562
+ self.resetBounds()
523
563
 
524
564
  def incrZoom(self, incr):
525
565
  self.xzoomlevel *= incr
@@ -527,7 +567,7 @@ class Canvas(Plotter):
527
567
 
528
568
  self.resetBounds()
529
569
 
530
- def resetBounds(self):
570
+ def resetBounds(self, refresh=True):
531
571
  'create canvasBox and cursorBox if necessary, and set visibleBox w/h according to zoomlevels. then redisplay legends.'
532
572
  if not self.canvasBox:
533
573
  xmin, ymin, xmax, ymax = None, None, None, None
@@ -564,6 +604,8 @@ class Canvas(Plotter):
564
604
  self.cursorBox = Box(cb_xmin, cb_ymin, self.canvasCharWidth, self.canvasCharHeight)
565
605
 
566
606
  self.plotlegends()
607
+ if refresh:
608
+ self.refresh()
567
609
 
568
610
  def calcTopCursorY(self):
569
611
  'ymin for the cursor that will align its top with the top edge of the graph'
@@ -641,13 +683,13 @@ class Canvas(Plotter):
641
683
  else:
642
684
  return h
643
685
 
644
- def scaleX(self, canvasX):
686
+ def scaleX(self, canvasX) -> int:
645
687
  'returns a plotter x coordinate'
646
- return round(self.plotviewBox.xmin+(canvasX-self.visibleBox.xmin)*self.xScaler)
688
+ return self.plotviewBox.xmin+round((canvasX-self.visibleBox.xmin)*self.xScaler)
647
689
 
648
- def scaleY(self, canvasY):
690
+ def scaleY(self, canvasY) -> int:
649
691
  'returns a plotter y coordinate'
650
- return round(self.plotviewBox.ymin+(canvasY-self.visibleBox.ymin)*self.yScaler)
692
+ return self.plotviewBox.ymin+round((canvasY-self.visibleBox.ymin)*self.yScaler)
651
693
 
652
694
  def unscaleX(self, plotterX):
653
695
  'performs the inverse of scaleX, returns a canvas x coordinate'
@@ -675,6 +717,7 @@ class Canvas(Plotter):
675
717
  vd.cancelThread(*(t for t in self.currentThreads if t.name == 'plotAll_async'))
676
718
  self.labels.clear()
677
719
  self.resetCanvasDimensions(h, w)
720
+ self.resetBounds(refresh=False)
678
721
  self.render_async()
679
722
 
680
723
  @asyncthread
@@ -701,12 +744,12 @@ class Canvas(Plotter):
701
744
  x1, y1 = float(x1), float(y1)
702
745
  if xmin <= x1 <= xmax and ymin <= y1 <= ymax:
703
746
  # equivalent to self.scaleX(x1) and self.scaleY(y1), inlined for speed
704
- x = plotxmin+(x1-xmin)*xfactor
747
+ x = plotxmin+round((x1-xmin)*xfactor)
705
748
  if invert_y:
706
- y = plotymax-(y1-ymin)*yfactor
749
+ y = plotymax-round((y1-ymin)*yfactor)
707
750
  else:
708
- y = plotymin+(y1-ymin)*yfactor
709
- self.plotpixel(round(x), round(y), attr, row)
751
+ y = plotymin+round((y1-ymin)*yfactor)
752
+ self.plotpixel(x, y, attr, row)
710
753
  continue
711
754
 
712
755
  prev_x, prev_y = vertexes[0]
@@ -735,7 +778,6 @@ class Canvas(Plotter):
735
778
  self.source.deleteBy(lambda r,rows=rows: r in rows)
736
779
  self.reload()
737
780
 
738
-
739
781
  Plotter.addCommand('v', 'visibility', 'options.disp_graph_labels = not options.disp_graph_labels', 'toggle disp_graph_labels option')
740
782
 
741
783
  Canvas.addCommand(None, 'go-left', 'if cursorBox: sheet.cursorBox.xmin -= cursorBox.w', 'move cursor left by its width')
@@ -768,7 +810,7 @@ Canvas.addCommand('zz', 'zoom-cursor', 'zoomTo(cursorBox)', 'set visible bounds
768
810
 
769
811
  Canvas.addCommand('-', 'zoomout-cursor', 'tmp=cursorBox.center; incrZoom(options.disp_zoom_incr); fixPoint(plotviewBox.center, tmp)', 'zoom out from cursor center')
770
812
  Canvas.addCommand('+', 'zoomin-cursor', 'tmp=cursorBox.center; incrZoom(1.0/options.disp_zoom_incr); fixPoint(plotviewBox.center, tmp)', 'zoom into cursor center')
771
- Canvas.addCommand('_', 'zoom-all', 'sheet.canvasBox = None; sheet.visibleBox = None; sheet.xzoomlevel=sheet.yzoomlevel=1.0; resetBounds(); refresh()', 'zoom to fit full extent')
813
+ Canvas.addCommand('_', 'zoom-all', 'sheet.canvasBox = None; sheet.visibleBox = None; sheet.xzoomlevel=sheet.yzoomlevel=1.0; resetBounds()', 'zoom to fit full extent')
772
814
  Canvas.addCommand('z_', 'set-aspect', 'sheet.aspectRatio = float(input("aspect ratio=", value=aspectRatio)); refresh()', 'set aspect ratio')
773
815
 
774
816
  # set cursor box with left click