diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index eedbdd8ef..bff91c148 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 @@ -30,15 +30,18 @@ jobs: python -m pip install . ruff typos coverage codecov mypy pytest readme_renderer types-contextvars asyncssh pip list - name: Ruff + if: ${{ matrix.python-version == '3.13' }} run: | - ruff . + ruff check . ruff format --check . + - name: Typos + if: ${{ matrix.python-version == '3.13' }} + run: | typos . - name: Tests run: | coverage run -m pytest - - if: "matrix.python-version != '3.7'" - name: Mypy + - name: Mypy # Check whether the imports were sorted correctly. # When this fails, please run ./tools/sort-imports.sh run: | diff --git a/CHANGELOG b/CHANGELOG index fa02b8568..14e3f311b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,57 @@ CHANGELOG ========= +3.0.50: 2025-01-20 +------------------ + +Fixes: +- Fixes non user impacting regression on the output rendering. Don't render + cursor hide/show ANSI escape codes if not needed. + + +3.0.49: 2025-01-20 +------------------ + +New features: +- On Windows, use virtual terminal input when available. +- Support for multiline suggestions. + +Fixes: +- Handle `InvalidStateError` during termination when using + `run_in_terminal`/`patch_stdout`. This can happen in some cases during + cancellation, probably when using anyio. +- Fix cursor that remains in hidden state when the application exits. This can + happen when the application doesn't show the cursor and `erase_when_done` is + being used. + +Breaking changes: +- Drop support for Python 3.7: + + +3.0.48: 2024-09-25 +------------------ + +Fixes: +- Typing improvements: + * Add `@overload` to `contrib.regular_languages.compiler.Variables.get`. + * Use `Sequence` instead of `list` for `words` argument in completers. +- Improve `ModalCursorShapeConfig`: + * Display an "underscore" cursor in Vi's "replace single" mode, like + "replace" mode. + * Display an "beam" cursor in Emacs (insert) mode. + + +3.0.47: 2024-06-10 +------------------ + +New features: +- Allow passing exception classes for `KeyboardInterrupt` and `EOFError` in + `PromptSession`. + +Fixes: +- Compute padding parameters for `Box` widget lazily. + + 3.0.46: 2024-06-04 ------------------ diff --git a/PROJECTS.rst b/PROJECTS.rst index eabfe13ab..5f862b3d4 100644 --- a/PROJECTS.rst +++ b/PROJECTS.rst @@ -52,7 +52,7 @@ Full screen applications: - `sanctuary-zero `_: A secure chatroom with zero logging and total transience. - `Hummingbot `_: A Cryptocurrency Algorithmic Trading Platform - `git-bbb `_: A `git blame` browser. -- `ass `_: An OpenAI Assistants API client. Libraries: diff --git a/docs/conf.py b/docs/conf.py index dbbcecc09..56c27487b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,13 +25,17 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.graphviz", "sphinx_copybutton"] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.graphviz", + "sphinx_copybutton", +] # Add any paths that contain templates here, relative to this directory. # templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = ".rst" +source_suffix = {".rst": "restructuredtext"} # The encoding of source files. # source_encoding = 'utf-8-sig' @@ -47,10 +51,21 @@ # |version| and |release|, also used in various other places throughout the # built documents. # +# --------------------------------------------------------------------- +# Versions. # The short X.Y version. -version = "3.0.46" +version = "3.0.50" # The full version, including alpha/beta/rc tags. -release = "3.0.46" +release = "3.0.50" +# The URL pattern to match releases to ReadTheDocs URLs. +docs_fmt_url = "https://python-prompt-toolkit.readthedocs.io/en/{release}/" +# The list of releases to include in the dropdown. +releases = [ + "latest", + release, + "2.0.9", + "1.0.15", +] # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -82,8 +97,7 @@ # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = "pastie" -pygments_dark_style = "dracula" +# pygments_style = "pastie" # Provided as a theme option below. # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -107,38 +121,103 @@ # on_rtd = os.environ.get("READTHEDOCS", None) == "True" -try: - import sphinx_nefertiti - - html_theme = "sphinx_nefertiti" - html_theme_path = [sphinx_nefertiti.get_html_theme_path()] - html_theme_options = { - # "style" can take the following values: "blue", "indigo", "purple", - # "pink", "red", "orange", "yellow", "green", "tail", and "default". - "style": "default", - # Fonts are customizable (and are not retrieved online). - # https://sphinx-nefertiti.readthedocs.io/en/latest/users-guide/customization/fonts.html - # "documentation_font": "Open Sans", - # "monospace_font": "Ubuntu Mono", - # "monospace_font_size": "1.1rem", - "logo": "logo_400px.png", - "logo_alt": "python-prompt-toolkit", - "logo_width": "36", - "logo_height": "36", - "repository_url": "https://github.com/prompt-toolkit/python-prompt-toolkit", - "repository_name": "python-prompt-toolkit", - "footer_links": ",".join( - [ - "Documentation|https://python-prompt-toolkit.readthedocs.io/", - "Package|https://pypi.org/project/prompt-toolkit/", - "Repository|https://github.com/prompt-toolkit/python-prompt-toolkit", - "Issues|https://github.com/prompt-toolkit/python-prompt-toolkit/issues", - ] - ), - } - -except ImportError: - html_theme = "pyramid" +html_theme = "sphinx_nefertiti" +# html_theme_path = [sphinx_nefertiti.get_html_theme_path()] +html_theme_options = { + "documentation_font": "Open Sans", + "monospace_font": "Ubuntu Mono", + "monospace_font_size": "1.1rem", + # "style" can take the following values: "blue", "indigo", "purple", + # "pink", "red", "orange", "yellow", "green", "tail", and "default". + "style": "blue", + "pygments_light_style": "pastie", + "pygments_dark_style": "dracula", + # Fonts are customizable (and are not retrieved online). + # https://sphinx-nefertiti.readthedocs.io/en/latest/users-guide/customization/fonts.html + "logo": "logo_400px.png", + "logo_alt": "python-prompt-toolkit", + "logo_width": "36", + "logo_height": "36", + "repository_url": "https://github.com/prompt-toolkit/python-prompt-toolkit", + "repository_name": "python-prompt-toolkit", + "current_version": "latest", + "versions": [(item, docs_fmt_url.format(release=item)) for item in releases], + "header_links": [ + {"text": "Getting started", "link": "pages/getting_started"}, + { + "text": "Tutorials", + "match": "/tutorials/*", + "dropdown": ( + {"text": "Build an SQLite REPL", "link": "pages/tutorials/repl"}, + ), + }, + { + "text": "Advanced", + "link": "pages/advanced_topics/index", + "match": "/advanced_topics/*", + "dropdown": ( + { + "text": "More about key bindings", + "link": "pages/advanced_topics/key_bindings", + }, + { + "text": "More about styling", + "link": "pages/advanced_topics/styling", + }, + { + "text": "Filters", + "link": "pages/advanced_topics/filters", + }, + { + "text": "The rendering flow", + "link": "pages/advanced_topics/rendering_flow", + }, + { + "text": "Running on top of the asyncio event loop", + "link": "pages/advanced_topics/asyncio", + }, + { + "text": "Unit testing", + "link": "pages/advanced_topics/unit_testing", + }, + { + "text": "Input hooks", + "link": "pages/advanced_topics/input_hooks", + }, + { + "text": "Architecture", + "link": "pages/advanced_topics/architecture", + }, + { + "text": "The rendering pipeline", + "link": "pages/advanced_topics/rendering_pipeline", + }, + ), + }, + { + "text": "Reference", + "link": "pages/reference", + }, + ], + "footer_links": [ + { + "text": "Documentation", + "link": "https://python-prompt-toolkit.readthedocs.io/", + }, + { + "text": "Package", + "link": "https://pypi.org/project/prompt-toolkit/", + }, + { + "text": "Repository", + "link": "https://github.com/prompt-toolkit/python-prompt-toolkit", + }, + { + "text": "Issues", + "link": "https://github.com/prompt-toolkit/python-prompt-toolkit/issues", + }, + ], +} # Theme options are theme-specific and customize the look and feel of a theme diff --git a/docs/index.rst b/docs/index.rst index 569e14ccf..feba68fcc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ and terminal applications in Python. It can be a very advanced pure Python replacement for `GNU readline -`_, but it can also be +`_, but it can also be used for building full screen applications. .. image:: images/ptpython-2.png diff --git a/docs/pages/asking_for_input.rst b/docs/pages/asking_for_input.rst index 20619ac1f..093bbe0d0 100644 --- a/docs/pages/asking_for_input.rst +++ b/docs/pages/asking_for_input.rst @@ -24,8 +24,8 @@ and returns the text. Just like ``(raw_)input``. from prompt_toolkit import prompt - text = prompt('Give me some input: ') - print('You said: %s' % text) + text = prompt("Give me some input: ") + print(f"You said: {text}") .. image:: ../images/hello-world-prompt.png @@ -85,8 +85,8 @@ base class. from prompt_toolkit.shortcuts import prompt from prompt_toolkit.lexers import PygmentsLexer - text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer)) - print('You said: %s' % text) + text = prompt("Enter HTML: ", lexer=PygmentsLexer(HtmlLexer)) + print(f"You said: {text}") .. image:: ../images/html-input.png @@ -102,10 +102,14 @@ you can do the following: from prompt_toolkit.lexers import PygmentsLexer from prompt_toolkit.styles.pygments import style_from_pygments_cls - style = style_from_pygments_cls(get_style_by_name('monokai')) - text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer), style=style, - include_default_pygments_style=False) - print('You said: %s' % text) + style = style_from_pygments_cls(get_style_by_name("monokai")) + text = prompt( + "Enter HTML: ", + lexer=PygmentsLexer(HtmlLexer), + style=style, + include_default_pygments_style=False + ) + print(f"You said: {text}") We pass ``include_default_pygments_style=False``, because otherwise, both styles will be merged, possibly giving slightly different colors in the outcome @@ -131,12 +135,15 @@ function: from prompt_toolkit.lexers import PygmentsLexer our_style = Style.from_dict({ - 'pygments.comment': '#888888 bold', - 'pygments.keyword': '#ff88ff bold', + "pygments.comment": "#888888 bold", + "pygments.keyword": "#ff88ff bold", }) - text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer), - style=our_style) + text = prompt( + "Enter HTML: ", + lexer=PygmentsLexer(HtmlLexer), + style=our_style + ) The style dictionary is very similar to the Pygments ``styles`` dictionary, @@ -167,12 +174,14 @@ Suppose we'd like to use a Pygments style, for instance from prompt_toolkit.lexers import PygmentsLexer from pygments.styles.tango import TangoStyle from pygments.lexers.html import HtmlLexer - - tango_style = style_from_pygments_cls (TangoStyle) - - text = prompt ('Enter HTML: ', - lexer=PygmentsLexer(HtmlLexer), - style=tango_style) + + tango_style = style_from_pygments_cls(TangoStyle) + + text = prompt( + "Enter HTML: ", + lexer=PygmentsLexer(HtmlLexer), + style=tango_style + ) Creating a custom style could be done like this: @@ -188,13 +197,16 @@ Creating a custom style could be done like this: our_style = merge_styles([ style_from_pygments_cls(TangoStyle), Style.from_dict({ - 'pygments.comment': '#888888 bold', - 'pygments.keyword': '#ff88ff bold', + "pygments.comment": "#888888 bold", + "pygments.keyword": "#ff88ff bold", }) ]) - text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer), - style=our_style) + text = prompt( + "Enter HTML: ", + lexer=PygmentsLexer(HtmlLexer), + style=our_style + ) Coloring the prompt itself @@ -212,24 +224,24 @@ names to refer to the style. style = Style.from_dict({ # User input (default text). - '': '#ff0066', + "": "#ff0066", # Prompt. - 'username': '#884444', - 'at': '#00aa00', - 'colon': '#0000aa', - 'pound': '#00aa00', - 'host': '#00ffff bg:#444400', - 'path': 'ansicyan underline', + "username": "#884444", + "at": "#00aa00", + "colon": "#0000aa", + "pound": "#00aa00", + "host": "#00ffff bg:#444400", + "path": "ansicyan underline", }) message = [ - ('class:username', 'john'), - ('class:at', '@'), - ('class:host', 'localhost'), - ('class:colon', ':'), - ('class:path', '/user/john'), - ('class:pound', '# '), + ("class:username", "john"), + ("class:at", "@"), + ("class:host", "localhost"), + ("class:colon", ":"), + ("class:path", "/user/john"), + ("class:pound", "# "), ] text = prompt(message, style=style) @@ -264,9 +276,9 @@ a completer that implements that interface. from prompt_toolkit import prompt from prompt_toolkit.completion import WordCompleter - html_completer = WordCompleter(['', '', '', '']) - text = prompt('Enter HTML: ', completer=html_completer) - print('You said: %s' % text) + html_completer = WordCompleter(["<html>", "<body>", "<head>", "<title>"]) + text = prompt("Enter HTML: ", completer=html_completer) + print(f"You said: {text}") :class:`~prompt_toolkit.completion.WordCompleter` is a simple completer that completes the last word before the cursor with any of the given words. @@ -299,18 +311,18 @@ levels. :class:`~prompt_toolkit.completion.NestedCompleter` solves this issue: from prompt_toolkit.completion import NestedCompleter completer = NestedCompleter.from_nested_dict({ - 'show': { - 'version': None, - 'clock': None, - 'ip': { - 'interface': {'brief'} + "show": { + "version": None, + "clock": None, + "ip": { + "interface": {"brief"} } }, - 'exit': None, + "exit": None, }) - text = prompt('# ', completer=completer) - print('You said: %s' % text) + text = prompt("# ", completer=completer) + print(f"You said: {text}") Whenever there is a ``None`` value in the dictionary, it means that there is no further nested completion at that point. When all values of a dictionary would @@ -330,9 +342,9 @@ instance: class MyCustomCompleter(Completer): def get_completions(self, document, complete_event): - yield Completion('completion', start_position=0) + yield Completion("completion", start_position=0) - text = prompt('> ', completer=MyCustomCompleter()) + text = prompt("> ", completer=MyCustomCompleter()) A :class:`~prompt_toolkit.completion.Completer` class has to implement a generator named :meth:`~prompt_toolkit.completion.Completer.get_completions` @@ -361,16 +373,25 @@ in the completion menu or toolbar. This is possible by passing a style to each class MyCustomCompleter(Completer): def get_completions(self, document, complete_event): # Display this completion, black on yellow. - yield Completion('completion1', start_position=0, - style='bg:ansiyellow fg:ansiblack') + yield Completion( + "completion1", + start_position=0, + style="bg:ansiyellow fg:ansiblack" + ) # Underline completion. - yield Completion('completion2', start_position=0, - style='underline') + yield Completion( + "completion2", + start_position=0, + style="underline" + ) # Specify class name, which will be looked up in the style sheet. - yield Completion('completion3', start_position=0, - style='class:special-completion') + yield Completion( + "completion3", + start_position=0, + style="class:special-completion" + ) The "colorful-prompts.py" example uses completion styling: @@ -390,9 +411,11 @@ can also be combined with the ``style`` attribute. For instance: class MyCustomCompleter(Completer): def get_completions(self, document, complete_event): yield Completion( - 'completion1', start_position=0, - display=HTML('<b>completion</b><ansired>1</ansired>'), - style='bg:ansiyellow') + "completion1", + start_position=0, + display=HTML("<b>completion</b><ansired>1</ansired>"), + style="bg:ansiyellow" + ) Fuzzy completion @@ -419,8 +442,11 @@ option: .. code:: python - text = prompt('Enter HTML: ', completer=my_completer, - complete_while_typing=True) + text = prompt( + "Enter HTML: ", + completer=my_completer, + complete_while_typing=True + ) Notice that this setting is incompatible with the ``enable_history_search`` option. The reason for this is that the up and down key bindings would conflict @@ -438,7 +464,7 @@ a background thread. This is possible by wrapping the completer in a .. code:: python - text = prompt('> ', completer=MyCustomCompleter(), complete_in_thread=True) + text = prompt("> ", completer=MyCustomCompleter(), complete_in_thread=True) Input validation @@ -472,11 +498,13 @@ takes a :class:`~prompt_toolkit.document.Document` as input and raises if not c.isdigit(): break - raise ValidationError(message='This input contains non-numeric characters', - cursor_position=i) + raise ValidationError( + message="This input contains non-numeric characters", + cursor_position=i + ) - number = int(prompt('Give a number: ', validator=NumberValidator())) - print('You said: %i' % number) + number = int(prompt("Give a number: ", validator=NumberValidator())) + print(f"You said: {number}") .. image:: ../images/number-validator.png @@ -485,8 +513,11 @@ prompt_toolkit can also validate after the user presses the enter key: .. code:: python - prompt('Give a number: ', validator=NumberValidator(), - validate_while_typing=False) + prompt( + "Give a number: ", + validator=NumberValidator(), + validate_while_typing=False + ) If the input validation contains some heavy CPU intensive code, but you don't want to block the event loop, then it's recommended to wrap the validator class @@ -511,11 +542,12 @@ follows: validator = Validator.from_callable( is_number, - error_message='This input contains non-numeric characters', - move_cursor_to_end=True) + error_message="This input contains non-numeric characters", + move_cursor_to_end=True + ) - number = int(prompt('Give a number: ', validator=validator)) - print('You said: %i' % number) + number = int(prompt("Give a number: ", validator=validator)) + print(f"You said: {number}") We define a function that takes a string, and tells whether it's valid input or not by returning a boolean. @@ -556,7 +588,7 @@ passed either to a :class:`~prompt_toolkit.shortcuts.PromptSession` or to the from prompt_toolkit import PromptSession from prompt_toolkit.history import FileHistory - session = PromptSession(history=FileHistory('~/.myhistory')) + session = PromptSession(history=FileHistory("~/.myhistory")) while True: session.prompt() @@ -591,8 +623,8 @@ Example: session = PromptSession() while True: - text = session.prompt('> ', auto_suggest=AutoSuggestFromHistory()) - print('You said: %s' % text) + text = session.prompt("> ", auto_suggest=AutoSuggestFromHistory()) + print(f"You said: {text}") .. image:: ../images/auto-suggestion.png @@ -624,10 +656,10 @@ of the foreground. from prompt_toolkit.formatted_text import HTML def bottom_toolbar(): - return HTML('This is a <b><style bg="ansired">Toolbar</style></b>!') + return HTML("This is a <b><style bg="ansired">Toolbar</style></b>!") - text = prompt('> ', bottom_toolbar=bottom_toolbar) - print('You said: %s' % text) + text = prompt("> ", bottom_toolbar=bottom_toolbar) + print(f"You said: {text}") .. image:: ../images/bottom-toolbar.png @@ -639,14 +671,14 @@ Similar, we could use a list of style/text tuples. from prompt_toolkit.styles import Style def bottom_toolbar(): - return [('class:bottom-toolbar', ' This is a toolbar. ')] + return [("class:bottom-toolbar", " This is a toolbar. ")] style = Style.from_dict({ - 'bottom-toolbar': '#ffffff bg:#333333', + "bottom-toolbar": "#ffffff bg:#333333", }) - text = prompt('> ', bottom_toolbar=bottom_toolbar, style=style) - print('You said: %s' % text) + text = prompt("> ", bottom_toolbar=bottom_toolbar, style=style) + print(f"You said: {text}") The default class name is ``bottom-toolbar`` and that will also be used to fill the background of the toolbar. @@ -669,13 +701,13 @@ callable which returns either. from prompt_toolkit.styles import Style example_style = Style.from_dict({ - 'rprompt': 'bg:#ff0066 #ffffff', + "rprompt": "bg:#ff0066 #ffffff", }) def get_rprompt(): - return '<rprompt>' + return "<rprompt>" - answer = prompt('> ', rprompt=get_rprompt, style=example_style) + answer = prompt("> ", rprompt=get_rprompt, style=example_style) .. image:: ../images/rprompt.png @@ -699,7 +731,7 @@ binding are required, just pass ``vi_mode=True``. from prompt_toolkit import prompt - prompt('> ', vi_mode=True) + prompt("> ", vi_mode=True) Adding custom key bindings @@ -711,7 +743,8 @@ usual Vi or Emacs behavior. We can extend this by passing another ``key_bindings`` argument of the :func:`~prompt_toolkit.shortcuts.prompt` function or the :class:`~prompt_toolkit.shortcuts.PromptSession` class. -An example of a prompt that prints ``'hello world'`` when :kbd:`Control-T` is pressed. +An example of a prompt that prints ``'hello world'`` when :kbd:`Control-T` is +pressed. .. code:: python @@ -721,20 +754,20 @@ An example of a prompt that prints ``'hello world'`` when :kbd:`Control-T` is pr bindings = KeyBindings() - @bindings.add('c-t') + @bindings.add("c-t") def _(event): - " Say 'hello' when `c-t` is pressed. " + " Say "hello" when `c-t` is pressed. " def print_hello(): - print('hello world') + print("hello world") run_in_terminal(print_hello) - @bindings.add('c-x') + @bindings.add("c-x") def _(event): " Exit when `c-x` is pressed. " event.app.exit() - text = prompt('> ', key_bindings=bindings) - print('You said: %s' % text) + text = prompt("> ", key_bindings=bindings) + print(f"You said: {text}") Note that we use @@ -770,12 +803,12 @@ filters <filters>`.) " Only activate key binding on the second half of each minute. " return datetime.datetime.now().second > 30 - @bindings.add('c-t', filter=is_active) + @bindings.add("c-t", filter=is_active) def _(event): # ... pass - prompt('> ', key_bindings=bindings) + prompt("> ", key_bindings=bindings) Dynamically switch between Emacs and Vi mode @@ -797,7 +830,7 @@ attribute. We can change the key bindings by changing this attribute from bindings = KeyBindings() # Add an additional key binding for toggling this flag. - @bindings.add('f4') + @bindings.add("f4") def _(event): " Toggle between Emacs and Vi mode. " app = event.app @@ -810,12 +843,12 @@ attribute. We can change the key bindings by changing this attribute from # Add a toolbar at the bottom to display the current input mode. def bottom_toolbar(): " Display the current input mode. " - text = 'Vi' if get_app().editing_mode == EditingMode.VI else 'Emacs' + text = "Vi" if get_app().editing_mode == EditingMode.VI else "Emacs" return [ - ('class:toolbar', ' [F4] %s ' % text) + ("class:toolbar", " [F4] %s " % text) ] - prompt('> ', key_bindings=bindings, bottom_toolbar=bottom_toolbar) + prompt("> ", key_bindings=bindings, bottom_toolbar=bottom_toolbar) run() @@ -832,7 +865,7 @@ the following key binding. kb = KeyBindings() - @kb.add('c-space') + @kb.add("c-space") def _(event): " Initialize autocompletion, or select the next completion. " buff = event.app.current_buffer @@ -854,7 +887,7 @@ Reading multiline input is as easy as passing the ``multiline=True`` parameter. from prompt_toolkit import prompt - prompt('> ', multiline=True) + prompt("> ", multiline=True) A side effect of this is that the enter key will now insert a newline instead of accepting and returning the input. The user will now have to press @@ -873,11 +906,14 @@ prompt.) from prompt_toolkit import prompt def prompt_continuation(width, line_number, is_soft_wrap): - return '.' * width - # Or: return [('', '.' * width)] + return "." * width + # Or: return [("", "." * width)] - prompt('multiline input> ', multiline=True, - prompt_continuation=prompt_continuation) + prompt( + "multiline input> ", + multiline=True, + prompt_continuation=prompt_continuation + ) .. image:: ../images/multiline-input.png @@ -892,7 +928,7 @@ A default value can be given: from prompt_toolkit import prompt import getpass - prompt('What is your name: ', default='%s' % getpass.getuser()) + prompt("What is your name: ", default=f"{getpass.getuser()}") Mouse support @@ -907,7 +943,7 @@ Enabling can be done by passing the ``mouse_support=True`` option. from prompt_toolkit import prompt - prompt('What is your name: ', mouse_support=True) + prompt("What is your name: ", mouse_support=True) Line wrapping @@ -921,7 +957,7 @@ scroll horizontally. from prompt_toolkit import prompt - prompt('What is your name: ', wrap_lines=False) + prompt("What is your name: ", wrap_lines=False) Password input @@ -934,7 +970,7 @@ asterisks (``*`` characters). from prompt_toolkit import prompt - prompt('Enter password: ', is_password=True) + prompt("Enter password: ", is_password=True) Cursor shapes @@ -952,13 +988,13 @@ input mode. from prompt_toolkit.cursor_shapes import CursorShape, ModalCursorShapeConfig # Several possible values for the `cursor_shape_config` parameter: - prompt('>', cursor=CursorShape.BLOCK) - prompt('>', cursor=CursorShape.UNDERLINE) - prompt('>', cursor=CursorShape.BEAM) - prompt('>', cursor=CursorShape.BLINKING_BLOCK) - prompt('>', cursor=CursorShape.BLINKING_UNDERLINE) - prompt('>', cursor=CursorShape.BLINKING_BEAM) - prompt('>', cursor=ModalCursorShapeConfig()) + prompt(">", cursor=CursorShape.BLOCK) + prompt(">", cursor=CursorShape.UNDERLINE) + prompt(">", cursor=CursorShape.BEAM) + prompt(">", cursor=CursorShape.BLINKING_BLOCK) + prompt(">", cursor=CursorShape.BLINKING_UNDERLINE) + prompt(">", cursor=CursorShape.BLINKING_BEAM) + prompt(">", cursor=ModalCursorShapeConfig()) Prompt in an `asyncio` application @@ -989,8 +1025,8 @@ returns a coroutines and is awaitable. session = PromptSession() while True: with patch_stdout(): - result = await session.prompt_async('Say something: ') - print('You said: %s' % result) + result = await session.prompt_async("Say something: ") + print(f"You said: {result}") The :func:`~prompt_toolkit.patch_stdout.patch_stdout` context manager is optional, but it's recommended, because other coroutines could print to stdout. diff --git a/docs/pages/getting_started.rst b/docs/pages/getting_started.rst index 06287a080..2c7bcc15d 100644 --- a/docs/pages/getting_started.rst +++ b/docs/pages/getting_started.rst @@ -56,8 +56,8 @@ and returns the text. Just like ``(raw_)input``. from prompt_toolkit import prompt - text = prompt('Give me some input: ') - print('You said: %s' % text) + text = prompt("Give me some input: ") + print(f"You said: {text}") Learning `prompt_toolkit` diff --git a/docs/pages/reference.rst b/docs/pages/reference.rst index d8a705ea4..a8c777535 100644 --- a/docs/pages/reference.rst +++ b/docs/pages/reference.rst @@ -301,9 +301,11 @@ Filters .. autoclass:: prompt_toolkit.filters.Filter :members: + :no-index: .. autoclass:: prompt_toolkit.filters.Condition :members: + :no-index: .. automodule:: prompt_toolkit.filters.utils :members: @@ -334,7 +336,7 @@ Eventloop .. automodule:: prompt_toolkit.eventloop :members: run_in_executor_with_context, call_soon_threadsafe, - get_traceback_from_context, get_event_loop + get_traceback_from_context .. automodule:: prompt_toolkit.eventloop.inputhook :members: diff --git a/docs/requirements.txt b/docs/requirements.txt index beb1c314b..844bca89d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ -Sphinx<7 +Sphinx>=8,<9 wcwidth<1 pyperclip<2 -sphinx_copybutton>=0.5.0,<1.0.0 -sphinx-nefertiti>=0.2.1 +sphinx_copybutton>=0.5.2,<1.0.0 +sphinx-nefertiti>=0.6.0 \ No newline at end of file diff --git a/examples/dialogs/progress_dialog.py b/examples/dialogs/progress_dialog.py index 7a29f1bb9..3d5227bc6 100755 --- a/examples/dialogs/progress_dialog.py +++ b/examples/dialogs/progress_dialog.py @@ -38,8 +38,7 @@ def worker(set_percentage, log_text): def main(): progress_dialog( title="Progress dialog example", - text="As an examples, we walk through the filesystem and print " - "all directories", + text="As an examples, we walk through the filesystem and print all directories", run_callback=worker, ).run() diff --git a/examples/full-screen/simple-demos/floats.py b/examples/full-screen/simple-demos/floats.py index 4d79c2b80..d31b7900c 100755 --- a/examples/full-screen/simple-demos/floats.py +++ b/examples/full-screen/simple-demos/floats.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """ -Horizontal split example. +Floats example. """ from prompt_toolkit.application import Application diff --git a/examples/progress-bar/a-lot-of-parallel-tasks.py b/examples/progress-bar/a-lot-of-parallel-tasks.py index a20982789..007bdb6ff 100755 --- a/examples/progress-bar/a-lot-of-parallel-tasks.py +++ b/examples/progress-bar/a-lot-of-parallel-tasks.py @@ -39,7 +39,7 @@ def stop_task(label, total, sleep_time): threads = [] for i in range(160): - label = "Task %i" % i + label = f"Task {i}" total = random.randrange(50, 200) sleep_time = random.randrange(5, 20) / 100.0 diff --git a/examples/prompts/asyncio-prompt.py b/examples/prompts/asyncio-prompt.py index 32a1481d9..bd97fb3a9 100755 --- a/examples/prompts/asyncio-prompt.py +++ b/examples/prompts/asyncio-prompt.py @@ -26,7 +26,7 @@ async def print_counter(): try: i = 0 while True: - print("Counter: %i" % i) + print(f"Counter: {i}") i += 1 await asyncio.sleep(3) except asyncio.CancelledError: diff --git a/examples/prompts/colored-prompt.py b/examples/prompts/colored-prompt.py index 428ff1dd0..883b3cf20 100755 --- a/examples/prompts/colored-prompt.py +++ b/examples/prompts/colored-prompt.py @@ -66,12 +66,7 @@ def example_3(): Using ANSI for the formatting. """ answer = prompt( - ANSI( - "\x1b[31mjohn\x1b[0m@" - "\x1b[44mlocalhost\x1b[0m:" - "\x1b[4m/user/john\x1b[0m" - "# " - ) + ANSI("\x1b[31mjohn\x1b[0m@\x1b[44mlocalhost\x1b[0m:\x1b[4m/user/john\x1b[0m# ") ) print(f"You said: {answer}") diff --git a/examples/prompts/multiline-autosuggest.py b/examples/prompts/multiline-autosuggest.py new file mode 100755 index 000000000..87f975a02 --- /dev/null +++ b/examples/prompts/multiline-autosuggest.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +""" +A more complex example of a CLI that demonstrates fish-style auto suggestion +across multiple lines. + +This can typically be used for LLM that may return multi-line responses. + +Note that unlike simple autosuggest, using multiline autosuggest requires more +care as it may shift the buffer layout, and care must taken ton consider the +various case when the number iof suggestions lines is longer than the number of +lines in the buffer, what happens to the existing text (is it pushed down, or +hidden until the suggestion is accepted) Etc. + +So generally multiline autosuggest will require a custom processor to handle the +different use case and user experience. + +We also have not hooked any keys to accept the suggestion, so it will be up to you +to decide how and when to accept the suggestion, accept it as a whole, like by line, or +token by token. +""" + +from prompt_toolkit import PromptSession +from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion +from prompt_toolkit.enums import DEFAULT_BUFFER +from prompt_toolkit.filters import HasFocus, IsDone +from prompt_toolkit.layout.processors import ( + ConditionalProcessor, + Processor, + Transformation, + TransformationInput, +) + +universal_declaration_of_human_rights = """ +All human beings are born free and equal in dignity and rights. +They are endowed with reason and conscience and should act towards one another +in a spirit of brotherhood +Everyone is entitled to all the rights and freedoms set forth in this +Declaration, without distinction of any kind, such as race, colour, sex, +language, religion, political or other opinion, national or social origin, +property, birth or other status. Furthermore, no distinction shall be made on +the basis of the political, jurisdictional or international status of the +country or territory to which a person belongs, whether it be independent, +trust, non-self-governing or under any other limitation of sovereignty.""".strip().splitlines() + + +class FakeLLMAutoSuggest(AutoSuggest): + def get_suggestion(self, buffer, document): + if document.line_count == 1: + return Suggestion(" (Add a few new lines to see multiline completion)") + cursor_line = document.cursor_position_row + text = document.text.split("\n")[cursor_line] + if not text.strip(): + return None + index = None + for i, l in enumerate(universal_declaration_of_human_rights): + if l.startswith(text): + index = i + break + if index is None: + return None + return Suggestion( + universal_declaration_of_human_rights[index][len(text) :] + + "\n" + + "\n".join(universal_declaration_of_human_rights[index + 1 :]) + ) + + +class AppendMultilineAutoSuggestionInAnyLine(Processor): + def __init__(self, style: str = "class:auto-suggestion") -> None: + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + # a convenient noop transformation that does nothing. + noop = Transformation(fragments=ti.fragments) + + # We get out of the way if the prompt is only one line, and let prompt_toolkit handle the rest. + if ti.document.line_count == 1: + return noop + + # first everything before the current line is unchanged. + if ti.lineno < ti.document.cursor_position_row: + return noop + + buffer = ti.buffer_control.buffer + if not buffer.suggestion or not ti.document.is_cursor_at_the_end_of_line: + return noop + + # compute the number delta between the current cursor line and line we are transforming + # transformed line can either be suggestions, or an existing line that is shifted. + delta = ti.lineno - ti.document.cursor_position_row + + # convert the suggestion into a list of lines + suggestions = buffer.suggestion.text.splitlines() + if not suggestions: + return noop + + if delta == 0: + # append suggestion to current line + suggestion = suggestions[0] + return Transformation(fragments=ti.fragments + [(self.style, suggestion)]) + elif delta < len(suggestions): + # append a line with the nth line of the suggestion + suggestion = suggestions[delta] + assert "\n" not in suggestion + return Transformation([(self.style, suggestion)]) + else: + # return the line that is by delta-1 suggestion (first suggestion does not shifts) + shift = ti.lineno - len(suggestions) + 1 + return Transformation(ti.get_line(shift)) + + +def main(): + # Create some history first. (Easy for testing.) + + autosuggest = FakeLLMAutoSuggest() + # Print help. + print("This CLI has fish-style auto-suggestion enabled across multiple lines.") + print("This will try to complete the universal declaration of human rights.") + print("") + print(" " + "\n ".join(universal_declaration_of_human_rights)) + print("") + print("Add a few new lines to see multiline completion, and start typing.") + print("Press Control-C to retry. Control-D to exit.") + print() + + session = PromptSession( + auto_suggest=autosuggest, + enable_history_search=False, + reserve_space_for_menu=5, + multiline=True, + prompt_continuation="... ", + input_processors=[ + ConditionalProcessor( + processor=AppendMultilineAutoSuggestionInAnyLine(), + filter=HasFocus(DEFAULT_BUFFER) & ~IsDone(), + ), + ], + ) + + while True: + try: + text = session.prompt( + "Say something (Esc-enter : accept, enter : new line): " + ) + except KeyboardInterrupt: + pass # Ctrl-C pressed. Try again. + else: + break + + print(f"You said: {text}") + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/patch-stdout.py b/examples/prompts/patch-stdout.py index 3a89f3650..7a283b601 100755 --- a/examples/prompts/patch-stdout.py +++ b/examples/prompts/patch-stdout.py @@ -21,7 +21,7 @@ def thread(): i = 0 while running: i += 1 - print("i=%i" % i) + print(f"i={i}") time.sleep(1) t = threading.Thread(target=thread) diff --git a/pyproject.toml b/pyproject.toml index 1bff4baec..630f124f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,8 @@ lint.ignore = [ "E741", # Ambiguous variable name. ] - [tool.ruff.lint.per-file-ignores] -"examples/*" = ["T201"] # Print allowed in examples. +"examples/*" = ["UP031", "T201"] # Print allowed in examples. "src/prompt_toolkit/application/application.py" = ["T100", "T201", "F821"] # pdb and print allowed. "src/prompt_toolkit/contrib/telnet/server.py" = ["T201"] # Print allowed. "src/prompt_toolkit/key_binding/bindings/named_commands.py" = ["T201"] # Print allowed. @@ -31,8 +30,7 @@ lint.ignore = [ "src/prompt_toolkit/filters/__init__.py" = ["F403", "F405"] # Possibly undefined due to star import. "src/prompt_toolkit/filters/cli.py" = ["F403", "F405"] # Possibly undefined due to star import. "src/prompt_toolkit/shortcuts/progress_bar/formatters.py" = ["UP031"] # %-style formatting. -"src/*" = ["UP032"] # f-strings instead of format calls. - +"src/*" = ["UP031", "UP032"] # f-strings instead of format calls. [tool.ruff.lint.isort] known-first-party = ["prompt_toolkit"] @@ -50,6 +48,7 @@ extend-ignore-re = [ "ret", "rouble", "x1b\\[4m", + "Vertica", # Database. # Deliberate spelling mistakes in autocorrection.py "wolrd", "impotr", @@ -62,6 +61,9 @@ locale = 'en-us' # US English. [tool.typos.files] extend-exclude = [ + "tests/test_buffer.py", "tests/test_cli.py", "tests/test_regular_languages.py", + # complains about some spelling in human right declaration. + "examples/prompts/multiline-autosuggest.py", ] diff --git a/setup.py b/setup.py index ca2170ce4..2febdf1de 100755 --- a/setup.py +++ b/setup.py @@ -30,22 +30,19 @@ def get_version(package): package_dir={"": "src"}, package_data={"prompt_toolkit": ["py.typed"]}, install_requires=["wcwidth"], - # We require Python 3.7, because we need: - # - Context variables - PEP 567 - # - `asyncio.run()` - python_requires=">=3.7.0", + python_requires=">=3.8.0", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python", "Topic :: Software Development", diff --git a/src/prompt_toolkit/__init__.py b/src/prompt_toolkit/__init__.py index 9f194f1b4..94727e7cb 100644 --- a/src/prompt_toolkit/__init__.py +++ b/src/prompt_toolkit/__init__.py @@ -28,7 +28,7 @@ from .shortcuts import PromptSession, print_formatted_text, prompt # Don't forget to update in `docs/conf.py`! -__version__ = "3.0.46" +__version__ = "3.0.50" assert pep440.match(__version__) diff --git a/src/prompt_toolkit/application/current.py b/src/prompt_toolkit/application/current.py index 7e2cf480b..3f7eb4bd4 100644 --- a/src/prompt_toolkit/application/current.py +++ b/src/prompt_toolkit/application/current.py @@ -143,8 +143,8 @@ def create_app_session( """ Create a separate AppSession. - This is useful if there can be multiple individual `AppSession`s going on. - Like in the case of an Telnet/SSH server. + This is useful if there can be multiple individual ``AppSession``'s going + on. Like in the case of a Telnet/SSH server. """ # If no input/output is specified, fall back to the current input/output, # if there was one that was set/created for the current session. diff --git a/src/prompt_toolkit/application/run_in_terminal.py b/src/prompt_toolkit/application/run_in_terminal.py index 18a3dadeb..1f5e18ea7 100644 --- a/src/prompt_toolkit/application/run_in_terminal.py +++ b/src/prompt_toolkit/application/run_in_terminal.py @@ -111,4 +111,7 @@ async def f(): app._request_absolute_cursor_position() app._redraw() finally: - new_run_in_terminal_f.set_result(None) + # (Check for `.done()`, because it can be that this future was + # cancelled.) + if not new_run_in_terminal_f.done(): + new_run_in_terminal_f.set_result(None) diff --git a/src/prompt_toolkit/completion/fuzzy_completer.py b/src/prompt_toolkit/completion/fuzzy_completer.py index 25ea8923a..82625ab63 100644 --- a/src/prompt_toolkit/completion/fuzzy_completer.py +++ b/src/prompt_toolkit/completion/fuzzy_completer.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Callable, Iterable, NamedTuple +from typing import Callable, Iterable, NamedTuple, Sequence from prompt_toolkit.document import Document from prompt_toolkit.filters import FilterOrBool, to_filter @@ -187,7 +187,7 @@ class FuzzyWordCompleter(Completer): def __init__( self, - words: list[str] | Callable[[], list[str]], + words: Sequence[str] | Callable[[], Sequence[str]], meta_dict: dict[str, str] | None = None, WORD: bool = False, ) -> None: diff --git a/src/prompt_toolkit/completion/word_completer.py b/src/prompt_toolkit/completion/word_completer.py index 6ef4031fa..2e124056b 100644 --- a/src/prompt_toolkit/completion/word_completer.py +++ b/src/prompt_toolkit/completion/word_completer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Callable, Iterable, Mapping, Pattern +from typing import Callable, Iterable, Mapping, Pattern, Sequence from prompt_toolkit.completion import CompleteEvent, Completer, Completion from prompt_toolkit.document import Document @@ -33,7 +33,7 @@ class WordCompleter(Completer): def __init__( self, - words: list[str] | Callable[[], list[str]], + words: Sequence[str] | Callable[[], Sequence[str]], ignore_case: bool = False, display_dict: Mapping[str, AnyFormattedText] | None = None, meta_dict: Mapping[str, AnyFormattedText] | None = None, diff --git a/src/prompt_toolkit/contrib/regular_languages/compiler.py b/src/prompt_toolkit/contrib/regular_languages/compiler.py index dd558a68a..4009d54f2 100644 --- a/src/prompt_toolkit/contrib/regular_languages/compiler.py +++ b/src/prompt_toolkit/contrib/regular_languages/compiler.py @@ -42,7 +42,7 @@ from __future__ import annotations import re -from typing import Callable, Dict, Iterable, Iterator, Pattern +from typing import Callable, Dict, Iterable, Iterator, Pattern, TypeVar, overload from typing import Match as RegexMatch from .regex_parser import ( @@ -57,9 +57,7 @@ tokenize_regex, ) -__all__ = [ - "compile", -] +__all__ = ["compile", "Match", "Variables"] # Name of the named group in the regex, matching trailing input. @@ -491,6 +489,9 @@ def end_nodes(self) -> Iterable[MatchVariable]: yield MatchVariable(varname, value, (reg[0], reg[1])) +_T = TypeVar("_T") + + class Variables: def __init__(self, tuples: list[tuple[str, str, tuple[int, int]]]) -> None: #: List of (varname, value, slice) tuples. @@ -502,7 +503,13 @@ def __repr__(self) -> str: ", ".join(f"{k}={v!r}" for k, v, _ in self._tuples), ) - def get(self, key: str, default: str | None = None) -> str | None: + @overload + def get(self, key: str) -> str | None: ... + + @overload + def get(self, key: str, default: str | _T) -> str | _T: ... + + def get(self, key: str, default: str | _T | None = None) -> str | _T | None: items = self.getall(key) return items[0] if items else default diff --git a/src/prompt_toolkit/cursor_shapes.py b/src/prompt_toolkit/cursor_shapes.py index 453b72c3c..01d10926a 100644 --- a/src/prompt_toolkit/cursor_shapes.py +++ b/src/prompt_toolkit/cursor_shapes.py @@ -69,10 +69,23 @@ class ModalCursorShapeConfig(CursorShapeConfig): def get_cursor_shape(self, application: Application[Any]) -> CursorShape: if application.editing_mode == EditingMode.VI: - if application.vi_state.input_mode == InputMode.INSERT: + if application.vi_state.input_mode in { + InputMode.NAVIGATION, + }: + return CursorShape.BLOCK + if application.vi_state.input_mode in { + InputMode.INSERT, + InputMode.INSERT_MULTIPLE, + }: return CursorShape.BEAM - if application.vi_state.input_mode == InputMode.REPLACE: + if application.vi_state.input_mode in { + InputMode.REPLACE, + InputMode.REPLACE_SINGLE, + }: return CursorShape.UNDERLINE + elif application.editing_mode == EditingMode.EMACS: + # like vi's INSERT + return CursorShape.BEAM # Default return CursorShape.BLOCK diff --git a/src/prompt_toolkit/filters/base.py b/src/prompt_toolkit/filters/base.py index 410749db4..cd95424dc 100644 --- a/src/prompt_toolkit/filters/base.py +++ b/src/prompt_toolkit/filters/base.py @@ -81,8 +81,7 @@ def __bool__(self) -> None: instead of for instance ``filter1 or Always()``. """ raise ValueError( - "The truth value of a Filter is ambiguous. " - "Instead, call it as a function." + "The truth value of a Filter is ambiguous. Instead, call it as a function." ) diff --git a/src/prompt_toolkit/input/win32.py b/src/prompt_toolkit/input/win32.py index 322d7c0d7..1ff3234a3 100644 --- a/src/prompt_toolkit/input/win32.py +++ b/src/prompt_toolkit/input/win32.py @@ -16,7 +16,7 @@ import msvcrt from ctypes import windll -from ctypes import Array, pointer +from ctypes import Array, byref, pointer from ctypes.wintypes import DWORD, HANDLE from typing import Callable, ContextManager, Iterable, Iterator, TextIO @@ -35,6 +35,7 @@ from .ansi_escape_sequences import REVERSE_ANSI_SEQUENCES from .base import Input +from .vt100_parser import Vt100Parser __all__ = [ "Win32Input", @@ -52,6 +53,9 @@ MOUSE_MOVED = 0x0001 MOUSE_WHEELED = 0x0004 +# See: https://msdn.microsoft.com/pl-pl/library/windows/desktop/ms686033(v=vs.85).aspx +ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 + class _Win32InputBase(Input): """ @@ -74,7 +78,14 @@ class Win32Input(_Win32InputBase): def __init__(self, stdin: TextIO | None = None) -> None: super().__init__() - self.console_input_reader = ConsoleInputReader() + self._use_virtual_terminal_input = _is_win_vt100_input_enabled() + + self.console_input_reader: Vt100ConsoleInputReader | ConsoleInputReader + + if self._use_virtual_terminal_input: + self.console_input_reader = Vt100ConsoleInputReader() + else: + self.console_input_reader = ConsoleInputReader() def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]: """ @@ -101,7 +112,9 @@ def closed(self) -> bool: return False def raw_mode(self) -> ContextManager[None]: - return raw_mode() + return raw_mode( + use_win10_virtual_terminal_input=self._use_virtual_terminal_input + ) def cooked_mode(self) -> ContextManager[None]: return cooked_mode() @@ -555,6 +568,102 @@ def _handle_mouse(self, ev: MOUSE_EVENT_RECORD) -> list[KeyPress]: return [KeyPress(Keys.WindowsMouseEvent, data)] +class Vt100ConsoleInputReader: + """ + Similar to `ConsoleInputReader`, but for usage when + `ENABLE_VIRTUAL_TERMINAL_INPUT` is enabled. This assumes that Windows sends + us the right vt100 escape sequences and we parse those with our vt100 + parser. + + (Using this instead of `ConsoleInputReader` results in the "data" attribute + from the `KeyPress` instances to be more correct in edge cases, because + this responds to for instance the terminal being in application cursor keys + mode.) + """ + + def __init__(self) -> None: + self._fdcon = None + + self._buffer: list[KeyPress] = [] # Buffer to collect the Key objects. + self._vt100_parser = Vt100Parser( + lambda key_press: self._buffer.append(key_press) + ) + + # When stdin is a tty, use that handle, otherwise, create a handle from + # CONIN$. + self.handle: HANDLE + if sys.stdin.isatty(): + self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)) + else: + self._fdcon = os.open("CONIN$", os.O_RDWR | os.O_BINARY) + self.handle = HANDLE(msvcrt.get_osfhandle(self._fdcon)) + + def close(self) -> None: + "Close fdcon." + if self._fdcon is not None: + os.close(self._fdcon) + + def read(self) -> Iterable[KeyPress]: + """ + Return a list of `KeyPress` instances. It won't return anything when + there was nothing to read. (This function doesn't block.) + + http://msdn.microsoft.com/en-us/library/windows/desktop/ms684961(v=vs.85).aspx + """ + max_count = 2048 # Max events to read at the same time. + + read = DWORD(0) + arrtype = INPUT_RECORD * max_count + input_records = arrtype() + + # Check whether there is some input to read. `ReadConsoleInputW` would + # block otherwise. + # (Actually, the event loop is responsible to make sure that this + # function is only called when there is something to read, but for some + # reason this happened in the asyncio_win32 loop, and it's better to be + # safe anyway.) + if not wait_for_handles([self.handle], timeout=0): + return [] + + # Get next batch of input event. + windll.kernel32.ReadConsoleInputW( + self.handle, pointer(input_records), max_count, pointer(read) + ) + + # First, get all the keys from the input buffer, in order to determine + # whether we should consider this a paste event or not. + for key_data in self._get_keys(read, input_records): + self._vt100_parser.feed(key_data) + + # Return result. + result = self._buffer + self._buffer = [] + return result + + def _get_keys( + self, read: DWORD, input_records: Array[INPUT_RECORD] + ) -> Iterator[str]: + """ + Generator that yields `KeyPress` objects from the input records. + """ + for i in range(read.value): + ir = input_records[i] + + # Get the right EventType from the EVENT_RECORD. + # (For some reason the Windows console application 'cmder' + # [http://gooseberrycreative.com/cmder/] can return '0' for + # ir.EventType. -- Just ignore that.) + if ir.EventType in EventTypes: + ev = getattr(ir.Event, EventTypes[ir.EventType]) + + # Process if this is a key event. (We also have mouse, menu and + # focus events.) + if isinstance(ev, KEY_EVENT_RECORD) and ev.KeyDown: + u_char = ev.uChar.UnicodeChar + if u_char != "\x00": + yield u_char + + class _Win32Handles: """ Utility to keep track of which handles are connectod to which callbacks. @@ -700,8 +809,11 @@ class raw_mode: `raw_input` method of `.vt100_input`. """ - def __init__(self, fileno: int | None = None) -> None: + def __init__( + self, fileno: int | None = None, use_win10_virtual_terminal_input: bool = False + ) -> None: self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)) + self.use_win10_virtual_terminal_input = use_win10_virtual_terminal_input def __enter__(self) -> None: # Remember original mode. @@ -717,12 +829,15 @@ def _patch(self) -> None: ENABLE_LINE_INPUT = 0x0002 ENABLE_PROCESSED_INPUT = 0x0001 - windll.kernel32.SetConsoleMode( - self.handle, - self.original_mode.value - & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT), + new_mode = self.original_mode.value & ~( + ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT ) + if self.use_win10_virtual_terminal_input: + new_mode |= ENABLE_VIRTUAL_TERMINAL_INPUT + + windll.kernel32.SetConsoleMode(self.handle, new_mode) + def __exit__(self, *a: object) -> None: # Restore original mode windll.kernel32.SetConsoleMode(self.handle, self.original_mode) @@ -747,3 +862,25 @@ def _patch(self) -> None: self.original_mode.value | (ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT), ) + + +def _is_win_vt100_input_enabled() -> bool: + """ + Returns True when we're running Windows and VT100 escape sequences are + supported. + """ + hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)) + + # Get original console mode. + original_mode = DWORD(0) + windll.kernel32.GetConsoleMode(hconsole, byref(original_mode)) + + try: + # Try to enable VT100 sequences. + result: int = windll.kernel32.SetConsoleMode( + hconsole, DWORD(ENABLE_VIRTUAL_TERMINAL_INPUT) + ) + + return result == 1 + finally: + windll.kernel32.SetConsoleMode(hconsole, original_mode) diff --git a/src/prompt_toolkit/layout/controls.py b/src/prompt_toolkit/layout/controls.py index 222e471c5..5083c8286 100644 --- a/src/prompt_toolkit/layout/controls.py +++ b/src/prompt_toolkit/layout/controls.py @@ -667,7 +667,11 @@ def _create_get_processed_line_func( merged_processor = merge_processors(input_processors) - def transform(lineno: int, fragments: StyleAndTextTuples) -> _ProcessedLine: + def transform( + lineno: int, + fragments: StyleAndTextTuples, + get_line: Callable[[int], StyleAndTextTuples], + ) -> _ProcessedLine: "Transform the fragments for a given line number." # Get cursor position at this line. @@ -679,7 +683,14 @@ def source_to_display(i: int) -> int: transformation = merged_processor.apply_transformation( TransformationInput( - self, document, lineno, source_to_display, fragments, width, height + self, + document, + lineno, + source_to_display, + fragments, + width, + height, + get_line, ) ) @@ -697,7 +708,7 @@ def get_processed_line(i: int) -> _ProcessedLine: try: return cache[i] except KeyError: - processed_line = transform(i, get_line(i)) + processed_line = transform(i, get_line(i), get_line) cache[i] = processed_line return processed_line diff --git a/src/prompt_toolkit/layout/processors.py b/src/prompt_toolkit/layout/processors.py index b10ecf718..666e79c66 100644 --- a/src/prompt_toolkit/layout/processors.py +++ b/src/prompt_toolkit/layout/processors.py @@ -86,6 +86,9 @@ class TransformationInput: previous processors into account.) :param fragments: List of fragments that we can transform. (Received from the previous processor.) + :param get_line: Optional ; a callable that returns the fragments of another + line in the current buffer; This can be used to create processors capable + of affecting transforms across multiple lines. """ def __init__( @@ -97,6 +100,7 @@ def __init__( fragments: StyleAndTextTuples, width: int, height: int, + get_line: Callable[[int], StyleAndTextTuples] | None = None, ) -> None: self.buffer_control = buffer_control self.document = document @@ -105,6 +109,7 @@ def __init__( self.fragments = fragments self.width = width self.height = height + self.get_line = get_line def unpack( self, @@ -842,9 +847,9 @@ def filter_processor(item: Processor) -> Processor | None: def apply_transformation(self, ti: TransformationInput) -> Transformation: from .controls import SearchBufferControl - assert isinstance( - ti.buffer_control, SearchBufferControl - ), "`ReverseSearchProcessor` should be applied to a `SearchBufferControl` only." + assert isinstance(ti.buffer_control, SearchBufferControl), ( + "`ReverseSearchProcessor` should be applied to a `SearchBufferControl` only." + ) source_to_display: SourceToDisplay | None display_to_source: DisplayToSource | None @@ -987,6 +992,7 @@ def source_to_display(i: int) -> int: fragments, ti.width, ti.height, + ti.get_line, ) ) fragments = transformation.fragments diff --git a/src/prompt_toolkit/layout/screen.py b/src/prompt_toolkit/layout/screen.py index 0f19f52a8..475f540d1 100644 --- a/src/prompt_toolkit/layout/screen.py +++ b/src/prompt_toolkit/layout/screen.py @@ -169,7 +169,7 @@ def __init__( #: Escape sequences to be injected. self.zero_width_escapes: defaultdict[int, defaultdict[int, str]] = defaultdict( - lambda: defaultdict(lambda: "") + lambda: defaultdict(str) ) #: Position of the cursor. diff --git a/src/prompt_toolkit/output/defaults.py b/src/prompt_toolkit/output/defaults.py index ed114e32a..6b06ed43c 100644 --- a/src/prompt_toolkit/output/defaults.py +++ b/src/prompt_toolkit/output/defaults.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import TextIO, cast +from typing import TYPE_CHECKING, TextIO, cast from prompt_toolkit.utils import ( get_bell_environment_variable, @@ -13,13 +13,17 @@ from .color_depth import ColorDepth from .plain_text import PlainTextOutput +if TYPE_CHECKING: + from prompt_toolkit.patch_stdout import StdoutProxy + + __all__ = [ "create_output", ] def create_output( - stdout: TextIO | None = None, always_prefer_tty: bool = False + stdout: TextIO | StdoutProxy | None = None, always_prefer_tty: bool = False ) -> Output: """ Return an :class:`~prompt_toolkit.output.Output` instance for the command @@ -54,13 +58,6 @@ def create_output( stdout = io break - # If the output is still `None`, use a DummyOutput. - # This happens for instance on Windows, when running the application under - # `pythonw.exe`. In that case, there won't be a terminal Window, and - # stdin/stdout/stderr are `None`. - if stdout is None: - return DummyOutput() - # If the patch_stdout context manager has been used, then sys.stdout is # replaced by this proxy. For prompt_toolkit applications, we want to use # the real stdout. @@ -69,6 +66,13 @@ def create_output( while isinstance(stdout, StdoutProxy): stdout = stdout.original_stdout + # If the output is still `None`, use a DummyOutput. + # This happens for instance on Windows, when running the application under + # `pythonw.exe`. In that case, there won't be a terminal Window, and + # stdin/stdout/stderr are `None`. + if stdout is None: + return DummyOutput() + if sys.platform == "win32": from .conemu import ConEmuOutput from .win32 import Win32Output diff --git a/src/prompt_toolkit/output/vt100.py b/src/prompt_toolkit/output/vt100.py index 069636b8c..90df21e55 100644 --- a/src/prompt_toolkit/output/vt100.py +++ b/src/prompt_toolkit/output/vt100.py @@ -436,6 +436,11 @@ def __init__( # default, we don't change them.) self._cursor_shape_changed = False + # Don't hide/show the cursor when this was already done. + # (`None` means that we don't know whether the cursor is visible or + # not.) + self._cursor_visible: bool | None = None + @classmethod def from_pty( cls, @@ -651,10 +656,14 @@ def cursor_backward(self, amount: int) -> None: self.write_raw("\x1b[%iD" % amount) def hide_cursor(self) -> None: - self.write_raw("\x1b[?25l") + if self._cursor_visible in (True, None): + self._cursor_visible = False + self.write_raw("\x1b[?25l") def show_cursor(self) -> None: - self.write_raw("\x1b[?12l\x1b[?25h") # Stop blinking cursor and show. + if self._cursor_visible in (False, None): + self._cursor_visible = True + self.write_raw("\x1b[?12l\x1b[?25h") # Stop blinking cursor and show. def set_cursor_shape(self, cursor_shape: CursorShape) -> None: if cursor_shape == CursorShape._NEVER_CHANGE: diff --git a/src/prompt_toolkit/output/windows10.py b/src/prompt_toolkit/output/windows10.py index c39f3ecfd..2b7e596e4 100644 --- a/src/prompt_toolkit/output/windows10.py +++ b/src/prompt_toolkit/output/windows10.py @@ -66,15 +66,20 @@ def responds_to_cpr(self) -> bool: return False # We don't need this on Windows. def __getattr__(self, name: str) -> Any: + # NOTE: Now that we use "virtual terminal input" on + # Windows, both input and output are done through + # ANSI escape sequences on Windows. This means, we + # should enable bracketed paste like on Linux, and + # enable mouse support by calling the vt100_output. if name in ( "get_size", "get_rows_below_cursor_position", - "enable_mouse_support", - "disable_mouse_support", "scroll_buffer_to_prompt", "get_win32_screen_buffer_info", - "enable_bracketed_paste", - "disable_bracketed_paste", + # "enable_mouse_support", + # "disable_mouse_support", + # "enable_bracketed_paste", + # "disable_bracketed_paste", ): return getattr(self.win32_output, name) else: diff --git a/src/prompt_toolkit/patch_stdout.py b/src/prompt_toolkit/patch_stdout.py index 4958e9d2e..e1f2a7a2c 100644 --- a/src/prompt_toolkit/patch_stdout.py +++ b/src/prompt_toolkit/patch_stdout.py @@ -273,7 +273,7 @@ def flush(self) -> None: self._flush() @property - def original_stdout(self) -> TextIO: + def original_stdout(self) -> TextIO | None: return self._output.stdout or sys.__stdout__ # Attributes for compatibility with sys.__stdout__: diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py index 3f92303a8..8d5e03c19 100644 --- a/src/prompt_toolkit/renderer.py +++ b/src/prompt_toolkit/renderer.py @@ -257,7 +257,7 @@ def get_max_column_index(row: dict[int, Char]) -> int: # give weird artifacts on resize events.) reset_attributes() - if screen.show_cursor or is_done: + if screen.show_cursor: output.show_cursor() return current_pos, last_style @@ -353,6 +353,11 @@ def __init__( self.mouse_support = to_filter(mouse_support) self.cpr_not_supported_callback = cpr_not_supported_callback + # TODO: Move following state flags into `Vt100_Output`, similar to + # `_cursor_shape_changed` and `_cursor_visible`. But then also + # adjust the `Win32Output` to not call win32 APIs if nothing has + # to be changed. + self._in_alternate_screen = False self._mouse_support_enabled = False self._bracketed_paste_enabled = False @@ -416,6 +421,7 @@ def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> N self._bracketed_paste_enabled = False self.output.reset_cursor_shape() + self.output.show_cursor() # NOTE: No need to set/reset cursor key mode here. diff --git a/src/prompt_toolkit/shortcuts/prompt.py b/src/prompt_toolkit/shortcuts/prompt.py index 115d89007..d0732bc13 100644 --- a/src/prompt_toolkit/shortcuts/prompt.py +++ b/src/prompt_toolkit/shortcuts/prompt.py @@ -324,6 +324,10 @@ class PromptSession(Generic[_T]): :param input: `Input` object. (Note that the preferred way to change the input/output is by creating an `AppSession`.) :param output: `Output` object. + :param interrupt_exception: The exception type that will be raised when + there is a keyboard interrupt (control-c keypress). + :param eof_exception: The exception type that will be raised when there is + an end-of-file/exit event (control-d keypress). """ _fields = ( @@ -410,6 +414,8 @@ def __init__( refresh_interval: float = 0, input: Input | None = None, output: Output | None = None, + interrupt_exception: type[BaseException] = KeyboardInterrupt, + eof_exception: type[BaseException] = EOFError, ) -> None: history = history or InMemoryHistory() clipboard = clipboard or InMemoryClipboard() @@ -459,6 +465,8 @@ def __init__( self.reserve_space_for_menu = reserve_space_for_menu self.tempfile_suffix = tempfile_suffix self.tempfile = tempfile + self.interrupt_exception = interrupt_exception + self.eof_exception = eof_exception # Create buffers, layout and Application. self.history = history @@ -811,7 +819,7 @@ def _complete_like_readline(event: E) -> None: @handle("<sigint>") def _keyboard_interrupt(event: E) -> None: "Abort when Control-C has been pressed." - event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + event.app.exit(exception=self.interrupt_exception(), style="class:aborting") @Condition def ctrl_d_condition() -> bool: @@ -826,7 +834,7 @@ def ctrl_d_condition() -> bool: @handle("c-d", filter=ctrl_d_condition & default_focused) def _eof(event: E) -> None: "Exit when Control-D has been pressed." - event.app.exit(exception=EOFError, style="class:exiting") + event.app.exit(exception=self.eof_exception(), style="class:exiting") suspend_supported = Condition(suspend_to_background_supported) diff --git a/src/prompt_toolkit/widgets/base.py b/src/prompt_toolkit/widgets/base.py index 709b7a949..e2330e9e1 100644 --- a/src/prompt_toolkit/widgets/base.py +++ b/src/prompt_toolkit/widgets/base.py @@ -59,7 +59,7 @@ FormattedTextControl, GetLinePrefixCallable, ) -from prompt_toolkit.layout.dimension import AnyDimension, to_dimension +from prompt_toolkit.layout.dimension import AnyDimension from prompt_toolkit.layout.dimension import Dimension as D from prompt_toolkit.layout.margins import ( ConditionalMargin, @@ -636,31 +636,44 @@ def __init__( modal: bool = False, key_bindings: KeyBindings | None = None, ) -> None: - if padding is None: - padding = D(preferred=0) - - def get(value: AnyDimension) -> D: - if value is None: - value = padding - return to_dimension(value) - - self.padding_left = get(padding_left) - self.padding_right = get(padding_right) - self.padding_top = get(padding_top) - self.padding_bottom = get(padding_bottom) + self.padding = padding + self.padding_left = padding_left + self.padding_right = padding_right + self.padding_top = padding_top + self.padding_bottom = padding_bottom self.body = body + def left() -> AnyDimension: + if self.padding_left is None: + return self.padding + return self.padding_left + + def right() -> AnyDimension: + if self.padding_right is None: + return self.padding + return self.padding_right + + def top() -> AnyDimension: + if self.padding_top is None: + return self.padding + return self.padding_top + + def bottom() -> AnyDimension: + if self.padding_bottom is None: + return self.padding + return self.padding_bottom + self.container = HSplit( [ - Window(height=self.padding_top, char=char), + Window(height=top, char=char), VSplit( [ - Window(width=self.padding_left, char=char), + Window(width=left, char=char), body, - Window(width=self.padding_right, char=char), + Window(width=right, char=char), ] ), - Window(height=self.padding_bottom, char=char), + Window(height=bottom, char=char), ], width=width, height=height, diff --git a/tests/test_cli.py b/tests/test_cli.py index c155325f9..a876f2993 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -870,11 +870,11 @@ def test_vi_temp_navigation_mode(): """ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) - result, cli = feed("abcde" "\x0f" "3h" "x\r") # c-o # 3 times to the left. + result, cli = feed("abcde\x0f3hx\r") # c-o # 3 times to the left. assert result.text == "axbcde" assert result.cursor_position == 2 - result, cli = feed("abcde" "\x0f" "b" "x\r") # c-o # One word backwards. + result, cli = feed("abcde\x0fbx\r") # c-o # One word backwards. assert result.text == "xabcde" assert result.cursor_position == 1