OILS / core / comp_ui.py View on Github | oilshell.org

578 lines, 292 significant
1"""comp_ui.py."""
2from __future__ import print_function
3
4from core import ansi
5from core import completion
6from data_lang import pretty
7import libc
8
9from mycpp import mylib
10
11from typing import Any, List, Optional, Dict, TYPE_CHECKING
12if TYPE_CHECKING:
13 from frontend.py_readline import Readline
14 from core.util import _DebugFile
15 from core import pyos
16
17# ANSI escape codes affect the prompt!
18# https://superuser.com/questions/301353/escape-non-printing-characters-in-a-function-for-a-bash-prompt
19#
20# Readline understands \x01 and \x02, while bash understands \[ and \].
21
22# NOTE: There were used in demoish.py. Do we still want those styles?
23if 0:
24 PROMPT_BOLD = '\x01%s\x02' % ansi.BOLD
25 PROMPT_RESET = '\x01%s\x02' % ansi.RESET
26 PROMPT_UNDERLINE = '\x01%s\x02' % ansi.UNDERLINE
27 PROMPT_REVERSE = '\x01%s\x02' % ansi.REVERSE
28
29
30def _PromptLen(prompt_str):
31 # type: (str) -> int
32 """Ignore all characters between \x01 and \x02 and handle unicode
33 characters.
34
35 In particular, the display width of a string may be different from
36 either the number of bytes or the number of unicode characters.
37 Additionally, if there are multiple lines in the prompt, only give
38 the length of the last line.
39 """
40 escaped = False
41 display_str = ""
42 for c in prompt_str:
43 if c == '\x01':
44 escaped = True
45 elif c == '\x02':
46 escaped = False
47 elif not escaped:
48 # mycpp: rewrite of +=
49 display_str = display_str + c
50 last_line = display_str.split('\n')[-1]
51 return pretty.TryUnicodeWidth(last_line)
52
53
54class PromptState(object):
55 """For the InteractiveLineReader to communicate with the Display
56 callback."""
57
58 def __init__(self):
59 # type: () -> None
60 self.last_prompt_str = None # type: Optional[str]
61 self.last_prompt_len = -1
62
63 def SetLastPrompt(self, prompt_str):
64 # type: (str) -> None
65 self.last_prompt_str = prompt_str
66 self.last_prompt_len = _PromptLen(prompt_str)
67
68
69class State(object):
70 """For the RootCompleter to communicate with the Display callback."""
71
72 def __init__(self):
73 # type: () -> None
74 # original line, truncated
75 self.line_until_tab = None # type: Optional[str]
76
77 # Start offset in EVERY candidate to display. We send fully-completed
78 # LINES to readline because we don't want it to do its own word splitting.
79 self.display_pos = -1
80
81 # completion candidate descriptions
82 self.descriptions = {} # type: Dict[str, str]
83
84
85class _IDisplay(object):
86 """Interface for completion displays."""
87
88 def __init__(self, comp_state, prompt_state, num_lines_cap, f, debug_f):
89 # type: (State, PromptState, int, mylib.Writer, _DebugFile) -> None
90 self.comp_state = comp_state
91 self.prompt_state = prompt_state
92 self.num_lines_cap = num_lines_cap
93 self.f = f
94 self.debug_f = debug_f
95
96 def PrintCandidates(self, unused_subst, matches, unused_match_len):
97 # type: (Optional[str], List[str], int) -> None
98 try:
99 self._PrintCandidates(unused_subst, matches, unused_match_len)
100 except Exception:
101 if 0:
102 import traceback
103 traceback.print_exc()
104
105 def _PrintCandidates(self, unused_subst, matches, unused_match_len):
106 # type: (Optional[str], List[str], int) -> None
107 """Abstract method."""
108 raise NotImplementedError()
109
110 def Reset(self):
111 # type: () -> None
112 """Call this in between commands."""
113 pass
114
115 def ShowPromptOnRight(self, rendered):
116 # type: (str) -> None
117 # Doesn't apply to MinimalDisplay
118 pass
119
120 def EraseLines(self):
121 # type: () -> None
122 # Doesn't apply to MinimalDisplay
123 pass
124
125 if mylib.PYTHON:
126
127 def PrintRequired(self, msg, *args):
128 # type: (str, *Any) -> None
129 # This gets called with "nothing to display"
130 pass
131
132 def PrintOptional(self, msg, *args):
133 # type: (str, *Any) -> None
134 pass
135
136
137class MinimalDisplay(_IDisplay):
138 """A display with minimal dependencies.
139
140 It doesn't output color or depend on the terminal width. It could be
141 useful if we ever have a browser build! We can see completion
142 without testing it.
143 """
144
145 def __init__(self, comp_state, prompt_state, debug_f):
146 # type: (State, PromptState, _DebugFile) -> None
147 _IDisplay.__init__(self, comp_state, prompt_state, 10, mylib.Stdout(),
148 debug_f)
149
150 self.reader = None
151
152 def _RedrawPrompt(self):
153 # type: () -> None
154 # NOTE: This has to reprint the prompt and the command line!
155 # Like bash, we SAVE the prompt and print it, rather than re-evaluating it.
156 self.f.write(self.prompt_state.last_prompt_str)
157 self.f.write(self.comp_state.line_until_tab)
158
159 def _PrintCandidates(self, unused_subst, matches, unused_match_len):
160 # type: (Optional[str], List[str], int) -> None
161 #log('_PrintCandidates %s', matches)
162 self.f.write('\n') # need this
163 display_pos = self.comp_state.display_pos
164 assert display_pos != -1
165
166 too_many = False
167 i = 0
168 for m in matches:
169 self.f.write(' %s\n' % m[display_pos:])
170
171 if i == self.num_lines_cap:
172 too_many = True
173 i += 1 # Count this one
174 break
175
176 i += 1
177
178 if too_many:
179 num_left = len(matches) - i
180 if num_left:
181 self.f.write(' ... and %d more\n' % num_left)
182
183 self._RedrawPrompt()
184
185 if mylib.PYTHON:
186
187 def PrintRequired(self, msg, *args):
188 # type: (str, *Any) -> None
189 self.f.write('\n')
190 if args:
191 msg = msg % args
192 self.f.write(' %s\n' % msg) # need a newline
193 self._RedrawPrompt()
194
195
196def _PrintPacked(matches, max_match_len, term_width, max_lines, f):
197 # type: (List[str], int, int, int, mylib.Writer) -> int
198 # With of each candidate. 2 spaces between each.
199 w = max_match_len + 2
200
201 # Number of candidates per line. Don't print in first or last column.
202 num_per_line = max(1, (term_width - 2) // w)
203
204 fmt = '%-' + str(w) + 's'
205 num_lines = 0
206
207 too_many = False
208 remainder = num_per_line - 1
209 i = 0 # num matches
210 for m in matches:
211 if i % num_per_line == 0:
212 f.write(' ') # 1 space left gutter
213
214 f.write(fmt % m)
215
216 if i % num_per_line == remainder:
217 f.write('\n') # newline (leaving 1 space right gutter)
218 num_lines += 1
219
220 # Check if we've printed enough lines
221 if num_lines == max_lines:
222 too_many = True
223 i += 1 # count this one
224 break
225 i += 1
226
227 # Write last line break, unless it came out exactly.
228 if i % num_per_line != 0:
229 #log('i = %d, num_per_line = %d, i %% num_per_line = %d',
230 # i, num_per_line, i % num_per_line)
231
232 f.write('\n')
233 num_lines += 1
234
235 if too_many:
236 # TODO: Save this in the Display class
237 fmt2 = ansi.BOLD + ansi.BLUE + '%' + str(term_width -
238 2) + 's' + ansi.RESET
239 num_left = len(matches) - i
240 if num_left:
241 f.write(fmt2 % '... and %d more\n' % num_left)
242 num_lines += 1
243
244 return num_lines
245
246
247def _PrintLong(
248 matches, # type: List[str]
249 max_match_len, # type: int
250 term_width, # type: int
251 max_lines, # type: int
252 descriptions, # type: Dict[str, str]
253 f, # type: mylib.Writer
254):
255 # type: (...) -> int
256 """Print flags with descriptions, one per line.
257
258 Args:
259 descriptions: dict of { prefix-stripped match -> description }
260
261 Returns:
262 The number of lines printed.
263 """
264 #log('desc = %s', descriptions)
265
266 # Subtract 3 chars: 1 for left and right margin, and then 1 for the space in
267 # between.
268 max_desc = max(0, term_width - max_match_len - 3)
269 fmt = ' %-' + str(
270 max_match_len) + 's ' + ansi.YELLOW + '%s' + ansi.RESET + '\n'
271
272 num_lines = 0
273
274 # rl_match is a raw string, which may or may not have a trailing space
275 for rl_match in matches:
276 desc = descriptions.get(rl_match)
277 if desc is None:
278 desc = ''
279 if max_desc == 0: # the window is not wide enough for some flag
280 f.write(' %s\n' % rl_match)
281 else:
282 if len(desc) > max_desc:
283 desc = desc[:max_desc - 5] + ' ... '
284 f.write(fmt % (rl_match, desc))
285
286 num_lines += 1
287
288 if num_lines == max_lines:
289 # right justify
290 fmt2 = ansi.BOLD + ansi.BLUE + '%' + str(term_width -
291 1) + 's' + ansi.RESET
292 num_left = len(matches) - num_lines
293 if num_left:
294 f.write(fmt2 % '... and %d more\n' % num_left)
295 num_lines += 1
296 break
297
298 return num_lines
299
300
301class NiceDisplay(_IDisplay):
302 """Methods to display completion candidates and other messages.
303
304 This object has to remember how many lines we last drew, in order to erase
305 them before drawing something new.
306
307 It's also useful for:
308 - Stripping off the common prefix according to OUR rules, not readline's.
309 - displaying descriptions of flags and builtins
310 """
311
312 def __init__(
313 self,
314 term_width, # type: int
315 comp_state, # type: State
316 prompt_state, # type: PromptState
317 debug_f, # type: _DebugFile
318 readline, # type: Optional[Readline]
319 signal_safe, # type: pyos.SignalSafe
320 ):
321 # type: (...) -> None
322 """
323 Args:
324 bold_line: Should user's entry be bold?
325 """
326 _IDisplay.__init__(self, comp_state, prompt_state, 10, mylib.Stdout(),
327 debug_f)
328
329 self.term_width = term_width # initial terminal width; will be invalidated
330
331 self.readline = readline
332 self.signal_safe = signal_safe
333
334 self.bold_line = False
335
336 self.num_lines_last_displayed = 0
337
338 # For debugging only, could get rid of
339 self.c_count = 0
340 self.m_count = 0
341
342 # hash of matches -> count. Has exactly ONE entry at a time.
343 self.dupes = {} # type: Dict[int, int]
344
345 def Reset(self):
346 # type: () -> None
347 """Call this in between commands."""
348 self.num_lines_last_displayed = 0
349 self.dupes.clear()
350
351 def _ReturnToPrompt(self, num_lines):
352 # type: (int) -> None
353 # NOTE: We can't use ANSI terminal codes to save and restore the prompt,
354 # because the screen may have scrolled. Instead we have to keep track of
355 # how many lines we printed and the original column of the cursor.
356
357 orig_len = len(self.comp_state.line_until_tab)
358
359 self.f.write('\x1b[%dA' % num_lines) # UP
360 last_prompt_len = self.prompt_state.last_prompt_len
361 assert last_prompt_len != -1
362
363 # Go right, but not more than the terminal width.
364 n = orig_len + last_prompt_len
365 n = n % self._GetTerminalWidth()
366 self.f.write('\x1b[%dC' % n) # RIGHT
367
368 if self.bold_line:
369 self.f.write(ansi.BOLD) # Experiment
370
371 self.f.flush()
372
373 def _PrintCandidates(self, unused_subst, matches, unused_max_match_len):
374 # type: (Optional[str], List[str], int) -> None
375 term_width = self._GetTerminalWidth()
376
377 # Variables set by the completion generator. They should always exist,
378 # because we can't get "matches" without calling that function.
379 display_pos = self.comp_state.display_pos
380 self.debug_f.write('DISPLAY POS in _PrintCandidates = %d\n' %
381 display_pos)
382
383 self.f.write('\n')
384
385 self.EraseLines() # Delete previous completions!
386 #log('_PrintCandidates %r', unused_subst, file=DEBUG_F)
387
388 # Figure out if the user hit TAB multiple times to show more matches.
389 # It's not correct to hash the line itself, because two different lines can
390 # have the same completions:
391 #
392 # ls <TAB>
393 # ls --<TAB>
394 #
395 # This is because there is a common prefix.
396 # So instead use the hash of all matches as the identity.
397
398 # This could be more accurate but I think it's good enough.
399 comp_id = hash(''.join(matches))
400 if comp_id in self.dupes:
401 # mycpp: rewrite of +=
402 self.dupes[comp_id] = self.dupes[comp_id] + 1
403 else:
404 self.dupes.clear() # delete the old ones
405 self.dupes[comp_id] = 1
406
407 max_lines = self.num_lines_cap * self.dupes[comp_id]
408
409 assert display_pos != -1
410 if display_pos == 0: # slight optimization for first word
411 to_display = matches
412 else:
413 to_display = [m[display_pos:] for m in matches]
414
415 # Calculate max length after stripping prefix.
416 lens = [len(m) for m in to_display]
417 max_match_len = max(lens)
418
419 # TODO: NiceDisplay should truncate when max_match_len > term_width?
420 # Also truncate when a single candidate is super long?
421
422 # Print and go back up. But we have to ERASE these before hitting enter!
423 if self.comp_state.descriptions is not None and len(
424 self.comp_state.descriptions) > 0: # exists and is NON EMPTY
425 num_lines = _PrintLong(to_display, max_match_len, term_width,
426 max_lines, self.comp_state.descriptions,
427 self.f)
428 else:
429 num_lines = _PrintPacked(to_display, max_match_len, term_width,
430 max_lines, self.f)
431
432 self._ReturnToPrompt(num_lines + 1)
433 self.num_lines_last_displayed = num_lines
434
435 self.c_count += 1
436
437 if mylib.PYTHON:
438
439 def PrintRequired(self, msg, *args):
440 # type: (str, *Any) -> None
441 """Print a message below the prompt, and then return to the
442 location on the prompt line."""
443 if args:
444 msg = msg % args
445
446 # This will mess up formatting
447 assert not msg.endswith('\n'), msg
448
449 self.f.write('\n')
450
451 self.EraseLines()
452 #log('PrintOptional %r', msg, file=DEBUG_F)
453
454 # Truncate to terminal width
455 max_len = self._GetTerminalWidth() - 2
456 if len(msg) > max_len:
457 msg = msg[:max_len - 5] + ' ... '
458
459 # NOTE: \n at end is REQUIRED. Otherwise we get drawing problems when on
460 # the last line.
461 fmt = ansi.BOLD + ansi.BLUE + '%' + str(
462 max_len) + 's' + ansi.RESET + '\n'
463 self.f.write(fmt % msg)
464
465 self._ReturnToPrompt(2)
466
467 self.num_lines_last_displayed = 1
468 self.m_count += 1
469
470 def PrintOptional(self, msg, *args):
471 # type: (str, *Any) -> None
472 self.PrintRequired(msg, *args)
473
474 def ShowPromptOnRight(self, rendered):
475 # type: (str) -> None
476 n = self._GetTerminalWidth() - 2 - len(rendered)
477 spaces = ' ' * n
478
479 # We avoid drawing problems if we print it on its own line:
480 # - inserting text doesn't push it to the right
481 # - you can't overwrite it
482 self.f.write(spaces + ansi.REVERSE + ' ' + rendered + ' ' +
483 ansi.RESET + '\r\n')
484
485 def EraseLines(self):
486 # type: () -> None
487 """Clear N lines one-by-one.
488
489 Assume the cursor is right below thep rompt:
490
491 ish$ echo hi
492 _ <-- HERE
493
494 That's the first line to erase out of N. After erasing them, return it
495 there.
496 """
497 if self.bold_line:
498 self.f.write(ansi.RESET) # if command is bold
499 self.f.flush()
500
501 n = self.num_lines_last_displayed
502
503 #log('EraseLines %d (c = %d, m = %d)', n, self.c_count, self.m_count,
504 # file=DEBUG_F)
505
506 if n == 0:
507 return
508
509 for i in xrange(n):
510 self.f.write('\x1b[2K') # 2K clears entire line (not 0K or 1K)
511 self.f.write('\x1b[1B') # go down one line
512
513 # Now go back up
514 self.f.write('\x1b[%dA' % n)
515 self.f.flush() # Without this, output will look messed up
516
517 def _GetTerminalWidth(self):
518 # type: () -> int
519 if self.signal_safe.PollSigWinch(): # is our value dirty?
520 try:
521 self.term_width = libc.get_terminal_width()
522 except (IOError, OSError):
523 # This shouldn't raise IOError because we did it at startup! Under
524 # rare circumstances stdin can change, e.g. if you do exec <&
525 # input.txt. So we have a fallback.
526 self.term_width = 80
527 return self.term_width
528
529
530def ExecutePrintCandidates(display, sub, matches, max_len):
531 # type: (_IDisplay, str, List[str], int) -> None
532 display.PrintCandidates(sub, matches, max_len)
533
534
535def InitReadline(
536 readline, # type: Optional[Readline]
537 hist_file, # type: Optional[str]
538 root_comp, # type: completion.RootCompleter
539 display, # type: _IDisplay
540 debug_f, # type: _DebugFile
541):
542 # type: (...) -> None
543 assert readline
544
545 if hist_file is not None:
546 try:
547 readline.read_history_file(hist_file)
548 except (IOError, OSError):
549 pass
550
551 readline.parse_and_bind('tab: complete')
552
553 readline.parse_and_bind('set horizontal-scroll-mode on')
554
555 # How does this map to C?
556 # https://cnswww.cns.cwru.edu/php/chet/readline/readline.html#SEC45
557
558 complete_cb = completion.ReadlineCallback(readline, root_comp, debug_f)
559 readline.set_completer(complete_cb)
560
561 # http://web.mit.edu/gnu/doc/html/rlman_2.html#SEC39
562 # "The basic list of characters that signal a break between words for the
563 # completer routine. The default value of this variable is the characters
564 # which break words for completion in Bash, i.e., " \t\n\"\\'`@$><=;|&{(""
565
566 # This determines the boundaries you get back from get_begidx() and
567 # get_endidx() at completion time!
568 # We could be more conservative and set it to ' ', but then cases like
569 # 'ls|w<TAB>' would try to complete the whole thing, instead of just 'w'.
570 #
571 # Note that this should not affect the OSH completion algorithm. It only
572 # affects what we pass back to readline and what readline displays to the
573 # user!
574
575 # No delimiters because readline isn't smart enough to tokenize shell!
576 readline.set_completer_delims('')
577
578 readline.set_completion_display_matches_hook(display)