1# -*- coding: utf-8 -*- # 2# Copyright 2017 Google LLC. All Rights Reserved. 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15 16"""Prompt completion support module.""" 17 18from __future__ import absolute_import 19from __future__ import division 20from __future__ import unicode_literals 21 22import os 23import sys 24 25from googlecloudsdk.core.console import console_attr 26 27from six.moves import range # pylint: disable=redefined-builtin 28 29 30def _IntegerCeilingDivide(numerator, denominator): 31 """returns numerator/denominator rounded up if there is any remainder.""" 32 return -(-numerator // denominator) 33 34 35def _TransposeListToRows(all_items, width=80, height=40, pad=' ', bold=None, 36 normal=None): 37 """Returns padded newline terminated column-wise list for items. 38 39 Used by PromptCompleter to pretty print the possible completions for TAB-TAB. 40 41 Args: 42 all_items: [str], The ordered list of all items to transpose. 43 width: int, The total display width in characters. 44 height: int, The total display height in lines. 45 pad: str, String inserted before each column. 46 bold: str, The bold font highlight control sequence. 47 normal: str, The normal font highlight control sequence. 48 49 Returns: 50 [str], A padded newline terminated list of colum-wise rows for the ordered 51 items list. The return value is a single list, not a list of row lists. 52 Convert the return value to a printable string by ''.join(return_value). 53 The first "row" is preceded by a newline and all rows start with the pad. 54 """ 55 56 def _Dimensions(items): 57 """Returns the transpose dimensions for items.""" 58 longest_item_len = max(len(x) for x in items) 59 column_count = int(width / (len(pad) + longest_item_len)) or 1 60 row_count = _IntegerCeilingDivide(len(items), column_count) 61 return longest_item_len, column_count, row_count 62 63 def _TrimAndAnnotate(item, longest_item_len): 64 """Truncates and appends '*' if len(item) > longest_item_len.""" 65 if len(item) <= longest_item_len: 66 return item 67 return item[:longest_item_len] + '*' 68 69 def _Highlight(item, longest_item_len, difference_index, bold, normal): 70 """Highlights the different part of the completion and left justfies.""" 71 length = len(item) 72 if length > difference_index: 73 item = (item[:difference_index] + bold + 74 item[difference_index] + normal + 75 item[difference_index+1:]) 76 return item + (longest_item_len - length) * ' ' 77 78 # Trim the items list until row_count <= height. 79 items = set(all_items) 80 longest_item_len, column_count, row_count = _Dimensions(items) 81 while row_count > height and longest_item_len > 3: 82 items = {_TrimAndAnnotate(x, longest_item_len - 2) for x in all_items} 83 longest_item_len, column_count, row_count = _Dimensions(items) 84 items = sorted(items) 85 86 # Highlight the start of the differences. 87 if bold: 88 difference_index = len(os.path.commonprefix(items)) 89 items = [_Highlight(x, longest_item_len, difference_index, bold, normal) 90 for x in items] 91 92 # Do the column-wise transpose with padding and newlines included. 93 row_data = ['\n'] 94 row_index = 0 95 while row_index < row_count: 96 column_index = row_index 97 for _ in range(column_count): 98 if column_index >= len(items): 99 break 100 row_data.append(pad) 101 row_data.append(items[column_index]) 102 column_index += row_count 103 row_data.append('\n') 104 row_index += 1 105 106 return row_data 107 108 109def _PrefixMatches(prefix, possible_matches): 110 """Returns the subset of possible_matches that start with prefix. 111 112 Args: 113 prefix: str, The prefix to match. 114 possible_matches: [str], The list of possible matching strings. 115 116 Returns: 117 [str], The subset of possible_matches that start with prefix. 118 """ 119 return [x for x in possible_matches if x.startswith(prefix)] 120 121 122class PromptCompleter(object): 123 """Prompt + input + completion. 124 125 Yes, this is a roll-your own implementation. 126 Yes, readline is that bad: 127 linux: is unaware of the prompt even though it overrise raw_input() 128 macos: different implementation than linux, and more brokener 129 windows: didn't even try to implement 130 """ 131 132 _CONTROL_C = '\x03' 133 _DELETE = '\x7f' 134 135 def __init__(self, prompt, choices=None, out=None, width=None, height=None, 136 pad=' '): 137 """Constructor. 138 139 Args: 140 prompt: str or None, The prompt string. 141 choices: callable or list, A callable with no arguments that returns the 142 list of all choices, or the list of choices. 143 out: stream, The output stream, sys.stderr by default. 144 width: int, The total display width in characters. 145 height: int, The total display height in lines. 146 pad: str, String inserted before each column. 147 """ 148 self._prompt = prompt 149 self._choices = choices 150 self._out = out or sys.stderr 151 self._attr = console_attr.ConsoleAttr() 152 term_width, term_height = self._attr.GetTermSize() 153 if width is None: 154 width = 80 155 if width > term_width: 156 width = term_width 157 self._width = width 158 if height is None: 159 height = 40 160 if height > term_height: 161 height = term_height 162 self._height = height 163 self._pad = pad 164 165 def Input(self): 166 """Reads and returns one line of user input with TAB complation.""" 167 all_choices = None 168 matches = [] 169 response = [] 170 if self._prompt: 171 self._out.write(self._prompt) 172 c = None 173 174 # Loop on input characters read one at a time without echo. 175 while True: 176 previous_c = c # for detecting <TAB><TAB>. 177 c = self._attr.GetRawKey() 178 179 if c in (None, '\n', '\r', PromptCompleter._CONTROL_C) or len(c) != 1: 180 # End of the input line. 181 self._out.write('\n') 182 break 183 184 elif c in ('\b', PromptCompleter._DELETE): 185 # Delete the last response character and reset the matches list. 186 if response: 187 response.pop() 188 self._out.write('\b \b') 189 matches = all_choices 190 191 elif c == '\t': 192 # <TAB> kicks in completion. 193 response_prefix = ''.join(response) 194 195 if previous_c == c: 196 # <TAB><TAB> displays all possible completions. 197 matches = _PrefixMatches(response_prefix, matches) 198 if len(matches) > 1: 199 self._Display(response_prefix, matches) 200 201 else: 202 # <TAB> complete as much of the current response as possible. 203 204 if all_choices is None: 205 if callable(self._choices): 206 all_choices = self._choices() 207 else: 208 all_choices = self._choices 209 matches = all_choices 210 211 # Determine the longest prefix match and adjust the matches list. 212 matches = _PrefixMatches(response_prefix, matches) 213 response_prefix = ''.join(response) 214 common_prefix = os.path.commonprefix(matches) 215 216 # If the longest common prefix is longer than the response then the 217 # portion past the response prefix chars can be appended. 218 if len(common_prefix) > len(response): 219 # As long as we are adding chars to the response its safe to prune 220 # the matches list to the new common prefix. 221 matches = _PrefixMatches(common_prefix, matches) 222 self._out.write(common_prefix[len(response):]) 223 response = list(common_prefix) 224 225 else: 226 # Echo and append all remaining chars to the response. 227 response.append(c) 228 self._out.write(c) 229 230 return ''.join(response) 231 232 def _Display(self, prefix, matches): 233 """Displays the possible completions and redraws the prompt and response. 234 235 Args: 236 prefix: str, The current response. 237 matches: [str], The list of strings that start with prefix. 238 """ 239 row_data = _TransposeListToRows( 240 matches, width=self._width, height=self._height, pad=self._pad, 241 bold=self._attr.GetFontCode(bold=True), normal=self._attr.GetFontCode()) 242 if self._prompt: 243 row_data.append(self._prompt) 244 row_data.append(prefix) 245 self._out.write(''.join(row_data)) 246