1"""
2Collection of reusable components for building full screen applications.
3
4All of these widgets implement the ``__pt_container__`` method, which makes
5them usable in any situation where we are expecting a `prompt_toolkit`
6container object.
7
8.. warning::
9
10    At this point, the API for these widgets is considered unstable, and can
11    potentially change between minor releases (we try not too, but no
12    guarantees are made yet). The public API in
13    `prompt_toolkit.shortcuts.dialogs` on the other hand is considered stable.
14"""
15from __future__ import unicode_literals
16
17from functools import partial
18
19import six
20
21from prompt_toolkit.application.current import get_app
22from prompt_toolkit.auto_suggest import DynamicAutoSuggest
23from prompt_toolkit.buffer import Buffer
24from prompt_toolkit.completion import DynamicCompleter
25from prompt_toolkit.document import Document
26from prompt_toolkit.filters import (
27    Condition,
28    has_focus,
29    is_done,
30    is_true,
31    to_filter,
32)
33from prompt_toolkit.formatted_text import (
34    Template,
35    is_formatted_text,
36    to_formatted_text,
37)
38from prompt_toolkit.formatted_text.utils import fragment_list_to_text
39from prompt_toolkit.key_binding.key_bindings import KeyBindings
40from prompt_toolkit.keys import Keys
41from prompt_toolkit.layout.containers import (
42    ConditionalContainer,
43    DynamicContainer,
44    Float,
45    FloatContainer,
46    HSplit,
47    VSplit,
48    Window,
49    WindowAlign,
50    is_container,
51)
52from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
53from prompt_toolkit.layout.dimension import Dimension as D
54from prompt_toolkit.layout.dimension import is_dimension, to_dimension
55from prompt_toolkit.layout.margins import NumberedMargin, ScrollbarMargin
56from prompt_toolkit.layout.processors import (
57    AppendAutoSuggestion,
58    BeforeInput,
59    ConditionalProcessor,
60    PasswordProcessor,
61)
62from prompt_toolkit.lexers import DynamicLexer
63from prompt_toolkit.mouse_events import MouseEventType
64from prompt_toolkit.utils import get_cwidth
65
66from .toolbars import SearchToolbar
67
68__all__ = [
69    'TextArea',
70    'Label',
71    'Button',
72    'Frame',
73    'Shadow',
74    'Box',
75    'VerticalLine',
76    'HorizontalLine',
77    'RadioList',
78
79    'Checkbox',  # XXX: refactor into CheckboxList.
80    'ProgressBar',
81]
82
83
84class Border:
85    " Box drawing characters. (Thin) "
86    HORIZONTAL = '\u2500'
87    VERTICAL = '\u2502'
88    TOP_LEFT = '\u250c'
89    TOP_RIGHT = '\u2510'
90    BOTTOM_LEFT = '\u2514'
91    BOTTOM_RIGHT = '\u2518'
92
93
94class TextArea(object):
95    """
96    A simple input field.
97
98    This is a higher level abstraction on top of several other classes with
99    sane defaults.
100
101    This widget does have the most common options, but it does not intend to
102    cover every single use case. For more configurations options, you can
103    always build a text area manually, using a
104    :class:`~prompt_toolkit.buffer.Buffer`,
105    :class:`~prompt_toolkit.layout.BufferControl` and
106    :class:`~prompt_toolkit.layout.Window`.
107
108    Buffer attributes:
109
110    :param text: The initial text.
111    :param multiline: If True, allow multiline input.
112    :param completer: :class:`~prompt_toolkit.completion.Completer` instance
113        for auto completion.
114    :param complete_while_typing: Boolean.
115    :param accept_handler: Called when `Enter` is pressed (This should be a
116        callable that takes a buffer as input).
117    :param history: :class:`~prompt_toolkit.history.History` instance.
118    :param auto_suggest: :class:`~prompt_toolkit.auto_suggest.AutoSuggest`
119        instance for input suggestions.
120
121    BufferControl attributes:
122
123    :param password: When `True`, display using asterisks.
124    :param focusable: When `True`, allow this widget to receive the focus.
125    :param focus_on_click: When `True`, focus after mouse click.
126    :param input_processors: `None` or a list of
127        :class:`~prompt_toolkit.layout.Processor` objects.
128
129    Window attributes:
130
131    :param lexer: :class:`~prompt_toolkit.lexers.Lexer` instance for syntax
132        highlighting.
133    :param wrap_lines: When `True`, don't scroll horizontally, but wrap lines.
134    :param width: Window width. (:class:`~prompt_toolkit.layout.Dimension` object.)
135    :param height: Window height. (:class:`~prompt_toolkit.layout.Dimension` object.)
136    :param scrollbar: When `True`, display a scroll bar.
137    :param style: A style string.
138    :param dont_extend_width: When `True`, don't take up more width then the
139                              preferred width reported by the control.
140    :param dont_extend_height: When `True`, don't take up more width then the
141                               preferred height reported by the control.
142    :param get_line_prefix: None or a callable that returns formatted text to
143        be inserted before a line. It takes a line number (int) and a
144        wrap_count and returns formatted text. This can be used for
145        implementation of line continuations, things like Vim "breakindent" and
146        so on.
147
148    Other attributes:
149
150    :param search_field: An optional `SearchToolbar` object.
151    """
152    def __init__(self, text='', multiline=True, password=False,
153                 lexer=None, auto_suggest=None, completer=None,
154                 complete_while_typing=True, accept_handler=None, history=None,
155                 focusable=True, focus_on_click=False, wrap_lines=True,
156                 read_only=False, width=None, height=None,
157                 dont_extend_height=False, dont_extend_width=False,
158                 line_numbers=False, get_line_prefix=None, scrollbar=False,
159                 style='', search_field=None, preview_search=True, prompt='',
160                 input_processors=None):
161        assert isinstance(text, six.text_type)
162        assert search_field is None or isinstance(search_field, SearchToolbar)
163
164        if search_field is None:
165            search_control = None
166        elif isinstance(search_field, SearchToolbar):
167            search_control = search_field.control
168
169        if input_processors is None:
170            input_processors = []
171
172        # Writeable attributes.
173        self.completer = completer
174        self.complete_while_typing = complete_while_typing
175        self.lexer = lexer
176        self.auto_suggest = auto_suggest
177        self.read_only = read_only
178        self.wrap_lines = wrap_lines
179
180        self.buffer = Buffer(
181            document=Document(text, 0),
182            multiline=multiline,
183            read_only=Condition(lambda: is_true(self.read_only)),
184            completer=DynamicCompleter(lambda: self.completer),
185            complete_while_typing=Condition(
186                lambda: is_true(self.complete_while_typing)),
187            auto_suggest=DynamicAutoSuggest(lambda: self.auto_suggest),
188            accept_handler=accept_handler,
189            history=history)
190
191        self.control = BufferControl(
192            buffer=self.buffer,
193            lexer=DynamicLexer(lambda: self.lexer),
194            input_processors=[
195                ConditionalProcessor(
196                    AppendAutoSuggestion(),
197                    has_focus(self.buffer) & ~is_done),
198                ConditionalProcessor(
199                    processor=PasswordProcessor(),
200                    filter=to_filter(password)
201                ),
202                BeforeInput(prompt, style='class:text-area.prompt'),
203            ] + input_processors,
204            search_buffer_control=search_control,
205            preview_search=preview_search,
206            focusable=focusable,
207            focus_on_click=focus_on_click)
208
209        if multiline:
210            if scrollbar:
211                right_margins = [ScrollbarMargin(display_arrows=True)]
212            else:
213                right_margins = []
214            if line_numbers:
215                left_margins = [NumberedMargin()]
216            else:
217                left_margins = []
218        else:
219            height = D.exact(1)
220            left_margins = []
221            right_margins = []
222
223        style = 'class:text-area ' + style
224
225        self.window = Window(
226            height=height,
227            width=width,
228            dont_extend_height=dont_extend_height,
229            dont_extend_width=dont_extend_width,
230            content=self.control,
231            style=style,
232            wrap_lines=Condition(lambda: is_true(self.wrap_lines)),
233            left_margins=left_margins,
234            right_margins=right_margins,
235            get_line_prefix=get_line_prefix)
236
237    @property
238    def text(self):
239        """
240        The `Buffer` text.
241        """
242        return self.buffer.text
243
244    @text.setter
245    def text(self, value):
246        self.buffer.set_document(Document(value, 0), bypass_readonly=True)
247
248    @property
249    def document(self):
250        """
251        The `Buffer` document (text + cursor position).
252        """
253        return self.buffer.document
254
255    @document.setter
256    def document(self, value):
257        self.buffer.document = value
258
259    @property
260    def accept_handler(self):
261        """
262        The accept handler. Called when the user accepts the input.
263        """
264        return self.buffer.accept_handler
265
266    @accept_handler.setter
267    def accept_handler(self, value):
268        self.buffer.accept_handler = value
269
270    def __pt_container__(self):
271        return self.window
272
273
274class Label(object):
275    """
276    Widget that displays the given text. It is not editable or focusable.
277
278    :param text: The text to be displayed. (This can be multiline. This can be
279        formatted text as well.)
280    :param style: A style string.
281    :param width: When given, use this width, rather than calculating it from
282        the text size.
283    """
284    def __init__(self, text, style='', width=None,
285                 dont_extend_height=True, dont_extend_width=False):
286        assert is_formatted_text(text)
287        self.text = text
288
289        def get_width():
290            if width is None:
291                text_fragments = to_formatted_text(self.text)
292                text = fragment_list_to_text(text_fragments)
293                if text:
294                    longest_line = max(get_cwidth(line) for line in text.splitlines())
295                else:
296                    return D(preferred=0)
297                return D(preferred=longest_line)
298            else:
299                return width
300
301        self.formatted_text_control = FormattedTextControl(
302            text=lambda: self.text)
303
304        self.window = Window(
305            content=self.formatted_text_control,
306            width=get_width,
307            style='class:label ' + style,
308            dont_extend_height=dont_extend_height,
309            dont_extend_width=dont_extend_width)
310
311    def __pt_container__(self):
312        return self.window
313
314
315class Button(object):
316    """
317    Clickable button.
318
319    :param text: The caption for the button.
320    :param handler: `None` or callable. Called when the button is clicked.
321    :param width: Width of the button.
322    """
323    def __init__(self, text, handler=None, width=12):
324        assert isinstance(text, six.text_type)
325        assert handler is None or callable(handler)
326        assert isinstance(width, int)
327
328        self.text = text
329        self.handler = handler
330        self.width = width
331        self.control = FormattedTextControl(
332            self._get_text_fragments,
333            key_bindings=self._get_key_bindings(),
334            focusable=True)
335
336        def get_style():
337            if get_app().layout.has_focus(self):
338                return 'class:button.focused'
339            else:
340                return 'class:button'
341
342        self.window = Window(
343            self.control,
344            align=WindowAlign.CENTER,
345            height=1,
346            width=width,
347            style=get_style,
348            dont_extend_width=True,
349            dont_extend_height=True)
350
351    def _get_text_fragments(self):
352        text = ('{:^%s}' % (self.width - 2)).format(self.text)
353
354        def handler(mouse_event):
355            if mouse_event.event_type == MouseEventType.MOUSE_UP:
356                self.handler()
357
358        return [
359            ('class:button.arrow', '<', handler),
360            ('[SetCursorPosition]', ''),
361            ('class:button.text', text, handler),
362            ('class:button.arrow', '>', handler),
363        ]
364
365    def _get_key_bindings(self):
366        " Key bindings for the Button. "
367        kb = KeyBindings()
368
369        @kb.add(' ')
370        @kb.add('enter')
371        def _(event):
372            if self.handler is not None:
373                self.handler()
374
375        return kb
376
377    def __pt_container__(self):
378        return self.window
379
380
381class Frame(object):
382    """
383    Draw a border around any container, optionally with a title text.
384
385    Changing the title and body of the frame is possible at runtime by
386    assigning to the `body` and `title` attributes of this class.
387
388    :param body: Another container object.
389    :param title: Text to be displayed in the top of the frame (can be formatted text).
390    :param style: Style string to be applied to this widget.
391    """
392    def __init__(self, body, title='', style='', width=None, height=None,
393                 key_bindings=None, modal=False):
394        assert is_container(body)
395        assert is_formatted_text(title)
396        assert isinstance(style, six.text_type)
397        assert is_dimension(width)
398        assert is_dimension(height)
399        assert key_bindings is None or isinstance(key_bindings, KeyBindings)
400        assert isinstance(modal, bool)
401
402        self.title = title
403        self.body = body
404
405        fill = partial(Window, style='class:frame.border')
406        style = 'class:frame ' + style
407
408        top_row_with_title = VSplit([
409            fill(width=1, height=1, char=Border.TOP_LEFT),
410            fill(char=Border.HORIZONTAL),
411            fill(width=1, height=1, char='|'),
412            # Notice: we use `Template` here, because `self.title` can be an
413            # `HTML` object for instance.
414            Label(lambda: Template(' {} ').format(self.title),
415                  style='class:frame.label',
416                  dont_extend_width=True),
417            fill(width=1, height=1, char='|'),
418            fill(char=Border.HORIZONTAL),
419            fill(width=1, height=1, char=Border.TOP_RIGHT),
420        ], height=1)
421
422        top_row_without_title = VSplit([
423            fill(width=1, height=1, char=Border.TOP_LEFT),
424            fill(char=Border.HORIZONTAL),
425            fill(width=1, height=1, char=Border.TOP_RIGHT),
426        ], height=1)
427
428        @Condition
429        def has_title():
430            return bool(self.title)
431
432        self.container = HSplit([
433            ConditionalContainer(
434                content=top_row_with_title,
435                filter=has_title),
436            ConditionalContainer(
437                content=top_row_without_title,
438                filter=~has_title),
439            VSplit([
440                fill(width=1, char=Border.VERTICAL),
441                DynamicContainer(lambda: self.body),
442                fill(width=1, char=Border.VERTICAL),
443                    # Padding is required to make sure that if the content is
444                    # too small, the right frame border is still aligned.
445            ], padding=0),
446            VSplit([
447                fill(width=1, height=1, char=Border.BOTTOM_LEFT),
448                fill(char=Border.HORIZONTAL),
449                fill(width=1, height=1, char=Border.BOTTOM_RIGHT),
450            ]),
451        ], width=width, height=height, style=style, key_bindings=key_bindings,
452        modal=modal)
453
454    def __pt_container__(self):
455        return self.container
456
457
458class Shadow(object):
459    """
460    Draw a shadow underneath/behind this container.
461    (This applies `class:shadow` the the cells under the shadow. The Style
462    should define the colors for the shadow.)
463
464    :param body: Another container object.
465    """
466    def __init__(self, body):
467        assert is_container(body)
468
469        self.container = FloatContainer(
470            content=body,
471            floats=[
472                Float(bottom=-1, height=1, left=1, right=-1,
473                      transparent=True,
474                      content=Window(style='class:shadow')),
475                Float(bottom=-1, top=1, width=1, right=-1,
476                      transparent=True,
477                      content=Window(style='class:shadow')),
478                ]
479            )
480
481    def __pt_container__(self):
482        return self.container
483
484
485class Box(object):
486    """
487    Add padding around a container.
488
489    This also makes sure that the parent can provide more space than required by
490    the child. This is very useful when wrapping a small element with a fixed
491    size into a ``VSplit`` or ``HSplit`` object. The ``HSplit`` and ``VSplit``
492    try to make sure to adapt respectively the width and height, possibly
493    shrinking other elements. Wrapping something in a ``Box`` makes it flexible.
494
495    :param body: Another container object.
496    :param padding: The margin to be used around the body. This can be
497        overridden by `padding_left`, padding_right`, `padding_top` and
498        `padding_bottom`.
499    :param style: A style string.
500    :param char: Character to be used for filling the space around the body.
501        (This is supposed to be a character with a terminal width of 1.)
502    """
503    def __init__(self, body, padding=None,
504                 padding_left=None, padding_right=None,
505                 padding_top=None, padding_bottom=None,
506                 width=None, height=None,
507                 style='', char=None, modal=False, key_bindings=None):
508        assert is_container(body)
509
510        if padding is None:
511            padding = D(preferred=0)
512
513        def get(value):
514            if value is None:
515                value = padding
516            return to_dimension(value)
517
518        self.padding_left = get(padding_left)
519        self.padding_right = get(padding_right)
520        self.padding_top = get(padding_top)
521        self.padding_bottom = get(padding_bottom)
522        self.body = body
523
524        self.container = HSplit([
525            Window(height=self.padding_top, char=char),
526            VSplit([
527                Window(width=self.padding_left, char=char),
528                body,
529                Window(width=self.padding_right, char=char),
530            ]),
531            Window(height=self.padding_bottom, char=char),
532        ],
533        width=width, height=height, style=style, modal=modal,
534        key_bindings=None)
535
536    def __pt_container__(self):
537        return self.container
538
539
540class Checkbox(object):
541    def __init__(self, text=''):
542        assert is_formatted_text(text)
543
544        self.checked = True
545
546        kb = KeyBindings()
547
548        @kb.add(' ')
549        @kb.add('enter')
550        def _(event):
551            self.checked = not self.checked
552
553        self.control = FormattedTextControl(
554            self._get_text_fragments,
555            key_bindings=kb,
556            focusable=True)
557
558        self.window = Window(
559            width=3, content=self.control, height=1)
560
561        self.container = VSplit([
562            self.window,
563            Label(text=Template(' {}').format(text))
564        ], style='class:checkbox')
565
566    def _get_text_fragments(self):
567        result = [('', '[')]
568        result.append(('[SetCursorPosition]', ''))
569
570        if self.checked:
571            result.append(('', '*'))
572        else:
573            result.append(('', ' '))
574
575        result.append(('', ']'))
576
577        return result
578
579    def __pt_container__(self):
580        return self.container
581
582
583class RadioList(object):
584    """
585    List of radio buttons. Only one can be checked at the same time.
586
587    :param values: List of (value, label) tuples.
588    """
589    def __init__(self, values):
590        assert isinstance(values, list)
591        assert len(values) > 0
592        assert all(isinstance(i, tuple) and len(i) == 2
593                   for i in values)
594
595        self.values = values
596        self.current_value = values[0][0]
597        self._selected_index = 0
598
599        # Key bindings.
600        kb = KeyBindings()
601
602        @kb.add('up')
603        def _(event):
604            self._selected_index = max(0, self._selected_index - 1)
605
606        @kb.add('down')
607        def _(event):
608            self._selected_index = min(
609                len(self.values) - 1, self._selected_index + 1)
610
611        @kb.add('pageup')
612        def _(event):
613            w = event.app.layout.current_window
614            self._selected_index = max(
615                0,
616                self._selected_index - len(w.render_info.displayed_lines)
617            )
618
619        @kb.add('pagedown')
620        def _(event):
621            w = event.app.layout.current_window
622            self._selected_index = min(
623                len(self.values) - 1,
624                self._selected_index + len(w.render_info.displayed_lines)
625            )
626
627        @kb.add('enter')
628        @kb.add(' ')
629        def _(event):
630            self.current_value = self.values[self._selected_index][0]
631
632        @kb.add(Keys.Any)
633        def _(event):
634            # We first check values after the selected value, then all values.
635            for value in self.values[self._selected_index + 1:] + self.values:
636                if value[1].startswith(event.data):
637                    self._selected_index = self.values.index(value)
638                    return
639
640        # Control and window.
641        self.control = FormattedTextControl(
642            self._get_text_fragments,
643            key_bindings=kb,
644            focusable=True)
645
646        self.window = Window(
647            content=self.control,
648            style='class:radio-list',
649            right_margins=[
650                ScrollbarMargin(display_arrows=True),
651            ],
652            dont_extend_height=True)
653
654    def _get_text_fragments(self):
655        def mouse_handler(mouse_event):
656            """
657            Set `_selected_index` and `current_value` according to the y
658            position of the mouse click event.
659            """
660            if mouse_event.event_type == MouseEventType.MOUSE_UP:
661                self._selected_index = mouse_event.position.y
662                self.current_value = self.values[self._selected_index][0]
663
664        result = []
665        for i, value in enumerate(self.values):
666            checked = (value[0] == self.current_value)
667            selected = (i == self._selected_index)
668
669            style = ''
670            if checked:
671                style += ' class:radio-checked'
672            if selected:
673                style += ' class:radio-selected'
674
675            result.append((style, '('))
676
677            if selected:
678                result.append(('[SetCursorPosition]', ''))
679
680            if checked:
681                result.append((style, '*'))
682            else:
683                result.append((style, ' '))
684
685            result.append((style, ')'))
686            result.append(('class:radio', ' '))
687            result.extend(to_formatted_text(value[1], style='class:radio'))
688            result.append(('', '\n'))
689
690        # Add mouse handler to all fragments.
691        for i in range(len(result)):
692            result[i] = (result[i][0], result[i][1], mouse_handler)
693
694        result.pop()  # Remove last newline.
695        return result
696
697    def __pt_container__(self):
698        return self.window
699
700
701class VerticalLine(object):
702    """
703    A simple vertical line with a width of 1.
704    """
705    def __init__(self):
706        self.window = Window(
707            char=Border.VERTICAL,
708            style='class:line,vertical-line',
709            width=1)
710
711    def __pt_container__(self):
712        return self.window
713
714
715class HorizontalLine(object):
716    """
717    A simple horizontal line with a height of 1.
718    """
719    def __init__(self):
720        self.window = Window(
721            char=Border.HORIZONTAL,
722            style='class:line,horizontal-line',
723            height=1)
724
725    def __pt_container__(self):
726        return self.window
727
728
729class ProgressBar(object):
730    def __init__(self):
731        self._percentage = 60
732
733        self.label = Label('60%')
734        self.container = FloatContainer(
735            content=Window(height=1),
736            floats=[
737                # We first draw the label, then the actual progress bar.  Right
738                # now, this is the only way to have the colors of the progress
739                # bar appear on top of the label. The problem is that our label
740                # can't be part of any `Window` below.
741                Float(content=self.label, top=0, bottom=0),
742
743                Float(left=0, top=0, right=0, bottom=0, content=VSplit([
744                    Window(style='class:progress-bar.used',
745                           width=lambda: D(weight=int(self._percentage))),
746
747                    Window(style='class:progress-bar',
748                           width=lambda: D(weight=int(100 - self._percentage))),
749                ])),
750            ])
751
752    @property
753    def percentage(self):
754        return self._percentage
755
756    @percentage.setter
757    def percentage(self, value):
758        assert isinstance(value, int)
759        self._percentage = value
760        self.label.text = '{0}%'.format(value)
761
762    def __pt_container__(self):
763        return self.container
764