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

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