1"""
2Utility to easily select lines from the history and execute them again.
3
4`create_history_application` creates an `Application` instance that runs will
5run as a sub application of the Repl/PythonInput.
6"""
7from functools import partial
8
9from prompt_toolkit.application import Application
10from prompt_toolkit.application.current import get_app
11from prompt_toolkit.buffer import Buffer
12from prompt_toolkit.document import Document
13from prompt_toolkit.enums import DEFAULT_BUFFER
14from prompt_toolkit.filters import Condition, has_focus
15from prompt_toolkit.formatted_text.utils import fragment_list_to_text
16from prompt_toolkit.key_binding import KeyBindings
17from prompt_toolkit.layout.containers import (
18    ConditionalContainer,
19    Container,
20    Float,
21    FloatContainer,
22    HSplit,
23    ScrollOffsets,
24    VSplit,
25    Window,
26    WindowAlign,
27)
28from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
29from prompt_toolkit.layout.dimension import Dimension as D
30from prompt_toolkit.layout.layout import Layout
31from prompt_toolkit.layout.margins import Margin, ScrollbarMargin
32from prompt_toolkit.layout.processors import Processor, Transformation
33from prompt_toolkit.lexers import PygmentsLexer
34from prompt_toolkit.widgets import Frame
35from prompt_toolkit.widgets.toolbars import ArgToolbar, SearchToolbar
36from pygments.lexers import Python3Lexer as PythonLexer
37from pygments.lexers import RstLexer
38
39from ptpython.layout import get_inputmode_fragments
40
41from .utils import if_mousedown
42
43HISTORY_COUNT = 2000
44
45__all__ = ["HistoryLayout", "PythonHistory"]
46
47HELP_TEXT = """
48This interface is meant to select multiple lines from the
49history and execute them together.
50
51Typical usage
52-------------
53
541. Move the ``cursor up`` in the history pane, until the
55   cursor is on the first desired line.
562. Hold down the ``space bar``, or press it multiple
57   times. Each time it will select one line and move to
58   the next one. Each selected line will appear on the
59   right side.
603. When all the required lines are displayed on the right
61   side, press ``Enter``. This will go back to the Python
62   REPL and show these lines as the current input. They
63   can still be edited from there.
64
65Key bindings
66------------
67
68Many Emacs and Vi navigation key bindings should work.
69Press ``F4`` to switch between Emacs and Vi mode.
70
71Additional bindings:
72
73- ``Space``: Select or delect a line.
74- ``Tab``: Move the focus between the history and input
75  pane. (Alternative: ``Ctrl-W``)
76- ``Ctrl-C``: Cancel. Ignore the result and go back to
77  the REPL. (Alternatives: ``q`` and ``Control-G``.)
78- ``Enter``: Accept the result and go back to the REPL.
79- ``F1``: Show/hide help. Press ``Enter`` to quit this
80  help message.
81
82Further, remember that searching works like in Emacs
83(using ``Ctrl-R``) or Vi (using ``/``).
84"""
85
86
87class BORDER:
88    "Box drawing characters."
89    HORIZONTAL = "\u2501"
90    VERTICAL = "\u2503"
91    TOP_LEFT = "\u250f"
92    TOP_RIGHT = "\u2513"
93    BOTTOM_LEFT = "\u2517"
94    BOTTOM_RIGHT = "\u251b"
95    LIGHT_VERTICAL = "\u2502"
96
97
98def _create_popup_window(title: str, body: Container) -> Frame:
99    """
100    Return the layout for a pop-up window. It consists of a title bar showing
101    the `title` text, and a body layout. The window is surrounded by borders.
102    """
103    return Frame(body=body, title=title)
104
105
106class HistoryLayout:
107    """
108    Create and return a `Container` instance for the history
109    application.
110    """
111
112    def __init__(self, history):
113        search_toolbar = SearchToolbar()
114
115        self.help_buffer_control = BufferControl(
116            buffer=history.help_buffer, lexer=PygmentsLexer(RstLexer)
117        )
118
119        help_window = _create_popup_window(
120            title="History Help",
121            body=Window(
122                content=self.help_buffer_control,
123                right_margins=[ScrollbarMargin(display_arrows=True)],
124                scroll_offsets=ScrollOffsets(top=2, bottom=2),
125            ),
126        )
127
128        self.default_buffer_control = BufferControl(
129            buffer=history.default_buffer,
130            input_processors=[GrayExistingText(history.history_mapping)],
131            lexer=PygmentsLexer(PythonLexer),
132        )
133
134        self.history_buffer_control = BufferControl(
135            buffer=history.history_buffer,
136            lexer=PygmentsLexer(PythonLexer),
137            search_buffer_control=search_toolbar.control,
138            preview_search=True,
139        )
140
141        history_window = Window(
142            content=self.history_buffer_control,
143            wrap_lines=False,
144            left_margins=[HistoryMargin(history)],
145            scroll_offsets=ScrollOffsets(top=2, bottom=2),
146        )
147
148        self.root_container = HSplit(
149            [
150                #  Top title bar.
151                Window(
152                    content=FormattedTextControl(_get_top_toolbar_fragments),
153                    align=WindowAlign.CENTER,
154                    style="class:status-toolbar",
155                ),
156                FloatContainer(
157                    content=VSplit(
158                        [
159                            # Left side: history.
160                            history_window,
161                            # Separator.
162                            Window(
163                                width=D.exact(1),
164                                char=BORDER.LIGHT_VERTICAL,
165                                style="class:separator",
166                            ),
167                            # Right side: result.
168                            Window(
169                                content=self.default_buffer_control,
170                                wrap_lines=False,
171                                left_margins=[ResultMargin(history)],
172                                scroll_offsets=ScrollOffsets(top=2, bottom=2),
173                            ),
174                        ]
175                    ),
176                    floats=[
177                        # Help text as a float.
178                        Float(
179                            width=60,
180                            top=3,
181                            bottom=2,
182                            content=ConditionalContainer(
183                                content=help_window,
184                                filter=has_focus(history.help_buffer),
185                            ),
186                        )
187                    ],
188                ),
189                # Bottom toolbars.
190                ArgToolbar(),
191                search_toolbar,
192                Window(
193                    content=FormattedTextControl(
194                        partial(_get_bottom_toolbar_fragments, history=history)
195                    ),
196                    style="class:status-toolbar",
197                ),
198            ]
199        )
200
201        self.layout = Layout(self.root_container, history_window)
202
203
204def _get_top_toolbar_fragments():
205    return [("class:status-bar.title", "History browser - Insert from history")]
206
207
208def _get_bottom_toolbar_fragments(history):
209    python_input = history.python_input
210
211    @if_mousedown
212    def f1(mouse_event):
213        _toggle_help(history)
214
215    @if_mousedown
216    def tab(mouse_event):
217        _select_other_window(history)
218
219    return (
220        [("class:status-toolbar", " ")]
221        + get_inputmode_fragments(python_input)
222        + [
223            ("class:status-toolbar", " "),
224            ("class:status-toolbar.key", "[Space]"),
225            ("class:status-toolbar", " Toggle "),
226            ("class:status-toolbar.key", "[Tab]", tab),
227            ("class:status-toolbar", " Focus ", tab),
228            ("class:status-toolbar.key", "[Enter]"),
229            ("class:status-toolbar", " Accept "),
230            ("class:status-toolbar.key", "[F1]", f1),
231            ("class:status-toolbar", " Help ", f1),
232        ]
233    )
234
235
236class HistoryMargin(Margin):
237    """
238    Margin for the history buffer.
239    This displays a green bar for the selected entries.
240    """
241
242    def __init__(self, history):
243        self.history_buffer = history.history_buffer
244        self.history_mapping = history.history_mapping
245
246    def get_width(self, ui_content):
247        return 2
248
249    def create_margin(self, window_render_info, width, height):
250        document = self.history_buffer.document
251
252        lines_starting_new_entries = self.history_mapping.lines_starting_new_entries
253        selected_lines = self.history_mapping.selected_lines
254
255        current_lineno = document.cursor_position_row
256
257        visible_line_to_input_line = window_render_info.visible_line_to_input_line
258        result = []
259
260        for y in range(height):
261            line_number = visible_line_to_input_line.get(y)
262
263            # Show stars at the start of each entry.
264            # (Visualises multiline entries.)
265            if line_number in lines_starting_new_entries:
266                char = "*"
267            else:
268                char = " "
269
270            if line_number in selected_lines:
271                t = "class:history-line,selected"
272            else:
273                t = "class:history-line"
274
275            if line_number == current_lineno:
276                t = t + ",current"
277
278            result.append((t, char))
279            result.append(("", "\n"))
280
281        return result
282
283
284class ResultMargin(Margin):
285    """
286    The margin to be shown in the result pane.
287    """
288
289    def __init__(self, history):
290        self.history_mapping = history.history_mapping
291        self.history_buffer = history.history_buffer
292
293    def get_width(self, ui_content):
294        return 2
295
296    def create_margin(self, window_render_info, width, height):
297        document = self.history_buffer.document
298
299        current_lineno = document.cursor_position_row
300        offset = (
301            self.history_mapping.result_line_offset
302        )  # original_document.cursor_position_row
303
304        visible_line_to_input_line = window_render_info.visible_line_to_input_line
305
306        result = []
307
308        for y in range(height):
309            line_number = visible_line_to_input_line.get(y)
310
311            if (
312                line_number is None
313                or line_number < offset
314                or line_number >= offset + len(self.history_mapping.selected_lines)
315            ):
316                t = ""
317            elif line_number == current_lineno:
318                t = "class:history-line,selected,current"
319            else:
320                t = "class:history-line,selected"
321
322            result.append((t, " "))
323            result.append(("", "\n"))
324
325        return result
326
327    def invalidation_hash(self, document):
328        return document.cursor_position_row
329
330
331class GrayExistingText(Processor):
332    """
333    Turn the existing input, before and after the inserted code gray.
334    """
335
336    def __init__(self, history_mapping):
337        self.history_mapping = history_mapping
338        self._lines_before = len(
339            history_mapping.original_document.text_before_cursor.splitlines()
340        )
341
342    def apply_transformation(self, transformation_input):
343        lineno = transformation_input.lineno
344        fragments = transformation_input.fragments
345
346        if lineno < self._lines_before or lineno >= self._lines_before + len(
347            self.history_mapping.selected_lines
348        ):
349            text = fragment_list_to_text(fragments)
350            return Transformation(fragments=[("class:history.existing-input", text)])
351        else:
352            return Transformation(fragments=fragments)
353
354
355class HistoryMapping:
356    """
357    Keep a list of all the lines from the history and the selected lines.
358    """
359
360    def __init__(self, history, python_history, original_document):
361        self.history = history
362        self.python_history = python_history
363        self.original_document = original_document
364
365        self.lines_starting_new_entries = set()
366        self.selected_lines = set()
367
368        # Process history.
369        history_strings = python_history.get_strings()
370        history_lines = []
371
372        for entry_nr, entry in list(enumerate(history_strings))[-HISTORY_COUNT:]:
373            self.lines_starting_new_entries.add(len(history_lines))
374
375            for line in entry.splitlines():
376                history_lines.append(line)
377
378        if len(history_strings) > HISTORY_COUNT:
379            history_lines[0] = (
380                "# *** History has been truncated to %s lines ***" % HISTORY_COUNT
381            )
382
383        self.history_lines = history_lines
384        self.concatenated_history = "\n".join(history_lines)
385
386        # Line offset.
387        if self.original_document.text_before_cursor:
388            self.result_line_offset = self.original_document.cursor_position_row + 1
389        else:
390            self.result_line_offset = 0
391
392    def get_new_document(self, cursor_pos=None):
393        """
394        Create a `Document` instance that contains the resulting text.
395        """
396        lines = []
397
398        # Original text, before cursor.
399        if self.original_document.text_before_cursor:
400            lines.append(self.original_document.text_before_cursor)
401
402        # Selected entries from the history.
403        for line_no in sorted(self.selected_lines):
404            lines.append(self.history_lines[line_no])
405
406        # Original text, after cursor.
407        if self.original_document.text_after_cursor:
408            lines.append(self.original_document.text_after_cursor)
409
410        # Create `Document` with cursor at the right position.
411        text = "\n".join(lines)
412        if cursor_pos is not None and cursor_pos > len(text):
413            cursor_pos = len(text)
414        return Document(text, cursor_pos)
415
416    def update_default_buffer(self):
417        b = self.history.default_buffer
418
419        b.set_document(self.get_new_document(b.cursor_position), bypass_readonly=True)
420
421
422def _toggle_help(history):
423    "Display/hide help."
424    help_buffer_control = history.history_layout.help_buffer_control
425
426    if history.app.layout.current_control == help_buffer_control:
427        history.app.layout.focus_previous()
428    else:
429        history.app.layout.current_control = help_buffer_control
430
431
432def _select_other_window(history):
433    "Toggle focus between left/right window."
434    current_buffer = history.app.current_buffer
435    layout = history.history_layout.layout
436
437    if current_buffer == history.history_buffer:
438        layout.current_control = history.history_layout.default_buffer_control
439
440    elif current_buffer == history.default_buffer:
441        layout.current_control = history.history_layout.history_buffer_control
442
443
444def create_key_bindings(history, python_input, history_mapping):
445    """
446    Key bindings.
447    """
448    bindings = KeyBindings()
449    handle = bindings.add
450
451    @handle(" ", filter=has_focus(history.history_buffer))
452    def _(event):
453        """
454        Space: select/deselect line from history pane.
455        """
456        b = event.current_buffer
457        line_no = b.document.cursor_position_row
458
459        if not history_mapping.history_lines:
460            # If we've no history, then nothing to do
461            return
462
463        if line_no in history_mapping.selected_lines:
464            # Remove line.
465            history_mapping.selected_lines.remove(line_no)
466            history_mapping.update_default_buffer()
467        else:
468            # Add line.
469            history_mapping.selected_lines.add(line_no)
470            history_mapping.update_default_buffer()
471
472            # Update cursor position
473            default_buffer = history.default_buffer
474            default_lineno = (
475                sorted(history_mapping.selected_lines).index(line_no)
476                + history_mapping.result_line_offset
477            )
478            default_buffer.cursor_position = (
479                default_buffer.document.translate_row_col_to_index(default_lineno, 0)
480            )
481
482        # Also move the cursor to the next line. (This way they can hold
483        # space to select a region.)
484        b.cursor_position = b.document.translate_row_col_to_index(line_no + 1, 0)
485
486    @handle(" ", filter=has_focus(DEFAULT_BUFFER))
487    @handle("delete", filter=has_focus(DEFAULT_BUFFER))
488    @handle("c-h", filter=has_focus(DEFAULT_BUFFER))
489    def _(event):
490        """
491        Space: remove line from default pane.
492        """
493        b = event.current_buffer
494        line_no = b.document.cursor_position_row - history_mapping.result_line_offset
495
496        if line_no >= 0:
497            try:
498                history_lineno = sorted(history_mapping.selected_lines)[line_no]
499            except IndexError:
500                pass  # When `selected_lines` is an empty set.
501            else:
502                history_mapping.selected_lines.remove(history_lineno)
503
504            history_mapping.update_default_buffer()
505
506    help_focussed = has_focus(history.help_buffer)
507    main_buffer_focussed = has_focus(history.history_buffer) | has_focus(
508        history.default_buffer
509    )
510
511    @handle("tab", filter=main_buffer_focussed)
512    @handle("c-x", filter=main_buffer_focussed, eager=True)
513    # Eager: ignore the Emacs [Ctrl-X Ctrl-X] binding.
514    @handle("c-w", filter=main_buffer_focussed)
515    def _(event):
516        "Select other window."
517        _select_other_window(history)
518
519    @handle("f4")
520    def _(event):
521        "Switch between Emacs/Vi mode."
522        python_input.vi_mode = not python_input.vi_mode
523
524    @handle("f1")
525    def _(event):
526        "Display/hide help."
527        _toggle_help(history)
528
529    @handle("enter", filter=help_focussed)
530    @handle("c-c", filter=help_focussed)
531    @handle("c-g", filter=help_focussed)
532    @handle("escape", filter=help_focussed)
533    def _(event):
534        "Leave help."
535        event.app.layout.focus_previous()
536
537    @handle("q", filter=main_buffer_focussed)
538    @handle("f3", filter=main_buffer_focussed)
539    @handle("c-c", filter=main_buffer_focussed)
540    @handle("c-g", filter=main_buffer_focussed)
541    def _(event):
542        "Cancel and go back."
543        event.app.exit(result=None)
544
545    @handle("enter", filter=main_buffer_focussed)
546    def _(event):
547        "Accept input."
548        event.app.exit(result=history.default_buffer.text)
549
550    enable_system_bindings = Condition(lambda: python_input.enable_system_bindings)
551
552    @handle("c-z", filter=enable_system_bindings)
553    def _(event):
554        "Suspend to background."
555        event.app.suspend_to_background()
556
557    return bindings
558
559
560class PythonHistory:
561    def __init__(self, python_input, original_document):
562        """
563        Create an `Application` for the history screen.
564        This has to be run as a sub application of `python_input`.
565
566        When this application runs and returns, it retuns the selected lines.
567        """
568        self.python_input = python_input
569
570        history_mapping = HistoryMapping(self, python_input.history, original_document)
571        self.history_mapping = history_mapping
572
573        document = Document(history_mapping.concatenated_history)
574        document = Document(
575            document.text,
576            cursor_position=document.cursor_position
577            + document.get_start_of_line_position(),
578        )
579
580        self.history_buffer = Buffer(
581            document=document,
582            on_cursor_position_changed=self._history_buffer_pos_changed,
583            accept_handler=(
584                lambda buff: get_app().exit(result=self.default_buffer.text)
585            ),
586            read_only=True,
587        )
588
589        self.default_buffer = Buffer(
590            name=DEFAULT_BUFFER,
591            document=history_mapping.get_new_document(),
592            on_cursor_position_changed=self._default_buffer_pos_changed,
593            read_only=True,
594        )
595
596        self.help_buffer = Buffer(document=Document(HELP_TEXT, 0), read_only=True)
597
598        self.history_layout = HistoryLayout(self)
599
600        self.app = Application(
601            layout=self.history_layout.layout,
602            full_screen=True,
603            style=python_input._current_style,
604            mouse_support=Condition(lambda: python_input.enable_mouse_support),
605            key_bindings=create_key_bindings(self, python_input, history_mapping),
606        )
607
608    def _default_buffer_pos_changed(self, _):
609        """When the cursor changes in the default buffer. Synchronize with
610        history buffer."""
611        # Only when this buffer has the focus.
612        if self.app.current_buffer == self.default_buffer:
613            try:
614                line_no = (
615                    self.default_buffer.document.cursor_position_row
616                    - self.history_mapping.result_line_offset
617                )
618
619                if line_no < 0:  # When the cursor is above the inserted region.
620                    raise IndexError
621
622                history_lineno = sorted(self.history_mapping.selected_lines)[line_no]
623            except IndexError:
624                pass
625            else:
626                self.history_buffer.cursor_position = (
627                    self.history_buffer.document.translate_row_col_to_index(
628                        history_lineno, 0
629                    )
630                )
631
632    def _history_buffer_pos_changed(self, _):
633        """When the cursor changes in the history buffer. Synchronize."""
634        # Only when this buffer has the focus.
635        if self.app.current_buffer == self.history_buffer:
636            line_no = self.history_buffer.document.cursor_position_row
637
638            if line_no in self.history_mapping.selected_lines:
639                default_lineno = (
640                    sorted(self.history_mapping.selected_lines).index(line_no)
641                    + self.history_mapping.result_line_offset
642                )
643
644                self.default_buffer.cursor_position = (
645                    self.default_buffer.document.translate_row_col_to_index(
646                        default_lineno, 0
647                    )
648                )
649