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

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