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