OILS / core / comp_ui.py View on Github | oils.pub

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