1from contextlib import contextmanager
2
3from ._compat import term_len
4from .parser import split_opt
5from .termui import get_terminal_size
6
7# Can force a width.  This is used by the test system
8FORCED_WIDTH = None
9
10
11def measure_table(rows):
12    widths = {}
13    for row in rows:
14        for idx, col in enumerate(row):
15            widths[idx] = max(widths.get(idx, 0), term_len(col))
16    return tuple(y for x, y in sorted(widths.items()))
17
18
19def iter_rows(rows, col_count):
20    for row in rows:
21        row = tuple(row)
22        yield row + ("",) * (col_count - len(row))
23
24
25def wrap_text(
26    text, width=78, initial_indent="", subsequent_indent="", preserve_paragraphs=False
27):
28    """A helper function that intelligently wraps text.  By default, it
29    assumes that it operates on a single paragraph of text but if the
30    `preserve_paragraphs` parameter is provided it will intelligently
31    handle paragraphs (defined by two empty lines).
32
33    If paragraphs are handled, a paragraph can be prefixed with an empty
34    line containing the ``\\b`` character (``\\x08``) to indicate that
35    no rewrapping should happen in that block.
36
37    :param text: the text that should be rewrapped.
38    :param width: the maximum width for the text.
39    :param initial_indent: the initial indent that should be placed on the
40                           first line as a string.
41    :param subsequent_indent: the indent string that should be placed on
42                              each consecutive line.
43    :param preserve_paragraphs: if this flag is set then the wrapping will
44                                intelligently handle paragraphs.
45    """
46    from ._textwrap import TextWrapper
47
48    text = text.expandtabs()
49    wrapper = TextWrapper(
50        width,
51        initial_indent=initial_indent,
52        subsequent_indent=subsequent_indent,
53        replace_whitespace=False,
54    )
55    if not preserve_paragraphs:
56        return wrapper.fill(text)
57
58    p = []
59    buf = []
60    indent = None
61
62    def _flush_par():
63        if not buf:
64            return
65        if buf[0].strip() == "\b":
66            p.append((indent or 0, True, "\n".join(buf[1:])))
67        else:
68            p.append((indent or 0, False, " ".join(buf)))
69        del buf[:]
70
71    for line in text.splitlines():
72        if not line:
73            _flush_par()
74            indent = None
75        else:
76            if indent is None:
77                orig_len = term_len(line)
78                line = line.lstrip()
79                indent = orig_len - term_len(line)
80            buf.append(line)
81    _flush_par()
82
83    rv = []
84    for indent, raw, text in p:
85        with wrapper.extra_indent(" " * indent):
86            if raw:
87                rv.append(wrapper.indent_only(text))
88            else:
89                rv.append(wrapper.fill(text))
90
91    return "\n\n".join(rv)
92
93
94class HelpFormatter(object):
95    """This class helps with formatting text-based help pages.  It's
96    usually just needed for very special internal cases, but it's also
97    exposed so that developers can write their own fancy outputs.
98
99    At present, it always writes into memory.
100
101    :param indent_increment: the additional increment for each level.
102    :param width: the width for the text.  This defaults to the terminal
103                  width clamped to a maximum of 78.
104    """
105
106    def __init__(self, indent_increment=2, width=None, max_width=None):
107        self.indent_increment = indent_increment
108        if max_width is None:
109            max_width = 80
110        if width is None:
111            width = FORCED_WIDTH
112            if width is None:
113                width = max(min(get_terminal_size()[0], max_width) - 2, 50)
114        self.width = width
115        self.current_indent = 0
116        self.buffer = []
117
118    def write(self, string):
119        """Writes a unicode string into the internal buffer."""
120        self.buffer.append(string)
121
122    def indent(self):
123        """Increases the indentation."""
124        self.current_indent += self.indent_increment
125
126    def dedent(self):
127        """Decreases the indentation."""
128        self.current_indent -= self.indent_increment
129
130    def write_usage(self, prog, args="", prefix="Usage: "):
131        """Writes a usage line into the buffer.
132
133        :param prog: the program name.
134        :param args: whitespace separated list of arguments.
135        :param prefix: the prefix for the first line.
136        """
137        usage_prefix = "{:>{w}}{} ".format(prefix, prog, w=self.current_indent)
138        text_width = self.width - self.current_indent
139
140        if text_width >= (term_len(usage_prefix) + 20):
141            # The arguments will fit to the right of the prefix.
142            indent = " " * term_len(usage_prefix)
143            self.write(
144                wrap_text(
145                    args,
146                    text_width,
147                    initial_indent=usage_prefix,
148                    subsequent_indent=indent,
149                )
150            )
151        else:
152            # The prefix is too long, put the arguments on the next line.
153            self.write(usage_prefix)
154            self.write("\n")
155            indent = " " * (max(self.current_indent, term_len(prefix)) + 4)
156            self.write(
157                wrap_text(
158                    args, text_width, initial_indent=indent, subsequent_indent=indent
159                )
160            )
161
162        self.write("\n")
163
164    def write_heading(self, heading):
165        """Writes a heading into the buffer."""
166        self.write("{:>{w}}{}:\n".format("", heading, w=self.current_indent))
167
168    def write_paragraph(self):
169        """Writes a paragraph into the buffer."""
170        if self.buffer:
171            self.write("\n")
172
173    def write_text(self, text):
174        """Writes re-indented text into the buffer.  This rewraps and
175        preserves paragraphs.
176        """
177        text_width = max(self.width - self.current_indent, 11)
178        indent = " " * self.current_indent
179        self.write(
180            wrap_text(
181                text,
182                text_width,
183                initial_indent=indent,
184                subsequent_indent=indent,
185                preserve_paragraphs=True,
186            )
187        )
188        self.write("\n")
189
190    def write_dl(self, rows, col_max=30, col_spacing=2):
191        """Writes a definition list into the buffer.  This is how options
192        and commands are usually formatted.
193
194        :param rows: a list of two item tuples for the terms and values.
195        :param col_max: the maximum width of the first column.
196        :param col_spacing: the number of spaces between the first and
197                            second column.
198        """
199        rows = list(rows)
200        widths = measure_table(rows)
201        if len(widths) != 2:
202            raise TypeError("Expected two columns for definition list")
203
204        first_col = min(widths[0], col_max) + col_spacing
205
206        for first, second in iter_rows(rows, len(widths)):
207            self.write("{:>{w}}{}".format("", first, w=self.current_indent))
208            if not second:
209                self.write("\n")
210                continue
211            if term_len(first) <= first_col - col_spacing:
212                self.write(" " * (first_col - term_len(first)))
213            else:
214                self.write("\n")
215                self.write(" " * (first_col + self.current_indent))
216
217            text_width = max(self.width - first_col - 2, 10)
218            wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True)
219            lines = wrapped_text.splitlines()
220
221            if lines:
222                self.write("{}\n".format(lines[0]))
223
224                for line in lines[1:]:
225                    self.write(
226                        "{:>{w}}{}\n".format(
227                            "", line, w=first_col + self.current_indent
228                        )
229                    )
230
231                if len(lines) > 1:
232                    # separate long help from next option
233                    self.write("\n")
234            else:
235                self.write("\n")
236
237    @contextmanager
238    def section(self, name):
239        """Helpful context manager that writes a paragraph, a heading,
240        and the indents.
241
242        :param name: the section name that is written as heading.
243        """
244        self.write_paragraph()
245        self.write_heading(name)
246        self.indent()
247        try:
248            yield
249        finally:
250            self.dedent()
251
252    @contextmanager
253    def indentation(self):
254        """A context manager that increases the indentation."""
255        self.indent()
256        try:
257            yield
258        finally:
259            self.dedent()
260
261    def getvalue(self):
262        """Returns the buffer contents."""
263        return "".join(self.buffer)
264
265
266def join_options(options):
267    """Given a list of option strings this joins them in the most appropriate
268    way and returns them in the form ``(formatted_string,
269    any_prefix_is_slash)`` where the second item in the tuple is a flag that
270    indicates if any of the option prefixes was a slash.
271    """
272    rv = []
273    any_prefix_is_slash = False
274    for opt in options:
275        prefix = split_opt(opt)[0]
276        if prefix == "/":
277            any_prefix_is_slash = True
278        rv.append((len(prefix), opt))
279
280    rv.sort(key=lambda x: x[0])
281
282    rv = ", ".join(x[1] for x in rv)
283    return rv, any_prefix_is_slash
284