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

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