| # Copyright 2000-2010 Michael Hudson-Doyle <micahel@gmail.com> | |
| # Antonio Cuni | |
| # | |
| # All Rights Reserved | |
| # | |
| # | |
| # Permission to use, copy, modify, and distribute this software and | |
| # its documentation for any purpose is hereby granted without fee, | |
| # provided that the above copyright notice appear in all copies and | |
| # that both that copyright notice and this permission notice appear in | |
| # supporting documentation. | |
| # | |
| # THE AUTHOR MICHAEL HUDSON DISCLAIMS ALL WARRANTIES WITH REGARD TO | |
| # THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY | |
| # AND FITNESS, IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, | |
| # INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER | |
| # RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF | |
| # CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN | |
| # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |
| from dataclasses import dataclass, field | |
| import re | |
| from . import commands, console, reader | |
| from .render import RenderLine, ScreenOverlay | |
| from .reader import Reader | |
| # types | |
| lazy from .types import CompletionAction, Keymap | |
| def prefix(wordlist: list[str], j: int = 0) -> str: | |
| d = {} | |
| i = j | |
| try: | |
| while 1: | |
| for word in wordlist: | |
| d[word[i]] = 1 | |
| if len(d) > 1: | |
| return wordlist[0][j:i] | |
| i += 1 | |
| d = {} | |
| except IndexError: | |
| return wordlist[0][j:i] | |
| return "" | |
| STRIPCOLOR_REGEX = re.compile(r"\x1B\[([0-9]{1,3}(;[0-9]{1,2})?)?[m|K]") | |
| def stripcolor(s: str) -> str: | |
| return STRIPCOLOR_REGEX.sub('', s) | |
| def real_len(s: str) -> int: | |
| return len(stripcolor(s)) | |
| def left_align(s: str, maxlen: int) -> str: | |
| stripped = stripcolor(s) | |
| if len(stripped) > maxlen: | |
| # too bad, we remove the color | |
| return stripped[:maxlen] | |
| padding = maxlen - len(stripped) | |
| return s + ' '*padding | |
| def build_menu( | |
| cons: console.Console, | |
| wordlist: list[str], | |
| start: int, | |
| use_brackets: bool, | |
| sort_in_column: bool, | |
| ) -> tuple[list[str], int]: | |
| if use_brackets: | |
| item = "[ %s ]" | |
| padding = 4 | |
| else: | |
| item = "%s " | |
| padding = 2 | |
| maxlen = min(max(map(real_len, wordlist)), cons.width - padding) | |
| cols = int(cons.width / (maxlen + padding)) | |
| rows = int((len(wordlist) - 1)/cols + 1) | |
| if sort_in_column: | |
| # sort_in_column=False (default) sort_in_column=True | |
| # A B C A D G | |
| # D E F B E | |
| # G C F | |
| # | |
| # "fill" the table with empty words, so we always have the same amount | |
| # of rows for each column | |
| missing = cols*rows - len(wordlist) | |
| wordlist = wordlist + ['']*missing | |
| indexes = [(i % cols) * rows + i // cols for i in range(len(wordlist))] | |
| wordlist = [wordlist[i] for i in indexes] | |
| menu = [] | |
| i = start | |
| for r in range(rows): | |
| row = [] | |
| for col in range(cols): | |
| row.append(item % left_align(wordlist[i], maxlen)) | |
| i += 1 | |
| if i >= len(wordlist): | |
| break | |
| menu.append(''.join(row)) | |
| if i >= len(wordlist): | |
| i = 0 | |
| break | |
| if r + 5 > cons.height: | |
| menu.append(" %d more... " % (len(wordlist) - i)) | |
| break | |
| return menu, i | |
| # this gets somewhat user interface-y, and as a result the logic gets | |
| # very convoluted. | |
| # | |
| # To summarise the summary of the summary:- people are a problem. | |
| # -- The Hitch-Hikers Guide to the Galaxy, Episode 12 | |
| #### Desired behaviour of the completions commands. | |
| # the considerations are: | |
| # (1) how many completions are possible | |
| # (2) whether the last command was a completion | |
| # (3) if we can assume that the completer is going to return the same set of | |
| # completions: this is controlled by the ``assume_immutable_completions`` | |
| # variable on the reader, which is True by default to match the historical | |
| # behaviour of pyrepl, but e.g. False in the ReadlineAlikeReader to match | |
| # more closely readline's semantics (this is needed e.g. by | |
| # fancycompleter) | |
| # | |
| # if there's no possible completion, beep at the user and point this out. | |
| # this is easy. | |
| # | |
| # if there's only one possible completion, stick it in. if the last thing | |
| # user did was a completion, point out that he isn't getting anywhere, but | |
| # only if the ``assume_immutable_completions`` is True. | |
| # | |
| # now it gets complicated. | |
| # | |
| # for the first press of a completion key: | |
| # if there's a common prefix, stick it in. | |
| # irrespective of whether anything got stuck in, if the word is now | |
| # complete, show the "complete but not unique" message | |
| # if there's no common prefix and if the word is not now complete, | |
| # beep. | |
| # common prefix -> yes no | |
| # word complete \/ | |
| # yes "cbnu" "cbnu" | |
| # no - beep | |
| # for the second bang on the completion key | |
| # there will necessarily be no common prefix | |
| # show a menu of the choices. | |
| # for subsequent bangs, rotate the menu around (if there are sufficient | |
| # choices). | |
| class complete(commands.Command): | |
| def do(self) -> None: | |
| r: CompletingReader | |
| r = self.reader # type: ignore[assignment] | |
| last_is_completer = r.last_command_is(self.__class__) | |
| if r.cmpltn_action: | |
| if last_is_completer: # double-tab: execute action | |
| msg = r.cmpltn_action[1]() | |
| r.cmpltn_action = None # consumed | |
| if msg: | |
| r.msg = msg | |
| r.cmpltn_message_visible = True | |
| r.invalidate_message() | |
| else: # other input since last tab: cancel action | |
| r.cmpltn_action = None | |
| immutable_completions = r.assume_immutable_completions | |
| completions_unchangable = last_is_completer and immutable_completions | |
| stem = r.get_stem() | |
| if not completions_unchangable: | |
| r.cmpltn_menu_choices, r.cmpltn_action = r.get_completions(stem) | |
| completions = r.cmpltn_menu_choices | |
| if not completions: | |
| if not r.cmpltn_action: | |
| r.error("no matches") | |
| elif len(completions) == 1: | |
| completion = stripcolor(completions[0]) | |
| if completions_unchangable and len(completion) == len(stem): | |
| r.msg = "[ sole completion ]" | |
| r.cmpltn_message_visible = True | |
| r.invalidate_message() | |
| r.insert(completion[len(stem):]) | |
| else: | |
| clean_completions = [stripcolor(word) for word in completions] | |
| p = prefix(clean_completions, len(stem)) | |
| if p: | |
| r.insert(p) | |
| if last_is_completer: | |
| r.cmpltn_menu_visible = True | |
| r.cmpltn_menu, r.cmpltn_menu_end = build_menu( | |
| r.console, completions, r.cmpltn_menu_end, | |
| r.use_brackets, r.sort_in_column) | |
| if r.msg: | |
| r.msg = "" | |
| r.cmpltn_message_visible = False | |
| r.invalidate_message() | |
| r.invalidate_overlay() | |
| elif not r.cmpltn_menu_visible: | |
| if stem + p in clean_completions: | |
| r.msg = "[ complete but not unique ]" | |
| r.cmpltn_message_visible = True | |
| r.invalidate_message() | |
| else: | |
| r.msg = "[ not unique ]" | |
| r.cmpltn_message_visible = True | |
| r.invalidate_message() | |
| if r.cmpltn_action: | |
| if r.msg and r.cmpltn_message_visible: | |
| # There is already a message (eg. [ not unique ]) that | |
| # would conflict for next tab: cancel action | |
| r.cmpltn_action = None | |
| else: | |
| r.msg = r.cmpltn_action[0] | |
| r.cmpltn_message_visible = True | |
| r.invalidate_message() | |
| class self_insert(commands.self_insert): | |
| def do(self) -> None: | |
| r: CompletingReader | |
| r = self.reader # type: ignore[assignment] | |
| commands.self_insert.do(self) | |
| if r.cmpltn_menu_visible: | |
| stem = r.get_stem() | |
| if len(stem) < 1: | |
| r.cmpltn_reset() | |
| else: | |
| completions = [w for w in r.cmpltn_menu_choices | |
| if stripcolor(w).startswith(stem)] | |
| if completions: | |
| r.cmpltn_menu, r.cmpltn_menu_end = build_menu( | |
| r.console, completions, 0, | |
| r.use_brackets, r.sort_in_column) | |
| r.invalidate_overlay() | |
| else: | |
| r.cmpltn_reset() | |
| @dataclass | |
| class CompletingReader(Reader): | |
| """Adds completion support""" | |
| ### Class variables | |
| # see the comment for the complete command | |
| assume_immutable_completions = True | |
| use_brackets = True # display completions inside [] | |
| sort_in_column = False | |
| ### Instance variables | |
| cmpltn_menu: list[str] = field(init=False) | |
| cmpltn_menu_visible: bool = field(init=False) | |
| cmpltn_message_visible: bool = field(init=False) | |
| cmpltn_menu_end: int = field(init=False) | |
| cmpltn_menu_choices: list[str] = field(init=False) | |
| cmpltn_action: CompletionAction | None = field(init=False) | |
| def __post_init__(self) -> None: | |
| super().__post_init__() | |
| self.cmpltn_reset() | |
| for c in (complete, self_insert): | |
| self.commands[c.__name__] = c | |
| self.commands[c.__name__.replace('_', '-')] = c | |
| def collect_keymap(self) -> Keymap: | |
| return super().collect_keymap() + ( | |
| (r'\t', 'complete'),) | |
| def after_command(self, cmd: commands.Command) -> None: | |
| super().after_command(cmd) | |
| if not isinstance(cmd, (complete, self_insert)): | |
| self.cmpltn_reset() | |
| def get_screen_overlays(self) -> tuple[ScreenOverlay, ...]: | |
| if not self.cmpltn_menu_visible: | |
| return () | |
| return ( | |
| ScreenOverlay( | |
| self.lxy[1] + 1, | |
| tuple(RenderLine.from_rendered_text(line) for line in self.cmpltn_menu), | |
| insert=True, | |
| ), | |
| ) | |
| def finish(self) -> None: | |
| super().finish() | |
| self.cmpltn_reset() | |
| def cmpltn_reset(self) -> None: | |
| if getattr(self, "cmpltn_menu_visible", False): | |
| self.invalidate_overlay() | |
| self.cmpltn_menu = [] | |
| self.cmpltn_menu_visible = False | |
| self.cmpltn_message_visible = False | |
| self.cmpltn_menu_end = 0 | |
| self.cmpltn_menu_choices = [] | |
| self.cmpltn_action = None | |
| def get_stem(self) -> str: | |
| st = self.syntax_table | |
| SW = reader.SYNTAX_WORD | |
| b = self.buffer | |
| p = self.pos - 1 | |
| while p >= 0 and st.get(b[p], SW) == SW: | |
| p -= 1 | |
| return ''.join(b[p+1:self.pos]) | |
| def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None]: | |
| return [], None | |
| def get_line(self) -> str: | |
| """Return the current line until the cursor position.""" | |
| return ''.join(self.buffer[:self.pos]) |